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)
|
# 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.
|
# — not a shared store. RATE_LIMIT_BACKEND=redis|postgres is docs-only and forced to memory.
|
||||||
# SESSION_COOKIE_NAME=descrybe_session
|
# SESSION_COOKIE_NAME=descrybe_session
|
||||||
# Session cookie Domain attribute. Empty = host-only (localhost / same-host).
|
# Session cookie Domain is derived automatically from WEB_ORIGIN + PUBLIC_API_URL:
|
||||||
# REQUIRED when web + api run on sibling subdomains (descrybe.io + api.descrybe.io):
|
# sibling hosts of one parent (descrybe.io + api.descrybe.io) share the parent
|
||||||
# set the parent domain so the browser also sends the session cookie to the web
|
# domain so SvelteKit SSR receives the session cookie; localhost stays host-only.
|
||||||
# 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
|
|
||||||
# CSRF_COOKIE_NAME=descrybe_csrf
|
# CSRF_COOKIE_NAME=descrybe_csrf
|
||||||
# PUBLIC_CSRF_COOKIE_NAME=descrybe_csrf
|
# PUBLIC_CSRF_COOKIE_NAME=descrybe_csrf
|
||||||
# SESSION_IDLE_HOURS=24
|
# SESSION_IDLE_HOURS=24
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
defer pool.Close()
|
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)
|
srv := httpapi.NewServer(cfg, pool, sessions)
|
||||||
|
|
||||||
runCtx, runCancel := context.WithCancel(context.Background())
|
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.
|
// (e.g. redis/postgres) so boot can warn that memory was forced.
|
||||||
RateLimitBackendRequested string
|
RateLimitBackendRequested string
|
||||||
SessionCookieName string
|
SessionCookieName string
|
||||||
SessionCookieDomain string
|
|
||||||
SessionSecure bool
|
SessionSecure bool
|
||||||
CSRFCookieName string
|
CSRFCookieName string
|
||||||
PublicAPIURL string
|
PublicAPIURL string
|
||||||
@@ -142,10 +141,6 @@ func Load() (Config, error) {
|
|||||||
RateLimitMultiReplica: getenvBool("RATE_LIMIT_MULTI_REPLICA", false),
|
RateLimitMultiReplica: getenvBool("RATE_LIMIT_MULTI_REPLICA", false),
|
||||||
RateLimitBackend: "memory",
|
RateLimitBackend: "memory",
|
||||||
SessionCookieName: getenv("SESSION_COOKIE_NAME", "descrybe_session"),
|
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
|
// 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.
|
// even if SESSION_SECURE is unset; explicit false still fails closed in validate.
|
||||||
SessionSecure: getenvBool("SESSION_SECURE", isProductionEnvValue(appEnv)),
|
SessionSecure: getenvBool("SESSION_SECURE", isProductionEnvValue(appEnv)),
|
||||||
@@ -251,6 +246,69 @@ func (c Config) InsecureLocalProductionActive() bool {
|
|||||||
return c.IsProduction() && c.AllowInsecureLocalProduction && isLoopbackWebOriginHost(c.WebOrigin)
|
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
|
// ShouldWarnRateLimits reports whether operators opted into multi-replica rate-limit
|
||||||
// awareness or requested an unsupported shared backend.
|
// awareness or requested an unsupported shared backend.
|
||||||
func (c Config) ShouldWarnRateLimits() bool {
|
func (c Config) ShouldWarnRateLimits() bool {
|
||||||
|
|||||||
@@ -528,3 +528,31 @@ func TestValidateProcessingPollInterval(t *testing.T) {
|
|||||||
t.Fatal("expected PROCESSING_POLL_INTERVAL > 0")
|
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 { goto } from "$app/navigation";
|
||||||
import { page } from "$app/state";
|
import { page } from "$app/state";
|
||||||
import { api, ApiError, failureMessage } from "$lib/api";
|
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 { findCategoryIdByUniqueId, listAllCategories, resolveCategory } from "$lib/categories/resolve";
|
||||||
import {
|
import {
|
||||||
composeEnhancePrompt,
|
composeEnhancePrompt,
|
||||||
@@ -12,7 +24,6 @@
|
|||||||
ENHANCE_PROMPT_SECTIONS,
|
ENHANCE_PROMPT_SECTIONS,
|
||||||
isEnhancePromptEmpty,
|
isEnhancePromptEmpty,
|
||||||
parseEnhancePrompt,
|
parseEnhancePrompt,
|
||||||
SECTION_SCHEMA_HINTS,
|
|
||||||
type EnhancePromptSectionId
|
type EnhancePromptSectionId
|
||||||
} from "$lib/categories/prompt-sections";
|
} from "$lib/categories/prompt-sections";
|
||||||
import PageShell from "$lib/components/PageShell.svelte";
|
import PageShell from "$lib/components/PageShell.svelte";
|
||||||
@@ -26,18 +37,29 @@
|
|||||||
CardDescription,
|
CardDescription,
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
|
Input,
|
||||||
Label,
|
Label,
|
||||||
|
Select,
|
||||||
Textarea
|
Textarea
|
||||||
} from "$lib/components/ui";
|
} from "$lib/components/ui";
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
FileText,
|
FileText,
|
||||||
|
GripVertical,
|
||||||
|
Plus,
|
||||||
Search,
|
Search,
|
||||||
Share2,
|
Share2,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
Type,
|
Type,
|
||||||
X
|
X
|
||||||
} from "@lucide/svelte";
|
} 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 TreeSelectDialog from "$lib/components/categories/TreeSelectDialog.svelte";
|
||||||
import {
|
import {
|
||||||
CONTENT_LANGUAGES,
|
CONTENT_LANGUAGES,
|
||||||
@@ -103,17 +125,47 @@
|
|||||||
});
|
});
|
||||||
let unstructuredHint = $state(false);
|
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(
|
const langLabel = $derived(
|
||||||
CONTENT_LANGUAGES.find((l) => l.value === selectedLang)?.label ?? selectedLang
|
CONTENT_LANGUAGES.find((l) => l.value === selectedLang)?.label ?? selectedLang
|
||||||
);
|
);
|
||||||
|
|
||||||
const schemaHint = $derived(SECTION_SCHEMA_HINTS[activeSection]);
|
|
||||||
const sectionMeta = $derived(SECTION_META[activeSection]);
|
const sectionMeta = $derived(SECTION_META[activeSection]);
|
||||||
const sectionBody = $derived(sectionBodies[activeSection] ?? "");
|
const sectionBody = $derived(sectionBodies[activeSection] ?? "");
|
||||||
|
|
||||||
const sectionVars = $derived(
|
const usedNames = $derived(
|
||||||
ALL_PROMPT_VARS.filter((v) => schemaHint.suggestedVars.includes(v.name))
|
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(() => {
|
const matchingUniqueIds = $derived.by(() => {
|
||||||
if (!category) return [] as string[];
|
if (!category) return [] as string[];
|
||||||
@@ -134,7 +186,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function currentCombined(): string {
|
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 "";
|
if (isEnhancePromptEmpty(DEFAULT_ENHANCE_PREAMBLE, sectionBodies)) return "";
|
||||||
return composeEnhancePrompt(DEFAULT_ENHANCE_PREAMBLE, sectionBodies);
|
return composeEnhancePrompt(DEFAULT_ENHANCE_PREAMBLE, sectionBodies);
|
||||||
}
|
}
|
||||||
@@ -168,6 +220,12 @@
|
|||||||
selectedLang = primaryLang;
|
selectedLang = primaryLang;
|
||||||
}
|
}
|
||||||
syncEditorFromStored(map[selectedLang] ?? "");
|
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) {
|
function onLangChange(code: string) {
|
||||||
@@ -181,10 +239,12 @@
|
|||||||
loading = true;
|
loading = true;
|
||||||
error = "";
|
error = "";
|
||||||
try {
|
try {
|
||||||
const [cat, me] = await Promise.all([
|
const [cat, me, vars] = await Promise.all([
|
||||||
resolveCategory(categoryParam),
|
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);
|
primaryLang = parseContentLanguage(me?.company?.language, DEFAULT_CONTENT_LANGUAGE);
|
||||||
const companyLangs = (me?.company as { content_languages?: string[] } | undefined)
|
const companyLangs = (me?.company as { content_languages?: string[] } | undefined)
|
||||||
?.content_languages;
|
?.content_languages;
|
||||||
@@ -245,8 +305,114 @@
|
|||||||
promptsByLang = next;
|
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;
|
if (!category) return;
|
||||||
saving = true;
|
saving = true;
|
||||||
error = "";
|
error = "";
|
||||||
@@ -257,17 +423,33 @@
|
|||||||
for (const [k, v] of Object.entries(promptsByLang)) {
|
for (const [k, v] of Object.entries(promptsByLang)) {
|
||||||
if (v.trim()) bodyPrompts[k] = v;
|
if (v.trim()) bodyPrompts[k] = v;
|
||||||
}
|
}
|
||||||
const updated = await api<Cat>(`/api/categories/${category.id}/prompt`, {
|
const titleTemplate = buildTemplateToSave(
|
||||||
method: "PATCH",
|
titleFormula.elements,
|
||||||
body: { prompts: bodyPrompts }
|
titleFormula.separator,
|
||||||
});
|
customVariables
|
||||||
applyCategory(updated);
|
);
|
||||||
|
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());
|
const has = Boolean((promptsByLang[selectedLang] ?? "").trim());
|
||||||
success = has
|
success = has
|
||||||
? i18n.t("categories.aiPromptSavedLang", { lang: langLabel })
|
? i18n.t("categories.aiPromptSavedLang", { lang: langLabel })
|
||||||
: i18n.t("categories.aiPromptClearedLang", { lang: langLabel });
|
: i18n.t("categories.aiPromptClearedLang", { lang: langLabel });
|
||||||
allCategories = allCategories.map((c) =>
|
allCategories = allCategories.map((c) =>
|
||||||
String(c.id) === String(updated.id) ? { ...c, ...updated } : c
|
String(c.id) === String(refreshed.id) ? { ...c, ...refreshed } : c
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error = failureMessage(err, i18n.t("categories.aiPromptSaveFailed"));
|
error = failureMessage(err, i18n.t("categories.aiPromptSaveFailed"));
|
||||||
@@ -299,14 +481,30 @@
|
|||||||
for (const [k, v] of Object.entries(promptsByLang)) {
|
for (const [k, v] of Object.entries(promptsByLang)) {
|
||||||
if (v.trim()) bodyPrompts[k] = v;
|
if (v.trim()) bodyPrompts[k] = v;
|
||||||
}
|
}
|
||||||
|
const titleTemplate = buildTemplateToSave(
|
||||||
|
titleFormula.elements,
|
||||||
|
titleFormula.separator,
|
||||||
|
customVariables
|
||||||
|
);
|
||||||
|
const descTemplate = buildDescriptionTemplate();
|
||||||
const ids = uniqueIds
|
const ids = uniqueIds
|
||||||
.map((uid) => findCategoryIdByUniqueId(allCategories, uid))
|
.map((uid) => findCategoryIdByUniqueId(allCategories, uid))
|
||||||
.filter((id): id is string => Boolean(id));
|
.filter((id): id is string => Boolean(id));
|
||||||
for (let i = 0; i < ids.length; i++) {
|
for (let i = 0; i < ids.length; i++) {
|
||||||
await api(`/api/categories/${ids[i]}/prompt`, {
|
await Promise.all([
|
||||||
method: "PATCH",
|
api(`/api/categories/${ids[i]}/prompt`, {
|
||||||
body: { prompts: bodyPrompts }
|
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);
|
assignProgress = Math.round(((i + 1) / ids.length) * 100);
|
||||||
}
|
}
|
||||||
success = i18n.t("flash.categories.promptAssigned", {
|
success = i18n.t("flash.categories.promptAssigned", {
|
||||||
@@ -329,7 +527,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function sectionFilled(id: EnhancePromptSectionId): boolean {
|
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>
|
</script>
|
||||||
|
|
||||||
@@ -365,7 +567,7 @@
|
|||||||
<Button variant="outline" onclick={() => void goto("/categories")}
|
<Button variant="outline" onclick={() => void goto("/categories")}
|
||||||
>{i18n.t("common.cancel")}</Button
|
>{i18n.t("common.cancel")}</Button
|
||||||
>
|
>
|
||||||
<Button onclick={savePrompt} loading={saving}>
|
<Button onclick={saveAll} loading={saving}>
|
||||||
<Sparkles class="h-4 w-4" />
|
<Sparkles class="h-4 w-4" />
|
||||||
{saving ? i18n.t("catalog.saving") : i18n.t("catalog.saveChanges")}
|
{saving ? i18n.t("catalog.saving") : i18n.t("catalog.saveChanges")}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -442,6 +644,223 @@
|
|||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<div class="min-w-0 space-y-4">
|
<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>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{i18n.t(sectionMeta.labelKey)}</CardTitle>
|
<CardTitle>{i18n.t(sectionMeta.labelKey)}</CardTitle>
|
||||||
@@ -451,7 +870,7 @@
|
|||||||
<p class="text-xs text-muted-foreground">{i18n.t(sectionMeta.formulaNoteKey)}</p>
|
<p class="text-xs text-muted-foreground">{i18n.t(sectionMeta.formulaNoteKey)}</p>
|
||||||
|
|
||||||
<div class="flex flex-wrap gap-2">
|
<div class="flex flex-wrap gap-2">
|
||||||
{#each sectionVars as v}
|
{#each ALL_PROMPT_VARS as v}
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -480,8 +899,8 @@
|
|||||||
id="category-prompt-section"
|
id="category-prompt-section"
|
||||||
value={sectionBody}
|
value={sectionBody}
|
||||||
oninput={(e) => setSectionBody((e.currentTarget as HTMLTextAreaElement).value)}
|
oninput={(e) => setSectionBody((e.currentTarget as HTMLTextAreaElement).value)}
|
||||||
rows={14}
|
rows={10}
|
||||||
class="min-h-[220px] font-mono text-sm"
|
class="min-h-[180px] font-mono text-sm"
|
||||||
placeholder={i18n.t("categories.promptSection.contentPlaceholder")}
|
placeholder={i18n.t("categories.promptSection.contentPlaceholder")}
|
||||||
/>
|
/>
|
||||||
<p class="text-xs text-muted-foreground">
|
<p class="text-xs text-muted-foreground">
|
||||||
@@ -512,5 +931,62 @@
|
|||||||
onClose={() => (assignOpen = false)}
|
onClose={() => (assignOpen = false)}
|
||||||
onSave={assignPrompt}
|
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}
|
{/if}
|
||||||
</PageShell>
|
</PageShell>
|
||||||
@@ -10,9 +10,9 @@
|
|||||||
buildTemplateToSave,
|
buildTemplateToSave,
|
||||||
elementId,
|
elementId,
|
||||||
generatePreviewElements,
|
generatePreviewElements,
|
||||||
mapApiVariable,
|
|
||||||
parseTemplateToFormula
|
parseTemplateToFormula
|
||||||
} from "$lib/categories/formula";
|
} from "$lib/categories/formula";
|
||||||
|
import { loadFormulaVariables } from "$lib/categories/formula-variables";
|
||||||
import { findCategoryIdByUniqueId, listAllCategories, resolveCategory } from "$lib/categories/resolve";
|
import { findCategoryIdByUniqueId, listAllCategories, resolveCategory } from "$lib/categories/resolve";
|
||||||
import PageShell from "$lib/components/PageShell.svelte";
|
import PageShell from "$lib/components/PageShell.svelte";
|
||||||
import Alert from "$lib/components/Alert.svelte";
|
import Alert from "$lib/components/Alert.svelte";
|
||||||
@@ -93,29 +93,7 @@
|
|||||||
);
|
);
|
||||||
|
|
||||||
async function loadVariables() {
|
async function loadVariables() {
|
||||||
const [payload, fieldsRes] = await Promise.all([
|
customVariables = await loadFormulaVariables();
|
||||||
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];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
|
|||||||
Reference in New Issue
Block a user