This commit is contained in:
MatejGhub
2026-09-13 20:23:36 +02:00
parent 90abd6846c
commit ab0e80db18
16 changed files with 542 additions and 527 deletions
@@ -312,8 +312,8 @@
<div class="sr-only" aria-live="polite" aria-atomic="true">{announce}</div> <div class="sr-only" aria-live="polite" aria-atomic="true">{announce}</div>
{#if navUi.commandPaletteOpen} {#if navUi.commandPaletteOpen}
<div class="fixed inset-0 z-[60] bg-black/80" aria-hidden="true"></div> <div class="fixed inset-0 z-[120] bg-black/80" aria-hidden="true"></div>
<div class="fixed inset-0 z-[60] flex items-start justify-center p-4 pt-[min(20vh,8rem)] sm:p-6"> <div class="fixed inset-0 z-[120] flex items-start justify-center p-4 pt-[min(20vh,8rem)] sm:p-6">
<button <button
type="button" type="button"
class="absolute inset-0 cursor-default" class="absolute inset-0 cursor-default"
@@ -327,7 +327,7 @@
aria-modal="true" aria-modal="true"
aria-label={i18n.t("commandPalette.ariaLabel")} aria-label={i18n.t("commandPalette.ariaLabel")}
tabindex="-1" tabindex="-1"
class="relative z-[61] flex w-full max-w-lg flex-col overflow-hidden rounded-lg border bg-background shadow-lg lg:fixed lg:left-[17.5rem] lg:top-[4.5rem] lg:w-80 lg:max-w-none" class="relative z-[121] flex w-full max-w-lg flex-col overflow-hidden rounded-lg border bg-background shadow-lg lg:fixed lg:left-[17.5rem] lg:top-[4.5rem] lg:w-80 lg:max-w-none"
onkeydown={onPanelKeydown} onkeydown={onPanelKeydown}
onclick={(event) => event.stopPropagation()} onclick={(event) => event.stopPropagation()}
> >
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { i18n } from "$lib/i18n"; import { i18n } from "$lib/i18n";
import { trackEvent } from "$lib/analytics"; import { trackEvent } from "$lib/analytics";
import { Badge, Button, Label, Select } from "$lib/components/ui"; import { Button, Label, Select } from "$lib/components/ui";
import { import {
CONTENT_LANGUAGES, CONTENT_LANGUAGES,
parseContentLanguage, parseContentLanguage,
@@ -14,7 +14,6 @@
languages = $bindable([] as string[]), languages = $bindable([] as string[]),
available = CONTENT_LANGUAGES, available = CONTENT_LANGUAGES,
configured = [] as string[], configured = [] as string[],
hasOverride = (_code: string) => false,
primary = "", primary = "",
allowAdd = true, allowAdd = true,
class: className = "", class: className = "",
@@ -26,7 +25,6 @@
available?: ContentLanguage[]; available?: ContentLanguage[];
/** Company content languages (preferred order). */ /** Company content languages (preferred order). */
configured?: string[]; configured?: string[];
hasOverride?: (code: string) => boolean;
primary?: string; primary?: string;
allowAdd?: boolean; allowAdd?: boolean;
class?: string; class?: string;
@@ -98,30 +96,40 @@
<Label>{i18n.t("contentLang.switcherLabel")}</Label> <Label>{i18n.t("contentLang.switcherLabel")}</Label>
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
{#each tabs as code} {#each tabs as code}
<Button {@const removable = allowAdd && code !== primaryCode && languages.includes(code)}
type="button" {#if removable}
size="sm" <div class="inline-flex items-stretch overflow-hidden rounded-md">
variant={value === code ? "default" : "outline"} <Button
onclick={() => select(code)} type="button"
> size="sm"
{labelFor(code)} variant={value === code ? "default" : "outline"}
{#if hasOverride(code)} class="rounded-r-none"
<Badge variant="secondary" class="ml-1">{i18n.t("contentLang.hasOverride")}</Badge> onclick={() => select(code)}
{/if} >
{#if code === primaryCode} {labelFor(code)}
<span class="ml-1 text-xs opacity-70">{i18n.t("contentLang.primary")}</span> </Button>
{/if} <Button
</Button> type="button"
{#if allowAdd && code !== primaryCode && languages.includes(code)} size="sm"
variant={value === code ? "default" : "outline"}
class="rounded-l-none border-l-0 px-2"
onclick={() => remove(code)}
aria-label={i18n.t("contentLang.removeLanguage", { lang: labelFor(code) })}
>
<X class="h-3.5 w-3.5 opacity-70" />
</Button>
</div>
{:else}
<Button <Button
type="button" type="button"
size="sm" size="sm"
variant="ghost" variant={value === code ? "default" : "outline"}
class="h-8 w-8 p-0" onclick={() => select(code)}
onclick={() => remove(code)}
aria-label={i18n.t("contentLang.removeLanguage", { lang: labelFor(code) })}
> >
<X class="h-3.5 w-3.5" /> {labelFor(code)}
{#if code === primaryCode}
<span class="ml-1 text-xs opacity-70">{i18n.t("contentLang.primary")}</span>
{/if}
</Button> </Button>
{/if} {/if}
{/each} {/each}
@@ -1,11 +1,10 @@
<script lang="ts"> <script lang="ts">
import { i18n } from "$lib/i18n"; import { i18n } from "$lib/i18n";
import CategoryTreeNode from "./CategoryTreeNode.svelte"; import CategoryTreeNode from "./CategoryTreeNode.svelte";
import { ChevronRight, MoreVertical } from "@lucide/svelte"; import { ChevronRight, Folder, FolderOpen, MoreVertical, Sparkles } from "@lucide/svelte";
import type { Cat } from "$lib/categories/types"; import type { Cat } from "$lib/categories/types";
import { cn } from "$lib/utils"; import { cn } from "$lib/utils";
import { Badge, Button } from "$lib/components/ui"; import { Button } from "$lib/components/ui";
type TreeNode = Cat & { type TreeNode = Cat & {
children: TreeNode[]; children: TreeNode[];
@@ -39,58 +38,71 @@ import CategoryTreeNode from "./CategoryTreeNode.svelte";
const nodeKey = $derived(String(node.id)); const nodeKey = $derived(String(node.id));
const menuOpen = $derived(menuOpenId === nodeKey); const menuOpen = $derived(menuOpenId === nodeKey);
const FolderIcon = $derived(node.hasChildren && node.isExpanded ? FolderOpen : Folder);
</script> </script>
<div> <div>
<div <div
class="group flex min-w-0 items-center gap-2 rounded-lg px-2 py-2 hover:bg-accent/50 sm:gap-3 sm:px-4" class={cn(
"group relative flex min-w-0 items-center gap-2 rounded-md border border-transparent px-1.5 py-2 transition-colors sm:gap-2 sm:px-2",
"hover:border-border/70 hover:bg-muted/40",
menuOpen && "border-border/70 bg-muted/40",
node.is_active === false && "opacity-70"
)}
style={depth > 0 ? `margin-left: ${Math.min(depth, 4) * 0.75}rem` : undefined} style={depth > 0 ? `margin-left: ${Math.min(depth, 4) * 0.75}rem` : undefined}
> >
{#if depth > 0}
<span
class="pointer-events-none absolute -left-2 top-1/2 h-px w-2 -translate-y-1/2 bg-border sm:-left-2.5 sm:w-2.5"
aria-hidden="true"
></span>
{/if}
<div class="flex min-w-0 flex-1 items-center gap-2"> <div class="flex min-w-0 flex-1 items-center gap-2">
{#if node.hasChildren} {#if node.hasChildren}
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
class="h-8 w-8 shrink-0 p-0" class="h-7 w-7 shrink-0 p-0 text-muted-foreground"
onclick={() => onToggleExpand(node)} onclick={() => onToggleExpand(node)}
aria-label={node.isExpanded ? i18n.t("catalog.collapse") : i18n.t("catalog.expand")} aria-label={node.isExpanded ? i18n.t("catalog.collapse") : i18n.t("catalog.expand")}
> >
<ChevronRight class={cn("h-4 w-4 transition-transform", node.isExpanded && "rotate-90")} /> <ChevronRight class={cn("h-4 w-4 transition-transform", node.isExpanded && "rotate-90")} />
</Button> </Button>
{:else}
<div class="h-8 w-8 shrink-0"></div>
{/if} {/if}
<div class="flex min-w-0 flex-1 items-center gap-2">
<span <div
class={cn( class={cn(
"min-w-0 truncate font-medium", "flex h-7 w-7 shrink-0 items-center justify-center rounded-md border border-border/60 bg-muted/50 text-muted-foreground",
node.is_active === false && "text-muted-foreground" node.hasChildren && node.isExpanded && "border-primary/20 bg-primary/5 text-primary"
)} )}
title={node.name} aria-hidden="true"
> >
{node.name} <FolderIcon class="h-3.5 w-3.5" />
</span> </div>
<Badge
variant="outline" <div class="min-w-0 flex-1">
class="max-w-[9rem] min-w-0 shrink truncate text-xs sm:max-w-[14rem]" <div class="flex min-w-0 items-center gap-2">
title={String(node.unique_id ?? "")} <span class="min-w-0 truncate text-sm font-medium" title={node.name}>
> {node.name}
{node.unique_id} </span>
</Badge> {#if node.has_prompt}
{#if node.has_prompt} <span
<Badge variant="secondary" class="shrink-0 text-xs" title={i18n.t("categories.aiPromptSet")}> class="inline-flex shrink-0 items-center gap-1 rounded-md bg-card-blue px-1.5 py-0.5 text-[10px] font-medium text-foreground"
AI prompt title={i18n.t("categories.aiPromptSet")}
</Badge> >
{/if} <Sparkles class="h-3 w-3" aria-hidden="true" />
{#if node.has_title_formula} AI
<Badge variant="outline" class="hidden shrink-0 text-xs sm:inline-flex" title={i18n.t("categories.titleFormula")}> </span>
Title {/if}
</Badge> </div>
{/if} {#if node.unique_id}
{#if node.has_description_formula} <div
<Badge variant="outline" class="hidden shrink-0 text-xs sm:inline-flex" title={i18n.t("categories.descriptionFormula")}> class="mt-0.5 truncate font-mono text-[11px] text-muted-foreground"
Desc title={String(node.unique_id)}
</Badge> >
{node.unique_id}
</div>
{/if} {/if}
</div> </div>
</div> </div>
@@ -106,7 +118,7 @@ import CategoryTreeNode from "./CategoryTreeNode.svelte";
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
class="h-9 w-9 sm:h-8 sm:w-8" class="h-8 w-8"
aria-label={i18n.t("categories.actions")} aria-label={i18n.t("categories.actions")}
onclick={(e) => { onclick={(e) => {
e.stopPropagation(); e.stopPropagation();
@@ -1,19 +1,14 @@
<script lang="ts"> <script lang="ts">
import { i18n } from "$lib/i18n"; import { i18n } from "$lib/i18n";
import { Badge } from "$lib/components/ui";
import type { FieldMappingRow, SchemaField, TargetField } from "./types"; import type { FieldMappingRow, SchemaField, TargetField } from "./types";
import { STANDARD_FIELDS } from "./standard-fields"; import type { MappingSuggestion } from "./suggest-mappings";
import {
confidenceBadgeVariant,
type MappingSuggestion
} from "./suggest-mappings";
let { let {
itemPath = "", itemPath = "",
rows = [], rows = [],
fields = STANDARD_FIELDS, fields: _fields = [],
schemaFields = [], schemaFields = [],
suggestions = [], suggestions: _suggestions = [],
format = "xml", format = "xml",
samplePreview = "" samplePreview = ""
}: { }: {
@@ -28,12 +23,6 @@
const mapped = $derived(rows.filter((r) => r.source.trim() && r.target && r.target !== "none")); const mapped = $derived(rows.filter((r) => r.source.trim() && r.target && r.target !== "none"));
const suggestionByPair = $derived.by(() => {
const m = new Map<string, MappingSuggestion>();
for (const s of suggestions) m.set(`${s.source}\0${s.target}`, s);
return m;
});
const sampleByPath = $derived.by(() => { const sampleByPath = $derived.by(() => {
const m = new Map<string, string>(); const m = new Map<string, string>();
for (const f of schemaFields) { for (const f of schemaFields) {
@@ -44,23 +33,9 @@
return m; return m;
}); });
function labelFor(target: string): string {
return fields.find((f) => f.value === target)?.label ?? target;
}
function sampleFor(source: string): string { function sampleFor(source: string): string {
return sampleByPath.get(source) ?? sampleByPath.get(source.split("/").pop() ?? "") ?? source; return sampleByPath.get(source) ?? sampleByPath.get(source.split("/").pop() ?? "") ?? source;
} }
function formatSource(source: string): string {
const parts = source.split("/").filter(Boolean);
if (parts.length <= 1) return source;
return parts.join(" ");
}
function suggestionFor(row: FieldMappingRow): MappingSuggestion | undefined {
return suggestionByPair.get(`${row.source.trim()}\0${row.target}`);
}
</script> </script>
<div class="space-y-4"> <div class="space-y-4">
@@ -103,24 +78,10 @@
<div class="mb-2 text-muted-foreground">&lt;product&gt;</div> <div class="mb-2 text-muted-foreground">&lt;product&gt;</div>
{#each mapped as row} {#each mapped as row}
{@const sample = sampleFor(row.source)} {@const sample = sampleFor(row.source)}
{@const suggestion = suggestionFor(row)} <div class="ml-3 py-1">
<div class="ml-3 flex flex-wrap items-baseline gap-2 py-1">
<span class="text-primary">&lt;{row.target}&gt;</span> <span class="text-primary">&lt;{row.target}&gt;</span>
<span class="text-foreground/80" title={`source: ${row.source}`}>{sample}</span> <span class="text-foreground/80" title={`source: ${row.source}`}>{sample}</span>
<span class="text-primary">&lt;/{row.target}&gt;</span> <span class="text-primary">&lt;/{row.target}&gt;</span>
<Badge variant="outline" class="font-sans text-[10px]">{labelFor(row.target)}</Badge>
{#if suggestion}
<Badge
variant={confidenceBadgeVariant(suggestion.confidence)}
class="font-sans text-[10px] capitalize"
title={`${suggestion.confidence} · ${Math.round(suggestion.score * 100)}%`}
>
{suggestion.confidence}
</Badge>
{/if}
{#if row.source.includes("/")}
<span class="font-sans text-[10px] text-muted-foreground">{formatSource(row.source)}</span>
{/if}
</div> </div>
{/each} {/each}
<div class="mt-2 text-muted-foreground">&lt;/product&gt;</div> <div class="mt-2 text-muted-foreground">&lt;/product&gt;</div>
@@ -113,18 +113,17 @@
<Table shellClass="max-h-[480px]"> <Table shellClass="max-h-[480px]">
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead class="w-[36%]" <TableHead class="w-[28%]"
>{isCsv ? i18n.t("mapping.table.fieldName") : i18n.t("mapping.table.sourcePath")}</TableHead >{isCsv ? i18n.t("mapping.table.fieldName") : i18n.t("mapping.table.sourcePath")}</TableHead
> >
<TableHead class="w-[28%]">{i18n.t("mapping.table.sampleValues")}</TableHead> <TableHead class="w-[30%]">{i18n.t("mapping.table.sampleValues")}</TableHead>
<TableHead class="w-[12%]">{i18n.t("mapping.table.type")}</TableHead> <TableHead class="w-[42%]">{i18n.t("mapping.table.mapTo")}</TableHead>
<TableHead class="w-[24%]">{i18n.t("mapping.table.mapTo")}</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{#if filtered.length === 0} {#if filtered.length === 0}
<TableRow> <TableRow>
<TableCell colspan={4} class="py-10 text-center text-muted-foreground"> <TableCell colspan={3} class="py-10 text-center text-muted-foreground">
{#if schemaFields.length === 0} {#if schemaFields.length === 0}
{i18n.t("mapping.table.emptySchema")} {i18n.t("mapping.table.emptySchema")}
{:else} {:else}
@@ -139,7 +138,7 @@
{@const depth = pathDepth(field.path)} {@const depth = pathDepth(field.path)}
{@const parent = pathParent(field.path)} {@const parent = pathParent(field.path)}
<TableRow> <TableRow>
<TableCell class="align-top"> <TableCell class="py-3 align-middle">
{#if isCsv && depth === 0} {#if isCsv && depth === 0}
<div class="text-sm font-medium">{field.field_name || field.path}</div> <div class="text-sm font-medium">{field.field_name || field.path}</div>
{#if field.path !== field.field_name} {#if field.path !== field.field_name}
@@ -161,23 +160,29 @@
{/if} {/if}
</div> </div>
{/if} {/if}
<div class="mt-1" style="padding-left: {Math.min(depth, 6) * 0.75}rem"> <div
class="mt-1.5 flex flex-wrap items-center gap-1.5"
style="padding-left: {Math.min(depth, 6) * 0.75}rem"
>
<Badge variant="outline" class="text-[10px]"> <Badge variant="outline" class="text-[10px]">
{i18n.t("mapping.table.unique", { {i18n.t("mapping.table.unique", {
count: field.unique_values_count ?? samples.length count: field.unique_values_count ?? samples.length
})} })}
</Badge> </Badge>
<Badge variant="secondary" class={`text-[10px] ${typeClass(field.data_type || "string")}`}>
{field.data_type || "string"}
</Badge>
{#if depth > 0} {#if depth > 0}
<Badge variant="secondary" class="ml-1 text-[10px]" <Badge variant="secondary" class="text-[10px]"
>{i18n.t("mapping.table.nested")}</Badge >{i18n.t("mapping.table.nested")}</Badge
> >
{/if} {/if}
</div> </div>
</TableCell> </TableCell>
<TableCell class="align-top"> <TableCell class="py-3 align-middle">
<div class="max-h-20 space-y-1 overflow-y-auto text-xs"> <div class="min-w-0 space-y-1 text-xs">
{#each samples.slice(0, 5) as val, i (i)} {#each samples.slice(0, 2) as val}
<div class="break-all border-b border-border/60 pb-1 last:border-0"> <div class="truncate" title={val}>
{val} {val}
</div> </div>
{/each} {/each}
@@ -186,12 +191,7 @@
{/if} {/if}
</div> </div>
</TableCell> </TableCell>
<TableCell class="align-top"> <TableCell class="py-3 align-middle">
<Badge variant="secondary" class={typeClass(field.data_type || "string")}>
{field.data_type || "string"}
</Badge>
</TableCell>
<TableCell class="align-top">
{@const suggestion = suggestionBySource.get(field.path)} {@const suggestion = suggestionBySource.get(field.path)}
{@const suggestionApplied = Boolean( {@const suggestionApplied = Boolean(
suggestion && current !== "none" && current === suggestion.target suggestion && current !== "none" && current === suggestion.target
@@ -520,12 +520,12 @@
</script> </script>
{#if open} {#if open}
<div class="fixed inset-0 z-[60] flex items-end justify-center sm:items-center sm:p-4 md:p-6"> <div class="fixed inset-0 z-[120] flex items-end justify-center sm:items-center sm:p-4 md:p-6">
<button type="button" class="absolute inset-0 bg-black/55" aria-label={i18n.t("catalog.closePanel")} onclick={close} <button type="button" class="absolute inset-0 bg-black/55" aria-label={i18n.t("catalog.closePanel")} onclick={close}
></button> ></button>
<div <div
bind:this={panelEl} bind:this={panelEl}
class="relative z-[60] flex h-[100dvh] w-full flex-col overflow-hidden border-border bg-background shadow-2xl sm:h-[min(92dvh,900px)] sm:max-w-5xl sm:rounded-xl sm:border lg:max-w-6xl" class="relative z-[120] flex h-[100dvh] w-full flex-col overflow-hidden border-border bg-background shadow-2xl sm:h-[min(92dvh,900px)] sm:max-w-5xl sm:rounded-xl sm:border lg:max-w-6xl"
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-labelledby="product-edit-title" aria-labelledby="product-edit-title"
@@ -644,11 +644,6 @@
configured={contentLanguages} configured={contentLanguages}
primary={contentLanguages[0] || DEFAULT_CONTENT_LANGUAGE} primary={contentLanguages[0] || DEFAULT_CONTENT_LANGUAGE}
allowAdd={false} allowAdd={false}
hasOverride={(code) => {
const primary = contentLanguages[0] || DEFAULT_CONTENT_LANGUAGE;
if (code === primary) return false;
return Boolean(product?.localized_content?.[code]);
}}
onChange={(code) => applyContentLang(code)} onChange={(code) => applyContentLang(code)}
/> />
<p class="text-xs text-muted-foreground">{i18n.t("contentLang.resultsHint")}</p> <p class="text-xs text-muted-foreground">{i18n.t("contentLang.resultsHint")}</p>
@@ -9,11 +9,11 @@
</script> </script>
{#if label === "completed"} {#if label === "completed"}
<Badge variant="success">{i18n.t("products.status.processed")}</Badge> <Badge variant="success" class="whitespace-nowrap">{i18n.t("products.status.processed")}</Badge>
{:else if label === "processed" || label === "needs_review"} {:else if label === "processed" || label === "needs_review"}
<Badge variant="warning">{i18n.t("products.status.needsReview")}</Badge> <Badge variant="warning" class="whitespace-nowrap">{i18n.t("products.status.needsReview")}</Badge>
{:else if label === "processing" || label === "in_progress"} {:else if label === "processing" || label === "in_progress"}
<Badge class="border-chart-sky/40 bg-card-blue text-foreground hover:bg-card-blue"> <Badge class="whitespace-nowrap border-chart-sky/40 bg-card-blue text-foreground hover:bg-card-blue">
<span class="flex items-center"> <span class="flex items-center">
{i18n.t("products.status.processing")} {i18n.t("products.status.processing")}
<span class="ml-1 flex space-x-1"> <span class="ml-1 flex space-x-1">
@@ -30,13 +30,13 @@
</span> </span>
</Badge> </Badge>
{:else if label === "error"} {:else if label === "error"}
<Badge variant="destructive">{i18n.t("products.status.error")}</Badge> <Badge variant="destructive" class="whitespace-nowrap">{i18n.t("products.status.error")}</Badge>
{:else if label === "failed"} {:else if label === "failed"}
<Badge variant="destructive">{i18n.t("products.status.failed")}</Badge> <Badge variant="destructive" class="whitespace-nowrap">{i18n.t("products.status.failed")}</Badge>
{:else if label === "unprocessed"} {:else if label === "unprocessed"}
<Badge variant="warning">{i18n.t("products.status.unprocessed")}</Badge> <Badge variant="warning" class="whitespace-nowrap">{i18n.t("products.status.unprocessed")}</Badge>
{:else if label === "pending"} {:else if label === "pending"}
<Badge class="border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80"> <Badge class="whitespace-nowrap border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80">
<span class="flex items-center"> <span class="flex items-center">
{i18n.t("products.status.pending")} {i18n.t("products.status.pending")}
<span class="ml-1 flex space-x-1"> <span class="ml-1 flex space-x-1">
@@ -53,5 +53,5 @@
</span> </span>
</Badge> </Badge>
{:else} {:else}
<Badge variant="outline">{i18n.t("products.status.unknown")}</Badge> <Badge variant="outline" class="whitespace-nowrap">{i18n.t("products.status.unknown")}</Badge>
{/if} {/if}
@@ -299,9 +299,9 @@ import { Check, Edit, MoreHorizontal, X } from "@lucide/svelte";
] ]
: categoryOptions} : categoryOptions}
<div class="flex min-w-0 flex-col gap-1"> <div class="flex min-w-0 flex-col gap-1">
<div class="flex min-w-0 items-center gap-1"> <div class="flex min-w-0 items-center gap-0.5">
<Select <Select
class="h-8 min-w-0 flex-1 py-1 text-xs" class="h-8 min-w-[12rem] flex-1 py-1 text-xs"
bind:value={editCategoryValue} bind:value={editCategoryValue}
onkeydown={(e) => onCategoryKey(e, product)} onkeydown={(e) => onCategoryKey(e, product)}
disabled={savingCategory || optionsForSelect.length === 0} disabled={savingCategory || optionsForSelect.length === 0}
@@ -326,21 +326,21 @@ import { Check, Edit, MoreHorizontal, X } from "@lucide/svelte";
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
class="h-10 w-10 shrink-0 touch-manipulation text-primary sm:h-7 sm:w-7" class="h-7 w-7 shrink-0 text-primary"
aria-label={i18n.t("products.table.saveCategory")} aria-label={i18n.t("products.table.saveCategory")}
data-testid="product-category-save" data-testid="product-category-save"
onclick={() => void saveCategory(product)} onclick={() => void saveCategory(product)}
> >
<Check class="h-4 w-4" /> <Check class="h-3.5 w-3.5" />
</Button> </Button>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
class="h-10 w-10 shrink-0 touch-manipulation text-muted-foreground sm:h-7 sm:w-7" class="h-7 w-7 shrink-0 text-muted-foreground"
aria-label={i18n.t("products.table.cancelCategory")} aria-label={i18n.t("products.table.cancelCategory")}
onclick={cancelCategoryEdit} onclick={cancelCategoryEdit}
> >
<X class="h-4 w-4" /> <X class="h-3.5 w-3.5" />
</Button> </Button>
{/if} {/if}
</div> </div>
@@ -419,14 +419,14 @@ import { Check, Edit, MoreHorizontal, X } from "@lucide/svelte";
isLoading && "bg-background/20 backdrop-blur-sm" isLoading && "bg-background/20 backdrop-blur-sm"
)} )}
> >
<div class="w-full min-w-0 md:min-w-[44rem] lg:min-w-[52rem]"> <div class="w-full min-w-0 md:min-w-[48rem] lg:min-w-[56rem]">
<div <div
class={cn( class={cn(
"sticky top-0 z-10 flex border-b bg-muted text-sm font-medium", "sticky top-0 z-10 flex border-b bg-muted text-sm font-medium",
isLoading && "bg-background/50 backdrop-blur-sm" isLoading && "bg-background/50 backdrop-blur-sm"
)} )}
> >
<div class="flex w-12 shrink-0 items-center justify-center p-3"> <div class="flex w-12 shrink-0 items-center justify-center px-3 py-2">
<input <input
bind:this={headerCheckbox} bind:this={headerCheckbox}
type="checkbox" type="checkbox"
@@ -443,17 +443,17 @@ import { Check, Edit, MoreHorizontal, X } from "@lucide/svelte";
/> />
</div> </div>
<div class="min-w-0 flex-1 p-3 font-medium md:min-w-[10rem]">{i18n.t("products.table.colIdName")}</div> <div class="min-w-0 flex-1 px-3 py-2 font-medium md:min-w-[10rem]">{i18n.t("products.table.colIdName")}</div>
<div class="hidden w-[128px] shrink-0 p-3 font-medium sm:block sm:w-[140px]" title={i18n.t("products.table.coverageTitle")}> <div class="hidden w-[128px] shrink-0 px-3 py-2 font-medium sm:block sm:w-[140px]" title={i18n.t("products.table.coverageTitle")}>
{i18n.t("products.table.colCoverage")} {i18n.t("products.table.colCoverage")}
</div> </div>
<div class="hidden w-[130px] shrink-0 p-3 font-medium xl:block">{i18n.t("products.table.colFeed")}</div> <div class="hidden w-[130px] shrink-0 px-3 py-2 font-medium xl:block">{i18n.t("products.table.colFeed")}</div>
{#if activeTab !== "unprocessed"} {#if activeTab !== "unprocessed"}
<div class="hidden w-[168px] shrink-0 p-3 font-medium md:block">{i18n.t("products.table.colCategory")}</div> <div class="hidden w-[300px] shrink-0 px-3 py-2 font-medium md:block">{i18n.t("products.table.colCategory")}</div>
{/if} {/if}
<div class="hidden w-[110px] shrink-0 p-3 font-medium md:block">{i18n.t("products.table.colStatus")}</div> <div class="hidden w-[128px] shrink-0 px-3 py-2 font-medium md:block">{i18n.t("products.table.colStatus")}</div>
<div class="hidden w-[120px] shrink-0 p-3 font-medium lg:block">{i18n.t("products.table.colUpdated")}</div> <div class="hidden w-[120px] shrink-0 px-3 py-2 font-medium lg:block">{i18n.t("products.table.colUpdated")}</div>
<div class="table-sticky-actions flex w-14 shrink-0 items-center justify-end bg-muted p-2 text-right font-medium sm:w-[100px] sm:p-3"> <div class="table-sticky-actions flex w-14 shrink-0 items-center justify-end bg-muted px-2 py-2 text-right font-medium sm:w-[100px] sm:px-3">
<span class="sr-only sm:not-sr-only">{i18n.t("products.table.colActions")}</span> <span class="sr-only sm:not-sr-only">{i18n.t("products.table.colActions")}</span>
</div> </div>
</div> </div>
@@ -461,35 +461,32 @@ import { Check, Edit, MoreHorizontal, X } from "@lucide/svelte";
{#if isLoading} {#if isLoading}
<div class="relative overflow-hidden" style="height: 400px" role="status" aria-label={i18n.t("products.table.loading")} aria-busy="true"> <div class="relative overflow-hidden" style="height: 400px" role="status" aria-label={i18n.t("products.table.loading")} aria-busy="true">
{#each Array.from({ length: 6 }, (_, i) => i) as index} {#each Array.from({ length: 6 }, (_, i) => i) as index}
<div class="flex h-20 w-full min-w-0 border-b border-border"> <div class="flex h-16 w-full min-w-0 border-b border-border">
<div class="flex w-12 shrink-0 items-center justify-center p-3"> <div class="flex w-12 shrink-0 items-center justify-center px-3 py-2">
<Skeleton class="h-4 w-4 rounded-sm" /> <Skeleton class="h-4 w-4 rounded-sm" />
</div> </div>
<div class="min-w-0 flex-1 space-y-2 p-3 md:min-w-[10rem]"> <div class="min-w-0 flex-1 space-y-1.5 px-3 py-2 md:min-w-[10rem]">
<Skeleton class="h-3 w-[100px] opacity-60" /> <Skeleton class="h-3 w-[100px] opacity-60" />
<Skeleton class="h-4 w-[min(220px,80%)]" /> <Skeleton class="h-4 w-[min(220px,80%)]" />
{#if index % 3 === 0}
<Skeleton class="mt-1 h-3 w-[min(160px,60%)] opacity-60" />
{/if}
</div> </div>
<div class="hidden w-[128px] shrink-0 items-center p-3 sm:flex sm:w-[140px]"> <div class="hidden w-[128px] shrink-0 items-center px-3 py-2 sm:flex sm:w-[140px]">
<Skeleton class="h-8 w-[100px]" /> <Skeleton class="h-5 w-[100px]" />
</div> </div>
<div class="hidden w-[130px] shrink-0 items-center p-3 xl:flex"> <div class="hidden w-[130px] shrink-0 items-center px-3 py-2 xl:flex">
<Skeleton class="h-8 w-[100px]" /> <Skeleton class="h-4 w-[100px]" />
</div> </div>
{#if activeTab !== "unprocessed"} {#if activeTab !== "unprocessed"}
<div class="hidden w-[168px] shrink-0 items-center p-3 md:flex"> <div class="hidden w-[300px] shrink-0 items-center px-3 py-2 md:flex">
<Skeleton class="h-6 w-[100px]" /> <Skeleton class="h-5 w-[140px]" />
</div> </div>
{/if} {/if}
<div class="hidden w-[110px] shrink-0 items-center p-3 md:flex"> <div class="hidden w-[128px] shrink-0 items-center px-3 py-2 md:flex">
<Skeleton class="h-6 w-[80px] rounded-full" /> <Skeleton class="h-5 w-[80px] rounded-full" />
</div> </div>
<div class="hidden w-[120px] shrink-0 items-center p-3 lg:flex"> <div class="hidden w-[120px] shrink-0 items-center px-3 py-2 lg:flex">
<Skeleton class="h-4 w-[90px]" /> <Skeleton class="h-4 w-[90px]" />
</div> </div>
<div class="table-sticky-actions flex w-14 shrink-0 items-center justify-end gap-1 bg-card p-2 sm:w-[100px] sm:p-3"> <div class="table-sticky-actions flex w-14 shrink-0 items-center justify-end gap-1 bg-card px-2 py-2 sm:w-[100px] sm:px-3">
<Skeleton class="h-8 w-8 rounded-full opacity-50" /> <Skeleton class="h-8 w-8 rounded-full opacity-50" />
</div> </div>
</div> </div>
@@ -501,7 +498,7 @@ import { Check, Edit, MoreHorizontal, X } from "@lucide/svelte";
{:else} {:else}
<VirtualList <VirtualList
items={products} items={products}
estimateSize={88} estimateSize={64}
overscan={6} overscan={6}
class="max-h-[min(70vh,640px)]" class="max-h-[min(70vh,640px)]"
getKey={(product) => String(product.id)} getKey={(product) => String(product.id)}
@@ -511,12 +508,12 @@ import { Check, Edit, MoreHorizontal, X } from "@lucide/svelte";
{@const qScore = Number(product.quality_score ?? NaN)} {@const qScore = Number(product.quality_score ?? NaN)}
<div <div
class={cn( class={cn(
"group flex min-h-[5.5rem] border-b border-border transition-all duration-300 hover:bg-muted/40", "group flex border-b border-border transition-colors hover:bg-muted/40",
selected && "bg-muted/60" selected && "bg-muted/60"
)} )}
data-state={selected ? "selected" : undefined} data-state={selected ? "selected" : undefined}
> >
<div class="flex w-12 shrink-0 items-center justify-center p-3"> <div class="flex w-12 shrink-0 items-center justify-center px-3 py-2">
<input <input
type="checkbox" type="checkbox"
class="h-4 w-4 rounded border border-primary" class="h-4 w-4 rounded border border-primary"
@@ -532,17 +529,17 @@ import { Check, Edit, MoreHorizontal, X } from "@lucide/svelte";
/> />
</div> </div>
<div class="flex min-w-0 flex-1 items-start gap-3 p-3 md:min-w-[10rem]"> <div class="flex min-w-0 flex-1 items-center gap-2.5 px-3 py-2 md:min-w-[10rem]">
<ProductThumb <ProductThumb
src={productThumbUrl(product)} src={productThumbUrl(product)}
alt={productDisplayName(product, kind)} alt={productDisplayName(product, kind)}
class="mt-0.5" size="sm"
/> />
<div class="flex min-w-0 flex-1 flex-col"> <div class="flex min-w-0 flex-1 flex-col">
<div class="truncate font-mono text-xs text-muted-foreground">{productSku(product)}</div> <div class="truncate font-mono text-[11px] text-muted-foreground">{productSku(product)}</div>
<div class="min-w-0 font-medium"> <div class="min-w-0 text-sm font-medium">
{#if editingId !== null && String(editingId) === String(product.id)} {#if editingId !== null && String(editingId) === String(product.id)}
<div class="flex items-center space-x-1 py-1"> <div class="flex items-center space-x-1">
<Input <Input
class="h-8 py-1" class="h-8 py-1"
bind:value={editValue} bind:value={editValue}
@@ -559,7 +556,7 @@ import { Check, Edit, MoreHorizontal, X } from "@lucide/svelte";
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
class="h-10 w-10 touch-manipulation text-primary sm:h-7 sm:w-7" class="h-8 w-8 touch-manipulation text-primary sm:h-7 sm:w-7"
aria-label={i18n.t("products.table.saveName")} aria-label={i18n.t("products.table.saveName")}
onclick={() => void saveInline()} onclick={() => void saveInline()}
> >
@@ -568,7 +565,7 @@ import { Check, Edit, MoreHorizontal, X } from "@lucide/svelte";
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
class="h-10 w-10 touch-manipulation text-muted-foreground sm:h-7 sm:w-7" class="h-8 w-8 touch-manipulation text-muted-foreground sm:h-7 sm:w-7"
aria-label={i18n.t("products.table.cancelRename")} aria-label={i18n.t("products.table.cancelRename")}
onclick={() => { onclick={() => {
editingId = null; editingId = null;
@@ -583,7 +580,7 @@ import { Check, Edit, MoreHorizontal, X } from "@lucide/svelte";
<div class="group/name flex min-w-0 items-center"> <div class="group/name flex min-w-0 items-center">
<button <button
type="button" type="button"
class="line-clamp-2 min-w-0 cursor-pointer pr-1 text-left hover:underline" class="line-clamp-1 min-w-0 cursor-pointer pr-1 text-left hover:underline"
onclick={() => onEditProduct(product)} onclick={() => onEditProduct(product)}
> >
{productDisplayName(product, kind)} {productDisplayName(product, kind)}
@@ -605,7 +602,7 @@ import { Check, Edit, MoreHorizontal, X } from "@lucide/svelte";
{/if} {/if}
</div> </div>
<!-- Coverage under name below sm; column takes over at sm+ --> <!-- Coverage under name below sm; column takes over at sm+ -->
<div class="mt-2 sm:hidden"> <div class="mt-1 sm:hidden">
{#if Number.isFinite(qScore)} {#if Number.isFinite(qScore)}
<span <span
class="inline-flex min-w-10 items-center justify-center rounded-md px-1.5 py-0.5 text-xs font-semibold {qualityClass(qScore)}" class="inline-flex min-w-10 items-center justify-center rounded-md px-1.5 py-0.5 text-xs font-semibold {qualityClass(qScore)}"
@@ -615,7 +612,7 @@ import { Check, Edit, MoreHorizontal, X } from "@lucide/svelte";
</span> </span>
{:else} {:else}
{@const states = productEnrichmentStates(product)} {@const states = productEnrichmentStates(product)}
<div class="flex flex-col gap-1" title={leanReadyTitle(product)}> <div class="flex flex-wrap items-center gap-1" title={leanReadyTitle(product)}>
<div class="flex flex-wrap items-center gap-0.5" aria-label={productEnrichmentTitle(product)}> <div class="flex flex-wrap items-center gap-0.5" aria-label={productEnrichmentTitle(product)}>
{#each COVERAGE_CHIPS as chip} {#each COVERAGE_CHIPS as chip}
<span <span
@@ -641,7 +638,7 @@ import { Check, Edit, MoreHorizontal, X } from "@lucide/svelte";
{/if} {/if}
</div> </div>
<!-- Category + status until md; columns take over at md+ (avoids smmd gap) --> <!-- Category + status until md; columns take over at md+ (avoids smmd gap) -->
<div class="mt-2 space-y-2 md:hidden"> <div class="mt-1 space-y-1 md:hidden">
{#if activeTab !== "unprocessed" && kind === "processed"} {#if activeTab !== "unprocessed" && kind === "processed"}
{@render categoryCell(product, "mobile")} {@render categoryCell(product, "mobile")}
{/if} {/if}
@@ -650,7 +647,7 @@ import { Check, Edit, MoreHorizontal, X } from "@lucide/svelte";
</div> </div>
</div> </div>
<div class="hidden w-[128px] shrink-0 items-center p-3 sm:flex sm:w-[140px]"> <div class="hidden w-[128px] shrink-0 items-center px-3 py-2 sm:flex sm:w-[140px]">
{#if Number.isFinite(qScore)} {#if Number.isFinite(qScore)}
<span <span
class="inline-flex min-w-10 items-center justify-center rounded-md px-1.5 py-0.5 text-xs font-semibold {qualityClass(qScore)}" class="inline-flex min-w-10 items-center justify-center rounded-md px-1.5 py-0.5 text-xs font-semibold {qualityClass(qScore)}"
@@ -660,7 +657,7 @@ import { Check, Edit, MoreHorizontal, X } from "@lucide/svelte";
</span> </span>
{:else} {:else}
{@const states = productEnrichmentStates(product)} {@const states = productEnrichmentStates(product)}
<div class="flex flex-col gap-1" title={leanReadyTitle(product)}> <div class="flex flex-wrap items-center gap-1" title={leanReadyTitle(product)}>
<div class="flex flex-wrap items-center gap-0.5" aria-label={productEnrichmentTitle(product)}> <div class="flex flex-wrap items-center gap-0.5" aria-label={productEnrichmentTitle(product)}>
{#each COVERAGE_CHIPS as chip} {#each COVERAGE_CHIPS as chip}
<span <span
@@ -685,27 +682,29 @@ import { Check, Edit, MoreHorizontal, X } from "@lucide/svelte";
</div> </div>
{/if} {/if}
</div> </div>
<div class="hidden w-[130px] shrink-0 flex-col justify-center p-3 xl:flex"> <div class="hidden w-[130px] shrink-0 items-center px-3 py-2 xl:flex">
{#if product.feed_name} {#if product.feed_name}
<div class="truncate text-sm font-medium" title={String(product.feed_name)}>{product.feed_name}</div> <div
<div class="truncate text-xs text-muted-foreground" title={productFeedSyncLabel(product)}> class="truncate text-sm"
{formatRelativeUpdated(product.feed_last_synced_at || product.raw_updated_at) || i18n.t("products.table.noSyncYet")} title={productFeedSyncLabel(product)}
>
{product.feed_name}
</div> </div>
{:else} {:else}
<div class="text-sm text-muted-foreground"></div> <div class="text-sm text-muted-foreground"></div>
{/if} {/if}
</div> </div>
{#if activeTab !== "unprocessed"} {#if activeTab !== "unprocessed"}
<div class="hidden w-[168px] shrink-0 items-center p-3 md:flex"> <div class="hidden w-[300px] shrink-0 items-center px-3 py-2 md:flex">
{@render categoryCell(product, "desktop")} {@render categoryCell(product, "desktop")}
</div> </div>
{/if} {/if}
<div class="hidden w-[110px] shrink-0 items-center p-3 md:flex"> <div class="hidden w-[128px] shrink-0 items-center px-3 py-2 md:flex">
<ProductStatusBadge status={product.status ?? product.processing_status} /> <ProductStatusBadge status={product.status ?? product.processing_status} />
</div> </div>
<div class="hidden w-[120px] shrink-0 items-center p-3 lg:flex"> <div class="hidden w-[120px] shrink-0 items-center px-3 py-2 lg:flex">
<div class="text-sm text-muted-foreground"> <div class="text-sm text-muted-foreground">
{formatRelativeUpdated(product.updated_at) || "—"} {formatRelativeUpdated(product.updated_at) || "—"}
</div> </div>
@@ -713,7 +712,7 @@ import { Check, Edit, MoreHorizontal, X } from "@lucide/svelte";
<div <div
class={cn( class={cn(
"table-sticky-actions relative flex w-14 shrink-0 items-center justify-end gap-1 p-2 sm:w-[100px] sm:p-3", "table-sticky-actions relative flex w-14 shrink-0 items-center justify-end gap-1 px-2 py-2 sm:w-[100px] sm:px-3",
selected ? "bg-muted/60" : "bg-card group-hover:bg-muted/40" selected ? "bg-muted/60" : "bg-card group-hover:bg-muted/40"
)} )}
> >
@@ -721,7 +720,7 @@ import { Check, Edit, MoreHorizontal, X } from "@lucide/svelte";
{#snippet trigger({ open, toggle })} {#snippet trigger({ open, toggle })}
<button <button
type="button" type="button"
class="relative z-10 inline-flex h-10 w-10 items-center justify-center rounded-md opacity-70 hover:bg-accent hover:opacity-100" class="relative z-10 inline-flex h-8 w-8 items-center justify-center rounded-md opacity-70 hover:bg-accent hover:opacity-100"
aria-label={i18n.t("products.table.productOptions")} aria-label={i18n.t("products.table.productOptions")}
aria-haspopup="menu" aria-haspopup="menu"
aria-expanded={open} aria-expanded={open}
+4 -4
View File
@@ -86,9 +86,9 @@
</script> </script>
{#if open} {#if open}
<!-- Overlay above assistant FAB (z-[55]); same layer as CommandPalette --> <!-- Overlay above app header (z-[110]) and assistant FAB -->
<div class="fixed inset-0 z-[60] bg-black/80" aria-hidden="true"></div> <div class="fixed inset-0 z-[120] bg-black/80" aria-hidden="true"></div>
<div class="fixed inset-0 z-[60] flex items-end justify-center p-0 sm:items-center sm:p-4"> <div class="fixed inset-0 z-[120] flex items-end justify-center p-0 sm:items-center sm:p-4">
<button <button
type="button" type="button"
class="absolute inset-0 cursor-default {!overlayArmed ? 'pointer-events-none' : ''}" class="absolute inset-0 cursor-default {!overlayArmed ? 'pointer-events-none' : ''}"
@@ -104,7 +104,7 @@
aria-describedby={description ? "ui-dialog-desc" : undefined} aria-describedby={description ? "ui-dialog-desc" : undefined}
tabindex="-1" tabindex="-1"
class={cn( class={cn(
"relative z-[60] grid max-h-[min(92dvh,900px)] w-full max-w-[600px] min-w-0 grid-rows-[auto_minmax(0,1fr)_auto] gap-4 overflow-hidden border bg-background p-4 shadow-lg sm:rounded-lg sm:p-6", "relative z-[120] grid max-h-[min(92dvh,900px)] w-full max-w-[600px] min-w-0 grid-rows-[auto_minmax(0,1fr)_auto] gap-4 overflow-hidden border bg-background p-4 shadow-lg sm:rounded-lg sm:p-6",
"rounded-t-xl pb-[max(1rem,env(safe-area-inset-bottom))] sm:pb-6", "rounded-t-xl pb-[max(1rem,env(safe-area-inset-bottom))] sm:pb-6",
className className
)} )}
+7 -2
View File
@@ -2115,7 +2115,7 @@ export const en: MessageDict = {
"mapping.highConfidence": "{count} high-confidence mapping ready", "mapping.highConfidence": "{count} high-confidence mapping ready",
"mapping.highConfidencePlural": "{count} high-confidence mappings ready", "mapping.highConfidencePlural": "{count} high-confidence mappings ready",
"mapping.highConfidenceHelp": "mapping.highConfidenceHelp":
"Exact and alias matches (for example EAN → GTIN). Apply all, or accept per-row chips in the table.", "Exact and alias matches (for example EAN → GTIN). Use Auto-map, or accept per-row in the table.",
"mapping.reviewMappings": "Review mappings", "mapping.reviewMappings": "Review mappings",
"mapping.needItemPath": "Enter a product element path before mapping fields.", "mapping.needItemPath": "Enter a product element path before mapping fields.",
"mapping.removeMapping": "Remove mapping", "mapping.removeMapping": "Remove mapping",
@@ -5283,7 +5283,7 @@ export const en: MessageDict = {
"categories.sectionType.ul": "Bullet List", "categories.sectionType.ul": "Bullet List",
"categories.aiGenerationPrompt": "AI generation prompt", "categories.aiGenerationPrompt": "AI generation prompt",
"categories.aiPromptOverrides": "categories.aiPromptOverrides":
"Overrides the company product-enhance user prompt for {name}", "Rules for how products title, description, meta in this category should be rewritten and enriched with AI.",
"categories.promptTemplate": "Prompt template", "categories.promptTemplate": "Prompt template",
"categories.promptTemplateHelp": "categories.promptTemplateHelp":
"Use placeholders like {{name}} and {{description}}. Leave empty to use the company default from Integrations → AI.", "Use placeholders like {{name}} and {{description}}. Leave empty to use the company default from Integrations → AI.",
@@ -5301,6 +5301,11 @@ export const en: MessageDict = {
"categories.promptSection.navHeading": "Sections", "categories.promptSection.navHeading": "Sections",
"categories.promptSection.navHint": "categories.promptSection.navHint":
"Edit one part of the enhance reply at a time.", "Edit one part of the enhance reply at a time.",
"categories.promptSection.structureHeading": "Structure & formulas",
"categories.promptSection.structureEmpty": "None set — expand to configure assembly for this section.",
"categories.promptSection.structureDescCount":
"{count} description section(s)",
"categories.promptSection.openFullEditor": "Open full editor",
"categories.promptSection.title": "Title", "categories.promptSection.title": "Title",
"categories.promptSection.titleHelp": "Instructions for the product title.", "categories.promptSection.titleHelp": "Instructions for the product title.",
"categories.promptSection.titleFormulaNote": "categories.promptSection.titleFormulaNote":
@@ -743,7 +743,7 @@
message={i18n.t("empty.admin.noProviderMessage")} message={i18n.t("empty.admin.noProviderMessage")}
/> />
{:else} {:else}
<TableShell> <TableShell tableClass="md:min-w-0">
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>{i18n.t("admin.analytics.provider")}</TableHead> <TableHead>{i18n.t("admin.analytics.provider")}</TableHead>
@@ -782,7 +782,7 @@
{#if data.companies.length === 0} {#if data.companies.length === 0}
<EmptyState title={i18n.t("empty.admin.noCompaniesTitle")} message={i18n.t("empty.admin.noCompaniesMessage")} /> <EmptyState title={i18n.t("empty.admin.noCompaniesTitle")} message={i18n.t("empty.admin.noCompaniesMessage")} />
{:else} {:else}
<TableShell> <TableShell tableClass="md:min-w-0">
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>{i18n.t("admin.analytics.company")}</TableHead> <TableHead>{i18n.t("admin.analytics.company")}</TableHead>
+3 -3
View File
@@ -216,9 +216,9 @@
{/if} {/if}
</div> </div>
</div> </div>
<CardContent class="p-0"> <CardContent class="!p-2">
{#if loading} {#if loading}
<div class="space-y-3 p-4"> <div class="space-y-3">
<div class="h-10 w-full animate-pulse rounded-md bg-muted"></div> <div class="h-10 w-full animate-pulse rounded-md bg-muted"></div>
<div class="h-10 w-full animate-pulse rounded-md bg-muted"></div> <div class="h-10 w-full animate-pulse rounded-md bg-muted"></div>
<div class="h-10 w-full animate-pulse rounded-md bg-muted"></div> <div class="h-10 w-full animate-pulse rounded-md bg-muted"></div>
@@ -249,7 +249,7 @@
</div> </div>
</div> </div>
{:else} {:else}
<div class="space-y-0 py-2"> <div class="space-y-0">
{#each roots as node (node.id)} {#each roots as node (node.id)}
<CategoryTreeNode <CategoryTreeNode
{node} {node}
@@ -14,7 +14,7 @@
parseTemplateToFormula parseTemplateToFormula
} from "$lib/categories/formula"; } from "$lib/categories/formula";
import { loadFormulaVariables } from "$lib/categories/formula-variables"; import { loadFormulaVariables } from "$lib/categories/formula-variables";
import { findCategoryIdByUniqueId, listAllCategories, resolveCategory } from "$lib/categories/resolve"; import { findCategoryIdByUniqueId, listAllCategories, resolveCategory, categoryFormulaPath } from "$lib/categories/resolve";
import { import {
composeEnhancePrompt, composeEnhancePrompt,
DEFAULT_ENHANCE_PREAMBLE, DEFAULT_ENHANCE_PREAMBLE,
@@ -23,7 +23,6 @@
parseEnhancePrompt, parseEnhancePrompt,
type EnhancePromptSectionId type EnhancePromptSectionId
} from "$lib/categories/prompt-sections"; } from "$lib/categories/prompt-sections";
import PageShell from "$lib/components/PageShell.svelte";
import Alert from "$lib/components/Alert.svelte"; import Alert from "$lib/components/Alert.svelte";
import Spinner from "$lib/components/Spinner.svelte"; import Spinner from "$lib/components/Spinner.svelte";
import ContentLanguageSwitcher from "$lib/components/ContentLanguageSwitcher.svelte"; import ContentLanguageSwitcher from "$lib/components/ContentLanguageSwitcher.svelte";
@@ -41,6 +40,8 @@
} from "$lib/components/ui"; } from "$lib/components/ui";
import { import {
ArrowLeft, ArrowLeft,
ChevronDown,
ExternalLink,
FileText, FileText,
GripVertical, GripVertical,
Plus, Plus,
@@ -150,6 +151,30 @@
const sectionMeta = $derived(SECTION_META[activeSection]); const sectionMeta = $derived(SECTION_META[activeSection]);
const sectionBody = $derived(sectionBodies[activeSection] ?? ""); const sectionBody = $derived(sectionBodies[activeSection] ?? "");
const titleStructureSummary = $derived(
titleFormula.elements.length === 0
? ""
: titleFormula.elements
.map((el) => (el.type === "variable" ? `{${el.value}}` : el.value))
.join(titleFormula.separator || " ")
);
const descriptionStructureSummary = $derived(
descSections.length === 0
? ""
: i18n.t("categories.promptSection.structureDescCount", { count: descSections.length })
);
const metaStructureSummary = $derived(
[metaTitle.trim() && i18n.t("categories.metaTitleFormula"), metaDescription.trim() && i18n.t("categories.metaDescriptionFormula")]
.filter(Boolean)
.join(" · ")
);
const structureSummary = $derived(
activeSection === "title"
? titleStructureSummary
: activeSection === "description"
? descriptionStructureSummary
: metaStructureSummary
);
const usedNames = $derived( const usedNames = $derived(
new Set(titleFormula.elements.filter((e) => e.type === "variable").map((e) => e.value)) new Set(titleFormula.elements.filter((e) => e.type === "variable").map((e) => e.value))
); );
@@ -303,12 +328,42 @@
setSectionBody(""); setSectionBody("");
} }
function clearLanguageOverride() { async function clearLanguageOverride() {
if (!category) return;
const hadLocal =
Object.values(sectionBodies).some((v) => v.trim()) ||
Boolean((promptsByLang[selectedLang] ?? "").trim());
if (!hadLocal) return;
sectionBodies = { title: "", description: "", meta: "" }; sectionBodies = { title: "", description: "", meta: "" };
unstructuredHint = false; unstructuredHint = false;
const next = { ...promptsByLang }; const next = { ...promptsByLang };
delete next[selectedLang]; delete next[selectedLang];
promptsByLang = next; promptsByLang = next;
saving = true;
error = "";
success = "";
try {
const bodyPrompts: Record<string, string> = {};
for (const [k, v] of Object.entries(promptsByLang)) {
if (v.trim()) bodyPrompts[k] = v;
}
const updated = await api<Cat>(`/api/categories/${category.id}/prompt`, {
method: "PATCH",
body: { prompts: bodyPrompts }
});
const refreshed = await resolveCategory(String(category.id));
applyCategory({ ...updated, ...refreshed });
success = i18n.t("categories.aiPromptClearedLang", { lang: langLabel });
allCategories = allCategories.map((c) =>
String(c.id) === String(refreshed.id) ? { ...c, ...refreshed } : c
);
} catch (err) {
error = failureMessage(err, i18n.t("categories.aiPromptSaveFailed"));
} finally {
saving = false;
}
} }
// ----- Title formula editing ----- // ----- Title formula editing -----
@@ -538,14 +593,11 @@
} }
</script> </script>
<PageShell <div class="mx-auto w-full max-w-7xl min-w-0 space-y-6 sm:space-y-8">
title={i18n.t("categories.aiPromptTitle")}
description={i18n.t("categories.aiPromptDescription")}
>
{#if loading} {#if loading}
<div class="flex justify-center p-12"><Spinner label={i18n.t("categories.loadingPrompt")} /></div> <div class="flex justify-center p-12"><Spinner label={i18n.t("categories.loadingPrompt")} /></div>
{:else if category} {:else if category}
<div class="mx-auto max-w-6xl space-y-6"> <div class="w-full space-y-6">
<Alert message={error} /> <Alert message={error} />
<Alert tone="success" message={success} /> <Alert tone="success" message={success} />
@@ -583,16 +635,17 @@
bind:languages={extraLangs} bind:languages={extraLangs}
configured={contentLanguages} configured={contentLanguages}
primary={primaryLang} primary={primaryLang}
hasOverride={(code) => Boolean((promptsByLang[code] ?? "").trim())}
onChange={onLangChange} onChange={onLangChange}
/> />
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
size="sm" size="sm"
onclick={clearLanguageOverride} onclick={() => void clearLanguageOverride()}
disabled={!Object.values(sectionBodies).some((v) => v.trim()) && loading={saving}
!(promptsByLang[selectedLang] ?? "").trim()} disabled={saving ||
(!Object.values(sectionBodies).some((v) => v.trim()) &&
!(promptsByLang[selectedLang] ?? "").trim())}
> >
<X class="h-4 w-4" /> <X class="h-4 w-4" />
{i18n.t("categories.promptSection.clearLanguage")} {i18n.t("categories.promptSection.clearLanguage")}
@@ -603,16 +656,14 @@
<Alert tone="info" message={i18n.t("categories.promptSection.unstructuredHint")} /> <Alert tone="info" message={i18n.t("categories.promptSection.unstructuredHint")} />
{/if} {/if}
<div class="grid gap-4 lg:grid-cols-[220px_minmax(0,1fr)]"> <div class="grid gap-4 lg:grid-cols-[200px_minmax(0,1fr)]">
<aside class="space-y-3 rounded-lg border bg-card/40 p-3" aria-label={i18n.t("categories.promptSection.navLabel")}> <aside
<div> class="space-y-2 rounded-lg border bg-card/40 p-2 sm:p-3"
<p class="text-xs font-semibold uppercase tracking-wide text-muted-foreground"> aria-label={i18n.t("categories.promptSection.navLabel")}
{i18n.t("categories.promptSection.navHeading")} >
</p> <p class="px-2 pt-1 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
<p class="mt-1 text-xs text-muted-foreground"> {i18n.t("categories.promptSection.navHeading")}
{i18n.t("categories.promptSection.navHint")} </p>
</p>
</div>
<ul class="space-y-0.5"> <ul class="space-y-0.5">
{#each ENHANCE_PROMPT_SECTIONS as id (id)} {#each ENHANCE_PROMPT_SECTIONS as id (id)}
{@const meta = SECTION_META[id]} {@const meta = SECTION_META[id]}
@@ -641,22 +692,101 @@
</li> </li>
{/each} {/each}
</ul> </ul>
<p class="border-t pt-3 text-xs text-muted-foreground">
{i18n.t("categories.promptSection.categorizeNote")}
</p>
</aside> </aside>
<div class="min-w-0 space-y-4"> <div class="min-w-0 space-y-4">
{#if activeSection === "title"} <Card>
<Card> <CardHeader>
<CardHeader> <CardTitle>{i18n.t(sectionMeta.labelKey)}</CardTitle>
<CardTitle>{i18n.t("categories.titleFormula")}</CardTitle> <CardDescription>{i18n.t(sectionMeta.helpKey)}</CardDescription>
<CardDescription>{i18n.t("categories.titleFormulaHelp")}</CardDescription> </CardHeader>
</CardHeader> <CardContent class="space-y-4">
<CardContent class="space-y-4"> <div class="flex flex-wrap gap-2">
<div class="grid grid-cols-1 gap-6 xl:grid-cols-3"> {#each ALL_PROMPT_VARS as v}
<Button
type="button"
variant="outline"
size="sm"
onclick={() => insertVariable(v.name)}
>
{i18n.t(v.labelKey)}
<span class="font-mono text-xs text-muted-foreground"
>{"{{"}{v.name}{"}}"}</span
>
</Button>
{/each}
{#if sectionBody.trim()}
<Button type="button" variant="ghost" size="sm" onclick={clearActiveSection}>
<X class="h-4 w-4" />
{i18n.t("categories.clear")}
</Button>
{/if}
</div>
<div class="space-y-2">
<Label for="category-prompt-section">
{i18n.t("categories.promptSection.contentLabel")} ({langLabel})
</Label>
<Textarea
id="category-prompt-section"
value={sectionBody}
oninput={(e) => setSectionBody((e.currentTarget as HTMLTextAreaElement).value)}
rows={12}
class="min-h-[220px] font-mono text-sm"
placeholder={i18n.t("categories.promptSection.contentPlaceholder")}
/>
<p class="text-xs text-muted-foreground">
{i18n.t("categories.charactersCount", {
count: sectionBody.length.toLocaleString()
})}
{#if (promptsByLang[selectedLang] ?? "").trim() || Object.values(sectionBodies).some((v) => v.trim())}
{i18n.t("categories.activeOverride")}
{:else}
{i18n.t("categories.usingCompanyDefaultForLang")}
{/if}
</p>
</div>
</CardContent>
</Card>
<details class="group rounded-lg border bg-card/30 open:bg-card">
<summary
class="flex cursor-pointer list-none items-center gap-3 px-4 py-3 [&::-webkit-details-marker]:hidden"
>
<div class="min-w-0 flex-1">
<p class="text-sm font-medium">{i18n.t("categories.promptSection.structureHeading")}</p>
<p class="truncate font-mono text-xs text-muted-foreground">
{structureSummary || i18n.t("categories.promptSection.structureEmpty")}
</p>
</div>
<ChevronDown
class="h-4 w-4 shrink-0 text-muted-foreground transition-transform group-open:rotate-180"
/>
</summary>
<div class="space-y-4 border-t px-4 py-4">
<p class="text-xs text-muted-foreground">{i18n.t(sectionMeta.formulaNoteKey)}</p>
{#if activeSection === "title" && category}
{@const cat = category}
<div class="flex flex-wrap items-center justify-between gap-2">
<p class="text-sm text-muted-foreground">{i18n.t("categories.titleFormulaHelp")}</p>
<Button
variant="outline"
size="sm"
onclick={() => void goto(categoryFormulaPath(cat, "title"))}
>
<ExternalLink class="h-3.5 w-3.5" />
{i18n.t("categories.promptSection.openFullEditor")}
</Button>
</div>
<div class="grid grid-cols-1 gap-4 xl:grid-cols-3">
<div class="space-y-4 xl:col-span-2"> <div class="space-y-4 xl:col-span-2">
<FormulaPreview preview={titlePreview} formula={titleFormula} brandTips={[]} aiApplyAllowed={true} /> <FormulaPreview
preview={titlePreview}
formula={titleFormula}
brandTips={[]}
aiApplyAllowed={true}
/>
<FormulaBuilder <FormulaBuilder
formula={titleFormula} formula={titleFormula}
onSeparatorChange={(value) => (titleFormula = { ...titleFormula, separator: value })} onSeparatorChange={(value) => (titleFormula = { ...titleFormula, separator: value })}
@@ -709,18 +839,26 @@
/> />
</div> </div>
</div> </div>
</CardContent> {:else if activeSection === "description" && category}
</Card> {@const cat = category}
{:else if activeSection === "description"} <div class="flex flex-wrap items-center justify-between gap-2">
<Card> <p class="text-sm text-muted-foreground">
<CardHeader> {i18n.t("categories.descriptionSectionsHelp")}
<CardTitle>{i18n.t("categories.descriptionSections")}</CardTitle> </p>
<CardDescription>{i18n.t("categories.descriptionSectionsHelp")}</CardDescription> <Button
</CardHeader> variant="outline"
<CardContent class="space-y-4"> size="sm"
onclick={() => void goto(categoryFormulaPath(cat, "description"))}
>
<ExternalLink class="h-3.5 w-3.5" />
{i18n.t("categories.promptSection.openFullEditor")}
</Button>
</div>
<div class="min-h-[80px] space-y-4" role="list"> <div class="min-h-[80px] space-y-4" role="list">
{#if descSections.length === 0} {#if descSections.length === 0}
<div class="flex h-[80px] items-center justify-center rounded-lg border-2 border-dashed"> <div
class="flex h-[80px] items-center justify-center rounded-lg border border-dashed"
>
<div class="text-center text-muted-foreground"> <div class="text-center text-muted-foreground">
<p>{i18n.t("categories.noSectionsYet")}</p> <p>{i18n.t("categories.noSectionsYet")}</p>
<p class="text-sm">{i18n.t("categories.addSectionsHint")}</p> <p class="text-sm">{i18n.t("categories.addSectionsHint")}</p>
@@ -730,7 +868,7 @@
{#each descSections as section, index (section.id)} {#each descSections as section, index (section.id)}
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<div <div
class="space-y-4 rounded-md bg-muted/50 p-4" class="space-y-3 rounded-md bg-muted/50 p-3"
role="listitem" role="listitem"
draggable="true" draggable="true"
ondragstart={() => (dragIndex = index)} ondragstart={() => (dragIndex = index)}
@@ -759,11 +897,17 @@
class="w-[180px]" class="w-[180px]"
> >
{#each SECTION_TYPES as type} {#each SECTION_TYPES as type}
<option value={type.value}>{i18n.t(`categories.sectionType.${type.value}`)}</option> <option value={type.value}
>{i18n.t(`categories.sectionType.${type.value}`)}</option
>
{/each} {/each}
</Select> </Select>
</div> </div>
<Button variant="ghost" size="sm" onclick={() => removeDescSection(section.id)}> <Button
variant="ghost"
size="sm"
onclick={() => removeDescSection(section.id)}
>
<X class="h-4 w-4" /> <X class="h-4 w-4" />
</Button> </Button>
</div> </div>
@@ -783,7 +927,9 @@
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<Label for={`export-id-${section.id}`}>{i18n.t("categories.exportIdOptional")}</Label> <Label for={`export-id-${section.id}`}
>{i18n.t("categories.exportIdOptional")}</Label
>
<Input <Input
id={`export-id-${section.id}`} id={`export-id-${section.id}`}
value={section.exportId || ""} value={section.exportId || ""}
@@ -805,100 +951,36 @@
<Plus class="h-4 w-4" /> <Plus class="h-4 w-4" />
{i18n.t("categories.addSection")} {i18n.t("categories.addSection")}
</Button> </Button>
</CardContent> {:else}
</Card> <div class="space-y-4">
{:else} <div class="space-y-2">
<Card> <Label for="metaTitle">{i18n.t("categories.metaTitleFormula")}</Label>
<CardHeader> <textarea
<CardTitle>{i18n.t("categories.metaInformation")}</CardTitle> id="metaTitle"
<CardDescription>{i18n.t("categories.metaInformationHelp")}</CardDescription> class="flex min-h-[60px] w-full rounded-md border border-border bg-background px-3 py-2 text-sm shadow-sm focus-visible:border-ring focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/30"
</CardHeader> bind:value={metaTitle}
<CardContent class="space-y-4"> rows="2"
<div class="space-y-2"> placeholder={i18n.t("categories.seoTitlePlaceholder")}
<Label for="metaTitle">{i18n.t("categories.metaTitleFormula")}</Label> ></textarea>
<textarea <p class="text-xs text-muted-foreground">{i18n.t("categories.metaTitleHint")}</p>
id="metaTitle" </div>
class="flex min-h-[60px] w-full rounded-md border border-border bg-background px-3 py-2 text-sm shadow-sm focus-visible:border-ring focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/30" <div class="space-y-2">
bind:value={metaTitle} <Label for="metaDescription">{i18n.t("categories.metaDescriptionFormula")}</Label>
rows="2" <textarea
placeholder={i18n.t("categories.seoTitlePlaceholder")} id="metaDescription"
></textarea> class="flex min-h-[80px] w-full rounded-md border border-border bg-background px-3 py-2 text-sm shadow-sm focus-visible:border-ring focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/30"
<p class="text-xs text-muted-foreground"> bind:value={metaDescription}
{i18n.t("categories.metaTitleHint")} rows="3"
</p> placeholder={i18n.t("categories.seoDescriptionPlaceholder")}
></textarea>
<p class="text-xs text-muted-foreground">
{i18n.t("categories.metaDescriptionHint")}
</p>
</div>
</div> </div>
<div class="space-y-2"> {/if}
<Label for="metaDescription">{i18n.t("categories.metaDescriptionFormula")}</Label> </div>
<textarea </details>
id="metaDescription"
class="flex min-h-[80px] w-full rounded-md border border-border bg-background px-3 py-2 text-sm shadow-sm focus-visible:border-ring focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/30"
bind:value={metaDescription}
rows="3"
placeholder={i18n.t("categories.seoDescriptionPlaceholder")}
></textarea>
<p class="text-xs text-muted-foreground">
{i18n.t("categories.metaDescriptionHint")}
</p>
</div>
</CardContent>
</Card>
{/if}
<Card>
<CardHeader>
<CardTitle>{i18n.t(sectionMeta.labelKey)}</CardTitle>
<CardDescription>{i18n.t(sectionMeta.helpKey)}</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<p class="text-xs text-muted-foreground">{i18n.t(sectionMeta.formulaNoteKey)}</p>
<div class="flex flex-wrap gap-2">
{#each ALL_PROMPT_VARS as v}
<Button
type="button"
variant="outline"
size="sm"
onclick={() => insertVariable(v.name)}
>
{i18n.t(v.labelKey)}
<span class="font-mono text-xs text-muted-foreground"
>{"{{"}{v.name}{"}}"}</span
>
</Button>
{/each}
{#if sectionBody.trim()}
<Button type="button" variant="ghost" size="sm" onclick={clearActiveSection}>
<X class="h-4 w-4" />
{i18n.t("categories.clear")}
</Button>
{/if}
</div>
<div class="space-y-2">
<Label for="category-prompt-section">
{i18n.t("categories.promptSection.contentLabel")} ({langLabel})
</Label>
<Textarea
id="category-prompt-section"
value={sectionBody}
oninput={(e) => setSectionBody((e.currentTarget as HTMLTextAreaElement).value)}
rows={10}
class="min-h-[180px] font-mono text-sm"
placeholder={i18n.t("categories.promptSection.contentPlaceholder")}
/>
<p class="text-xs text-muted-foreground">
{i18n.t("categories.charactersCount", {
count: sectionBody.length.toLocaleString()
})}
{#if (promptsByLang[selectedLang] ?? "").trim() || Object.values(sectionBodies).some((v) => v.trim())}
{i18n.t("categories.activeOverride")}
{:else}
{i18n.t("categories.usingCompanyDefaultForLang")}
{/if}
</p>
</div>
</CardContent>
</Card>
</div> </div>
</div> </div>
</div> </div>
@@ -972,4 +1054,4 @@
onConfirm={confirmDelete} onConfirm={confirmDelete}
/> />
{/if} {/if}
</PageShell> </div>
@@ -31,8 +31,6 @@
import { import {
AUTO_APPLY_CONFIDENCES, AUTO_APPLY_CONFIDENCES,
applySuggestions, applySuggestions,
confidenceBadgeVariant,
confidenceLabel,
evaluateMappingPreflight, evaluateMappingPreflight,
filterSuggestionsByConfidence, filterSuggestionsByConfidence,
formatPreflightMessage, formatPreflightMessage,
@@ -148,9 +146,6 @@
const unmappedAutoSuggestions = $derived(unmappedFrom(autoSuggestions)); const unmappedAutoSuggestions = $derived(unmappedFrom(autoSuggestions));
const unmappedFuzzySuggestions = $derived(unmappedFrom(fuzzySuggestions)); const unmappedFuzzySuggestions = $derived(unmappedFrom(fuzzySuggestions));
const unmappedSuggestionCount = $derived(
unmappedAutoSuggestions.length + unmappedFuzzySuggestions.length
);
const unmappedAutoCount = $derived(unmappedAutoSuggestions.length); const unmappedAutoCount = $derived(unmappedAutoSuggestions.length);
const unmappedFuzzyCount = $derived(unmappedFuzzySuggestions.length); const unmappedFuzzyCount = $derived(unmappedFuzzySuggestions.length);
@@ -549,7 +544,7 @@
if (syncBlocked) { if (syncBlocked) {
error = preflightErrorMessage; error = preflightErrorMessage;
notifyError(error, { notifyError(error, {
actions: [{ label: i18n.t("mapping.reviewMappings"), href: `#mapping-preflight` }] actions: [{ label: i18n.t("mapping.reviewMappings"), href: `#mapping-missing-required` }]
}); });
if (!isCsv && !itemPath.trim()) { if (!isCsv && !itemPath.trim()) {
activeTab = "item"; activeTab = "item";
@@ -708,11 +703,6 @@
{/if} {/if}
{#if ftpUnsupported} {#if ftpUnsupported}
<FtpMigrateNotice id="mapping-ftp-unsupported" variant="mapping" /> <FtpMigrateNotice id="mapping-ftp-unsupported" variant="mapping" />
{:else if !error && preflightErrorMessage}
<Alert id="mapping-preflight" message={preflightErrorMessage} />
{/if}
{#if !ftpUnsupported && !error && !preflightErrorMessage && preflightWarningMessage}
<Alert id="mapping-preflight" tone="info" message={preflightWarningMessage} />
{/if} {/if}
{#if loading} {#if loading}
@@ -730,12 +720,12 @@
<TabsTrigger <TabsTrigger
value="item" value="item"
class={cn( class={cn(
"flex w-full items-center gap-2 rounded-md px-3 py-2 text-left data-[state=active]:bg-primary data-[state=active]:text-primary-foreground sm:w-auto sm:px-4", "group flex w-full items-center gap-2 rounded-md px-3 py-2 text-left data-[state=active]:bg-primary data-[state=active]:text-primary-foreground sm:w-auto sm:px-4",
activeTab === "item" ? "shadow-sm" : "" activeTab === "item" ? "shadow-sm" : ""
)} )}
> >
<div <div
class="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary" class="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary group-data-[state=active]:bg-primary-foreground/20 group-data-[state=active]:text-primary-foreground"
> >
1 1
</div> </div>
@@ -744,13 +734,13 @@
<TabsTrigger <TabsTrigger
value="fields" value="fields"
class={cn( class={cn(
"flex w-full items-center gap-2 rounded-md px-3 py-2 text-left data-[state=active]:bg-primary data-[state=active]:text-primary-foreground sm:w-auto sm:px-4", "group flex w-full items-center gap-2 rounded-md px-3 py-2 text-left data-[state=active]:bg-primary data-[state=active]:text-primary-foreground sm:w-auto sm:px-4",
activeTab === "fields" ? "shadow-sm" : "" activeTab === "fields" ? "shadow-sm" : ""
)} )}
disabled={!isCsv && !itemPath.trim()} disabled={!isCsv && !itemPath.trim()}
> >
<div <div
class="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary" class="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary group-data-[state=active]:bg-primary-foreground/20 group-data-[state=active]:text-primary-foreground"
> >
2 2
</div> </div>
@@ -932,42 +922,15 @@
role="status" role="status"
> >
{#if unmappedAutoCount > 0} {#if unmappedAutoCount > 0}
<div class="flex flex-wrap items-start justify-between gap-3"> <div>
<div class="min-w-0 flex-1"> <p class="font-medium text-foreground">
<p class="font-medium text-foreground"> {unmappedAutoCount === 1
{unmappedAutoCount === 1 ? i18n.t("mapping.highConfidence", { count: unmappedAutoCount })
? i18n.t("mapping.highConfidence", { count: unmappedAutoCount }) : i18n.t("mapping.highConfidencePlural", { count: unmappedAutoCount })}
: i18n.t("mapping.highConfidencePlural", { count: unmappedAutoCount })} </p>
</p> <p class="mt-1 text-muted-foreground">
<p class="mt-1 text-muted-foreground"> {i18n.t("mapping.highConfidenceHelp")}
{i18n.t("mapping.highConfidenceHelp")} </p>
</p>
<div class="mt-2 flex flex-wrap gap-1.5">
{#each unmappedAutoSuggestions.slice(0, 8) as s (s.source + s.target)}
<Badge
variant={confidenceBadgeVariant(s.confidence)}
class="text-[10px] font-medium"
title={`${s.source}${s.target}`}
>
{confidenceLabel(s.confidence)}
</Badge>
{/each}
{#if unmappedAutoSuggestions.length > 8}
<span class="text-[10px] text-muted-foreground">
{i18n.t("common.more", { count: unmappedAutoSuggestions.length - 8 })}
</span>
{/if}
</div>
</div>
<Button
size="sm"
variant="default"
data-tour="feed-automap"
onclick={() => applySuggestedMappings()}
>
<Wand2 class="h-4 w-4" />
{i18n.t("mapping.applySuggestions", { count: unmappedAutoCount })}
</Button>
</div> </div>
{/if} {/if}
{#if unmappedFuzzyCount > 0} {#if unmappedFuzzyCount > 0}
@@ -985,22 +948,6 @@
<p class="mt-1 text-muted-foreground"> <p class="mt-1 text-muted-foreground">
{i18n.t("mapping.fuzzyHelp")} {i18n.t("mapping.fuzzyHelp")}
</p> </p>
<div class="mt-2 flex flex-wrap gap-1.5">
{#each unmappedFuzzySuggestions.slice(0, 8) as s (s.source + s.target)}
<Badge
variant="warning"
class="text-[10px] font-medium capitalize"
title={`${s.source}${s.target} (${Math.round(s.score * 100)}%)`}
>
{i18n.t("mapping.kindFuzzy")}
</Badge>
{/each}
{#if unmappedFuzzySuggestions.length > 8}
<span class="text-[10px] text-muted-foreground">
{i18n.t("common.more", { count: unmappedFuzzySuggestions.length - 8 })}
</span>
{/if}
</div>
</div> </div>
<Button <Button
size="sm" size="sm"
@@ -1024,26 +971,9 @@
<p class="font-medium">{i18n.t("mapping.missingRequiredTitle")}</p> <p class="font-medium">{i18n.t("mapping.missingRequiredTitle")}</p>
<ul class="mt-1 space-y-1"> <ul class="mt-1 space-y-1">
{#each missingRequired as field} {#each missingRequired as field}
{@const tip = fieldTip(field.value, field.description)} <li class="font-medium">{field.label}</li>
<li class="flex items-start gap-1.5">
<span class="font-medium">{field.label}</span>
{#if tip}
<span
class="inline-flex items-center gap-0.5 text-destructive/80"
title={tip}
>
<CircleHelp class="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
<span class="text-xs">{tip}</span>
</span>
{/if}
</li>
{/each} {/each}
</ul> </ul>
{#if unmappedSuggestionCount > 0}
<p class="mt-2 text-xs text-destructive/90">
{i18n.t("mapping.applyTip")}
</p>
{/if}
</div> </div>
{/if} {/if}
@@ -1255,27 +1185,20 @@
</Badge> </Badge>
</div> </div>
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<details class="rounded-md border border-border bg-muted/30 px-2 py-1.5"> <div class="flex items-center gap-2">
<summary <Label for="sample-limit" class="whitespace-nowrap text-xs text-muted-foreground">
class="cursor-pointer list-none text-xs font-medium text-muted-foreground marker:content-none [&::-webkit-details-marker]:hidden" {i18n.t("mapping.sampleSize")}
> </Label>
{i18n.t("mapping.sampleOptions")} <Input
</summary> id="sample-limit"
<div class="mt-2 flex items-center gap-2 border-t border-border/60 pt-2"> class="w-16"
<Label for="sample-limit" class="text-xs text-muted-foreground whitespace-nowrap" type="number"
>{i18n.t("mapping.sampleSize")}</Label min="1"
> max="100"
<Input bind:value={sampleLimit}
id="sample-limit" title={i18n.t("mapping.processAfterSync")}
class="w-16" />
type="number" </div>
min="1"
max="100"
bind:value={sampleLimit}
title={i18n.t("mapping.processAfterSync")}
/>
</div>
</details>
<Button <Button
variant="outline" variant="outline"
loading={sampleRunning} loading={sampleRunning}
@@ -535,7 +535,6 @@
bind:languages={promptExtraLangs} bind:languages={promptExtraLangs}
configured={contentLanguages} configured={contentLanguages}
primary={contentLanguages[0] || "en"} primary={contentLanguages[0] || "en"}
hasOverride={(code) => (customLanguages[promptKey] ?? []).includes(code)}
onChange={(code) => { onChange={(code) => {
void loadPromptBundle(code).catch((err) => { void loadPromptBundle(code).catch((err) => {
error = failureMessage(err, i18n.t("ai.flash.loadFailed")); error = failureMessage(err, i18n.t("ai.flash.loadFailed"));
+100 -69
View File
@@ -2,7 +2,7 @@
import { i18n } from "$lib/i18n"; import { i18n } from "$lib/i18n";
import { onMount } from "svelte"; import { onMount } from "svelte";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { Layers, Play, RefreshCw, RotateCcw, X } from "@lucide/svelte"; import { Layers, MoreHorizontal, Play, RefreshCw, RotateCcw, X } from "@lucide/svelte";
import { api, ApiError, failureMessage } from "$lib/api"; import { api, ApiError, failureMessage } from "$lib/api";
import { unwrapList } from "$lib/list"; import { unwrapList } from "$lib/list";
import { formatDateTime } from "$lib/utils"; import { formatDateTime } from "$lib/utils";
@@ -19,6 +19,8 @@
CardDescription, CardDescription,
CardHeader, CardHeader,
CardTitle, CardTitle,
DropdownMenu,
DropdownMenuItem,
Progress, Progress,
Skeleton, Skeleton,
TableBody, TableBody,
@@ -538,37 +540,42 @@
</div> </div>
{#if completed || cancellable || retryable} {#if completed || cancellable || retryable}
<div class="mt-3 flex flex-wrap gap-2"> <div class="mt-3 flex justify-end">
{#if completed} <DropdownMenu class="w-48">
<a href="/products?status=needs_review"> {#snippet trigger({ open, toggle })}
<Button size="sm" variant="outline" title={i18n.t("processing.reviewTitle")}> <Button
{i18n.t("processing.reviewResults")} variant="ghost"
class="h-8 w-8 p-0"
aria-label={i18n.t("processing.col.actions")}
aria-haspopup="menu"
aria-expanded={open}
onclick={toggle}
>
<MoreHorizontal class="h-4 w-4" />
</Button> </Button>
</a> {/snippet}
{/if} {#if completed}
{#if cancellable} <DropdownMenuItem onclick={() => goto("/products?status=needs_review")}>
<Button {i18n.t("processing.reviewResults")}
size="sm" </DropdownMenuItem>
variant="destructive" {/if}
disabled={busy} {#if cancellable}
title={i18n.t("processing.cancelTitle")} <DropdownMenuItem
onclick={() => cancelJob(job)} class="text-destructive focus:text-destructive"
> disabled={busy}
<X class="mr-1 h-4 w-4" /> onclick={() => cancelJob(job)}
{busy ? i18n.t("processing.cancelling") : i18n.t("processing.cancel")} >
</Button> <X class="h-4 w-4" />
{/if} {busy ? i18n.t("processing.cancelling") : i18n.t("processing.cancel")}
{#if retryable} </DropdownMenuItem>
<Button {/if}
size="sm" {#if retryable}
variant="outline" <DropdownMenuItem disabled={busy} onclick={() => retryJob(job)}>
disabled={busy} <RotateCcw class="h-4 w-4" />
onclick={() => retryJob(job)} {busy ? i18n.t("processing.retrying") : i18n.t("processing.retry")}
> </DropdownMenuItem>
<RotateCcw class="mr-1 h-4 w-4" /> {/if}
{busy ? i18n.t("processing.retrying") : i18n.t("processing.retry")} </DropdownMenu>
</Button>
{/if}
</div> </div>
{/if} {/if}
</div> </div>
@@ -584,8 +591,9 @@
<TableHead>{i18n.t("processing.col.status")}</TableHead> <TableHead>{i18n.t("processing.col.status")}</TableHead>
<TableHead>{i18n.t("processing.col.steps")}</TableHead> <TableHead>{i18n.t("processing.col.steps")}</TableHead>
<TableHead>{i18n.t("processing.col.progress")}</TableHead> <TableHead>{i18n.t("processing.col.progress")}</TableHead>
<TableHead>{i18n.t("processing.col.started")}</TableHead> <TableHead>
<TableHead>{i18n.t("processing.col.completed")}</TableHead> {i18n.t("processing.col.started")} / {i18n.t("processing.col.completed")}
</TableHead>
<TableHead stickyRight>{i18n.t("processing.col.actions")}</TableHead> <TableHead stickyRight>{i18n.t("processing.col.actions")}</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
@@ -597,6 +605,10 @@
{@const errText = jobErrorText(job)} {@const errText = jobErrorText(job)}
{@const cancellable = canCancel(job.status)} {@const cancellable = canCancel(job.status)}
{@const retryable = canRetry(job.status)} {@const retryable = canRetry(job.status)}
{@const completed =
(job.status ?? "").toLowerCase() === "completed" ||
(job.status ?? "").toLowerCase() === "success" ||
(job.status ?? "").toLowerCase() === "done"}
<TableRow> <TableRow>
<TableCell class="font-medium"> <TableCell class="font-medium">
<div>{taskName(job)}</div> <div>{taskName(job)}</div>
@@ -629,7 +641,7 @@
{#if steps.length === 0} {#if steps.length === 0}
<span class="text-sm text-muted-foreground">{i18n.t("status.emDash")}</span> <span class="text-sm text-muted-foreground">{i18n.t("status.emDash")}</span>
{:else} {:else}
<div class="flex max-w-[280px] flex-wrap gap-1"> <div class="flex w-full flex-wrap gap-1">
{#each steps as st} {#each steps as st}
<Badge variant={statusVariant(st.status)} class="text-[10px]"> <Badge variant={statusVariant(st.status)} class="text-[10px]">
{stepLabel(st.step)} {stepLabel(st.step)}
@@ -658,44 +670,63 @@
</div> </div>
</TableCell> </TableCell>
<TableCell class="whitespace-nowrap"> <TableCell class="whitespace-nowrap">
{job.started_at ? formatDateTime(String(job.started_at)) : i18n.t("status.emDash")} <div>
</TableCell> <span class="text-xs text-muted-foreground">
<TableCell class="whitespace-nowrap"> {i18n.t("processing.col.started")}
{job.completed_at ? formatDateTime(String(job.completed_at)) : i18n.t("status.emDash")} </span>
<div>
{job.started_at ? formatDateTime(String(job.started_at)) : i18n.t("status.emDash")}
</div>
</div>
<div class="mt-1">
<span class="text-xs text-muted-foreground">
{i18n.t("processing.col.completed")}
</span>
<div>
{job.completed_at ? formatDateTime(String(job.completed_at)) : i18n.t("status.emDash")}
</div>
</div>
</TableCell> </TableCell>
<TableCell stickyRight> <TableCell stickyRight>
<div class="flex flex-wrap items-center gap-2"> {#if completed || cancellable || retryable}
{#if (job.status ?? "").toLowerCase() === "completed" || (job.status ?? "").toLowerCase() === "success" || (job.status ?? "").toLowerCase() === "done"} <DropdownMenu class="w-48">
<a href="/products?status=needs_review"> {#snippet trigger({ open, toggle })}
<Button size="sm" variant="outline" title={i18n.t("processing.reviewTitle")}> <Button
{i18n.t("processing.reviewResults")} variant="ghost"
class="h-8 w-8 p-0"
aria-label={i18n.t("processing.col.actions")}
aria-haspopup="menu"
aria-expanded={open}
onclick={toggle}
>
<MoreHorizontal class="h-4 w-4" />
</Button> </Button>
</a> {/snippet}
{/if} {#if completed}
{#if cancellable} <DropdownMenuItem onclick={() => goto("/products?status=needs_review")}>
<Button {i18n.t("processing.reviewResults")}
size="sm" </DropdownMenuItem>
variant="destructive" {/if}
disabled={busy} {#if cancellable}
title={i18n.t("processing.cancelTitle")} <DropdownMenuItem
onclick={() => cancelJob(job)} class="text-destructive focus:text-destructive"
> disabled={busy}
<X class="mr-1 h-4 w-4" /> onclick={() => cancelJob(job)}
{busy ? i18n.t("processing.cancelling") : i18n.t("processing.cancel")} >
</Button> <X class="h-4 w-4" />
{/if} {busy ? i18n.t("processing.cancelling") : i18n.t("processing.cancel")}
{#if retryable} </DropdownMenuItem>
<Button {/if}
size="sm" {#if retryable}
variant="outline" <DropdownMenuItem disabled={busy} onclick={() => retryJob(job)}>
disabled={busy} <RotateCcw class="h-4 w-4" />
onclick={() => retryJob(job)} {busy ? i18n.t("processing.retrying") : i18n.t("processing.retry")}
> </DropdownMenuItem>
<RotateCcw class="mr-1 h-4 w-4" /> {/if}
{busy ? i18n.t("processing.retrying") : i18n.t("processing.retry")} </DropdownMenu>
</Button> {:else}
{/if} <span class="text-muted-foreground">{i18n.t("status.emDash")}</span>
</div> {/if}
</TableCell> </TableCell>
</TableRow> </TableRow>
{/each} {/each}