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:
+3
-6
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user