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 { unwrapList, OPTION_LIST_LIMIT } from "$lib/list";
import { import {
canConfirmExport, canConfirmExport,
fieldKeysFromExportTemplate,
mapActiveExportFeedOptions, mapActiveExportFeedOptions,
resolveExportDialogBody, resolveExportDialogBody,
type ExportFeedOption type ExportFeedOption
@@ -28,6 +29,8 @@
let exportFeeds = $state<ExportFeedOption[]>([]); let exportFeeds = $state<ExportFeedOption[]>([]);
let selectedFeedId = $state(""); let selectedFeedId = $state("");
let selectedFieldKeys = $state<string[]>([]);
let fieldsLoading = $state(false);
let loading = $state(false); let loading = $state(false);
let error = $state(""); let error = $state("");
@@ -42,11 +45,13 @@
feedCount: exportFeeds.length feedCount: exportFeeds.length
}) })
); );
const selectedFeed = $derived(exportFeeds.find((f) => f.id === selectedFeedId) ?? null);
async function fetchFeeds() { async function fetchFeeds() {
loading = true; loading = true;
error = ""; error = "";
selectedFeedId = ""; selectedFeedId = "";
selectedFieldKeys = [];
try { try {
const payload = await api<ListResponse<ExportFeed & { format?: string | null; is_active?: boolean | null }>>( const payload = await api<ListResponse<ExportFeed & { format?: string | null; is_active?: boolean | null }>>(
`/api/export-feeds?limit=${OPTION_LIST_LIMIT}` `/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() { function close() {
open = false; open = false;
selectedFeedId = ""; selectedFeedId = "";
selectedFieldKeys = [];
error = ""; error = "";
onClose?.(); onClose?.();
} }
@@ -88,30 +120,35 @@
close(); close();
void goto("/export-feeds"); void goto("/export-feeds");
} }
function goEditFeed() {
close();
void goto("/export-feeds");
}
</script> </script>
<!-- Mount only while open — avoids nested bind:open / one-way $bindable dropping open. --> <!-- Mount only while open — avoids nested bind:open / one-way $bindable dropping open. -->
{#if open} {#if open}
<Dialog <Dialog
open={true} 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")} title={i18n.t("products.export.title")}
description={i18n.t("export.dialog.description", { count: selectedProductIds.length })} description={i18n.t("export.dialog.description", { count: selectedProductIds.length })}
onClose={close} onClose={close}
> >
{#if dialogBody === "loading"} {#if dialogBody === "loading"}
<div class="flex items-center justify-center py-8"> <div class="flex items-center justify-center py-6">
<Spinner class="h-8 w-8 text-muted-foreground" /> <Spinner class="h-6 w-6 text-muted-foreground" />
</div> </div>
{:else if dialogBody === "error"} {:else if dialogBody === "error"}
<div class="space-y-3 py-2"> <div class="space-y-3">
<p class="text-sm text-destructive">{error}</p> <p class="text-sm text-destructive">{error}</p>
<Button variant="outline" size="sm" onclick={goCreateFeed}> <Button variant="outline" size="sm" onclick={goCreateFeed}>
{i18n.t("export.dialog.createFeedCta")} {i18n.t("export.dialog.createFeedCta")}
</Button> </Button>
</div> </div>
{:else if dialogBody === "empty"} {: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 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> <p class="text-sm leading-relaxed text-muted-foreground">{i18n.t("export.dialog.emptyMessage")}</p>
<Button size="sm" onclick={goCreateFeed}> <Button size="sm" onclick={goCreateFeed}>
@@ -119,7 +156,7 @@
</Button> </Button>
</div> </div>
{:else} {:else}
<div class="grid gap-4 py-4"> <div class="space-y-3">
{#if error} {#if error}
<Alert variant="destructive"> <Alert variant="destructive">
<AlertDescription>{error}</AlertDescription> <AlertDescription>{error}</AlertDescription>
@@ -134,6 +171,39 @@
</Select> </Select>
<p class="text-xs text-muted-foreground">{i18n.t("export.dialog.help")}</p> <p class="text-xs text-muted-foreground">{i18n.t("export.dialog.help")}</p>
</div> </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> </div>
{/if} {/if}
@@ -502,7 +502,7 @@ import { Check, Edit, MoreHorizontal, X } from "@lucide/svelte";
{:else} {:else}
<VirtualList <VirtualList
items={products} items={products}
estimateSize={72} estimateSize={57}
overscan={6} overscan={6}
maxHeight={640} maxHeight={640}
getKey={(product) => String(product.id)} getKey={(product) => String(product.id)}
@@ -12,6 +12,7 @@ import {
activeFormValue, activeFormValue,
buildExportFilters, buildExportFilters,
canConfirmExport, canConfirmExport,
fieldKeysFromExportTemplate,
filterExportFeedsBySearch, filterExportFeedsBySearch,
formatStatusList, formatStatusList,
isActiveFromForm, 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", () => { describe("resolveExportDialogBody", () => {
it("prefers loading, then error-empty, empty, ready", () => { it("prefers loading, then error-empty, empty, ready", () => {
assert.equal(resolveExportDialogBody({ loading: true, error: "x", feedCount: 0 }), "loading"); 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. */ /** Which body the product export dialog should show. */
export function resolveExportDialogBody(input: { export function resolveExportDialogBody(input: {
loading: boolean; loading: boolean;
+4
View File
@@ -3360,6 +3360,10 @@ export const de: MessageDict = {
"export.dialog.createFeedCta": "Export-Feed erstellen", "export.dialog.createFeedCta": "Export-Feed erstellen",
"export.dialog.confirmAria": "Export bestätigen", "export.dialog.confirmAria": "Export bestätigen",
"export.dialog.export": "Exportieren", "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.title": "Nutzung & Abrechnung",
"billing.loadFailed": "Abrechnung konnte nicht geladen werden", "billing.loadFailed": "Abrechnung konnte nicht geladen werden",
"billing.usageLoadFailed": "Nutzung 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.createFeedCta": "Create export feed",
"export.dialog.confirmAria": "Confirm export", "export.dialog.confirmAria": "Confirm export",
"export.dialog.export": "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.title": "Usage & Billing",
"billing.loadFailed": "Failed to load billing", "billing.loadFailed": "Failed to load billing",
"billing.usageLoadFailed": "Failed to load usage", "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.createFeedCta": "Crear feed de exportación",
"export.dialog.confirmAria": "Confirmar exportación", "export.dialog.confirmAria": "Confirmar exportación",
"export.dialog.export": "Exportar", "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.title": "Uso y facturación",
"billing.loadFailed": "No se pudo cargar la facturación", "billing.loadFailed": "No se pudo cargar la facturación",
"billing.usageLoadFailed": "No se pudo cargar el uso", "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.createFeedCta": "Créer un feed d'export",
"export.dialog.confirmAria": "Confirmer l'export", "export.dialog.confirmAria": "Confirmer l'export",
"export.dialog.export": "Exporter", "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.title": "Utilisation et facturation",
"billing.loadFailed": "Échec du chargement de la facturation", "billing.loadFailed": "Échec du chargement de la facturation",
"billing.usageLoadFailed": "Échec du chargement de l'utilisation", "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.createFeedCta": "Crea feed di esportazione",
"export.dialog.confirmAria": "Conferma esportazione", "export.dialog.confirmAria": "Conferma esportazione",
"export.dialog.export": "Esporta", "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.title": "Utilizzo e fatturazione",
"billing.loadFailed": "Caricamento fatturazione non riuscito", "billing.loadFailed": "Caricamento fatturazione non riuscito",
"billing.usageLoadFailed": "Caricamento utilizzo non riuscito", "billing.usageLoadFailed": "Caricamento utilizzo non riuscito",
+4
View File
@@ -3360,6 +3360,10 @@ export const ja: MessageDict = {
"export.dialog.createFeedCta": "エクスポートフィードを作成", "export.dialog.createFeedCta": "エクスポートフィードを作成",
"export.dialog.confirmAria": "エクスポートを確認", "export.dialog.confirmAria": "エクスポートを確認",
"export.dialog.export": "エクスポート", "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.title": "利用状況と請求",
"billing.loadFailed": "請求情報の読み込みに失敗しました", "billing.loadFailed": "請求情報の読み込みに失敗しました",
"billing.usageLoadFailed": "利用状況の読み込みに失敗しました", "billing.usageLoadFailed": "利用状況の読み込みに失敗しました",
+4
View File
@@ -3360,6 +3360,10 @@ export const nl: MessageDict = {
"export.dialog.createFeedCta": "Exportfeed maken", "export.dialog.createFeedCta": "Exportfeed maken",
"export.dialog.confirmAria": "Export bevestigen", "export.dialog.confirmAria": "Export bevestigen",
"export.dialog.export": "Exporteren", "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.title": "Gebruik & facturering",
"billing.loadFailed": "Facturering laden mislukt", "billing.loadFailed": "Facturering laden mislukt",
"billing.usageLoadFailed": "Gebruik 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.createFeedCta": "Utwórz feed eksportu",
"export.dialog.confirmAria": "Potwierdź eksport", "export.dialog.confirmAria": "Potwierdź eksport",
"export.dialog.export": "Eksportuj", "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.title": "Użycie i rozliczenia",
"billing.loadFailed": "Nie udało się wczytać rozliczeń", "billing.loadFailed": "Nie udało się wczytać rozliczeń",
"billing.usageLoadFailed": "Nie udało się wczytać użycia", "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.createFeedCta": "Criar feed de exportação",
"export.dialog.confirmAria": "Confirmar exportação", "export.dialog.confirmAria": "Confirmar exportação",
"export.dialog.export": "Exportar", "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.title": "Uso e faturação",
"billing.loadFailed": "Falha ao carregar a faturação", "billing.loadFailed": "Falha ao carregar a faturação",
"billing.usageLoadFailed": "Falha ao carregar a utilização", "billing.usageLoadFailed": "Falha ao carregar a utilização",
+2 -1
View File
@@ -550,7 +550,8 @@
</DropdownMenu> </DropdownMenu>
{/if} {/if}
</DashboardHeader> </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 by pathname so client nav never leaves a prior page shell (e.g. campaign edit) mounted. -->
{#key page.url.pathname} {#key page.url.pathname}
<PlanRouteGuard> <PlanRouteGuard>
+11 -1
View File
@@ -56,7 +56,7 @@
TabsList, TabsList,
TabsTrigger TabsTrigger
} from "$lib/components/ui"; } 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"; type TabKey = "users" | "companies";
@@ -66,6 +66,7 @@
let busyUserId = $state<string | null>(null); let busyUserId = $state<string | null>(null);
let error = $state(""); let error = $state("");
let success = $state(""); let success = $state("");
let inviteLinkCopied = $state(false);
let inviteAcceptLink = $state<string | null>(null); let inviteAcceptLink = $state<string | null>(null);
let tab = $state<TabKey>("users"); let tab = $state<TabKey>("users");
let search = $state(""); let search = $state("");
@@ -547,6 +548,10 @@
await navigator.clipboard.writeText(inviteAcceptLink); await navigator.clipboard.writeText(inviteAcceptLink);
success = i18n.t("admin.users.inviteLinkCopied"); success = i18n.t("admin.users.inviteLinkCopied");
error = ""; error = "";
inviteLinkCopied = true;
window.setTimeout(() => {
inviteLinkCopied = false;
}, 1500);
} catch { } catch {
error = i18n.t("flash.settings.copyFailed"); error = i18n.t("flash.settings.copyFailed");
} }
@@ -647,8 +652,13 @@
aria-label={i18n.t("admin.users.inviteLinkLabel")} aria-label={i18n.t("admin.users.inviteLinkLabel")}
/> />
<Button type="button" variant="outline" size="sm" onclick={() => copyInviteLink()}> <Button type="button" variant="outline" size="sm" onclick={() => 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" /> <Copy class="mr-2 h-4 w-4" />
{i18n.t("admin.users.copyInviteLink")} {i18n.t("admin.users.copyInviteLink")}
{/if}
</Button> </Button>
</div> </div>
{/if} {/if}
+57 -30
View File
@@ -35,6 +35,7 @@
import { import {
ChevronDown, ChevronDown,
Copy, Copy,
Check,
Download, Download,
Edit, Edit,
Eye, Eye,
@@ -121,6 +122,7 @@
/** Set only by list `load()` — keeps create/save failures from looking like a failed list fetch. */ /** Set only by list `load()` — keeps create/save failures from looking like a failed list fetch. */
let listError = $state(""); let listError = $state("");
let success = $state(""); let success = $state("");
let copiedId = $state("");
let loading = $state(true); let loading = $state(true);
let saving = $state(false); let saving = $state(false);
let canAdmin = $state(false); let canAdmin = $state(false);
@@ -365,10 +367,19 @@
} }
async function copyUrl(feed: ExportRow) { async function copyUrl(feed: ExportRow) {
const id = String(feed.id);
try { try {
await navigator.clipboard.writeText(publicUrl(feed)); 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 = ""; error = "";
notifySuccess(msg);
copiedId = id;
window.setTimeout(() => {
if (copiedId === id) copiedId = "";
}, 1500);
} catch { } catch {
error = i18n.t("flash.export.copyFailed"); error = i18n.t("flash.export.copyFailed");
} }
@@ -649,9 +660,15 @@
class="h-8 w-8 shrink-0 p-0" class="h-8 w-8 shrink-0 p-0"
disabled={refreshing} disabled={refreshing}
onclick={() => void copyUrl(feed)} onclick={() => void copyUrl(feed)}
aria-label={i18n.t("exports.copyUrl")} aria-label={copiedId === id
? i18n.t("common.copied")
: i18n.t("exports.copyUrl")}
> >
{#if copiedId === id}
<Check class="h-3.5 w-3.5 text-success" />
{:else}
<Copy class="h-3.5 w-3.5" /> <Copy class="h-3.5 w-3.5" />
{/if}
</Button> </Button>
</div> </div>
</TableCell> </TableCell>
@@ -727,9 +744,9 @@
description={editingId description={editingId
? i18n.t("exports.editDescription") ? i18n.t("exports.editDescription")
: presetHint(activePreset)} : 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"> <div class="grid gap-3 sm:grid-cols-2">
{#if !editingId} {#if !editingId}
<div class="space-y-1.5 sm:col-span-2"> <div class="space-y-1.5 sm:col-span-2">
@@ -757,7 +774,18 @@
<Label for="export-format">{i18n.t("exports.format")}</Label> <Label for="export-format">{i18n.t("exports.format")}</Label>
<Input id="export-format" value={formFormat.toUpperCase()} disabled /> <Input id="export-format" value={formFormat.toUpperCase()} disabled />
</div> </div>
{#if editingId}
<div class="space-y-1.5"> <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> <Label for="export-source">{i18n.t("exports.sourceFeed")}</Label>
<Select id="export-source" bind:value={formSourceFeedId}> <Select id="export-source" bind:value={formSourceFeedId}>
<option value="">{i18n.t("exports.allProducts")}</option> <option value="">{i18n.t("exports.allProducts")}</option>
@@ -782,41 +810,32 @@
<Input id="export-statuses" class="font-mono" bind:value={formStatuses} /> <Input id="export-statuses" class="font-mono" bind:value={formStatuses} />
<p class="text-xs text-muted-foreground">{i18n.t("exports.productStatusesHelp")}</p> <p class="text-xs text-muted-foreground">{i18n.t("exports.productStatusesHelp")}</p>
</div> </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>
<div class="space-y-2"> <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> <Label>{i18n.t("exports.fieldMappings")}</Label>
<Button type="button" variant="outline" size="sm" onclick={addField}> <Button type="button" variant="outline" size="sm" onclick={addField}>
<Plus class="mr-1 h-3.5 w-3.5" /> {i18n.t("exports.addField")} <Plus class="mr-1 h-3.5 w-3.5" /> {i18n.t("exports.addField")}
</Button> </Button>
</div> </div>
<div class="overflow-hidden rounded-md border border-border"> <div class="overflow-hidden rounded-md border border-border">
<Table> <Table class="md:min-w-0">
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>{i18n.t("exports.outputKey")}</TableHead> <TableHead class="h-9 w-[32%] px-2.5">{i18n.t("exports.outputKey")}</TableHead>
<TableHead>{i18n.t("exports.source")}</TableHead> <TableHead class="h-9 px-2.5">{i18n.t("exports.source")}</TableHead>
<TableHead class="w-16"></TableHead> <TableHead class="h-9 w-12 px-2"></TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{#each formFields as field, index} {#each formFields as field, index}
<TableRow> <TableRow>
<TableCell> <TableCell class="p-2 align-top">
<Input class="font-mono" bind:value={field.key} placeholder="name" required /> <Input class="h-9 font-mono" bind:value={field.key} placeholder="name" required />
</TableCell> </TableCell>
<TableCell> <TableCell class="p-2 align-top">
<div class="space-y-1"> <div class="space-y-1.5">
<Select <Select
value={SOURCE_OPTIONS.includes(field.source) ? field.source : "__custom__"} value={SOURCE_OPTIONS.includes(field.source) ? field.source : "__custom__"}
onchange={(e) => { onchange={(e) => {
@@ -836,19 +855,19 @@
</Select> </Select>
{#if !SOURCE_OPTIONS.includes(field.source)} {#if !SOURCE_OPTIONS.includes(field.source)}
<Input <Input
class="font-mono" class="h-9 font-mono"
bind:value={field.source} bind:value={field.source}
placeholder="attr.color or spec.weight" placeholder="attr.color or spec.weight"
/> />
{/if} {/if}
</div> </div>
</TableCell> </TableCell>
<TableCell class="text-right"> <TableCell class="p-2 text-right align-top">
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
size="sm" size="sm"
class="h-8 w-8 p-0 text-destructive" class="h-9 w-9 p-0 text-destructive"
onclick={() => removeField(index)} onclick={() => removeField(index)}
aria-label={i18n.t("exports.removeField")} aria-label={i18n.t("exports.removeField")}
> >
@@ -864,10 +883,18 @@
{i18n.t("exports.fieldHelp")} {i18n.t("exports.fieldHelp")}
</p> </p>
</div> </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> </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> </Dialog>
+39 -5
View File
@@ -4,6 +4,7 @@
import { page } from "$app/state"; import { page } from "$app/state";
import { import {
Building2, Building2,
Check,
Copy, Copy,
CreditCard, CreditCard,
ExternalLink, ExternalLink,
@@ -180,6 +181,7 @@
let error = $state(""); let error = $state("");
let fieldErrors = $state<Record<string, string>>({}); let fieldErrors = $state<Record<string, string>>({});
let success = $state(""); let success = $state("");
let copiedKey = $state("");
let saving = $state(false); let saving = $state(false);
let user = $state<User | null>(null); 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 { try {
await navigator.clipboard.writeText(text); await navigator.clipboard.writeText(text);
success = okMessage; success = okMessage;
error = ""; error = "";
notifySuccess(success); notifySuccess(success);
copiedKey = key;
window.setTimeout(() => {
if (copiedKey === key) copiedKey = "";
}, 1500);
} catch { } catch {
error = i18n.t("flash.settings.copyFailed"); error = i18n.t("flash.settings.copyFailed");
notifyError(error); notifyError(error);
@@ -1539,11 +1545,20 @@
{#if sessionSecret} {#if sessionSecret}
<DropdownMenuItem <DropdownMenuItem
onclick={() => onclick={() =>
copyText(sessionSecret, i18n.t("settings.apiKeyCopied")) copyText(
sessionSecret,
i18n.t("settings.apiKeyCopied"),
`api-key-${key.id}`
)
} }
> >
{#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" /> <Copy class="mr-2 h-4 w-4" />
{i18n.t("settings.copyApiKey")} {i18n.t("settings.copyApiKey")}
{/if}
</DropdownMenuItem> </DropdownMenuItem>
{:else} {:else}
<DropdownMenuItem <DropdownMenuItem
@@ -1614,10 +1629,21 @@
<Input value={inviteAcceptLink} readonly autocomplete="off" spellcheck={false} class="font-mono text-xs" aria-label={i18n.t("settings.acceptLinkLabel")} /> <Input value={inviteAcceptLink} readonly autocomplete="off" spellcheck={false} class="font-mono text-xs" aria-label={i18n.t("settings.acceptLinkLabel")} />
<Button <Button
variant="outline" variant="outline"
onclick={() => copyText(inviteAcceptLink!, i18n.t("settings.linkCopied"))} onclick={() =>
copyText(
inviteAcceptLink!,
i18n.t("settings.linkCopied"),
"invite-link"
)
}
> >
{#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" /> <Copy class="mr-2 h-4 w-4" />
{i18n.t("settings.copyLink")} {i18n.t("settings.copyLink")}
{/if}
</Button> </Button>
</CardContent> </CardContent>
</Card> </Card>
@@ -1895,10 +1921,18 @@
<Button <Button
variant="outline" variant="outline"
size="icon" size="icon"
aria-label={i18n.t("settings.copyApiKeyAria")} aria-label={copiedKey === "new-api-key"
onclick={() => copyText(newApiKey!, i18n.t("settings.apiKeyCopied"))} ? i18n.t("common.copied")
: i18n.t("settings.copyApiKeyAria")}
onclick={() =>
copyText(newApiKey!, i18n.t("settings.apiKeyCopied"), "new-api-key")
}
> >
{#if copiedKey === "new-api-key"}
<Check class="h-4 w-4 text-success" />
{:else}
<Copy class="h-4 w-4" /> <Copy class="h-4 w-4" />
{/if}
</Button> </Button>
</div> </div>
<p class="text-sm text-muted-foreground">{i18n.t("settings.storeKeySafe")}</p> <p class="text-sm text-muted-foreground">{i18n.t("settings.storeKeySafe")}</p>