mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-08-22 22:32:17 +10:00
v5.2.0: undo/redo, version history, embedded AI assistant, mobile builder & more (#3205)
This commit is contained in:
@@ -114,7 +114,7 @@ export function PasskeysSection() {
|
||||
|
||||
return (
|
||||
<m.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
initial={{ y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.2, delay: 0.3, ease: "easeOut" }}
|
||||
className="will-change-[transform,opacity]"
|
||||
|
||||
@@ -25,7 +25,7 @@ export function PasswordSection() {
|
||||
|
||||
return (
|
||||
<m.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
initial={{ y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.2, delay: 0.1, ease: "easeOut" }}
|
||||
className="flex items-center justify-between gap-x-4 will-change-[transform,opacity]"
|
||||
|
||||
@@ -36,7 +36,7 @@ export function SocialProviderSection({ provider, name, animationDelay = 0 }: So
|
||||
return (
|
||||
<m.div
|
||||
className="will-change-[transform,opacity]"
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
initial={{ y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.2, delay: animationDelay, ease: "easeOut" }}
|
||||
>
|
||||
|
||||
@@ -30,7 +30,7 @@ export function TwoFactorSection() {
|
||||
return (
|
||||
<m.div
|
||||
className="will-change-[transform,opacity]"
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
initial={{ y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.2, delay: 0.2, ease: "easeOut" }}
|
||||
>
|
||||
|
||||
@@ -10,7 +10,7 @@ export function AuthenticationSettingsPage() {
|
||||
|
||||
return (
|
||||
<m.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
initial={{ y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, ease: "easeOut" }}
|
||||
className="grid max-w-xl gap-4 will-change-[transform,opacity]"
|
||||
@@ -21,14 +21,14 @@ export function AuthenticationSettingsPage() {
|
||||
|
||||
<PasskeysSection />
|
||||
|
||||
{"google" in enabledProviders && <SocialProviderSection provider="google" animationDelay={0.4} />}
|
||||
{"google" in enabledProviders && <SocialProviderSection provider="google" animationDelay={0.04} />}
|
||||
|
||||
{"github" in enabledProviders && <SocialProviderSection provider="github" animationDelay={0.5} />}
|
||||
{"github" in enabledProviders && <SocialProviderSection provider="github" animationDelay={0.08} />}
|
||||
|
||||
{"linkedin" in enabledProviders && <SocialProviderSection provider="linkedin" animationDelay={0.6} />}
|
||||
{"linkedin" in enabledProviders && <SocialProviderSection provider="linkedin" animationDelay={0.12} />}
|
||||
|
||||
{"custom" in enabledProviders && (
|
||||
<SocialProviderSection provider="custom" animationDelay={0.7} name={enabledProviders.custom} />
|
||||
<SocialProviderSection provider="custom" animationDelay={0.16} name={enabledProviders.custom} />
|
||||
)}
|
||||
</m.div>
|
||||
);
|
||||
|
||||
@@ -17,11 +17,12 @@ import { Spinner } from "@reactive-resume/ui/components/spinner";
|
||||
import { Switch } from "@reactive-resume/ui/components/switch";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { Combobox } from "@/components/ui/combobox";
|
||||
import { useHasUsableAiProvider } from "@/features/settings/integrations/hooks/use-has-usable-ai-provider";
|
||||
import { getOrpcErrorMessage } from "@/libs/error-message";
|
||||
import { orpc } from "@/libs/orpc/client";
|
||||
|
||||
type SavedProvider = RouterOutput["aiProviders"]["list"][number];
|
||||
type AIProviderOption = ComboboxOption<AIProvider> & { defaultBaseURL: string };
|
||||
type AIProviderOption = ComboboxOption<AIProvider> & { defaultBaseURL: string; defaultModel: string };
|
||||
|
||||
type ProviderRowProps = {
|
||||
provider: SavedProvider;
|
||||
@@ -33,71 +34,86 @@ const providerOptions: AIProviderOption[] = [
|
||||
label: t`OpenAI`,
|
||||
keywords: ["openai", "gpt", "chatgpt"],
|
||||
defaultBaseURL: AI_PROVIDER_DEFAULT_BASE_URLS.openai,
|
||||
defaultModel: "gpt-4.1",
|
||||
},
|
||||
{
|
||||
value: "anthropic",
|
||||
label: t`Anthropic Claude`,
|
||||
keywords: ["anthropic", "claude", "ai"],
|
||||
defaultBaseURL: AI_PROVIDER_DEFAULT_BASE_URLS.anthropic,
|
||||
defaultModel: "claude-3-5-sonnet-latest",
|
||||
},
|
||||
{
|
||||
value: "gemini",
|
||||
label: t`Google Gemini`,
|
||||
keywords: ["gemini", "google"],
|
||||
defaultBaseURL: AI_PROVIDER_DEFAULT_BASE_URLS.gemini,
|
||||
defaultModel: "gemini-2.0-flash",
|
||||
},
|
||||
{
|
||||
value: "vercel-ai-gateway",
|
||||
label: t`Vercel AI Gateway`,
|
||||
keywords: ["vercel", "gateway", "ai"],
|
||||
defaultBaseURL: AI_PROVIDER_DEFAULT_BASE_URLS["vercel-ai-gateway"],
|
||||
defaultModel: "openai/gpt-4.1",
|
||||
},
|
||||
{
|
||||
value: "openrouter",
|
||||
label: t`OpenRouter`,
|
||||
keywords: ["openrouter", "router"],
|
||||
defaultBaseURL: AI_PROVIDER_DEFAULT_BASE_URLS.openrouter,
|
||||
defaultModel: "openai/gpt-4.1",
|
||||
},
|
||||
{
|
||||
value: "ollama",
|
||||
label: t`Ollama`,
|
||||
keywords: ["ollama", "local"],
|
||||
defaultBaseURL: AI_PROVIDER_DEFAULT_BASE_URLS.ollama,
|
||||
defaultModel: "llama3.1",
|
||||
},
|
||||
{
|
||||
value: "openai-compatible",
|
||||
label: t`OpenAI-compatible`,
|
||||
keywords: ["compatible", "custom", "gateway"],
|
||||
defaultBaseURL: AI_PROVIDER_DEFAULT_BASE_URLS["openai-compatible"],
|
||||
defaultModel: "",
|
||||
},
|
||||
];
|
||||
|
||||
// Prefill Base URL + Model from the provider's known defaults when the provider changes.
|
||||
function providerDefaults(provider: AIProvider) {
|
||||
const option = providerOptions.find((entry) => entry.value === provider);
|
||||
return {
|
||||
baseURL: option?.defaultBaseURL ?? AI_PROVIDER_DEFAULT_BASE_URLS[provider] ?? "",
|
||||
model: option?.defaultModel ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
const emptyForm = {
|
||||
label: "",
|
||||
provider: "openai" as AIProvider,
|
||||
model: "",
|
||||
baseURL: "",
|
||||
apiKey: "",
|
||||
...providerDefaults("openai"),
|
||||
};
|
||||
|
||||
function statusBadge(provider: SavedProvider) {
|
||||
if (provider.testStatus === "success") {
|
||||
return (
|
||||
<Badge className="bg-emerald-600 text-white">
|
||||
<Trans>Tested</Trans>
|
||||
<Trans>Connected</Trans>
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
if (provider.testStatus === "failure") {
|
||||
return (
|
||||
<Badge variant="destructive">
|
||||
<Trans>Failed</Trans>
|
||||
<Trans>Connection failed</Trans>
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Badge variant="secondary">
|
||||
<Trans>Untested</Trans>
|
||||
<Trans>Not connected</Trans>
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
@@ -220,15 +236,67 @@ function ProviderRow({ provider }: ProviderRowProps) {
|
||||
);
|
||||
}
|
||||
|
||||
type SaveResult = { ok: boolean; message: string };
|
||||
|
||||
function CreateProviderForm() {
|
||||
const queryClient = useQueryClient();
|
||||
const [form, setForm] = useState(emptyForm);
|
||||
const [result, setResult] = useState<SaveResult | null>(null);
|
||||
const selectedOption = useMemo(
|
||||
() => providerOptions.find((option) => option.value === form.provider),
|
||||
[form.provider],
|
||||
);
|
||||
const canCreate = form.label.trim() && form.model.trim() && form.apiKey.trim();
|
||||
const { mutate: createProvider, isPending } = useMutation(orpc.aiProviders.create.mutationOptions());
|
||||
const invalidate = () => queryClient.invalidateQueries({ queryKey: orpc.aiProviders.list.queryKey() });
|
||||
|
||||
const { mutateAsync: createProvider, isPending: isCreating } = useMutation(orpc.aiProviders.create.mutationOptions());
|
||||
const { mutateAsync: testProvider, isPending: isTesting } = useMutation(orpc.aiProviders.test.mutationOptions());
|
||||
const { mutateAsync: enableProvider, isPending: isEnabling } = useMutation(orpc.aiProviders.update.mutationOptions());
|
||||
const isSaving = isCreating || isTesting || isEnabling;
|
||||
|
||||
// Model/label are prefilled from provider defaults, so step 1 (Provider + API Key) is enough to save.
|
||||
const model = form.model.trim();
|
||||
const label = form.label.trim() || String(selectedOption?.label ?? form.provider);
|
||||
const canSave = Boolean(form.apiKey.trim() && model);
|
||||
|
||||
const save = async () => {
|
||||
setResult(null);
|
||||
try {
|
||||
const created = await createProvider({
|
||||
label,
|
||||
provider: form.provider,
|
||||
model,
|
||||
baseURL: form.baseURL.trim(),
|
||||
apiKey: form.apiKey.trim(),
|
||||
});
|
||||
|
||||
// Test on save: verify the connection immediately instead of leaving it to a manual step.
|
||||
const tested = await testProvider({ id: created.id });
|
||||
if (tested.testStatus === "success") {
|
||||
await enableProvider({ id: created.id, enabled: true });
|
||||
setForm(emptyForm);
|
||||
setResult({ ok: true, message: t`Connection verified — provider is ready to use.` });
|
||||
} else {
|
||||
// ponytail: provider stays persisted on failure so it shows in the list; a re-save creates a new row.
|
||||
setResult({
|
||||
ok: false,
|
||||
message: tested.testError ?? t`Could not verify the connection. Check the API key, model, and base URL.`,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
setResult({
|
||||
ok: false,
|
||||
message: getOrpcErrorMessage(error, {
|
||||
byCode: {
|
||||
PRECONDITION_FAILED: t`AI providers require REDIS_URL and ENCRYPTION_SECRET to be configured.`,
|
||||
BAD_REQUEST: t`Invalid AI provider configuration.`,
|
||||
},
|
||||
fallback: t`Failed to save AI provider.`,
|
||||
}),
|
||||
});
|
||||
} finally {
|
||||
void invalidate();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-md border bg-card p-4">
|
||||
@@ -241,19 +309,7 @@ function CreateProviderForm() {
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ai-label">
|
||||
<Trans>Label</Trans>
|
||||
</Label>
|
||||
<Input
|
||||
id="ai-label"
|
||||
value={form.label}
|
||||
onChange={(event) => setForm((current) => ({ ...current, label: event.target.value }))}
|
||||
placeholder={t`Work OpenAI`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ai-provider">
|
||||
<Trans>Provider</Trans>
|
||||
@@ -265,43 +321,12 @@ function CreateProviderForm() {
|
||||
options={providerOptions}
|
||||
onValueChange={(provider) => {
|
||||
if (!provider) return;
|
||||
setForm((current) => ({ ...current, provider }));
|
||||
setForm((current) => ({ ...current, provider, ...providerDefaults(provider) }));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ai-model">
|
||||
<Trans>Model</Trans>
|
||||
</Label>
|
||||
<Input
|
||||
id="ai-model"
|
||||
value={form.model}
|
||||
onChange={(event) => setForm((current) => ({ ...current, model: event.target.value }))}
|
||||
placeholder={t`gpt-4.1`}
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck="false"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ai-base-url">
|
||||
<Trans>Base URL</Trans>
|
||||
</Label>
|
||||
<Input
|
||||
id="ai-base-url"
|
||||
type="url"
|
||||
value={form.baseURL}
|
||||
onChange={(event) => setForm((current) => ({ ...current, baseURL: event.target.value }))}
|
||||
placeholder={selectedOption?.defaultBaseURL || t`https://gateway.example.com/v1`}
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck="false"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 md:col-span-2">
|
||||
<Label htmlFor="ai-api-key">
|
||||
<Trans>API Key</Trans>
|
||||
</Label>
|
||||
@@ -318,42 +343,81 @@ function CreateProviderForm() {
|
||||
data-1p-ignore="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<details className="rounded-md border bg-background/50 px-3 py-2 [&_summary]:cursor-pointer">
|
||||
<summary className="font-medium text-muted-foreground text-sm">
|
||||
<Trans>Advanced</Trans>
|
||||
</summary>
|
||||
|
||||
<div className="mt-3 grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ai-label">
|
||||
<Trans>Label</Trans>
|
||||
</Label>
|
||||
<Input
|
||||
id="ai-label"
|
||||
value={form.label}
|
||||
onChange={(event) => setForm((current) => ({ ...current, label: event.target.value }))}
|
||||
placeholder={selectedOption?.label ? String(selectedOption.label) : t`Work OpenAI`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ai-model">
|
||||
<Trans>Model</Trans>
|
||||
</Label>
|
||||
<Input
|
||||
id="ai-model"
|
||||
value={form.model}
|
||||
onChange={(event) => setForm((current) => ({ ...current, model: event.target.value }))}
|
||||
placeholder={t`gpt-4.1`}
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck="false"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 md:col-span-2">
|
||||
<Label htmlFor="ai-base-url">
|
||||
<Trans>Base URL</Trans>
|
||||
</Label>
|
||||
<Input
|
||||
id="ai-base-url"
|
||||
type="url"
|
||||
value={form.baseURL}
|
||||
onChange={(event) => setForm((current) => ({ ...current, baseURL: event.target.value }))}
|
||||
placeholder={selectedOption?.defaultBaseURL || t`https://gateway.example.com/v1`}
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck="false"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex justify-end">
|
||||
<Button
|
||||
disabled={!canCreate || isPending}
|
||||
onClick={() =>
|
||||
createProvider(
|
||||
{
|
||||
label: form.label.trim(),
|
||||
provider: form.provider,
|
||||
model: form.model.trim(),
|
||||
baseURL: form.baseURL.trim(),
|
||||
apiKey: form.apiKey.trim(),
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setForm(emptyForm);
|
||||
toast.success(t`AI provider saved. Test it before use.`);
|
||||
void queryClient.invalidateQueries({ queryKey: orpc.aiProviders.list.queryKey() });
|
||||
},
|
||||
onError: (error) =>
|
||||
toast.error(
|
||||
getOrpcErrorMessage(error, {
|
||||
byCode: {
|
||||
PRECONDITION_FAILED: t`AI providers require REDIS_URL and ENCRYPTION_SECRET to be configured.`,
|
||||
BAD_REQUEST: t`Invalid AI provider configuration.`,
|
||||
},
|
||||
fallback: t`Failed to save AI provider.`,
|
||||
}),
|
||||
),
|
||||
},
|
||||
)
|
||||
}
|
||||
{result ? (
|
||||
<div
|
||||
className={cn(
|
||||
"mt-4 flex items-start gap-2 rounded-md border p-3 text-sm",
|
||||
result.ok
|
||||
? "border-emerald-300 bg-emerald-50 text-emerald-950 dark:bg-emerald-950/20 dark:text-emerald-200"
|
||||
: "border-rose-300 bg-rose-50 text-rose-950 dark:bg-rose-950/20 dark:text-rose-200",
|
||||
)}
|
||||
>
|
||||
{isPending ? <Spinner /> : <KeyIcon />}
|
||||
<Trans>Save Provider</Trans>
|
||||
{result.ok ? (
|
||||
<CheckCircleIcon className="mt-0.5 shrink-0 text-emerald-600" />
|
||||
) : (
|
||||
<WarningCircleIcon className="mt-0.5 shrink-0 text-rose-600" />
|
||||
)}
|
||||
<span>{result.message}</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-4 flex justify-end">
|
||||
<Button disabled={!canSave || isSaving} onClick={() => void save()}>
|
||||
{isSaving ? <Spinner /> : <KeyIcon />}
|
||||
{isTesting ? <Trans>Testing…</Trans> : <Trans>Save & Test Provider</Trans>}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -362,7 +426,7 @@ function CreateProviderForm() {
|
||||
|
||||
export function AISettingsSection() {
|
||||
const { data: providers, isLoading, error } = useQuery(orpc.aiProviders.list.queryOptions());
|
||||
const hasUsableProvider = providers?.some((provider) => provider.enabled && provider.testStatus === "success");
|
||||
const { hasUsableProvider } = useHasUsableAiProvider();
|
||||
const isConfigError = isAiProviderConfigError(error);
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { orpc } from "@/libs/orpc/client";
|
||||
|
||||
/**
|
||||
* Single source of truth for "is an AI provider ready to use" (enabled AND its connection test succeeded).
|
||||
* Replaces the predicate that was duplicated across the import dialog, agent setup, and AI settings.
|
||||
*/
|
||||
export function useHasUsableAiProvider() {
|
||||
const { data: providers, isLoading } = useQuery(orpc.aiProviders.list.queryOptions());
|
||||
const usableProviders = (providers ?? []).filter((provider) => provider.enabled && provider.testStatus === "success");
|
||||
|
||||
return {
|
||||
hasUsableProvider: usableProviders.length > 0,
|
||||
usableProviders,
|
||||
isLoading,
|
||||
};
|
||||
}
|
||||
@@ -9,7 +9,7 @@ export function IntegrationsSettingsPage() {
|
||||
|
||||
return (
|
||||
<m.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
initial={{ y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, ease: "easeOut" }}
|
||||
className="grid max-w-4xl gap-8 will-change-[transform,opacity]"
|
||||
|
||||
@@ -67,7 +67,7 @@ export function ApiKeysSettingsPage() {
|
||||
|
||||
return (
|
||||
<m.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
initial={{ y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, ease: "easeOut" }}
|
||||
className="grid max-w-xl gap-6 will-change-[transform,opacity]"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { TrashSimpleIcon } from "@phosphor-icons/react";
|
||||
import { DownloadSimpleIcon, TrashSimpleIcon } from "@phosphor-icons/react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { m } from "motion/react";
|
||||
@@ -8,6 +8,7 @@ import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { Input } from "@reactive-resume/ui/components/input";
|
||||
import { downloadWithAnchor, generateFilename } from "@reactive-resume/utils/file";
|
||||
import { useConfirm } from "@/hooks/use-confirm";
|
||||
import { authClient } from "@/libs/auth/client";
|
||||
import { getReadableErrorMessage } from "@/libs/error-message";
|
||||
@@ -23,6 +24,27 @@ export function DangerZoneSettingsPage() {
|
||||
|
||||
const { mutate: deleteAccount } = useMutation(orpc.auth.deleteAccount.mutationOptions());
|
||||
|
||||
const { mutate: exportData, isPending: isExporting } = useMutation(
|
||||
orpc.auth.exportData.mutationOptions({
|
||||
onSuccess: (data) => {
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
|
||||
downloadWithAnchor(blob, generateFilename("reactive-resume-export", "json"));
|
||||
toast.success(t`Your data has been exported successfully.`);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(
|
||||
getReadableErrorMessage(
|
||||
error,
|
||||
t({
|
||||
comment: "Fallback toast when data export fails",
|
||||
message: "Failed to export your data. Please try again.",
|
||||
}),
|
||||
),
|
||||
);
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const handleDeleteAccount = async () => {
|
||||
const confirmed = await confirm(t`Are you sure you want to delete your account?`, {
|
||||
description: t`This action cannot be undone. All your data will be permanently deleted.`,
|
||||
@@ -63,11 +85,31 @@ export function DangerZoneSettingsPage() {
|
||||
|
||||
return (
|
||||
<m.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
initial={{ 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="grid gap-3">
|
||||
<p className="leading-relaxed">
|
||||
<Trans>Download a copy of all your data, including your profile and every resume, as a JSON file.</Trans>
|
||||
</p>
|
||||
|
||||
<m.div
|
||||
className="justify-self-start will-change-transform"
|
||||
whileHover={{ y: -1, scale: 1.01 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
transition={{ duration: 0.14, ease: "easeOut" }}
|
||||
>
|
||||
<Button variant="outline" onClick={() => exportData(undefined)} disabled={isExporting}>
|
||||
<DownloadSimpleIcon />
|
||||
<Trans>Export my data</Trans>
|
||||
</Button>
|
||||
</m.div>
|
||||
</div>
|
||||
|
||||
<hr className="border-border" />
|
||||
|
||||
<p className="leading-relaxed">
|
||||
<Trans>To delete your account, you need to enter the confirmation text and click the button below.</Trans>
|
||||
</p>
|
||||
|
||||
@@ -9,7 +9,7 @@ import { ThemeCombobox } from "@/features/theme/combobox";
|
||||
export function PreferencesSettingsPage() {
|
||||
return (
|
||||
<m.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
initial={{ y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, ease: "easeOut" }}
|
||||
className="grid max-w-xl gap-6 will-change-[transform,opacity]"
|
||||
|
||||
@@ -3,7 +3,7 @@ import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { CheckIcon, WarningIcon } from "@phosphor-icons/react";
|
||||
import { useStore } from "@tanstack/react-form";
|
||||
import { useRouter } from "@tanstack/react-router";
|
||||
import { useRouteContext, useRouter } from "@tanstack/react-router";
|
||||
import { AnimatePresence, m } from "motion/react";
|
||||
import { toast } from "sonner";
|
||||
import z from "zod";
|
||||
@@ -33,6 +33,8 @@ type Props = {
|
||||
|
||||
export function ProfileSettingsPage({ session }: Props) {
|
||||
const router = useRouter();
|
||||
const context = useRouteContext({ strict: false });
|
||||
const smtpEnabled = context.flags?.smtpEnabled ?? false;
|
||||
|
||||
const form = useAppForm({
|
||||
defaultValues: {
|
||||
@@ -130,7 +132,7 @@ export function ProfileSettingsPage({ session }: Props) {
|
||||
|
||||
return (
|
||||
<m.form
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
initial={{ y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, ease: "easeOut" }}
|
||||
className="grid max-w-xl gap-6 will-change-[transform,opacity]"
|
||||
@@ -226,7 +228,7 @@ export function ProfileSettingsPage({ session }: Props) {
|
||||
<CheckIcon />
|
||||
<Trans>Verified</Trans>
|
||||
</p>
|
||||
) : (
|
||||
) : smtpEnabled ? (
|
||||
<p className="flex items-center gap-x-1.5 text-amber-600 text-xs">
|
||||
<WarningIcon className="size-3.5" />
|
||||
<Trans>Unverified</Trans>
|
||||
@@ -239,6 +241,10 @@ export function ProfileSettingsPage({ session }: Props) {
|
||||
<Trans>Resend verification email</Trans>
|
||||
</Button>
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
<Trans>Email delivery isn't configured on this instance, so verification is disabled.</Trans>
|
||||
</p>
|
||||
)}
|
||||
</FormItem>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user