mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-07-11 13:35:13 +10:00
feat: combine settings pages into a single integrations page
This commit is contained in:
@@ -95,12 +95,12 @@ export function NavigationCommandGroup() {
|
||||
</CommandItem>
|
||||
|
||||
<CommandItem
|
||||
keywords={[t`Artificial Intelligence`]}
|
||||
value="navigation.settings.ai"
|
||||
onSelect={() => onNavigate("/dashboard/settings/ai")}
|
||||
keywords={[t`Integrations`, t`Artificial Intelligence`, t`Job Search API`]}
|
||||
value="navigation.settings.integrations"
|
||||
onSelect={() => onNavigate("/dashboard/settings/integrations")}
|
||||
>
|
||||
<OpenAiLogoIcon />
|
||||
<Trans>Artificial Intelligence</Trans>
|
||||
<Trans>Integrations</Trans>
|
||||
</CommandItem>
|
||||
|
||||
<CommandItem
|
||||
|
||||
@@ -49,6 +49,7 @@ import { Route as ApiAuthSplatRouteImport } from "./routes/api/auth.$";
|
||||
import { Route as DotwellKnownOauthProtectedResourceSplatRouteImport } from "./routes/[.]well-known/oauth-protected-resource.$";
|
||||
import { Route as DotwellKnownOauthAuthorizationServerSplatRouteImport } from "./routes/[.]well-known/oauth-authorization-server.$";
|
||||
import { Route as DotwellKnownMcpServerCardDotjsonRouteImport } from "./routes/[.]well-known/mcp/server-card[.]json";
|
||||
import { Route as DashboardSettingsIntegrationsRouteRouteImport } from "./routes/dashboard/settings/integrations/route";
|
||||
import { Route as DashboardSettingsAuthenticationIndexRouteImport } from "./routes/dashboard/settings/authentication/index";
|
||||
|
||||
const SchemaDotjsonRoute = SchemaDotjsonRouteImport.update({
|
||||
@@ -261,6 +262,12 @@ const DotwellKnownMcpServerCardDotjsonRoute =
|
||||
path: "/.well-known/mcp/server-card.json",
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any);
|
||||
const DashboardSettingsIntegrationsRouteRoute =
|
||||
DashboardSettingsIntegrationsRouteRouteImport.update({
|
||||
id: "/settings/integrations",
|
||||
path: "/settings/integrations",
|
||||
getParentRoute: () => DashboardRouteRoute,
|
||||
} as any);
|
||||
const DashboardSettingsAuthenticationIndexRoute =
|
||||
DashboardSettingsAuthenticationIndexRouteImport.update({
|
||||
id: "/settings/authentication/",
|
||||
@@ -292,6 +299,7 @@ export interface FileRoutesByFullPath {
|
||||
"/auth/": typeof AuthIndexRoute;
|
||||
"/dashboard/": typeof DashboardIndexRoute;
|
||||
"/mcp/": typeof McpIndexRoute;
|
||||
"/dashboard/settings/integrations": typeof DashboardSettingsIntegrationsRouteRoute;
|
||||
"/.well-known/mcp/server-card.json": typeof DotwellKnownMcpServerCardDotjsonRoute;
|
||||
"/.well-known/oauth-authorization-server/$": typeof DotwellKnownOauthAuthorizationServerSplatRoute;
|
||||
"/.well-known/oauth-protected-resource/$": typeof DotwellKnownOauthProtectedResourceSplatRoute;
|
||||
@@ -331,6 +339,7 @@ export interface FileRoutesByTo {
|
||||
"/auth": typeof AuthIndexRoute;
|
||||
"/dashboard": typeof DashboardIndexRoute;
|
||||
"/mcp": typeof McpIndexRoute;
|
||||
"/dashboard/settings/integrations": typeof DashboardSettingsIntegrationsRouteRoute;
|
||||
"/.well-known/mcp/server-card.json": typeof DotwellKnownMcpServerCardDotjsonRoute;
|
||||
"/.well-known/oauth-authorization-server/$": typeof DotwellKnownOauthAuthorizationServerSplatRoute;
|
||||
"/.well-known/oauth-protected-resource/$": typeof DotwellKnownOauthProtectedResourceSplatRoute;
|
||||
@@ -375,6 +384,7 @@ export interface FileRoutesById {
|
||||
"/auth/": typeof AuthIndexRoute;
|
||||
"/dashboard/": typeof DashboardIndexRoute;
|
||||
"/mcp/": typeof McpIndexRoute;
|
||||
"/dashboard/settings/integrations": typeof DashboardSettingsIntegrationsRouteRoute;
|
||||
"/.well-known/mcp/server-card.json": typeof DotwellKnownMcpServerCardDotjsonRoute;
|
||||
"/.well-known/oauth-authorization-server/$": typeof DotwellKnownOauthAuthorizationServerSplatRoute;
|
||||
"/.well-known/oauth-protected-resource/$": typeof DotwellKnownOauthProtectedResourceSplatRoute;
|
||||
@@ -419,6 +429,7 @@ export interface FileRouteTypes {
|
||||
| "/auth/"
|
||||
| "/dashboard/"
|
||||
| "/mcp/"
|
||||
| "/dashboard/settings/integrations"
|
||||
| "/.well-known/mcp/server-card.json"
|
||||
| "/.well-known/oauth-authorization-server/$"
|
||||
| "/.well-known/oauth-protected-resource/$"
|
||||
@@ -458,6 +469,7 @@ export interface FileRouteTypes {
|
||||
| "/auth"
|
||||
| "/dashboard"
|
||||
| "/mcp"
|
||||
| "/dashboard/settings/integrations"
|
||||
| "/.well-known/mcp/server-card.json"
|
||||
| "/.well-known/oauth-authorization-server/$"
|
||||
| "/.well-known/oauth-protected-resource/$"
|
||||
@@ -501,6 +513,7 @@ export interface FileRouteTypes {
|
||||
| "/auth/"
|
||||
| "/dashboard/"
|
||||
| "/mcp/"
|
||||
| "/dashboard/settings/integrations"
|
||||
| "/.well-known/mcp/server-card.json"
|
||||
| "/.well-known/oauth-authorization-server/$"
|
||||
| "/.well-known/oauth-protected-resource/$"
|
||||
@@ -823,6 +836,13 @@ declare module "@tanstack/react-router" {
|
||||
preLoaderRoute: typeof DotwellKnownMcpServerCardDotjsonRouteImport;
|
||||
parentRoute: typeof rootRouteImport;
|
||||
};
|
||||
"/dashboard/settings/integrations": {
|
||||
id: "/dashboard/settings/integrations";
|
||||
path: "/settings/integrations";
|
||||
fullPath: "/dashboard/settings/integrations";
|
||||
preLoaderRoute: typeof DashboardSettingsIntegrationsRouteRouteImport;
|
||||
parentRoute: typeof DashboardRouteRoute;
|
||||
};
|
||||
"/dashboard/settings/authentication/": {
|
||||
id: "/dashboard/settings/authentication/";
|
||||
path: "/settings/authentication";
|
||||
@@ -875,6 +895,7 @@ const AuthRouteRouteWithChildren = AuthRouteRoute._addFileChildren(
|
||||
|
||||
interface DashboardRouteRouteChildren {
|
||||
DashboardIndexRoute: typeof DashboardIndexRoute;
|
||||
DashboardSettingsIntegrationsRouteRoute: typeof DashboardSettingsIntegrationsRouteRoute;
|
||||
DashboardSettingsAiRoute: typeof DashboardSettingsAiRoute;
|
||||
DashboardSettingsApiKeysRoute: typeof DashboardSettingsApiKeysRoute;
|
||||
DashboardSettingsDangerZoneRoute: typeof DashboardSettingsDangerZoneRoute;
|
||||
@@ -888,6 +909,8 @@ interface DashboardRouteRouteChildren {
|
||||
|
||||
const DashboardRouteRouteChildren: DashboardRouteRouteChildren = {
|
||||
DashboardIndexRoute: DashboardIndexRoute,
|
||||
DashboardSettingsIntegrationsRouteRoute:
|
||||
DashboardSettingsIntegrationsRouteRoute,
|
||||
DashboardSettingsAiRoute: DashboardSettingsAiRoute,
|
||||
DashboardSettingsApiKeysRoute: DashboardSettingsApiKeysRoute,
|
||||
DashboardSettingsDangerZoneRoute: DashboardSettingsDangerZoneRoute,
|
||||
|
||||
@@ -266,8 +266,8 @@ function DisabledState() {
|
||||
variant="outline"
|
||||
nativeButton={false}
|
||||
render={
|
||||
<Link to="/dashboard/settings/ai">
|
||||
<Trans>Open AI Settings</Trans>
|
||||
<Link to="/dashboard/settings/integrations">
|
||||
<Trans>Open Integrations Settings</Trans>
|
||||
<ArrowRightIcon />
|
||||
</Link>
|
||||
}
|
||||
|
||||
@@ -79,13 +79,8 @@ const settingsSidebarItems = [
|
||||
},
|
||||
{
|
||||
icon: <BrainIcon />,
|
||||
label: msg`Artificial Intelligence`,
|
||||
href: "/dashboard/settings/ai",
|
||||
},
|
||||
{
|
||||
icon: <BriefcaseIcon />,
|
||||
label: msg`Job Search API`,
|
||||
href: "/dashboard/settings/job-search",
|
||||
label: msg`Integrations`,
|
||||
href: "/dashboard/settings/integrations",
|
||||
},
|
||||
{
|
||||
icon: <WarningIcon />,
|
||||
|
||||
@@ -79,8 +79,8 @@ function RouteComponent() {
|
||||
<p className="text-muted-foreground">
|
||||
<Trans>To search for job listings, you need to configure your RapidAPI key in settings.</Trans>
|
||||
</p>
|
||||
<Button nativeButton={false} variant="outline" render={<Link to="/dashboard/settings/job-search" />}>
|
||||
<Trans>Go to Settings</Trans>
|
||||
<Button nativeButton={false} variant="outline" render={<Link to="/dashboard/settings/integrations" />}>
|
||||
<Trans>Go to Integrations</Trans>
|
||||
</Button>
|
||||
</motion.div>
|
||||
) : (
|
||||
|
||||
@@ -1,311 +1,7 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { BrainIcon, CheckCircleIcon, InfoIcon, XCircleIcon } from "@phosphor-icons/react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { motion } from "motion/react";
|
||||
import { useMemo } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { useIsClient } from "usehooks-ts";
|
||||
|
||||
import type { AIProvider } from "@/integrations/ai/types";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Combobox, type ComboboxOption } from "@/components/ui/combobox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useAIStore } from "@/integrations/ai/store";
|
||||
import { orpc } from "@/integrations/orpc/client";
|
||||
import { getOrpcErrorMessage } from "@/utils/error-message";
|
||||
import { cn } from "@/utils/style";
|
||||
|
||||
import { DashboardHeader } from "../-components/header";
|
||||
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute("/dashboard/settings/ai")({
|
||||
component: RouteComponent,
|
||||
beforeLoad: () => {
|
||||
throw redirect({ to: "/dashboard/settings/integrations", replace: true });
|
||||
},
|
||||
});
|
||||
|
||||
const providerOptions: (ComboboxOption<AIProvider> & { defaultBaseURL: string })[] = [
|
||||
{
|
||||
value: "openai",
|
||||
label: t({
|
||||
comment: "AI provider option label in dashboard AI settings",
|
||||
message: "OpenAI",
|
||||
}),
|
||||
keywords: ["openai", "gpt", "chatgpt"],
|
||||
defaultBaseURL: "https://api.openai.com/v1",
|
||||
},
|
||||
{
|
||||
value: "anthropic",
|
||||
label: t({
|
||||
comment: "AI provider option label in dashboard AI settings",
|
||||
message: "Anthropic Claude",
|
||||
}),
|
||||
keywords: ["anthropic", "claude", "ai"],
|
||||
defaultBaseURL: "https://api.anthropic.com/v1",
|
||||
},
|
||||
{
|
||||
value: "gemini",
|
||||
label: t({
|
||||
comment: "AI provider option label in dashboard AI settings",
|
||||
message: "Google Gemini",
|
||||
}),
|
||||
keywords: ["gemini", "google", "bard"],
|
||||
defaultBaseURL: "https://generativelanguage.googleapis.com/v1beta",
|
||||
},
|
||||
{
|
||||
value: "vercel-ai-gateway",
|
||||
label: t({
|
||||
comment: "AI provider option label in dashboard AI settings",
|
||||
message: "Vercel AI Gateway",
|
||||
}),
|
||||
keywords: ["vercel", "gateway", "ai"],
|
||||
defaultBaseURL: "https://ai-gateway.vercel.sh/v1/ai",
|
||||
},
|
||||
{
|
||||
value: "openrouter",
|
||||
label: t({
|
||||
comment: "AI provider option label in dashboard AI settings",
|
||||
message: "OpenRouter",
|
||||
}),
|
||||
keywords: ["openrouter", "router", "multi", "proxy"],
|
||||
defaultBaseURL: "https://openrouter.ai/api/v1",
|
||||
},
|
||||
{
|
||||
value: "ollama",
|
||||
label: t({
|
||||
comment: "AI provider option label in dashboard AI settings",
|
||||
message: "Ollama",
|
||||
}),
|
||||
keywords: ["ollama", "ai", "local"],
|
||||
defaultBaseURL: "https://ollama.com/api",
|
||||
},
|
||||
];
|
||||
|
||||
function AIForm() {
|
||||
const { set, model, apiKey, baseURL, provider, enabled, testStatus } = useAIStore();
|
||||
|
||||
const selectedOption = useMemo(() => {
|
||||
return providerOptions.find((option) => option.value === provider);
|
||||
}, [provider]);
|
||||
|
||||
const { mutate: testConnection, isPending: isTesting } = useMutation(orpc.ai.testConnection.mutationOptions());
|
||||
|
||||
const handleProviderChange = (value: AIProvider | null) => {
|
||||
if (!value) return;
|
||||
set((draft) => {
|
||||
draft.provider = value;
|
||||
});
|
||||
};
|
||||
|
||||
const handleModelChange = (value: string) => {
|
||||
set((draft) => {
|
||||
draft.model = value;
|
||||
});
|
||||
};
|
||||
|
||||
const handleApiKeyChange = (value: string) => {
|
||||
set((draft) => {
|
||||
draft.apiKey = value;
|
||||
});
|
||||
};
|
||||
|
||||
const handleBaseURLChange = (value: string) => {
|
||||
set((draft) => {
|
||||
draft.baseURL = value;
|
||||
});
|
||||
};
|
||||
|
||||
const handleTestConnection = () => {
|
||||
testConnection(
|
||||
{ provider, model, apiKey, baseURL },
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
set((draft) => {
|
||||
draft.testStatus = data ? "success" : "failure";
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
set((draft) => {
|
||||
draft.testStatus = "failure";
|
||||
});
|
||||
|
||||
toast.error(
|
||||
getOrpcErrorMessage(error, {
|
||||
byCode: {
|
||||
BAD_REQUEST: t({
|
||||
comment: "Error shown when AI provider credentials or base URL are invalid in AI settings",
|
||||
message: "Invalid AI provider configuration. Please check your settings.",
|
||||
}),
|
||||
BAD_GATEWAY: t({
|
||||
comment: "Error shown when the configured AI provider cannot be reached during connection test",
|
||||
message: "Could not reach the AI provider. Please try again.",
|
||||
}),
|
||||
},
|
||||
fallback: t({
|
||||
comment: "Fallback toast when testing AI provider connection fails",
|
||||
message: "Failed to test AI provider connection. Please try again.",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid gap-6 sm:grid-cols-2">
|
||||
<div className="flex flex-col gap-y-2">
|
||||
<Label htmlFor="ai-provider">
|
||||
<Trans>Provider</Trans>
|
||||
</Label>
|
||||
<Combobox
|
||||
id="ai-provider"
|
||||
value={provider}
|
||||
disabled={enabled}
|
||||
options={providerOptions}
|
||||
onValueChange={handleProviderChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-y-2">
|
||||
<Label htmlFor="ai-model">
|
||||
<Trans>Model</Trans>
|
||||
</Label>
|
||||
<Input
|
||||
id="ai-model"
|
||||
name="ai-model"
|
||||
type="text"
|
||||
value={model}
|
||||
disabled={enabled}
|
||||
onChange={(e) => handleModelChange(e.target.value)}
|
||||
placeholder={t({
|
||||
comment: "Example model-name placeholder in AI settings",
|
||||
message: "e.g., gpt-4, claude-3-opus, gemini-pro",
|
||||
})}
|
||||
autoCorrect="off"
|
||||
autoComplete="off"
|
||||
spellCheck="false"
|
||||
autoCapitalize="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-y-2 sm:col-span-2">
|
||||
<Label htmlFor="ai-api-key">
|
||||
<Trans>API Key</Trans>
|
||||
</Label>
|
||||
<Input
|
||||
id="ai-api-key"
|
||||
name="ai-api-key"
|
||||
type="password"
|
||||
value={apiKey}
|
||||
disabled={enabled}
|
||||
onChange={(e) => handleApiKeyChange(e.target.value)}
|
||||
autoCorrect="off"
|
||||
autoComplete="off"
|
||||
spellCheck="false"
|
||||
autoCapitalize="off"
|
||||
// ignore password managers
|
||||
data-lpignore="true"
|
||||
data-bwignore="true"
|
||||
data-1p-ignore="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-y-2 sm:col-span-2">
|
||||
<Label htmlFor="ai-base-url">
|
||||
<Trans>Base URL (Optional)</Trans>
|
||||
</Label>
|
||||
<Input
|
||||
id="ai-base-url"
|
||||
name="ai-base-url"
|
||||
type="url"
|
||||
value={baseURL}
|
||||
disabled={enabled}
|
||||
placeholder={selectedOption?.defaultBaseURL}
|
||||
onChange={(e) => handleBaseURLChange(e.target.value)}
|
||||
autoCorrect="off"
|
||||
autoComplete="off"
|
||||
spellCheck="false"
|
||||
autoCapitalize="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Button variant="outline" disabled={isTesting || enabled} onClick={handleTestConnection}>
|
||||
{isTesting ? (
|
||||
<Spinner />
|
||||
) : testStatus === "success" ? (
|
||||
<CheckCircleIcon className="text-success" />
|
||||
) : testStatus === "failure" ? (
|
||||
<XCircleIcon className="text-destructive" />
|
||||
) : null}
|
||||
<Trans>Test Connection</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RouteComponent() {
|
||||
const isClient = useIsClient();
|
||||
|
||||
const enabled = useAIStore((state) => state.enabled);
|
||||
const canEnable = useAIStore((state) => state.canEnable());
|
||||
const setEnabled = useAIStore((state) => state.setEnabled);
|
||||
|
||||
if (!isClient) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<DashboardHeader icon={BrainIcon} title={t`Artificial Intelligence`} />
|
||||
|
||||
<Separator />
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, ease: "easeOut" }}
|
||||
className="grid max-w-xl gap-6 will-change-[transform,opacity]"
|
||||
>
|
||||
<div className="flex items-start gap-4 rounded-md border bg-popover p-6">
|
||||
<div className="rounded-md bg-primary/10 p-2.5">
|
||||
<InfoIcon className="text-primary" size={24} />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 space-y-2">
|
||||
<h3 className="font-semibold">
|
||||
<Trans>Your data is stored locally</Trans>
|
||||
</h3>
|
||||
|
||||
<p className="leading-relaxed text-muted-foreground">
|
||||
<Trans>
|
||||
Everything entered here is stored locally on your browser. Your data is only sent to the server when
|
||||
making a request to the AI provider, and is never stored or logged on our servers.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="enable-ai">
|
||||
<Trans>Enable AI Features</Trans>
|
||||
</Label>
|
||||
<Switch id="enable-ai" checked={enabled} disabled={!canEnable} onCheckedChange={setEnabled} />
|
||||
</div>
|
||||
|
||||
<p className={cn("flex items-center gap-x-2", enabled ? "text-success" : "text-destructive")}>
|
||||
{enabled ? <CheckCircleIcon /> : <XCircleIcon />}
|
||||
{enabled ? <Trans>Enabled</Trans> : <Trans>Disabled</Trans>}
|
||||
</p>
|
||||
|
||||
<AIForm />
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { CheckCircleIcon, InfoIcon, XCircleIcon } from "@phosphor-icons/react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useMemo } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import type { AIProvider } from "@/integrations/ai/types";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Combobox, type ComboboxOption } from "@/components/ui/combobox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useAIStore } from "@/integrations/ai/store";
|
||||
import { orpc } from "@/integrations/orpc/client";
|
||||
import { getOrpcErrorMessage } from "@/utils/error-message";
|
||||
import { cn } from "@/utils/style";
|
||||
|
||||
type AIProviderOption = ComboboxOption<AIProvider> & { defaultBaseURL: string };
|
||||
|
||||
const providerOptions: AIProviderOption[] = [
|
||||
{
|
||||
value: "openai",
|
||||
label: t({
|
||||
comment: "AI provider option label in dashboard AI settings",
|
||||
message: "OpenAI",
|
||||
}),
|
||||
keywords: ["openai", "gpt", "chatgpt"],
|
||||
defaultBaseURL: "https://api.openai.com/v1",
|
||||
},
|
||||
{
|
||||
value: "anthropic",
|
||||
label: t({
|
||||
comment: "AI provider option label in dashboard AI settings",
|
||||
message: "Anthropic Claude",
|
||||
}),
|
||||
keywords: ["anthropic", "claude", "ai"],
|
||||
defaultBaseURL: "https://api.anthropic.com/v1",
|
||||
},
|
||||
{
|
||||
value: "gemini",
|
||||
label: t({
|
||||
comment: "AI provider option label in dashboard AI settings",
|
||||
message: "Google Gemini",
|
||||
}),
|
||||
keywords: ["gemini", "google", "bard"],
|
||||
defaultBaseURL: "https://generativelanguage.googleapis.com/v1beta",
|
||||
},
|
||||
{
|
||||
value: "vercel-ai-gateway",
|
||||
label: t({
|
||||
comment: "AI provider option label in dashboard AI settings",
|
||||
message: "Vercel AI Gateway",
|
||||
}),
|
||||
keywords: ["vercel", "gateway", "ai"],
|
||||
defaultBaseURL: "https://ai-gateway.vercel.sh/v1/ai",
|
||||
},
|
||||
{
|
||||
value: "openrouter",
|
||||
label: t({
|
||||
comment: "AI provider option label in dashboard AI settings",
|
||||
message: "OpenRouter",
|
||||
}),
|
||||
keywords: ["openrouter", "router", "multi", "proxy"],
|
||||
defaultBaseURL: "https://openrouter.ai/api/v1",
|
||||
},
|
||||
{
|
||||
value: "ollama",
|
||||
label: t({
|
||||
comment: "AI provider option label in dashboard AI settings",
|
||||
message: "Ollama",
|
||||
}),
|
||||
keywords: ["ollama", "ai", "local"],
|
||||
defaultBaseURL: "https://ollama.com/api",
|
||||
},
|
||||
];
|
||||
|
||||
function AIForm() {
|
||||
const { set, model, apiKey, baseURL, provider, enabled, testStatus } = useAIStore();
|
||||
|
||||
const selectedOption = useMemo(() => {
|
||||
return providerOptions.find((option) => option.value === provider);
|
||||
}, [provider]);
|
||||
|
||||
const { mutate: testConnection, isPending: isTesting } = useMutation(orpc.ai.testConnection.mutationOptions());
|
||||
|
||||
const handleProviderChange = (value: AIProvider | null) => {
|
||||
if (!value) return;
|
||||
|
||||
set((draft) => {
|
||||
draft.provider = value;
|
||||
});
|
||||
};
|
||||
|
||||
const handleTestConnection = () => {
|
||||
testConnection(
|
||||
{ provider, model, apiKey, baseURL },
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
set((draft) => {
|
||||
draft.testStatus = data ? "success" : "failure";
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
set((draft) => {
|
||||
draft.testStatus = "failure";
|
||||
});
|
||||
|
||||
toast.error(
|
||||
getOrpcErrorMessage(error, {
|
||||
byCode: {
|
||||
BAD_REQUEST: t({
|
||||
comment: "Error shown when AI provider credentials or base URL are invalid in AI settings",
|
||||
message: "Invalid AI provider configuration. Please check your settings.",
|
||||
}),
|
||||
BAD_GATEWAY: t({
|
||||
comment: "Error shown when the configured AI provider cannot be reached during connection test",
|
||||
message: "Could not reach the AI provider. Please try again.",
|
||||
}),
|
||||
},
|
||||
fallback: t({
|
||||
comment: "Fallback toast when testing AI provider connection fails",
|
||||
message: "Failed to test AI provider connection. Please try again.",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid gap-6 sm:grid-cols-2">
|
||||
<div className="flex flex-col gap-y-2">
|
||||
<Label htmlFor="ai-provider">
|
||||
<Trans>Provider</Trans>
|
||||
</Label>
|
||||
<Combobox
|
||||
id="ai-provider"
|
||||
value={provider}
|
||||
disabled={enabled}
|
||||
options={providerOptions}
|
||||
onValueChange={handleProviderChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-y-2">
|
||||
<Label htmlFor="ai-model">
|
||||
<Trans>Model</Trans>
|
||||
</Label>
|
||||
<Input
|
||||
id="ai-model"
|
||||
name="ai-model"
|
||||
type="text"
|
||||
value={model}
|
||||
disabled={enabled}
|
||||
onChange={(e) =>
|
||||
set((draft) => {
|
||||
draft.model = e.target.value;
|
||||
})
|
||||
}
|
||||
placeholder={t({
|
||||
comment: "Example model-name placeholder in AI settings",
|
||||
message: "e.g., gpt-4, claude-3-opus, gemini-pro",
|
||||
})}
|
||||
autoCorrect="off"
|
||||
autoComplete="off"
|
||||
spellCheck="false"
|
||||
autoCapitalize="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-y-2 sm:col-span-2">
|
||||
<Label htmlFor="ai-api-key">
|
||||
<Trans>API Key</Trans>
|
||||
</Label>
|
||||
<Input
|
||||
id="ai-api-key"
|
||||
name="ai-api-key"
|
||||
type="password"
|
||||
value={apiKey}
|
||||
disabled={enabled}
|
||||
onChange={(e) =>
|
||||
set((draft) => {
|
||||
draft.apiKey = e.target.value;
|
||||
})
|
||||
}
|
||||
autoCorrect="off"
|
||||
autoComplete="off"
|
||||
spellCheck="false"
|
||||
autoCapitalize="off"
|
||||
data-lpignore="true"
|
||||
data-bwignore="true"
|
||||
data-1p-ignore="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-y-2 sm:col-span-2">
|
||||
<Label htmlFor="ai-base-url">
|
||||
<Trans>Base URL (Optional)</Trans>
|
||||
</Label>
|
||||
<Input
|
||||
id="ai-base-url"
|
||||
name="ai-base-url"
|
||||
type="url"
|
||||
value={baseURL}
|
||||
disabled={enabled}
|
||||
placeholder={selectedOption?.defaultBaseURL}
|
||||
onChange={(e) =>
|
||||
set((draft) => {
|
||||
draft.baseURL = e.target.value;
|
||||
})
|
||||
}
|
||||
autoCorrect="off"
|
||||
autoComplete="off"
|
||||
spellCheck="false"
|
||||
autoCapitalize="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Button variant="outline" disabled={isTesting || enabled} onClick={handleTestConnection}>
|
||||
{isTesting ? (
|
||||
<Spinner />
|
||||
) : testStatus === "success" ? (
|
||||
<CheckCircleIcon className="text-success" />
|
||||
) : testStatus === "failure" ? (
|
||||
<XCircleIcon className="text-destructive" />
|
||||
) : null}
|
||||
<Trans>Test Connection</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AISettingsSection() {
|
||||
const aiEnabled = useAIStore((state) => state.enabled);
|
||||
const canEnableAI = useAIStore((state) => state.canEnable());
|
||||
const setAIEnabled = useAIStore((state) => state.setEnabled);
|
||||
|
||||
return (
|
||||
<section className="grid gap-6">
|
||||
<h2 className="text-lg font-semibold">
|
||||
<Trans>Artificial Intelligence</Trans>
|
||||
</h2>
|
||||
|
||||
<div className="flex items-start gap-4 rounded-md border bg-popover p-6">
|
||||
<div className="rounded-md bg-primary/10 p-2.5">
|
||||
<InfoIcon className="text-primary" size={24} />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 space-y-2">
|
||||
<h3 className="font-semibold">
|
||||
<Trans>Your data is stored locally</Trans>
|
||||
</h3>
|
||||
|
||||
<p className="leading-relaxed text-muted-foreground">
|
||||
<Trans>
|
||||
Everything entered here is stored locally on your browser. Your data is only sent to the server when
|
||||
making a request to the AI provider, and is never stored or logged on our servers.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="enable-ai">
|
||||
<Trans>Enable AI Features</Trans>
|
||||
</Label>
|
||||
<Switch id="enable-ai" checked={aiEnabled} disabled={!canEnableAI} onCheckedChange={setAIEnabled} />
|
||||
</div>
|
||||
|
||||
<p className={cn("flex items-center gap-x-2", aiEnabled ? "text-success" : "text-destructive")}>
|
||||
{aiEnabled ? <CheckCircleIcon /> : <XCircleIcon />}
|
||||
{aiEnabled ? <Trans>Enabled</Trans> : <Trans>Disabled</Trans>}
|
||||
</p>
|
||||
|
||||
<AIForm />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { CheckCircleIcon, InfoIcon, LinkSimpleIcon, XCircleIcon } from "@phosphor-icons/react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Progress, ProgressLabel, ProgressValue } from "@/components/ui/progress";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { useJobsStore } from "@/integrations/jobs/store";
|
||||
import { orpc } from "@/integrations/orpc/client";
|
||||
import { getOrpcErrorMessage } from "@/utils/error-message";
|
||||
|
||||
function RapidAPIKeyForm() {
|
||||
const { set, rapidApiKey, testStatus } = useJobsStore();
|
||||
|
||||
const { mutate: testConnection, isPending: isTesting } = useMutation(orpc.jobs.testConnection.mutationOptions());
|
||||
|
||||
const handleTestConnection = () => {
|
||||
testConnection(
|
||||
{ apiKey: rapidApiKey },
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
set((draft) => {
|
||||
draft.testStatus = data.success ? "success" : "failure";
|
||||
draft.rapidApiQuota = data.rapidApiQuota ?? null;
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
set((draft) => {
|
||||
draft.testStatus = "failure";
|
||||
draft.rapidApiQuota = null;
|
||||
});
|
||||
|
||||
toast.error(
|
||||
getOrpcErrorMessage(error, {
|
||||
byCode: {
|
||||
BAD_GATEWAY: t({
|
||||
comment: "Error shown when JSearch API connection test fails in job search settings",
|
||||
message: "Could not reach JSearch API. Check your API key and try again.",
|
||||
}),
|
||||
},
|
||||
fallback: t({
|
||||
comment: "Fallback toast when testing RapidAPI job search connection fails",
|
||||
message: "Failed to test RapidAPI connection. Please try again.",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid gap-6">
|
||||
<div className="flex flex-col gap-y-2">
|
||||
<Label htmlFor="rapidapi-key">
|
||||
<Trans>RapidAPI Key</Trans>
|
||||
</Label>
|
||||
|
||||
<Input
|
||||
id="rapidapi-key"
|
||||
name="rapidapi-key"
|
||||
type="password"
|
||||
value={rapidApiKey}
|
||||
onChange={(e) =>
|
||||
set((draft) => {
|
||||
draft.rapidApiKey = e.target.value;
|
||||
})
|
||||
}
|
||||
placeholder={t`Enter your RapidAPI key`}
|
||||
autoCorrect="off"
|
||||
autoComplete="off"
|
||||
spellCheck="false"
|
||||
autoCapitalize="off"
|
||||
data-lpignore="true"
|
||||
data-bwignore="true"
|
||||
data-1p-ignore="true"
|
||||
/>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<Trans>Get your API key from RapidAPI by subscribing to the JSearch API.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Button variant="outline" disabled={isTesting || !rapidApiKey} onClick={handleTestConnection}>
|
||||
{isTesting ? (
|
||||
<Spinner />
|
||||
) : testStatus === "success" ? (
|
||||
<CheckCircleIcon className="text-success" />
|
||||
) : testStatus === "failure" ? (
|
||||
<XCircleIcon className="text-destructive" />
|
||||
) : null}
|
||||
<Trans>Test Connection</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RapidAPIQuotaDisplay() {
|
||||
const { testStatus, rapidApiQuota } = useJobsStore();
|
||||
|
||||
if (!rapidApiQuota || testStatus !== "success") return null;
|
||||
|
||||
const { used, limit, remaining } = rapidApiQuota;
|
||||
const percent = limit > 0 ? Math.min(100, Math.round((used / limit) * 100)) : 0;
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
<Progress value={percent} id="jobs-quota-progress" className="w-full max-w-md">
|
||||
<ProgressLabel>
|
||||
<Trans>API Usage</Trans>
|
||||
</ProgressLabel>
|
||||
<ProgressValue />
|
||||
</Progress>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<Trans>
|
||||
{used} of {limit} requests used ({remaining} remaining)
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function JobSearchSettingsSection() {
|
||||
return (
|
||||
<section className="grid gap-6">
|
||||
<h2 className="text-xl font-semibold">
|
||||
<Trans>Job Search</Trans>
|
||||
</h2>
|
||||
|
||||
<div className="flex items-start gap-4 rounded-md border bg-popover p-6">
|
||||
<div className="rounded-md bg-primary/10 p-2.5">
|
||||
<InfoIcon className="text-primary" size={24} />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 space-y-2">
|
||||
<h3 className="font-semibold">
|
||||
<Trans>What is JSearch API?</Trans>
|
||||
</h3>
|
||||
|
||||
<p className="leading-relaxed text-muted-foreground">
|
||||
<Trans>
|
||||
JSearch aggregates job listings from multiple job boards. You can filter by country, date posted, job
|
||||
type, remote options, and experience level.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<Button
|
||||
variant="link"
|
||||
nativeButton={false}
|
||||
render={
|
||||
<a
|
||||
href="https://rapidapi.com/letscrape-6bRBa3QguO5/api/jsearch"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<LinkSimpleIcon />
|
||||
<Trans>API Reference</Trans>
|
||||
</a>
|
||||
}
|
||||
/>
|
||||
|
||||
<p className="leading-relaxed text-muted-foreground">
|
||||
<Trans>
|
||||
Your RapidAPI key is stored locally on your browser. It is only sent to the server when making a request
|
||||
to search for jobs, and is never stored or logged on our servers.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<RapidAPIKeyForm />
|
||||
<RapidAPIQuotaDisplay />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { BrainIcon } from "@phosphor-icons/react";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { motion } from "motion/react";
|
||||
import { useIsClient } from "usehooks-ts";
|
||||
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
|
||||
import { DashboardHeader } from "../../-components/header";
|
||||
import { AISettingsSection } from "./-components/ai-section";
|
||||
import { JobSearchSettingsSection } from "./-components/job-search-section";
|
||||
|
||||
export const Route = createFileRoute("/dashboard/settings/integrations")({
|
||||
component: RouteComponent,
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
const isClient = useIsClient();
|
||||
|
||||
if (!isClient) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<DashboardHeader icon={BrainIcon} title={t`Integrations`} />
|
||||
|
||||
<Separator />
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, ease: "easeOut" }}
|
||||
className="grid max-w-xl gap-8 will-change-[transform,opacity]"
|
||||
>
|
||||
<AISettingsSection />
|
||||
|
||||
<Separator />
|
||||
|
||||
<JobSearchSettingsSection />
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,206 +1,7 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { BriefcaseIcon, CheckCircleIcon, InfoIcon, LinkSimpleIcon, XCircleIcon } from "@phosphor-icons/react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { motion } from "motion/react";
|
||||
import { toast } from "sonner";
|
||||
import { match } from "ts-pattern";
|
||||
import { useIsClient } from "usehooks-ts";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Progress, ProgressLabel, ProgressValue } from "@/components/ui/progress";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { useJobsStore } from "@/integrations/jobs/store";
|
||||
import { orpc } from "@/integrations/orpc/client";
|
||||
import { getOrpcErrorMessage } from "@/utils/error-message";
|
||||
|
||||
import { DashboardHeader } from "../-components/header";
|
||||
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute("/dashboard/settings/job-search")({
|
||||
component: RouteComponent,
|
||||
beforeLoad: () => {
|
||||
throw redirect({ to: "/dashboard/settings/integrations", replace: true });
|
||||
},
|
||||
});
|
||||
|
||||
function RapidAPIKeyForm() {
|
||||
const { set, rapidApiKey, testStatus } = useJobsStore();
|
||||
|
||||
const { mutate: testConnection, isPending: isTesting } = useMutation(orpc.jobs.testConnection.mutationOptions());
|
||||
|
||||
const handleApiKeyChange = (value: string) => {
|
||||
set((draft) => {
|
||||
draft.rapidApiKey = value;
|
||||
});
|
||||
};
|
||||
|
||||
const handleTestConnection = () => {
|
||||
testConnection(
|
||||
{ apiKey: rapidApiKey },
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
set((draft) => {
|
||||
draft.testStatus = data.success ? "success" : "failure";
|
||||
draft.rapidApiQuota = data.rapidApiQuota ?? null;
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
set((draft) => {
|
||||
draft.testStatus = "failure";
|
||||
draft.rapidApiQuota = null;
|
||||
});
|
||||
|
||||
toast.error(
|
||||
getOrpcErrorMessage(error, {
|
||||
byCode: {
|
||||
BAD_GATEWAY: t({
|
||||
comment: "Error shown when JSearch API connection test fails in job search settings",
|
||||
message: "Could not reach JSearch API. Check your API key and try again.",
|
||||
}),
|
||||
},
|
||||
fallback: t({
|
||||
comment: "Fallback toast when testing RapidAPI job search connection fails",
|
||||
message: "Failed to test RapidAPI connection. Please try again.",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid gap-6">
|
||||
<div className="flex flex-col gap-y-2">
|
||||
<Label htmlFor="rapidapi-key">
|
||||
<Trans>RapidAPI Key</Trans>
|
||||
</Label>
|
||||
|
||||
<Input
|
||||
id="rapidapi-key"
|
||||
name="rapidapi-key"
|
||||
type="password"
|
||||
value={rapidApiKey}
|
||||
onChange={(e) => handleApiKeyChange(e.target.value)}
|
||||
placeholder={t`Enter your RapidAPI key`}
|
||||
autoCorrect="off"
|
||||
autoComplete="off"
|
||||
spellCheck="false"
|
||||
autoCapitalize="off"
|
||||
data-lpignore="true"
|
||||
data-bwignore="true"
|
||||
data-1p-ignore="true"
|
||||
/>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<Trans>Get your API key from RapidAPI by subscribing to the JSearch API.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Button variant="outline" disabled={isTesting || !rapidApiKey} onClick={handleTestConnection}>
|
||||
{match({ isTesting, testStatus })
|
||||
.with({ isTesting: true }, () => <Spinner />)
|
||||
.with({ isTesting: false, testStatus: "success" }, () => <CheckCircleIcon className="text-success" />)
|
||||
.with({ isTesting: false, testStatus: "failure" }, () => <XCircleIcon className="text-destructive" />)
|
||||
.otherwise(() => null)}
|
||||
<Trans>Test Connection</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RapidAPIQuotaDisplay() {
|
||||
const { testStatus, rapidApiQuota } = useJobsStore();
|
||||
|
||||
if (!rapidApiQuota || testStatus !== "success") return null;
|
||||
|
||||
const { used, limit, remaining } = rapidApiQuota;
|
||||
const percent = limit > 0 ? Math.min(100, Math.round((used / limit) * 100)) : 0;
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
<Progress value={percent} id="jobs-quota-progress" className="w-full max-w-md">
|
||||
<ProgressLabel>
|
||||
<Trans>API Usage</Trans>
|
||||
</ProgressLabel>
|
||||
<ProgressValue />
|
||||
</Progress>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<Trans>
|
||||
{used} of {limit} requests used ({remaining} remaining)
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RouteComponent() {
|
||||
const isClient = useIsClient();
|
||||
|
||||
if (!isClient) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<DashboardHeader icon={BriefcaseIcon} title={t`Job Search API`} />
|
||||
|
||||
<Separator />
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="grid max-w-xl gap-6"
|
||||
>
|
||||
<div className="flex items-start gap-4 rounded-md border bg-popover p-6">
|
||||
<div className="rounded-md bg-primary/10 p-2.5">
|
||||
<InfoIcon className="text-primary" size={24} />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 space-y-2">
|
||||
<h3 className="font-semibold">
|
||||
<Trans>What is JSearch API?</Trans>
|
||||
</h3>
|
||||
|
||||
<p className="leading-relaxed text-muted-foreground">
|
||||
<Trans>
|
||||
JSearch aggregates job listings from multiple job boards using Google for Jobs. You can filter by
|
||||
country (ISO alpha-2 code), date posted, job type, remote options, and experience level.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<Button
|
||||
variant="link"
|
||||
nativeButton={false}
|
||||
render={
|
||||
<a
|
||||
href="https://rapidapi.com/letscrape-6bRBa3QguO5/api/jsearch"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<LinkSimpleIcon />
|
||||
<Trans>JSearch API Documentation</Trans>
|
||||
</a>
|
||||
}
|
||||
/>
|
||||
|
||||
<p className="leading-relaxed text-muted-foreground">
|
||||
<Trans>
|
||||
Your RapidAPI key is stored locally on your browser. It is only sent to the server when making a request
|
||||
to search for jobs, and is never stored or logged on our servers.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<RapidAPIKeyForm />
|
||||
<RapidAPIQuotaDisplay />
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user