Auto-derive session cookie domain; AI prompt page = formula builder hub

Session (no env needed):
- SESSION_COOKIE_DOMAIN env removed. Config.SessionCookieParentDomain()
  derives the cookie Domain from WEB_ORIGIN + PUBLIC_API_URL, which the
  API already requires: sibling hosts of one parent (descrybe.io +
  api.descrybe.io) share the parent domain so SvelteKit SSR (/admin
  gate, user switching) receives the session cookie; localhost, IPs,
  same-host, and unrelated hosts stay host-only. Deploying the new build
  is the whole fix — nothing to configure.

AI generation prompt page:
- Each section now embeds its formula editor next to the per-language
  prompt instructions: Title = full title formula builder (preview,
  elements, separator, variable selector, custom variables), Description
  = description formula sections editor (type + instructions + export
  id, drag reorder), Meta = meta title / meta description formula
  fields. One Save writes categories.prompt + title_template +
  description_template together; Assign copies all three to the
  selected categories.
- New $lib/categories/formula-variables.ts loads every usable field for
  the builder: custom variables (/api/variables), company attributes
  (/api/attributes — attribute_key, name, unit, example), and standard
  fields (/api/standard-fields). Used by both the prompt page and the
  title-formula page (which previously ignored attributes).

Verified locally: svelte-check clean for changed files, unit tests pass,
and the full save contract exercised over HTTP as the page does it
(login → load variables/attributes/standard-fields → PATCH prompt +
title-formula + description-formula → round-trip read), then the test
category restored via repair-category-prompts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-18 01:12:23 +02:00
co-authored by Claude Fable 5
parent 9a839d6d13
commit d03c2a5c57
7 changed files with 657 additions and 62 deletions
+3 -6
View File
@@ -105,12 +105,9 @@ APP_ENCRYPTION_KEY=
# Optional RATE_LIMIT_REPLICAS divides HTTP middleware caps only (not lockout/StartLimiter/AI/email)
# — not a shared store. RATE_LIMIT_BACKEND=redis|postgres is docs-only and forced to memory.
# SESSION_COOKIE_NAME=descrybe_session
# Session cookie Domain attribute. Empty = host-only (localhost / same-host).
# REQUIRED when web + api run on sibling subdomains (descrybe.io + api.descrybe.io):
# set the parent domain so the browser also sends the session cookie to the web
# host — SvelteKit SSR gates (/admin) forward it to /api/auth/me and otherwise
# always see 401 (login loops back to /login?next=...).
# SESSION_COOKIE_DOMAIN=descrybe.io
# Session cookie Domain is derived automatically from WEB_ORIGIN + PUBLIC_API_URL:
# sibling hosts of one parent (descrybe.io + api.descrybe.io) share the parent
# domain so SvelteKit SSR receives the session cookie; localhost stays host-only.
# CSRF_COOKIE_NAME=descrybe_csrf
# PUBLIC_CSRF_COOKIE_NAME=descrybe_csrf
# SESSION_IDLE_HOURS=24
+1 -1
View File
@@ -56,7 +56,7 @@ func main() {
}
defer pool.Close()
sessions := auth.NewSessionManager(pool, cfg.SessionCookieName, cfg.SessionCookieDomain, cfg.CookieSecure(), cfg.SessionIdleHours)
sessions := auth.NewSessionManager(pool, cfg.SessionCookieName, cfg.SessionCookieParentDomain(), cfg.CookieSecure(), cfg.SessionIdleHours)
srv := httpapi.NewServer(cfg, pool, sessions)
runCtx, runCancel := context.WithCancel(context.Background())
+63 -5
View File
@@ -36,7 +36,6 @@ type Config struct {
// (e.g. redis/postgres) so boot can warn that memory was forced.
RateLimitBackendRequested string
SessionCookieName string
SessionCookieDomain string
SessionSecure bool
CSRFCookieName string
PublicAPIURL string
@@ -142,10 +141,6 @@ func Load() (Config, error) {
RateLimitMultiReplica: getenvBool("RATE_LIMIT_MULTI_REPLICA", false),
RateLimitBackend: "memory",
SessionCookieName: getenv("SESSION_COOKIE_NAME", "descrybe_session"),
// Empty = host-only cookie (localhost). Set to the parent domain
// (e.g. descrybe.io) when web + api run on sibling subdomains so
// SvelteKit SSR (descrybe.io) receives the session cookie too.
SessionCookieDomain: getenv("SESSION_COOKIE_DOMAIN", ""),
// Default Secure=true when APP_ENV is production|prod so cookies are HTTPS-only
// even if SESSION_SECURE is unset; explicit false still fails closed in validate.
SessionSecure: getenvBool("SESSION_SECURE", isProductionEnvValue(appEnv)),
@@ -251,6 +246,69 @@ func (c Config) InsecureLocalProductionActive() bool {
return c.IsProduction() && c.AllowInsecureLocalProduction && isLoopbackWebOriginHost(c.WebOrigin)
}
// SessionCookieParentDomain derives the session cookie Domain attribute from
// WEB_ORIGIN and PUBLIC_API_URL — no extra env needed. When web and API run on
// sibling hosts of one parent domain (descrybe.io + api.descrybe.io), the
// shared parent is returned so the browser also sends the session cookie to
// the web host; SvelteKit SSR gates (/admin) forward it to /api/auth/me and
// would otherwise always see 401. Same hostname (localhost dev, single-host
// deploys), IPs, or unrelated hosts → "" (host-only cookie, old behavior).
func (c Config) SessionCookieParentDomain() string {
web := hostnameOfURL(c.WebOrigin)
api := hostnameOfURL(c.PublicAPIURL)
if web == "" || api == "" || web == api {
return ""
}
if net.ParseIP(web) != nil || net.ParseIP(api) != nil ||
!strings.Contains(web, ".") || !strings.Contains(api, ".") {
return ""
}
// Direct parent/child: one host is the other's registrable parent.
if strings.HasSuffix(api, "."+web) {
return web
}
if strings.HasSuffix(web, "."+api) {
return api
}
// Sibling subdomains (app.x.y + api.x.y): share the deepest common suffix,
// but only when it has at least two labels (never a bare TLD).
if suffix := commonDotSuffix(web, api); strings.Count(suffix, ".") >= 1 {
return suffix
}
return ""
}
func hostnameOfURL(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
u, err := url.Parse(raw)
if err != nil || u.Hostname() == "" {
return ""
}
return strings.ToLower(u.Hostname())
}
// commonDotSuffix returns the longest label-aligned common suffix of two hostnames
// ("app.descrybe.io", "api.descrybe.io" → "descrybe.io"); "" when nothing matches.
func commonDotSuffix(a, b string) string {
la := strings.Split(a, ".")
lb := strings.Split(b, ".")
n := 0
for n < len(la) && n < len(lb) {
if la[len(la)-1-n] != lb[len(lb)-1-n] {
break
}
n++
}
// Never return one of the full hostnames itself (that is the parent/child case).
if n == 0 || n == len(la) || n == len(lb) {
return ""
}
return strings.Join(la[len(la)-n:], ".")
}
// ShouldWarnRateLimits reports whether operators opted into multi-replica rate-limit
// awareness or requested an unsupported shared backend.
func (c Config) ShouldWarnRateLimits() bool {
+28
View File
@@ -528,3 +528,31 @@ func TestValidateProcessingPollInterval(t *testing.T) {
t.Fatal("expected PROCESSING_POLL_INTERVAL > 0")
}
}
func TestSessionCookieParentDomain(t *testing.T) {
t.Parallel()
cases := []struct {
name string
web string
api string
want string
}{
{name: "prod_split", web: "https://descrybe.io", api: "https://api.descrybe.io", want: "descrybe.io"},
{name: "prod_split_reversed", web: "https://app.descrybe.io", api: "https://descrybe.io", want: "descrybe.io"},
{name: "sibling_subdomains", web: "https://app.descrybe.io", api: "https://api.descrybe.io", want: "descrybe.io"},
{name: "localhost_ports", web: "http://localhost:28472", api: "http://localhost:28471", want: ""},
{name: "loopback_ip", web: "http://127.0.0.1:28472", api: "http://127.0.0.1:28471", want: ""},
{name: "same_host", web: "https://descrybe.io", api: "https://descrybe.io", want: ""},
{name: "unrelated_hosts", web: "https://descrybe.io", api: "https://example.com", want: ""},
{name: "empty_api", web: "https://descrybe.io", api: "", want: ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
c := Config{WebOrigin: tc.web, PublicAPIURL: tc.api}
if got := c.SessionCookieParentDomain(); got != tc.want {
t.Fatalf("web=%q api=%q got %q want %q", tc.web, tc.api, got, tc.want)
}
})
}
}
@@ -0,0 +1,58 @@
import { api } from "$lib/api";
import { mapApiVariable } from "./formula";
import type { FormulaVariable } from "./types";
/**
* Load every field usable as a formula variable for this company:
* custom variables (/api/variables), company attributes (/api/attributes),
* and standard fields (/api/standard-fields). First definition of a name wins
* (custom variables can override attribute labels/examples).
*/
export async function loadFormulaVariables(): Promise<FormulaVariable[]> {
const [varsRes, attrsRes, fieldsRes] = await Promise.all([
api<{ variables: Record<string, unknown>[] }>("/api/variables?limit=2000").catch(() => ({
variables: [] as Record<string, unknown>[]
})),
api<{ attributes?: Record<string, unknown>[] }>("/api/attributes?limit=2000").catch(() => ({
attributes: [] as Record<string, unknown>[]
})),
api<{ fields?: Record<string, unknown>[] }>("/api/standard-fields?limit=2000").catch(() => ({
fields: [] as Record<string, unknown>[]
}))
]);
const out = (varsRes.variables ?? []).map(mapApiVariable);
const seen = new Set(out.map((v) => v.name));
for (const row of attrsRes.attributes ?? []) {
const name = String(row.attribute_key ?? "").trim();
if (!name || seen.has(name)) continue;
seen.add(name);
const label = String(row.name ?? name);
const unit = row.unit != null ? String(row.unit).trim() : "";
out.push({
id: `attr-${name}`,
name,
label: unit ? `${label} (${unit})` : label,
description: row.description != null ? String(row.description) : undefined,
example: row.example != null ? String(row.example) : undefined,
value: label
});
}
for (const row of fieldsRes.fields ?? []) {
const name = String(row.key ?? "").trim();
if (!name || seen.has(name)) continue;
seen.add(name);
out.push({
id: `sf-${name}`,
name,
label: String(row.name ?? name),
description: row.description != null ? String(row.description) : undefined,
example: undefined,
value: String(row.name ?? name)
});
}
return out;
}
@@ -4,7 +4,19 @@
import { goto } from "$app/navigation";
import { page } from "$app/state";
import { api, ApiError, failureMessage } from "$lib/api";
import type { Cat } from "$lib/categories/types";
import type { Cat, DescriptionSection, DescriptionSectionType, FormulaElement, FormulaVariable, TitleFormula } from "$lib/categories/types";
import { SECTION_TYPES } from "$lib/categories/types";
import {
buildTemplateToSave,
elementId,
generatePreviewElements,
getDefaultInstructions,
getDefaultMetaDescription,
getDefaultMetaTitle,
parseDescriptionTemplate,
parseTemplateToFormula
} from "$lib/categories/formula";
import { loadFormulaVariables } from "$lib/categories/formula-variables";
import { findCategoryIdByUniqueId, listAllCategories, resolveCategory } from "$lib/categories/resolve";
import {
composeEnhancePrompt,
@@ -12,7 +24,6 @@
ENHANCE_PROMPT_SECTIONS,
isEnhancePromptEmpty,
parseEnhancePrompt,
SECTION_SCHEMA_HINTS,
type EnhancePromptSectionId
} from "$lib/categories/prompt-sections";
import PageShell from "$lib/components/PageShell.svelte";
@@ -26,18 +37,29 @@
CardDescription,
CardHeader,
CardTitle,
Input,
Label,
Select,
Textarea
} from "$lib/components/ui";
import {
ArrowLeft,
FileText,
GripVertical,
Plus,
Search,
Share2,
Sparkles,
Type,
X
} from "@lucide/svelte";
import FormulaPreview from "$lib/components/categories/formula/FormulaPreview.svelte";
import FormulaBuilder from "$lib/components/categories/formula/FormulaBuilder.svelte";
import VariableSelector from "$lib/components/categories/formula/VariableSelector.svelte";
import TextElementDialog from "$lib/components/categories/formula/TextElementDialog.svelte";
import CustomVariableDialog from "$lib/components/categories/formula/CustomVariableDialog.svelte";
import ManageVariablesDialog from "$lib/components/categories/formula/ManageVariablesDialog.svelte";
import ConfirmationDialog from "$lib/components/categories/formula/ConfirmationDialog.svelte";
import TreeSelectDialog from "$lib/components/categories/TreeSelectDialog.svelte";
import {
CONTENT_LANGUAGES,
@@ -103,17 +125,47 @@
});
let unstructuredHint = $state(false);
// Title formula (categories.title_template) — same builder as /title-formula.
let customVariables = $state<FormulaVariable[]>([]);
let titleFormula = $state<TitleFormula>({ elements: [], separator: " " });
let searchQuery = $state("");
let textOpen = $state(false);
let textToEdit = $state<{ index: number; text: string } | null>(null);
let customVarOpen = $state(false);
let variableToEdit = $state<FormulaVariable | null>(null);
let manageVarsOpen = $state(false);
let confirmOpen = $state(false);
let itemToDelete = $state<{ type: string; id: string } | null>(null);
// Description formula (categories.description_template) — same editor as /description-formula.
let descSections = $state<DescriptionSection[]>([]);
let metaTitle = $state(getDefaultMetaTitle());
let metaDescription = $state(getDefaultMetaDescription());
let dragIndex = $state<number | null>(null);
const langLabel = $derived(
CONTENT_LANGUAGES.find((l) => l.value === selectedLang)?.label ?? selectedLang
);
const schemaHint = $derived(SECTION_SCHEMA_HINTS[activeSection]);
const sectionMeta = $derived(SECTION_META[activeSection]);
const sectionBody = $derived(sectionBodies[activeSection] ?? "");
const sectionVars = $derived(
ALL_PROMPT_VARS.filter((v) => schemaHint.suggestedVars.includes(v.name))
const usedNames = $derived(
new Set(titleFormula.elements.filter((e) => e.type === "variable").map((e) => e.value))
);
const filteredVariables = $derived(
customVariables.filter((v) => {
if (usedNames.has(v.name)) return false;
const q = searchQuery.toLowerCase();
if (!q) return true;
return (
v.label.toLowerCase().includes(q) ||
v.name.toLowerCase().includes(q) ||
(v.description?.toLowerCase() || "").includes(q)
);
})
);
const titlePreview = $derived(generatePreviewElements(titleFormula.elements, customVariables));
const matchingUniqueIds = $derived.by(() => {
if (!category) return [] as string[];
@@ -134,7 +186,7 @@
}
function currentCombined(): string {
// Shared JSON schema intro is system-owned (not editable in the UI).
// Only the user's section text is stored; the pipeline supplies framing.
if (isEnhancePromptEmpty(DEFAULT_ENHANCE_PREAMBLE, sectionBodies)) return "";
return composeEnhancePrompt(DEFAULT_ENHANCE_PREAMBLE, sectionBodies);
}
@@ -168,6 +220,12 @@
selectedLang = primaryLang;
}
syncEditorFromStored(map[selectedLang] ?? "");
titleFormula = parseTemplateToFormula(cat.title_template, customVariables);
const parsedDesc = parseDescriptionTemplate(cat.description_template);
descSections = parsedDesc.sections;
metaTitle = parsedDesc.metaTitle;
metaDescription = parsedDesc.metaDescription;
}
function onLangChange(code: string) {
@@ -181,10 +239,12 @@
loading = true;
error = "";
try {
const [cat, me] = await Promise.all([
const [cat, me, vars] = await Promise.all([
resolveCategory(categoryParam),
api<MeResponse>("/api/auth/me").catch(() => null)
api<MeResponse>("/api/auth/me").catch(() => null),
loadFormulaVariables().catch(() => [] as FormulaVariable[])
]);
customVariables = vars;
primaryLang = parseContentLanguage(me?.company?.language, DEFAULT_CONTENT_LANGUAGE);
const companyLangs = (me?.company as { content_languages?: string[] } | undefined)
?.content_languages;
@@ -245,8 +305,114 @@
promptsByLang = next;
}
// ----- Title formula editing -----
function addTitleElement(partial: Omit<FormulaElement, "id">) {
titleFormula = {
...titleFormula,
elements: [
...titleFormula.elements,
{ ...partial, id: elementId(partial.type, partial.value, titleFormula.elements.length) }
]
};
}
async function savePrompt() {
function removeTitleElement(index: number) {
titleFormula = {
...titleFormula,
elements: titleFormula.elements.filter((_, i) => i !== index)
};
}
function updateTitleElement(index: number, next: Partial<FormulaElement>) {
titleFormula = {
...titleFormula,
elements: titleFormula.elements.map((el, i) => (i === index ? { ...el, ...next } : el))
};
}
function reorderTitle(from: number, to: number) {
const elements = [...titleFormula.elements];
const [item] = elements.splice(from, 1);
elements.splice(to, 0, item);
titleFormula = { ...titleFormula, elements };
}
async function saveVariable(variable: FormulaVariable) {
if (variableToEdit?.id) {
await api(`/api/variables/${variableToEdit.id}`, { method: "DELETE" }).catch(() => undefined);
}
await api("/api/variables", {
method: "POST",
body: {
name: variable.name,
value: variable.label,
label: variable.label,
description: variable.description ?? null
}
});
customVariables = await loadFormulaVariables();
success = variableToEdit ? i18n.t("categories.variableUpdated") : i18n.t("categories.variableAdded");
}
async function confirmDelete() {
if (!itemToDelete) return;
if (itemToDelete.type === "variable") {
try {
await api(`/api/variables/${itemToDelete.id}`, { method: "DELETE" });
customVariables = await loadFormulaVariables();
success = i18n.t("flash.categories.variableDeleted");
} catch (err) {
error = failureMessage(err, i18n.t("categories.variableDeleteFailed"));
}
} else if (itemToDelete.type === "element") {
const index = parseInt(itemToDelete.id, 10);
if (!Number.isNaN(index)) removeTitleElement(index);
}
confirmOpen = false;
itemToDelete = null;
}
// ----- Description formula editing -----
function addDescSection() {
descSections = [
...descSections,
{ id: crypto.randomUUID(), type: "p", instructions: getDefaultInstructions("p") }
];
}
function removeDescSection(id: string) {
descSections = descSections.filter((s) => s.id !== id);
}
function updateDescSection(id: string, updates: Partial<DescriptionSection>) {
descSections = descSections.map((section) => {
if (section.id !== id) return section;
const next = { ...section, ...updates };
if (updates.type && updates.type !== section.type) {
next.instructions = getDefaultInstructions(updates.type);
}
return next;
});
}
function reorderDescSections(from: number, to: number) {
const next = [...descSections];
const [item] = next.splice(from, 1);
next.splice(to, 0, item);
descSections = next;
}
function buildDescriptionTemplate() {
if (descSections.length === 0) return null;
return {
sections: descSections,
metaTitle: metaTitle.trim() || undefined,
metaDescription: metaDescription.trim() || undefined
};
}
// ----- Save all three stores together -----
async function saveAll() {
if (!category) return;
saving = true;
error = "";
@@ -257,17 +423,33 @@
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 }
});
applyCategory(updated);
const titleTemplate = buildTemplateToSave(
titleFormula.elements,
titleFormula.separator,
customVariables
);
const [updated] = await Promise.all([
api<Cat>(`/api/categories/${category.id}/prompt`, {
method: "PATCH",
body: { prompts: bodyPrompts }
}),
api(`/api/categories/${category.id}/title-formula`, {
method: "PATCH",
body: { title_template: titleTemplate }
}),
api(`/api/categories/${category.id}/description-formula`, {
method: "PATCH",
body: { description_template: buildDescriptionTemplate() }
})
]);
const refreshed = await resolveCategory(String(category.id));
applyCategory({ ...updated, ...refreshed });
const has = Boolean((promptsByLang[selectedLang] ?? "").trim());
success = has
? i18n.t("categories.aiPromptSavedLang", { lang: langLabel })
: i18n.t("categories.aiPromptClearedLang", { lang: langLabel });
allCategories = allCategories.map((c) =>
String(c.id) === String(updated.id) ? { ...c, ...updated } : c
String(c.id) === String(refreshed.id) ? { ...c, ...refreshed } : c
);
} catch (err) {
error = failureMessage(err, i18n.t("categories.aiPromptSaveFailed"));
@@ -299,14 +481,30 @@
for (const [k, v] of Object.entries(promptsByLang)) {
if (v.trim()) bodyPrompts[k] = v;
}
const titleTemplate = buildTemplateToSave(
titleFormula.elements,
titleFormula.separator,
customVariables
);
const descTemplate = buildDescriptionTemplate();
const ids = uniqueIds
.map((uid) => findCategoryIdByUniqueId(allCategories, uid))
.filter((id): id is string => Boolean(id));
for (let i = 0; i < ids.length; i++) {
await api(`/api/categories/${ids[i]}/prompt`, {
method: "PATCH",
body: { prompts: bodyPrompts }
});
await Promise.all([
api(`/api/categories/${ids[i]}/prompt`, {
method: "PATCH",
body: { prompts: bodyPrompts }
}),
api(`/api/categories/${ids[i]}/title-formula`, {
method: "PATCH",
body: { title_template: titleTemplate }
}),
api(`/api/categories/${ids[i]}/description-formula`, {
method: "PATCH",
body: { description_template: descTemplate }
})
]);
assignProgress = Math.round(((i + 1) / ids.length) * 100);
}
success = i18n.t("flash.categories.promptAssigned", {
@@ -329,7 +527,11 @@
}
function sectionFilled(id: EnhancePromptSectionId): boolean {
return Boolean((sectionBodies[id] ?? "").trim());
if ((sectionBodies[id] ?? "").trim()) return true;
if (id === "title") return titleFormula.elements.length > 0;
if (id === "description") return descSections.length > 0;
if (id === "meta") return Boolean(metaTitle.trim() || metaDescription.trim());
return false;
}
</script>
@@ -365,7 +567,7 @@
<Button variant="outline" onclick={() => void goto("/categories")}
>{i18n.t("common.cancel")}</Button
>
<Button onclick={savePrompt} loading={saving}>
<Button onclick={saveAll} loading={saving}>
<Sparkles class="h-4 w-4" />
{saving ? i18n.t("catalog.saving") : i18n.t("catalog.saveChanges")}
</Button>
@@ -442,6 +644,223 @@
</aside>
<div class="min-w-0 space-y-4">
{#if activeSection === "title"}
<Card>
<CardHeader>
<CardTitle>{i18n.t("categories.titleFormula")}</CardTitle>
<CardDescription>{i18n.t("categories.titleFormulaHelp")}</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<div class="grid grid-cols-1 gap-6 xl:grid-cols-3">
<div class="space-y-4 xl:col-span-2">
<FormulaPreview preview={titlePreview} formula={titleFormula} brandTips={[]} aiApplyAllowed={true} />
<FormulaBuilder
formula={titleFormula}
onSeparatorChange={(value) => (titleFormula = { ...titleFormula, separator: value })}
onReorder={reorderTitle}
onRemoveElement={(index) => {
itemToDelete = { type: "element", id: String(index) };
confirmOpen = true;
}}
onEditElement={(index) => {
const el = titleFormula.elements[index];
if (el?.type === "text") {
textToEdit = { index, text: el.value };
textOpen = true;
}
}}
/>
<Button
variant="outline"
class="flex items-center gap-1"
onclick={() => {
textToEdit = null;
textOpen = true;
}}
>
<Plus class="h-4 w-4" />
{i18n.t("categories.addText")}
</Button>
</div>
<div>
<VariableSelector
variables={filteredVariables}
onAddVariable={(variable) =>
addTitleElement({
type: "variable",
value: variable.name,
label: variable.label,
description: variable.description,
example: variable.example
})}
onAddCustomVariable={() => {
variableToEdit = null;
customVarOpen = true;
}}
onEditCustomVariable={(variable) => {
variableToEdit = variable;
customVarOpen = true;
}}
onManageVariables={() => (manageVarsOpen = true)}
onSearch={(q) => (searchQuery = q)}
/>
</div>
</div>
</CardContent>
</Card>
{:else if activeSection === "description"}
<Card>
<CardHeader>
<CardTitle>{i18n.t("categories.descriptionSections")}</CardTitle>
<CardDescription>{i18n.t("categories.descriptionSectionsHelp")}</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<div class="min-h-[80px] space-y-4" role="list">
{#if descSections.length === 0}
<div class="flex h-[80px] items-center justify-center rounded-lg border-2 border-dashed">
<div class="text-center text-muted-foreground">
<p>{i18n.t("categories.noSectionsYet")}</p>
<p class="text-sm">{i18n.t("categories.addSectionsHint")}</p>
</div>
</div>
{:else}
{#each descSections as section, index (section.id)}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="space-y-4 rounded-md bg-muted/50 p-4"
role="listitem"
draggable="true"
ondragstart={() => (dragIndex = index)}
ondragover={(e) => e.preventDefault()}
ondrop={() => {
if (dragIndex === null || dragIndex === index) {
dragIndex = null;
return;
}
reorderDescSections(dragIndex, index);
dragIndex = null;
}}
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="cursor-grab text-muted-foreground">
<GripVertical class="h-4 w-4" />
</span>
<Select
value={section.type}
onchange={(e) =>
updateDescSection(section.id, {
type: (e.currentTarget as HTMLSelectElement)
.value as DescriptionSectionType
})}
class="w-[180px]"
>
{#each SECTION_TYPES as type}
<option value={type.value}>{i18n.t(`categories.sectionType.${type.value}`)}</option>
{/each}
</Select>
</div>
<Button variant="ghost" size="sm" onclick={() => removeDescSection(section.id)}>
<X class="h-4 w-4" />
</Button>
</div>
<div class="space-y-2">
<Label>{i18n.t("categories.aiInstructions")}</Label>
<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"
value={section.instructions}
oninput={(e) =>
updateDescSection(section.id, {
instructions: (e.currentTarget as HTMLTextAreaElement).value
})}
rows="3"
placeholder={i18n.t("categories.sectionPromptPlaceholder")}
></textarea>
</div>
<div class="space-y-2">
<Label for={`export-id-${section.id}`}>{i18n.t("categories.exportIdOptional")}</Label>
<Input
id={`export-id-${section.id}`}
value={section.exportId || ""}
oninput={(e) =>
updateDescSection(section.id, {
exportId: (e.currentTarget as HTMLInputElement).value.trim()
})}
placeholder={i18n.t("categories.sectionKeyPlaceholder")}
/>
{#if section.exportId && descSections.filter((s) => s.exportId === section.exportId).length > 1}
<p class="text-sm text-red-500">{i18n.t("categories.exportIdUnique")}</p>
{/if}
</div>
</div>
{/each}
{/if}
</div>
<Button variant="outline" onclick={addDescSection}>
<Plus class="h-4 w-4" />
{i18n.t("categories.addSection")}
</Button>
</CardContent>
</Card>
{:else}
<Card>
<CardHeader>
<CardTitle>{i18n.t("categories.metaInformation")}</CardTitle>
<CardDescription>{i18n.t("categories.metaInformationHelp")}</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<div class="space-y-2">
<Label for="metaTitle">{i18n.t("categories.metaTitleFormula")}</Label>
<textarea
id="metaTitle"
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"
bind:value={metaTitle}
rows="2"
placeholder={i18n.t("categories.seoTitlePlaceholder")}
></textarea>
<div class="flex items-center justify-between">
<p class="text-xs text-muted-foreground">
{i18n.t("categories.metaTitleHint")}
</p>
<Button
variant="ghost"
size="sm"
class="text-xs"
onclick={() => (metaTitle = getDefaultMetaTitle())}
>
{i18n.t("categories.resetToDefault")}
</Button>
</div>
</div>
<div class="space-y-2">
<Label for="metaDescription">{i18n.t("categories.metaDescriptionFormula")}</Label>
<textarea
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>
<div class="flex items-center justify-between">
<p class="text-xs text-muted-foreground">
{i18n.t("categories.metaDescriptionHint")}
</p>
<Button
variant="ghost"
size="sm"
class="text-xs"
onclick={() => (metaDescription = getDefaultMetaDescription())}
>
{i18n.t("categories.resetToDefault")}
</Button>
</div>
</div>
</CardContent>
</Card>
{/if}
<Card>
<CardHeader>
<CardTitle>{i18n.t(sectionMeta.labelKey)}</CardTitle>
@@ -451,7 +870,7 @@
<p class="text-xs text-muted-foreground">{i18n.t(sectionMeta.formulaNoteKey)}</p>
<div class="flex flex-wrap gap-2">
{#each sectionVars as v}
{#each ALL_PROMPT_VARS as v}
<Button
type="button"
variant="outline"
@@ -480,8 +899,8 @@
id="category-prompt-section"
value={sectionBody}
oninput={(e) => setSectionBody((e.currentTarget as HTMLTextAreaElement).value)}
rows={14}
class="min-h-[220px] font-mono text-sm"
rows={10}
class="min-h-[180px] font-mono text-sm"
placeholder={i18n.t("categories.promptSection.contentPlaceholder")}
/>
<p class="text-xs text-muted-foreground">
@@ -512,5 +931,62 @@
onClose={() => (assignOpen = false)}
onSave={assignPrompt}
/>
<TextElementDialog
bind:open={textOpen}
initialText={textToEdit?.text ?? ""}
index={textToEdit?.index ?? null}
onClose={() => {
textOpen = false;
textToEdit = null;
}}
onSave={(text) => {
if (textToEdit) updateTitleElement(textToEdit.index, { type: "text", value: text });
else addTitleElement({ type: "text", value: text });
}}
/>
<CustomVariableDialog
bind:open={customVarOpen}
initialVariable={variableToEdit}
title={variableToEdit ? i18n.t("categories.editCustomVariable") : i18n.t("categories.addCustomVariable")}
existingVariables={customVariables}
onClose={() => {
customVarOpen = false;
variableToEdit = null;
}}
onSave={saveVariable}
/>
<ManageVariablesDialog
bind:open={manageVarsOpen}
variables={customVariables}
onClose={() => (manageVarsOpen = false)}
onAddVariable={() => {
variableToEdit = null;
customVarOpen = true;
}}
onEditVariable={(variable) => {
variableToEdit = variable;
customVarOpen = true;
}}
onDeleteVariable={(id) => {
itemToDelete = { type: "variable", id };
confirmOpen = true;
}}
/>
<ConfirmationDialog
bind:open={confirmOpen}
title={i18n.t("catalog.confirmDeletion")}
description={itemToDelete?.type === "variable"
? i18n.t("categories.deleteVariableConfirm")
: i18n.t("categories.removeElementConfirm")}
onClose={() => {
confirmOpen = false;
itemToDelete = null;
}}
onConfirm={confirmDelete}
/>
{/if}
</PageShell>
@@ -10,9 +10,9 @@
buildTemplateToSave,
elementId,
generatePreviewElements,
mapApiVariable,
parseTemplateToFormula
} from "$lib/categories/formula";
import { loadFormulaVariables } from "$lib/categories/formula-variables";
import { findCategoryIdByUniqueId, listAllCategories, resolveCategory } from "$lib/categories/resolve";
import PageShell from "$lib/components/PageShell.svelte";
import Alert from "$lib/components/Alert.svelte";
@@ -93,29 +93,7 @@
);
async function loadVariables() {
const [payload, fieldsRes] = await Promise.all([
api<{ variables: Record<string, unknown>[] }>("/api/variables?limit=2000"),
api<{ fields?: Record<string, unknown>[] }>("/api/standard-fields?limit=2000").catch(
() => ({ fields: [] as Record<string, unknown>[] })
)
]);
const custom = (payload.variables ?? []).map(mapApiVariable);
const fromFields: FormulaVariable[] = [];
const seen = new Set(custom.map((v) => v.name));
for (const row of fieldsRes.fields ?? []) {
const name = String(row.key ?? "").trim();
if (!name || seen.has(name)) continue;
seen.add(name);
fromFields.push({
id: `sf-${name}`,
name,
label: String(row.name ?? name),
description: row.description != null ? String(row.description) : undefined,
example: undefined,
value: String(row.name ?? name)
});
}
customVariables = [...custom, ...fromFields];
customVariables = await loadFormulaVariables();
}
async function load() {