fixes
This commit is contained in:
@@ -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"><product></div>
|
<div class="mb-2 text-muted-foreground"><product></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"><{row.target}></span>
|
<span class="text-primary"><{row.target}></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"></{row.target}></span>
|
<span class="text-primary"></{row.target}></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"></product></div>
|
<div class="mt-2 text-muted-foreground"></product></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 sm–md gap) -->
|
<!-- Category + status until md; columns take over at md+ (avoids sm–md 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}
|
||||||
|
|||||||
@@ -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
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"));
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user