From d03c2a5c574bc6ff632fce05e4ac86e31446da18 Mon Sep 17 00:00:00 2001 From: GreenEclipse Date: Tue, 18 Aug 2026 01:12:23 +0200 Subject: [PATCH] Auto-derive session cookie domain; AI prompt page = formula builder hub MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .env.example | 9 +- apps/api/cmd/api/main.go | 2 +- apps/api/internal/config/config.go | 68 ++- apps/api/internal/config/config_test.go | 28 + .../src/lib/categories/formula-variables.ts | 58 ++ .../[categoryId]/prompt/+page.svelte | 528 +++++++++++++++++- .../[categoryId]/title-formula/+page.svelte | 26 +- 7 files changed, 657 insertions(+), 62 deletions(-) create mode 100644 apps/web/src/lib/categories/formula-variables.ts diff --git a/.env.example b/.env.example index bb5cb57..b919a0d 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/apps/api/cmd/api/main.go b/apps/api/cmd/api/main.go index c5a0bc2..0df1e7b 100644 --- a/apps/api/cmd/api/main.go +++ b/apps/api/cmd/api/main.go @@ -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()) diff --git a/apps/api/internal/config/config.go b/apps/api/internal/config/config.go index 0403c7d..402928b 100644 --- a/apps/api/internal/config/config.go +++ b/apps/api/internal/config/config.go @@ -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 { diff --git a/apps/api/internal/config/config_test.go b/apps/api/internal/config/config_test.go index a390722..1badfda 100644 --- a/apps/api/internal/config/config_test.go +++ b/apps/api/internal/config/config_test.go @@ -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) + } + }) + } +} diff --git a/apps/web/src/lib/categories/formula-variables.ts b/apps/web/src/lib/categories/formula-variables.ts new file mode 100644 index 0000000..911db8a --- /dev/null +++ b/apps/web/src/lib/categories/formula-variables.ts @@ -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 { + const [varsRes, attrsRes, fieldsRes] = await Promise.all([ + api<{ variables: Record[] }>("/api/variables?limit=2000").catch(() => ({ + variables: [] as Record[] + })), + api<{ attributes?: Record[] }>("/api/attributes?limit=2000").catch(() => ({ + attributes: [] as Record[] + })), + api<{ fields?: Record[] }>("/api/standard-fields?limit=2000").catch(() => ({ + fields: [] as Record[] + })) + ]); + + 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; +} diff --git a/apps/web/src/routes/categories/[categoryId]/prompt/+page.svelte b/apps/web/src/routes/categories/[categoryId]/prompt/+page.svelte index 347496e..343aea3 100644 --- a/apps/web/src/routes/categories/[categoryId]/prompt/+page.svelte +++ b/apps/web/src/routes/categories/[categoryId]/prompt/+page.svelte @@ -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, @@ -102,18 +124,48 @@ meta: "" }); let unstructuredHint = $state(false); - + + // Title formula (categories.title_template) — same builder as /title-formula. + let customVariables = $state([]); + let titleFormula = $state({ 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(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([]); + let metaTitle = $state(getDefaultMetaTitle()); + let metaDescription = $state(getDefaultMetaDescription()); + let dragIndex = $state(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("/api/auth/me").catch(() => null) + api("/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) { + 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) { + 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) { + 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(`/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(`/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; } @@ -365,7 +567,7 @@ - @@ -442,6 +644,223 @@
+ {#if activeSection === "title"} + + + {i18n.t("categories.titleFormula")} + {i18n.t("categories.titleFormulaHelp")} + + +
+
+ + (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; + } + }} + /> + +
+
+ + 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)} + /> +
+
+
+
+ {:else if activeSection === "description"} + + + {i18n.t("categories.descriptionSections")} + {i18n.t("categories.descriptionSectionsHelp")} + + +
+ {#if descSections.length === 0} +
+
+

{i18n.t("categories.noSectionsYet")}

+

{i18n.t("categories.addSectionsHint")}

+
+
+ {:else} + {#each descSections as section, index (section.id)} + +
(dragIndex = index)} + ondragover={(e) => e.preventDefault()} + ondrop={() => { + if (dragIndex === null || dragIndex === index) { + dragIndex = null; + return; + } + reorderDescSections(dragIndex, index); + dragIndex = null; + }} + > +
+
+ + + + +
+ +
+ +
+ + +
+ +
+ + + 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} +

{i18n.t("categories.exportIdUnique")}

+ {/if} +
+
+ {/each} + {/if} +
+ +
+
+ {:else} + + + {i18n.t("categories.metaInformation")} + {i18n.t("categories.metaInformationHelp")} + + +
+ + +
+

+ {i18n.t("categories.metaTitleHint")} +

+ +
+
+
+ + +
+

+ {i18n.t("categories.metaDescriptionHint")} +

+ +
+
+
+
+ {/if} + {i18n.t(sectionMeta.labelKey)} @@ -451,7 +870,7 @@

{i18n.t(sectionMeta.formulaNoteKey)}

- {#each sectionVars as v} + {#each ALL_PROMPT_VARS as v}