polished and testing exports

This commit is contained in:
MatejGhub
2026-09-20 10:48:19 +02:00
parent 9b4457d96e
commit dd7db5032a
17 changed files with 269 additions and 53 deletions
@@ -6,6 +6,7 @@
import { unwrapList, OPTION_LIST_LIMIT } from "$lib/list";
import {
canConfirmExport,
fieldKeysFromExportTemplate,
mapActiveExportFeedOptions,
resolveExportDialogBody,
type ExportFeedOption
@@ -28,6 +29,8 @@
let exportFeeds = $state<ExportFeedOption[]>([]);
let selectedFeedId = $state("");
let selectedFieldKeys = $state<string[]>([]);
let fieldsLoading = $state(false);
let loading = $state(false);
let error = $state("");
@@ -42,11 +45,13 @@
feedCount: exportFeeds.length
})
);
const selectedFeed = $derived(exportFeeds.find((f) => f.id === selectedFeedId) ?? null);
async function fetchFeeds() {
loading = true;
error = "";
selectedFeedId = "";
selectedFieldKeys = [];
try {
const payload = await api<ListResponse<ExportFeed & { format?: string | null; is_active?: boolean | null }>>(
`/api/export-feeds?limit=${OPTION_LIST_LIMIT}`
@@ -69,9 +74,36 @@
}
});
$effect(() => {
const id = selectedFeedId;
if (!open || !id) {
selectedFieldKeys = [];
fieldsLoading = false;
return;
}
let cancelled = false;
fieldsLoading = true;
void (async () => {
try {
const full = await api<{ template?: unknown }>(`/api/export-feeds/${id}`);
if (cancelled) return;
selectedFieldKeys = fieldKeysFromExportTemplate(full.template);
} catch {
if (cancelled) return;
selectedFieldKeys = [];
} finally {
if (!cancelled) fieldsLoading = false;
}
})();
return () => {
cancelled = true;
};
});
function close() {
open = false;
selectedFeedId = "";
selectedFieldKeys = [];
error = "";
onClose?.();
}
@@ -88,30 +120,35 @@
close();
void goto("/export-feeds");
}
function goEditFeed() {
close();
void goto("/export-feeds");
}
</script>
<!-- Mount only while open — avoids nested bind:open / one-way $bindable dropping open. -->
{#if open}
<Dialog
open={true}
class="sm:max-w-[425px] sm:min-w-0"
class="sm:max-w-md sm:min-w-0"
title={i18n.t("products.export.title")}
description={i18n.t("export.dialog.description", { count: selectedProductIds.length })}
onClose={close}
>
{#if dialogBody === "loading"}
<div class="flex items-center justify-center py-8">
<Spinner class="h-8 w-8 text-muted-foreground" />
<div class="flex items-center justify-center py-6">
<Spinner class="h-6 w-6 text-muted-foreground" />
</div>
{:else if dialogBody === "error"}
<div class="space-y-3 py-2">
<div class="space-y-3">
<p class="text-sm text-destructive">{error}</p>
<Button variant="outline" size="sm" onclick={goCreateFeed}>
{i18n.t("export.dialog.createFeedCta")}
</Button>
</div>
{:else if dialogBody === "empty"}
<div class="space-y-3 py-2" role="status">
<div class="space-y-3" role="status">
<p class="text-sm font-medium text-foreground">{i18n.t("export.dialog.emptyTitle")}</p>
<p class="text-sm leading-relaxed text-muted-foreground">{i18n.t("export.dialog.emptyMessage")}</p>
<Button size="sm" onclick={goCreateFeed}>
@@ -119,7 +156,7 @@
</Button>
</div>
{:else}
<div class="grid gap-4 py-4">
<div class="space-y-3">
{#if error}
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
@@ -134,6 +171,39 @@
</Select>
<p class="text-xs text-muted-foreground">{i18n.t("export.dialog.help")}</p>
</div>
{#if selectedFeed}
<div class="rounded-md border border-border bg-muted/30 p-3">
<div class="flex items-start justify-between gap-3">
<p class="min-w-0 text-xs font-medium leading-snug text-foreground">
{#if fieldsLoading}
{i18n.t("export.dialog.fieldsLoading")}
{:else if selectedFieldKeys.length === 0}
{i18n.t("export.dialog.fieldsEmpty")}
{:else}
{i18n.t("export.dialog.fieldsSummary", { count: selectedFieldKeys.length })}
{/if}
</p>
<button
type="button"
class="shrink-0 text-[11px] font-medium text-primary underline-offset-2 hover:underline"
onclick={goEditFeed}
>
{i18n.t("export.dialog.editFields")}
</button>
</div>
{#if !fieldsLoading && selectedFieldKeys.length > 0}
<ul class="mt-2.5 flex flex-wrap gap-1.5">
{#each selectedFieldKeys as key}
<li
class="rounded border border-border/70 bg-background px-1.5 py-0.5 font-mono text-[10px] leading-none text-muted-foreground"
>
{key}
</li>
{/each}
</ul>
{/if}
</div>
{/if}
</div>
{/if}
@@ -502,7 +502,7 @@ import { Check, Edit, MoreHorizontal, X } from "@lucide/svelte";
{:else}
<VirtualList
items={products}
estimateSize={72}
estimateSize={57}
overscan={6}
maxHeight={640}
getKey={(product) => String(product.id)}
@@ -12,6 +12,7 @@ import {
activeFormValue,
buildExportFilters,
canConfirmExport,
fieldKeysFromExportTemplate,
filterExportFeedsBySearch,
formatStatusList,
isActiveFromForm,
@@ -79,6 +80,23 @@ describe("mapActiveExportFeedOptions", () => {
});
});
describe("fieldKeysFromExportTemplate", () => {
it("reads fields[], then mappings, else empty", () => {
assert.deepEqual(
fieldKeysFromExportTemplate({
fields: [{ key: "id" }, { name: "title" }, { key: " " }]
}),
["id", "title"]
);
assert.deepEqual(fieldKeysFromExportTemplate({ mappings: { gtin: "gtin", brand: "brand" } }), [
"gtin",
"brand"
]);
assert.deepEqual(fieldKeysFromExportTemplate(null), []);
assert.deepEqual(fieldKeysFromExportTemplate({}), []);
});
});
describe("resolveExportDialogBody", () => {
it("prefers loading, then error-empty, empty, ready", () => {
assert.equal(resolveExportDialogBody({ loading: true, error: "x", feedCount: 0 }), "loading");
+20
View File
@@ -76,6 +76,26 @@ export function mapActiveExportFeedOptions(
}));
}
/** Column keys from an export feed template (fields[] or legacy mappings). */
export function fieldKeysFromExportTemplate(template: unknown): string[] {
if (!template || typeof template !== "object") return [];
const t = template as { fields?: unknown; mappings?: unknown };
if (Array.isArray(t.fields) && t.fields.length > 0) {
const keys: string[] = [];
for (const f of t.fields) {
if (!f || typeof f !== "object") continue;
const row = f as { key?: unknown; name?: unknown };
const key = String(row.key ?? row.name ?? "").trim();
if (key) keys.push(key);
}
return keys;
}
if (t.mappings && typeof t.mappings === "object" && !Array.isArray(t.mappings)) {
return Object.keys(t.mappings as Record<string, unknown>);
}
return [];
}
/** Which body the product export dialog should show. */
export function resolveExportDialogBody(input: {
loading: boolean;
+4
View File
@@ -3360,6 +3360,10 @@ export const de: MessageDict = {
"export.dialog.createFeedCta": "Export-Feed erstellen",
"export.dialog.confirmAria": "Export bestätigen",
"export.dialog.export": "Exportieren",
"export.dialog.fieldsLoading": "Feed-Felder werden geladen…",
"export.dialog.fieldsSummary": "Dieser Export enthält {count} Felder aus dem Feed:",
"export.dialog.fieldsEmpty": "Für diesen Feed sind noch keine Felder konfiguriert.",
"export.dialog.editFields": "Feed-Felder bearbeiten",
"billing.title": "Nutzung & Abrechnung",
"billing.loadFailed": "Abrechnung konnte nicht geladen werden",
"billing.usageLoadFailed": "Nutzung konnte nicht geladen werden",
+4
View File
@@ -4048,6 +4048,10 @@ export const en: MessageDict = {
"export.dialog.createFeedCta": "Create export feed",
"export.dialog.confirmAria": "Confirm export",
"export.dialog.export": "Export",
"export.dialog.fieldsLoading": "Loading feed fields…",
"export.dialog.fieldsSummary": "This export will include {count} fields from the feed:",
"export.dialog.fieldsEmpty": "This feed has no fields configured yet.",
"export.dialog.editFields": "Edit feed fields",
"billing.title": "Usage & Billing",
"billing.loadFailed": "Failed to load billing",
"billing.usageLoadFailed": "Failed to load usage",
+4
View File
@@ -3360,6 +3360,10 @@ export const es: MessageDict = {
"export.dialog.createFeedCta": "Crear feed de exportación",
"export.dialog.confirmAria": "Confirmar exportación",
"export.dialog.export": "Exportar",
"export.dialog.fieldsLoading": "Cargando campos del feed…",
"export.dialog.fieldsSummary": "Esta exportación incluirá {count} campos del feed:",
"export.dialog.fieldsEmpty": "Este feed aún no tiene campos configurados.",
"export.dialog.editFields": "Editar campos del feed",
"billing.title": "Uso y facturación",
"billing.loadFailed": "No se pudo cargar la facturación",
"billing.usageLoadFailed": "No se pudo cargar el uso",
+4
View File
@@ -3360,6 +3360,10 @@ export const fr: MessageDict = {
"export.dialog.createFeedCta": "Créer un feed d'export",
"export.dialog.confirmAria": "Confirmer l'export",
"export.dialog.export": "Exporter",
"export.dialog.fieldsLoading": "Chargement des champs du feed…",
"export.dialog.fieldsSummary": "Cet export inclura {count} champs du feed :",
"export.dialog.fieldsEmpty": "Ce feed na pas encore de champs configurés.",
"export.dialog.editFields": "Modifier les champs du feed",
"billing.title": "Utilisation et facturation",
"billing.loadFailed": "Échec du chargement de la facturation",
"billing.usageLoadFailed": "Échec du chargement de l'utilisation",
+4
View File
@@ -3360,6 +3360,10 @@ export const it: MessageDict = {
"export.dialog.createFeedCta": "Crea feed di esportazione",
"export.dialog.confirmAria": "Conferma esportazione",
"export.dialog.export": "Esporta",
"export.dialog.fieldsLoading": "Caricamento campi del feed…",
"export.dialog.fieldsSummary": "Questa esportazione includerà {count} campi dal feed:",
"export.dialog.fieldsEmpty": "Questo feed non ha ancora campi configurati.",
"export.dialog.editFields": "Modifica campi del feed",
"billing.title": "Utilizzo e fatturazione",
"billing.loadFailed": "Caricamento fatturazione non riuscito",
"billing.usageLoadFailed": "Caricamento utilizzo non riuscito",
+4
View File
@@ -3360,6 +3360,10 @@ export const ja: MessageDict = {
"export.dialog.createFeedCta": "エクスポートフィードを作成",
"export.dialog.confirmAria": "エクスポートを確認",
"export.dialog.export": "エクスポート",
"export.dialog.fieldsLoading": "Loading feed fields…",
"export.dialog.fieldsSummary": "This export will include {count} fields from the feed:",
"export.dialog.fieldsEmpty": "This feed has no fields configured yet.",
"export.dialog.editFields": "Edit feed fields",
"billing.title": "利用状況と請求",
"billing.loadFailed": "請求情報の読み込みに失敗しました",
"billing.usageLoadFailed": "利用状況の読み込みに失敗しました",
+4
View File
@@ -3360,6 +3360,10 @@ export const nl: MessageDict = {
"export.dialog.createFeedCta": "Exportfeed maken",
"export.dialog.confirmAria": "Export bevestigen",
"export.dialog.export": "Exporteren",
"export.dialog.fieldsLoading": "Feedvelden laden…",
"export.dialog.fieldsSummary": "Deze export bevat {count} velden uit de feed:",
"export.dialog.fieldsEmpty": "Deze feed heeft nog geen velden geconfigureerd.",
"export.dialog.editFields": "Feedvelden bewerken",
"billing.title": "Gebruik & facturering",
"billing.loadFailed": "Facturering laden mislukt",
"billing.usageLoadFailed": "Gebruik laden mislukt",
+4
View File
@@ -3360,6 +3360,10 @@ export const pl: MessageDict = {
"export.dialog.createFeedCta": "Utwórz feed eksportu",
"export.dialog.confirmAria": "Potwierdź eksport",
"export.dialog.export": "Eksportuj",
"export.dialog.fieldsLoading": "Ładowanie pól feedu…",
"export.dialog.fieldsSummary": "Ten eksport będzie zawierał {count} pól z feedu:",
"export.dialog.fieldsEmpty": "Ten feed nie ma jeszcze skonfigurowanych pól.",
"export.dialog.editFields": "Edytuj pola feedu",
"billing.title": "Użycie i rozliczenia",
"billing.loadFailed": "Nie udało się wczytać rozliczeń",
"billing.usageLoadFailed": "Nie udało się wczytać użycia",
+4
View File
@@ -3360,6 +3360,10 @@ export const pt: MessageDict = {
"export.dialog.createFeedCta": "Criar feed de exportação",
"export.dialog.confirmAria": "Confirmar exportação",
"export.dialog.export": "Exportar",
"export.dialog.fieldsLoading": "A carregar campos do feed…",
"export.dialog.fieldsSummary": "Esta exportação incluirá {count} campos do feed:",
"export.dialog.fieldsEmpty": "Este feed ainda não tem campos configurados.",
"export.dialog.editFields": "Editar campos do feed",
"billing.title": "Uso e faturação",
"billing.loadFailed": "Falha ao carregar a faturação",
"billing.usageLoadFailed": "Falha ao carregar a utilização",
+2 -1
View File
@@ -550,7 +550,8 @@
</DropdownMenu>
{/if}
</DashboardHeader>
<div class="min-w-0 flex-1 overflow-x-auto overflow-y-auto p-3 sm:p-6">
<!-- Extra bottom padding clears the assistant FAB so last-row actions stay clickable. -->
<div class="min-w-0 flex-1 overflow-x-auto overflow-y-auto p-3 pb-20 sm:p-6 sm:pb-24">
<!-- Key by pathname so client nav never leaves a prior page shell (e.g. campaign edit) mounted. -->
{#key page.url.pathname}
<PlanRouteGuard>
+13 -3
View File
@@ -56,7 +56,7 @@
TabsList,
TabsTrigger
} from "$lib/components/ui";
import { Building2, Copy, KeyRound, RefreshCw, Search, Shield, UserPlus, Users } from "@lucide/svelte";
import { Building2, Check, Copy, KeyRound, RefreshCw, Search, Shield, UserPlus, Users } from "@lucide/svelte";
type TabKey = "users" | "companies";
@@ -66,6 +66,7 @@
let busyUserId = $state<string | null>(null);
let error = $state("");
let success = $state("");
let inviteLinkCopied = $state(false);
let inviteAcceptLink = $state<string | null>(null);
let tab = $state<TabKey>("users");
let search = $state("");
@@ -547,6 +548,10 @@
await navigator.clipboard.writeText(inviteAcceptLink);
success = i18n.t("admin.users.inviteLinkCopied");
error = "";
inviteLinkCopied = true;
window.setTimeout(() => {
inviteLinkCopied = false;
}, 1500);
} catch {
error = i18n.t("flash.settings.copyFailed");
}
@@ -647,8 +652,13 @@
aria-label={i18n.t("admin.users.inviteLinkLabel")}
/>
<Button type="button" variant="outline" size="sm" onclick={() => copyInviteLink()}>
<Copy class="mr-2 h-4 w-4" />
{i18n.t("admin.users.copyInviteLink")}
{#if inviteLinkCopied}
<Check class="mr-2 h-4 w-4 text-success" />
{i18n.t("common.copied")}
{:else}
<Copy class="mr-2 h-4 w-4" />
{i18n.t("admin.users.copyInviteLink")}
{/if}
</Button>
</div>
{/if}
+59 -32
View File
@@ -35,6 +35,7 @@
import {
ChevronDown,
Copy,
Check,
Download,
Edit,
Eye,
@@ -121,6 +122,7 @@
/** Set only by list `load()` — keeps create/save failures from looking like a failed list fetch. */
let listError = $state("");
let success = $state("");
let copiedId = $state("");
let loading = $state(true);
let saving = $state(false);
let canAdmin = $state(false);
@@ -365,10 +367,19 @@
}
async function copyUrl(feed: ExportRow) {
const id = String(feed.id);
try {
await navigator.clipboard.writeText(publicUrl(feed));
success = i18n.t("flash.export.urlCopied", { format: String(feed.format ?? "xml").toUpperCase() });
const msg = i18n.t("flash.export.urlCopied", {
format: String(feed.format ?? "xml").toUpperCase()
});
success = msg;
error = "";
notifySuccess(msg);
copiedId = id;
window.setTimeout(() => {
if (copiedId === id) copiedId = "";
}, 1500);
} catch {
error = i18n.t("flash.export.copyFailed");
}
@@ -649,9 +660,15 @@
class="h-8 w-8 shrink-0 p-0"
disabled={refreshing}
onclick={() => void copyUrl(feed)}
aria-label={i18n.t("exports.copyUrl")}
aria-label={copiedId === id
? i18n.t("common.copied")
: i18n.t("exports.copyUrl")}
>
<Copy class="h-3.5 w-3.5" />
{#if copiedId === id}
<Check class="h-3.5 w-3.5 text-success" />
{:else}
<Copy class="h-3.5 w-3.5" />
{/if}
</Button>
</div>
</TableCell>
@@ -727,9 +744,9 @@
description={editingId
? i18n.t("exports.editDescription")
: presetHint(activePreset)}
class="max-h-[90vh] max-w-2xl overflow-y-auto"
class="max-w-3xl sm:max-w-4xl"
>
<form class="space-y-4" onsubmit={saveFeed}>
<form id="export-feed-form" class="space-y-4" onsubmit={saveFeed}>
<div class="grid gap-3 sm:grid-cols-2">
{#if !editingId}
<div class="space-y-1.5 sm:col-span-2">
@@ -757,7 +774,18 @@
<Label for="export-format">{i18n.t("exports.format")}</Label>
<Input id="export-format" value={formFormat.toUpperCase()} disabled />
</div>
<div class="space-y-1.5">
{#if editingId}
<div class="space-y-1.5">
<Label for="export-active">{i18n.t("exports.status")}</Label>
<Select id="export-active" bind:value={formActive}>
<option value="active">{i18n.t("exports.active")}</option>
<option value="inactive">{i18n.t("exports.inactive")}</option>
</Select>
</div>
{:else}
<div class="hidden sm:block" aria-hidden="true"></div>
{/if}
<div class="space-y-1.5 sm:col-span-2">
<Label for="export-source">{i18n.t("exports.sourceFeed")}</Label>
<Select id="export-source" bind:value={formSourceFeedId}>
<option value="">{i18n.t("exports.allProducts")}</option>
@@ -782,41 +810,32 @@
<Input id="export-statuses" class="font-mono" bind:value={formStatuses} />
<p class="text-xs text-muted-foreground">{i18n.t("exports.productStatusesHelp")}</p>
</div>
{#if editingId}
<div class="space-y-1.5">
<Label for="export-active">{i18n.t("exports.status")}</Label>
<Select id="export-active" bind:value={formActive}>
<option value="active">{i18n.t("exports.active")}</option>
<option value="inactive">{i18n.t("exports.inactive")}</option>
</Select>
</div>
{/if}
</div>
<div class="space-y-2">
<div class="flex items-center justify-between">
<div class="flex items-center justify-between gap-2">
<Label>{i18n.t("exports.fieldMappings")}</Label>
<Button type="button" variant="outline" size="sm" onclick={addField}>
<Plus class="mr-1 h-3.5 w-3.5" /> {i18n.t("exports.addField")}
</Button>
</div>
<div class="overflow-hidden rounded-md border border-border">
<Table>
<Table class="md:min-w-0">
<TableHeader>
<TableRow>
<TableHead>{i18n.t("exports.outputKey")}</TableHead>
<TableHead>{i18n.t("exports.source")}</TableHead>
<TableHead class="w-16"></TableHead>
<TableHead class="h-9 w-[32%] px-2.5">{i18n.t("exports.outputKey")}</TableHead>
<TableHead class="h-9 px-2.5">{i18n.t("exports.source")}</TableHead>
<TableHead class="h-9 w-12 px-2"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{#each formFields as field, index}
<TableRow>
<TableCell>
<Input class="font-mono" bind:value={field.key} placeholder="name" required />
<TableCell class="p-2 align-top">
<Input class="h-9 font-mono" bind:value={field.key} placeholder="name" required />
</TableCell>
<TableCell>
<div class="space-y-1">
<TableCell class="p-2 align-top">
<div class="space-y-1.5">
<Select
value={SOURCE_OPTIONS.includes(field.source) ? field.source : "__custom__"}
onchange={(e) => {
@@ -836,19 +855,19 @@
</Select>
{#if !SOURCE_OPTIONS.includes(field.source)}
<Input
class="font-mono"
class="h-9 font-mono"
bind:value={field.source}
placeholder="attr.color or spec.weight"
/>
{/if}
</div>
</TableCell>
<TableCell class="text-right">
<TableCell class="p-2 text-right align-top">
<Button
type="button"
variant="ghost"
size="sm"
class="h-8 w-8 p-0 text-destructive"
class="h-9 w-9 p-0 text-destructive"
onclick={() => removeField(index)}
aria-label={i18n.t("exports.removeField")}
>
@@ -864,10 +883,18 @@
{i18n.t("exports.fieldHelp")}
</p>
</div>
<div class="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<Button type="button" variant="outline" onclick={() => (dialogOpen = false)}>{i18n.t("common.cancel")}</Button>
<Button type="submit" loading={saving}>{saving ? i18n.t("exports.saving") : editingId ? i18n.t("exports.saveChanges") : i18n.t("exports.createFeed")}</Button>
</div>
</form>
{#snippet footer()}
<Button type="button" variant="outline" onclick={() => (dialogOpen = false)}
>{i18n.t("common.cancel")}</Button
>
<Button type="submit" form="export-feed-form" loading={saving}
>{saving
? i18n.t("exports.saving")
: editingId
? i18n.t("exports.saveChanges")
: i18n.t("exports.createFeed")}</Button
>
{/snippet}
</Dialog>
+44 -10
View File
@@ -4,6 +4,7 @@
import { page } from "$app/state";
import {
Building2,
Check,
Copy,
CreditCard,
ExternalLink,
@@ -180,6 +181,7 @@
let error = $state("");
let fieldErrors = $state<Record<string, string>>({});
let success = $state("");
let copiedKey = $state("");
let saving = $state(false);
let user = $state<User | null>(null);
@@ -838,12 +840,16 @@
}
}
async function copyText(text: string, okMessage = i18n.t("common.copied")) {
async function copyText(text: string, okMessage = i18n.t("common.copied"), key = "default") {
try {
await navigator.clipboard.writeText(text);
success = okMessage;
error = "";
notifySuccess(success);
copiedKey = key;
window.setTimeout(() => {
if (copiedKey === key) copiedKey = "";
}, 1500);
} catch {
error = i18n.t("flash.settings.copyFailed");
notifyError(error);
@@ -1539,11 +1545,20 @@
{#if sessionSecret}
<DropdownMenuItem
onclick={() =>
copyText(sessionSecret, i18n.t("settings.apiKeyCopied"))
copyText(
sessionSecret,
i18n.t("settings.apiKeyCopied"),
`api-key-${key.id}`
)
}
>
<Copy class="mr-2 h-4 w-4" />
{i18n.t("settings.copyApiKey")}
{#if copiedKey === `api-key-${key.id}`}
<Check class="mr-2 h-4 w-4 text-success" />
{i18n.t("common.copied")}
{:else}
<Copy class="mr-2 h-4 w-4" />
{i18n.t("settings.copyApiKey")}
{/if}
</DropdownMenuItem>
{:else}
<DropdownMenuItem
@@ -1614,10 +1629,21 @@
<Input value={inviteAcceptLink} readonly autocomplete="off" spellcheck={false} class="font-mono text-xs" aria-label={i18n.t("settings.acceptLinkLabel")} />
<Button
variant="outline"
onclick={() => copyText(inviteAcceptLink!, i18n.t("settings.linkCopied"))}
onclick={() =>
copyText(
inviteAcceptLink!,
i18n.t("settings.linkCopied"),
"invite-link"
)
}
>
<Copy class="mr-2 h-4 w-4" />
{i18n.t("settings.copyLink")}
{#if copiedKey === "invite-link"}
<Check class="mr-2 h-4 w-4 text-success" />
{i18n.t("common.copied")}
{:else}
<Copy class="mr-2 h-4 w-4" />
{i18n.t("settings.copyLink")}
{/if}
</Button>
</CardContent>
</Card>
@@ -1895,10 +1921,18 @@
<Button
variant="outline"
size="icon"
aria-label={i18n.t("settings.copyApiKeyAria")}
onclick={() => copyText(newApiKey!, i18n.t("settings.apiKeyCopied"))}
aria-label={copiedKey === "new-api-key"
? i18n.t("common.copied")
: i18n.t("settings.copyApiKeyAria")}
onclick={() =>
copyText(newApiKey!, i18n.t("settings.apiKeyCopied"), "new-api-key")
}
>
<Copy class="h-4 w-4" />
{#if copiedKey === "new-api-key"}
<Check class="h-4 w-4 text-success" />
{:else}
<Copy class="h-4 w-4" />
{/if}
</Button>
</div>
<p class="text-sm text-muted-foreground">{i18n.t("settings.storeKeySafe")}</p>