@saena-io/content 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/drizzle/0000_tranquil_golden_guardian.sql +16 -0
- package/drizzle/0001_little_darwin.sql +12 -0
- package/drizzle/meta/0000_snapshot.json +94 -0
- package/drizzle/meta/0001_snapshot.json +197 -0
- package/drizzle/meta/_journal.json +20 -0
- package/package.json +41 -0
- package/src/admin/client.ts +43 -0
- package/src/admin/collection-manager.tsx +328 -0
- package/src/admin/editors.tsx +284 -0
- package/src/admin/field-registry.tsx +33 -0
- package/src/admin/form-engine.tsx +394 -0
- package/src/admin/icons.tsx +45 -0
- package/src/admin/index.tsx +343 -0
- package/src/contract.ts +57 -0
- package/src/db/schema.ts +70 -0
- package/src/define.ts +92 -0
- package/src/field/builtins.ts +524 -0
- package/src/field/field.test.ts +292 -0
- package/src/field/index.ts +49 -0
- package/src/field/infer.ts +10 -0
- package/src/field/keys.ts +69 -0
- package/src/field/registry.ts +32 -0
- package/src/field/types.ts +83 -0
- package/src/index.test.ts +23 -0
- package/src/index.ts +108 -0
- package/src/public/index.tsx +133 -0
- package/src/service.ts +375 -0
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { type AssetRef, IMAGE_WIDTH_LADDER } from '@saena-io/plugin-sdk';
|
|
2
|
+
import { RichTextStatic } from '@saena-io/ui/components/rich-text/static';
|
|
3
|
+
import type { ComponentType } from 'react';
|
|
4
|
+
import type { ImageValue } from '../field';
|
|
5
|
+
|
|
6
|
+
// @saena-io/content/public — the public-site readers a section's React component uses to render field values.
|
|
7
|
+
// Editor-free (RichTextStatic ships no editor), so the public bundle stays editor-free (ADR-0005/0008). v1
|
|
8
|
+
// exposes the richtext reader; custom field types provide their own public reader alongside their editor. The
|
|
9
|
+
// page composition + section components live in the client app and call these.
|
|
10
|
+
|
|
11
|
+
/** Render a `richtext` field value (the stored serialized-Slate string) for the public site, editor-free. */
|
|
12
|
+
export function ContentRichText({ value, className }: { value: string; className?: string }) {
|
|
13
|
+
return <RichTextStatic value={value} className={className} />;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// webp is requested for all variants (universal support, small). Widths come from the shared IMAGE_WIDTH_LADDER
|
|
17
|
+
// (the serve route snaps to the same ladder, so descriptors are honest and the derivative cache stays bounded).
|
|
18
|
+
const CENTRE = { x: 0.5, y: 0.5 };
|
|
19
|
+
|
|
20
|
+
/** Build a derivative URL for the serve route (ADR-0009): `/api/assets/<id>?w=&fmt=webp[&ar=&fx=&fy=&q=]`. */
|
|
21
|
+
function variantUrl(
|
|
22
|
+
url: string,
|
|
23
|
+
width: number,
|
|
24
|
+
ratio: number | undefined,
|
|
25
|
+
hot: { x: number; y: number },
|
|
26
|
+
quality: number | undefined,
|
|
27
|
+
): string {
|
|
28
|
+
const p = new URLSearchParams({ w: String(width), fmt: 'webp' });
|
|
29
|
+
if (ratio) {
|
|
30
|
+
p.set('ar', String(ratio));
|
|
31
|
+
p.set('fx', String(hot.x));
|
|
32
|
+
p.set('fy', String(hot.y));
|
|
33
|
+
}
|
|
34
|
+
if (quality) p.set('q', String(quality));
|
|
35
|
+
return `${url}?${p.toString()}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Render an `image` field value for the public site (ADR-0009): an optimized, responsive `<img>` — a webp
|
|
40
|
+
* `srcset` of widths the browser picks from by DPR + the `sizes` hint, with intrinsic `width`/`height` set so
|
|
41
|
+
* there's no layout shift. Pass `ratio` (w/h) to render at a fixed aspect: variants are cropped to it, centred
|
|
42
|
+
* on the field's focal point (`hotspot`), so the subject stays in frame across breakpoints. Editor-free (just an
|
|
43
|
+
* `<img>`), so the public bundle stays editor-free. While the image loads, its dominant colour (when known) is
|
|
44
|
+
* shown as the background so the reserved box isn't blank. Renders nothing until an image is set.
|
|
45
|
+
*/
|
|
46
|
+
export function ContentImage({
|
|
47
|
+
value,
|
|
48
|
+
ratio,
|
|
49
|
+
sizes = '100vw',
|
|
50
|
+
quality,
|
|
51
|
+
priority = false,
|
|
52
|
+
className,
|
|
53
|
+
}: {
|
|
54
|
+
value: ImageValue;
|
|
55
|
+
/** Render aspect ratio (w/h). When set, variants are cropped to it, centred on the focal point. */
|
|
56
|
+
ratio?: number;
|
|
57
|
+
/** The `sizes` attribute — how wide the image renders per breakpoint (e.g. `(min-width:768px) 50vw, 100vw`). */
|
|
58
|
+
sizes?: string;
|
|
59
|
+
/** Override the webp encoder quality (1–100) for this usage; omitted uses the pipeline's tuned default. */
|
|
60
|
+
quality?: number;
|
|
61
|
+
/** Eager-load above-the-fold images (default lazy). */
|
|
62
|
+
priority?: boolean;
|
|
63
|
+
className?: string;
|
|
64
|
+
}) {
|
|
65
|
+
const ref: AssetRef | null = value.ref;
|
|
66
|
+
if (!ref) return null;
|
|
67
|
+
const loading = priority ? 'eager' : 'lazy';
|
|
68
|
+
// Dominant colour as a load placeholder (when probed); the opaque image paints over it.
|
|
69
|
+
const placeholder = ref.dominantColor ? { backgroundColor: ref.dominantColor } : undefined;
|
|
70
|
+
|
|
71
|
+
// Without intrinsic dimensions (a pre-ADR-0009 upload), or a source narrower than the smallest ladder width
|
|
72
|
+
// (a srcset would add nothing), serve the original as a plain, still-lazy `<img>`.
|
|
73
|
+
const maxW =
|
|
74
|
+
ratio && ref.width && ref.height && ratio < ref.width / ref.height
|
|
75
|
+
? Math.round(ref.height * ratio)
|
|
76
|
+
: (ref.width ?? 0);
|
|
77
|
+
const ladder = IMAGE_WIDTH_LADDER.filter((w) => w <= maxW);
|
|
78
|
+
if (!ref.width || !ref.height || ladder.length === 0) {
|
|
79
|
+
return (
|
|
80
|
+
<img
|
|
81
|
+
src={ref.url}
|
|
82
|
+
alt={value.alt}
|
|
83
|
+
width={ref.width}
|
|
84
|
+
height={ref.height}
|
|
85
|
+
loading={loading}
|
|
86
|
+
decoding="async"
|
|
87
|
+
style={placeholder}
|
|
88
|
+
className={className}
|
|
89
|
+
/>
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const hot = value.hotspot ?? CENTRE;
|
|
94
|
+
// Largest deliverable width = top of the per-image ladder (non-empty here); intrinsic width/height carry the
|
|
95
|
+
// render aspect (ratio when cropping, else the source's), so the box is reserved with no layout shift.
|
|
96
|
+
const outW = ladder[ladder.length - 1] ?? maxW;
|
|
97
|
+
const outH = ratio ? Math.round(outW / ratio) : Math.round(outW / (ref.width / ref.height));
|
|
98
|
+
|
|
99
|
+
return (
|
|
100
|
+
<img
|
|
101
|
+
src={variantUrl(ref.url, outW, ratio, hot, quality)}
|
|
102
|
+
srcSet={ladder.map((w) => `${variantUrl(ref.url, w, ratio, hot, quality)} ${w}w`).join(', ')}
|
|
103
|
+
sizes={sizes}
|
|
104
|
+
alt={value.alt}
|
|
105
|
+
width={outW}
|
|
106
|
+
height={outH}
|
|
107
|
+
style={placeholder}
|
|
108
|
+
loading={loading}
|
|
109
|
+
decoding="async"
|
|
110
|
+
className={className}
|
|
111
|
+
/>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// --- Section component registry --------------------------------------------------------------------------
|
|
116
|
+
// A section's public component, keyed by its section id. Registering one lets the admin's live preview render
|
|
117
|
+
// the real public component from the in-editor value (the same component the public route renders), so the
|
|
118
|
+
// preview is WYSIWYG. Section composition stays code (definePage); this only makes the components addressable.
|
|
119
|
+
|
|
120
|
+
/** A section's public component — receives its hydrated `content`. */
|
|
121
|
+
export type SectionComponent<C = unknown> = ComponentType<{ content: C }>;
|
|
122
|
+
|
|
123
|
+
const sectionComponents = new Map<string, SectionComponent>();
|
|
124
|
+
|
|
125
|
+
/** Register a section's public component by id (call once at module load, alongside the component). */
|
|
126
|
+
export function registerSectionComponent<C>(id: string, component: SectionComponent<C>): void {
|
|
127
|
+
sectionComponents.set(id, component as SectionComponent);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** The registered public component for a section id, if any (the admin live preview reads this). */
|
|
131
|
+
export function getSectionComponent(id: string): SectionComponent | undefined {
|
|
132
|
+
return sectionComponents.get(id);
|
|
133
|
+
}
|
package/src/service.ts
ADDED
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import type { RequestContext, TranslatableInput } from '@saena-io/plugin-sdk';
|
|
3
|
+
import { and, asc, eq, sql } from 'drizzle-orm';
|
|
4
|
+
import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js';
|
|
5
|
+
import { contentCollectionItems, contentSectionValues } from './db/schema';
|
|
6
|
+
import { type CollectionDef, getCollection, getPage, getSection } from './define';
|
|
7
|
+
import {
|
|
8
|
+
fieldEmpty,
|
|
9
|
+
fieldToConfig,
|
|
10
|
+
fieldValidate,
|
|
11
|
+
fillSection,
|
|
12
|
+
hydrateSection,
|
|
13
|
+
sectionLeaves,
|
|
14
|
+
slugify,
|
|
15
|
+
} from './field';
|
|
16
|
+
|
|
17
|
+
// The content service (ADR-0008). Sections + pages are CODE (the registry in ./define); this persists only
|
|
18
|
+
// the editable VALUES: structure/config in content_section_values, and every translatable leaf as an i18n key
|
|
19
|
+
// via the field core's leaf walk. Live-edit (no draft/publish in v1). Plugins query their own tables through
|
|
20
|
+
// ctx.db, which the SDK keeps opaque (Database = unknown) — cast to the drizzle handle the core provides.
|
|
21
|
+
function db(ctx: RequestContext): PostgresJsDatabase {
|
|
22
|
+
return ctx.db as PostgresJsDatabase;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The i18n key prefix for a section instance: `content.<page>.<section>`. */
|
|
26
|
+
function sectionPrefix(page: string, section: string): string {
|
|
27
|
+
return `content.${page}.${section}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface SaveSectionInput {
|
|
31
|
+
page: string;
|
|
32
|
+
section: string;
|
|
33
|
+
/** The section's value as produced by the admin editor (validated against the section schema here). */
|
|
34
|
+
value: unknown;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Persist a section's content: validate against its schema, prune the i18n keys of any removed list items /
|
|
39
|
+
* abandoned union variants, store the config projection (text blanked), then sync every text leaf to i18n.
|
|
40
|
+
*/
|
|
41
|
+
export async function saveSection(ctx: RequestContext, input: SaveSectionInput): Promise<void> {
|
|
42
|
+
ctx.requirePermission('content.manage');
|
|
43
|
+
const def = getSection(input.section);
|
|
44
|
+
if (!def) throw new Error(`unknown content section: ${input.section}`);
|
|
45
|
+
const prefix = sectionPrefix(input.page, input.section);
|
|
46
|
+
const storage = fieldValidate(def.schema, input.value);
|
|
47
|
+
|
|
48
|
+
const [existing] = await db(ctx)
|
|
49
|
+
.select({ config: contentSectionValues.config })
|
|
50
|
+
.from(contentSectionValues)
|
|
51
|
+
.where(
|
|
52
|
+
and(
|
|
53
|
+
eq(contentSectionValues.page, input.page),
|
|
54
|
+
eq(contentSectionValues.section, input.section),
|
|
55
|
+
),
|
|
56
|
+
)
|
|
57
|
+
.limit(1);
|
|
58
|
+
|
|
59
|
+
const leaves = sectionLeaves(def.schema, storage, prefix);
|
|
60
|
+
const newKeys = new Set(leaves.map((l) => l.key));
|
|
61
|
+
const config = fieldToConfig(def.schema, storage);
|
|
62
|
+
const fields: TranslatableInput[] = leaves.map((l) => ({
|
|
63
|
+
key: l.key,
|
|
64
|
+
sourceText: l.sourceText,
|
|
65
|
+
format: l.format,
|
|
66
|
+
}));
|
|
67
|
+
// One transaction: prune removed i18n keys, write the config projection, and sync the text leaves together —
|
|
68
|
+
// so a mid-save failure can never leave the config and its translations out of sync. The i18n seam runs on
|
|
69
|
+
// the same `tx` (ADR-0005/0008).
|
|
70
|
+
await db(ctx).transaction(async (tx) => {
|
|
71
|
+
if (existing) {
|
|
72
|
+
// Keys that existed but no longer do (a removed item, a switched union variant) → prune from i18n.
|
|
73
|
+
const oldKeys = sectionLeaves(def.schema, existing.config, prefix).map((l) => l.key);
|
|
74
|
+
for (const key of oldKeys) {
|
|
75
|
+
if (!newKeys.has(key)) await ctx.i18n.deleteKeysByPrefix(key, { tx });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
await tx
|
|
79
|
+
.insert(contentSectionValues)
|
|
80
|
+
.values({ page: input.page, section: input.section, config })
|
|
81
|
+
.onConflictDoUpdate({
|
|
82
|
+
target: [contentSectionValues.page, contentSectionValues.section],
|
|
83
|
+
set: { config, updatedAt: new Date() },
|
|
84
|
+
});
|
|
85
|
+
await ctx.i18n.syncTranslatable(fields, { tx });
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** `localizeMany` echoes unknown keys back as their own value (a public "missing string" marker). A content
|
|
90
|
+
* field with no translation yet — e.g. one newly added to an already-saved section — should read as empty in
|
|
91
|
+
* both the editor and the public render, so drop the echoes (value === key) before hydrate/fill. */
|
|
92
|
+
function dropMissing(localized: Record<string, string>): Record<string, string> {
|
|
93
|
+
const out: Record<string, string> = {};
|
|
94
|
+
for (const [k, v] of Object.entries(localized)) if (v !== k) out[k] = v;
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Load a section's content, hydrated + localized for `ctx.locale`. Unsaved sections hydrate to empty values. */
|
|
99
|
+
export async function getSectionContent(
|
|
100
|
+
ctx: RequestContext,
|
|
101
|
+
page: string,
|
|
102
|
+
section: string,
|
|
103
|
+
): Promise<unknown> {
|
|
104
|
+
const def = getSection(section);
|
|
105
|
+
if (!def) throw new Error(`unknown content section: ${section}`);
|
|
106
|
+
const prefix = sectionPrefix(page, section);
|
|
107
|
+
const [row] = await db(ctx)
|
|
108
|
+
.select({ config: contentSectionValues.config })
|
|
109
|
+
.from(contentSectionValues)
|
|
110
|
+
.where(and(eq(contentSectionValues.page, page), eq(contentSectionValues.section, section)))
|
|
111
|
+
.limit(1);
|
|
112
|
+
|
|
113
|
+
if (!row) {
|
|
114
|
+
// Never saved: hydrate the empty config with no localized text (text leaves → '').
|
|
115
|
+
const empty = fieldToConfig(def.schema, fieldEmpty(def.schema));
|
|
116
|
+
return hydrateSection(def.schema, empty, {}, prefix);
|
|
117
|
+
}
|
|
118
|
+
const keys = sectionLeaves(def.schema, row.config, prefix).map((l) => l.key);
|
|
119
|
+
const localized = dropMissing(await ctx.i18n.localizeMany(keys, ctx.locale));
|
|
120
|
+
return hydrateSection(def.schema, row.config, localized, prefix);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Load a section's content for the ADMIN editor: the storage shape (list item ids preserved) with translatable
|
|
125
|
+
* leaves filled from the editing locale's source — so the editor mutates a complete payload that saves back
|
|
126
|
+
* with stable ids + i18n keys. Distinct from getSectionContent, which gives the public a clean value shape.
|
|
127
|
+
*/
|
|
128
|
+
export async function getSectionForEdit(
|
|
129
|
+
ctx: RequestContext,
|
|
130
|
+
page: string,
|
|
131
|
+
section: string,
|
|
132
|
+
): Promise<unknown> {
|
|
133
|
+
const def = getSection(section);
|
|
134
|
+
if (!def) throw new Error(`unknown content section: ${section}`);
|
|
135
|
+
const prefix = sectionPrefix(page, section);
|
|
136
|
+
const [row] = await db(ctx)
|
|
137
|
+
.select({ config: contentSectionValues.config })
|
|
138
|
+
.from(contentSectionValues)
|
|
139
|
+
.where(and(eq(contentSectionValues.page, page), eq(contentSectionValues.section, section)))
|
|
140
|
+
.limit(1);
|
|
141
|
+
|
|
142
|
+
const config = row?.config ?? fieldToConfig(def.schema, fieldEmpty(def.schema));
|
|
143
|
+
if (!row) return fillSection(def.schema, config, {}, prefix);
|
|
144
|
+
const keys = sectionLeaves(def.schema, config, prefix).map((l) => l.key);
|
|
145
|
+
const localized = dropMissing(await ctx.i18n.localizeMany(keys, ctx.locale));
|
|
146
|
+
return fillSection(def.schema, config, localized, prefix);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Load every section of a page, keyed by section id — the public route's hydration payload. */
|
|
150
|
+
export async function getPageContent(
|
|
151
|
+
ctx: RequestContext,
|
|
152
|
+
page: string,
|
|
153
|
+
): Promise<Record<string, unknown> | null> {
|
|
154
|
+
const def = getPage(page);
|
|
155
|
+
if (!def) return null;
|
|
156
|
+
const out: Record<string, unknown> = {};
|
|
157
|
+
for (const section of def.sections) out[section] = await getSectionContent(ctx, page, section);
|
|
158
|
+
return out;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// --- Routed collections (ADR-0011) -----------------------------------------------------------------------
|
|
162
|
+
// One row per entry in content_collection_items; the per-entry field pipeline is the SAME field core sections
|
|
163
|
+
// use. Each entry's translatable leaves are rooted at its STABLE row id — `content.collection.<c>.<id>.…` — so
|
|
164
|
+
// reorder/rename never re-keys or orphans translations. Saves are transactional (config + i18n together).
|
|
165
|
+
|
|
166
|
+
/** The i18n key prefix for a collection entry — rooted at the entry's stable row id (ADR-0011). */
|
|
167
|
+
function collectionItemPrefix(collection: string, id: string): string {
|
|
168
|
+
return `content.collection.${collection}.${id}`;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function requireCollection(id: string): CollectionDef {
|
|
172
|
+
const def = getCollection(id);
|
|
173
|
+
if (!def) throw new Error(`unknown content collection: ${id}`);
|
|
174
|
+
return def;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** A collection entry as the service returns it: identity + slug + the (opaque) hydrated value. Callers cast
|
|
178
|
+
* `value` to their schema's `InferValue` via `CollectionItem<typeof def>`. */
|
|
179
|
+
export interface CollectionItemRecord {
|
|
180
|
+
id: string;
|
|
181
|
+
slug: string;
|
|
182
|
+
value: unknown;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export interface SaveCollectionItemInput {
|
|
186
|
+
collection: string;
|
|
187
|
+
/** Omit to create a new entry; provide to update an existing one. */
|
|
188
|
+
id?: string;
|
|
189
|
+
slug: string;
|
|
190
|
+
/** The entry value from the admin editor (validated server-side against the collection schema). */
|
|
191
|
+
value: unknown;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Create or update a collection entry. In ONE transaction: upsert the row, prune removed i18n keys, and sync
|
|
195
|
+
* the text leaves (rooted at the entry's stable id). Returns the entry id. */
|
|
196
|
+
export async function saveItem(
|
|
197
|
+
ctx: RequestContext,
|
|
198
|
+
input: SaveCollectionItemInput,
|
|
199
|
+
): Promise<{ id: string }> {
|
|
200
|
+
ctx.requirePermission('content.manage');
|
|
201
|
+
const def = requireCollection(input.collection);
|
|
202
|
+
const id = input.id ?? randomUUID();
|
|
203
|
+
const slug = slugify(input.slug) || id;
|
|
204
|
+
const prefix = collectionItemPrefix(input.collection, id);
|
|
205
|
+
const storage = fieldValidate(def.schema, input.value);
|
|
206
|
+
const leaves = sectionLeaves(def.schema, storage, prefix);
|
|
207
|
+
const newKeys = new Set(leaves.map((l) => l.key));
|
|
208
|
+
const config = fieldToConfig(def.schema, storage);
|
|
209
|
+
const fields: TranslatableInput[] = leaves.map((l) => ({
|
|
210
|
+
key: l.key,
|
|
211
|
+
sourceText: l.sourceText,
|
|
212
|
+
format: l.format,
|
|
213
|
+
}));
|
|
214
|
+
|
|
215
|
+
await db(ctx).transaction(async (tx) => {
|
|
216
|
+
const [existing] = await tx
|
|
217
|
+
.select({ config: contentCollectionItems.config })
|
|
218
|
+
.from(contentCollectionItems)
|
|
219
|
+
.where(eq(contentCollectionItems.id, id))
|
|
220
|
+
.limit(1);
|
|
221
|
+
if (existing) {
|
|
222
|
+
// Prune keys that existed but no longer do (a removed list item / switched union variant).
|
|
223
|
+
const oldKeys = sectionLeaves(def.schema, existing.config, prefix).map((l) => l.key);
|
|
224
|
+
for (const key of oldKeys) {
|
|
225
|
+
if (!newKeys.has(key)) await ctx.i18n.deleteKeysByPrefix(key, { tx });
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
// New entries append at the end of the collection's order.
|
|
229
|
+
let sortOrder = 0;
|
|
230
|
+
if (!existing) {
|
|
231
|
+
const [m] = await tx
|
|
232
|
+
.select({ max: sql<number>`coalesce(max(${contentCollectionItems.sortOrder}), -1)` })
|
|
233
|
+
.from(contentCollectionItems)
|
|
234
|
+
.where(eq(contentCollectionItems.collection, input.collection));
|
|
235
|
+
sortOrder = (m?.max ?? -1) + 1;
|
|
236
|
+
}
|
|
237
|
+
await tx
|
|
238
|
+
.insert(contentCollectionItems)
|
|
239
|
+
.values({ id, collection: input.collection, slug, config, sortOrder })
|
|
240
|
+
.onConflictDoUpdate({
|
|
241
|
+
target: contentCollectionItems.id,
|
|
242
|
+
set: { slug, config, updatedAt: new Date() },
|
|
243
|
+
});
|
|
244
|
+
await ctx.i18n.syncTranslatable(fields, { tx });
|
|
245
|
+
});
|
|
246
|
+
return { id };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Delete a collection entry + its i18n keys, atomically. */
|
|
250
|
+
export async function deleteItem(
|
|
251
|
+
ctx: RequestContext,
|
|
252
|
+
input: { collection: string; id: string },
|
|
253
|
+
): Promise<void> {
|
|
254
|
+
ctx.requirePermission('content.manage');
|
|
255
|
+
const prefix = collectionItemPrefix(input.collection, input.id);
|
|
256
|
+
await db(ctx).transaction(async (tx) => {
|
|
257
|
+
await tx
|
|
258
|
+
.delete(contentCollectionItems)
|
|
259
|
+
.where(
|
|
260
|
+
and(
|
|
261
|
+
eq(contentCollectionItems.collection, input.collection),
|
|
262
|
+
eq(contentCollectionItems.id, input.id),
|
|
263
|
+
),
|
|
264
|
+
);
|
|
265
|
+
await ctx.i18n.deleteKeysByPrefix(prefix, { tx });
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Set the collection's order from an explicit id sequence (the admin manager's drag-to-reorder). */
|
|
270
|
+
export async function reorderItems(
|
|
271
|
+
ctx: RequestContext,
|
|
272
|
+
collection: string,
|
|
273
|
+
orderedIds: string[],
|
|
274
|
+
): Promise<void> {
|
|
275
|
+
ctx.requirePermission('content.manage');
|
|
276
|
+
await db(ctx).transaction(async (tx) => {
|
|
277
|
+
for (const [i, id] of orderedIds.entries()) {
|
|
278
|
+
await tx
|
|
279
|
+
.update(contentCollectionItems)
|
|
280
|
+
.set({ sortOrder: i, updatedAt: new Date() })
|
|
281
|
+
.where(
|
|
282
|
+
and(eq(contentCollectionItems.collection, collection), eq(contentCollectionItems.id, id)),
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** Hydrate rows into public records, batching the i18n lookup across all rows (no N+1). */
|
|
289
|
+
async function hydrateRows(
|
|
290
|
+
ctx: RequestContext,
|
|
291
|
+
def: CollectionDef,
|
|
292
|
+
collection: string,
|
|
293
|
+
rows: { id: string; slug: string; config: unknown }[],
|
|
294
|
+
): Promise<CollectionItemRecord[]> {
|
|
295
|
+
const withPrefix = rows.map((row) => ({ row, prefix: collectionItemPrefix(collection, row.id) }));
|
|
296
|
+
const allKeys = withPrefix.flatMap(({ row, prefix }) =>
|
|
297
|
+
sectionLeaves(def.schema, row.config, prefix).map((l) => l.key),
|
|
298
|
+
);
|
|
299
|
+
const localized = allKeys.length
|
|
300
|
+
? dropMissing(await ctx.i18n.localizeMany(allKeys, ctx.locale))
|
|
301
|
+
: {};
|
|
302
|
+
return withPrefix.map(({ row, prefix }) => ({
|
|
303
|
+
id: row.id,
|
|
304
|
+
slug: row.slug,
|
|
305
|
+
value: hydrateSection(def.schema, row.config, localized, prefix),
|
|
306
|
+
}));
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
export interface ListItemsOptions {
|
|
310
|
+
limit?: number;
|
|
311
|
+
offset?: number;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** List a collection's entries (ordered, hydrated + localized) — the list/archive route's payload. */
|
|
315
|
+
export async function listItems(
|
|
316
|
+
ctx: RequestContext,
|
|
317
|
+
collection: string,
|
|
318
|
+
opts?: ListItemsOptions,
|
|
319
|
+
): Promise<CollectionItemRecord[]> {
|
|
320
|
+
const def = requireCollection(collection);
|
|
321
|
+
let q = db(ctx)
|
|
322
|
+
.select()
|
|
323
|
+
.from(contentCollectionItems)
|
|
324
|
+
.where(eq(contentCollectionItems.collection, collection))
|
|
325
|
+
.orderBy(asc(contentCollectionItems.sortOrder))
|
|
326
|
+
.$dynamic();
|
|
327
|
+
if (opts?.limit != null) q = q.limit(opts.limit);
|
|
328
|
+
if (opts?.offset != null) q = q.offset(opts.offset);
|
|
329
|
+
return hydrateRows(ctx, def, collection, await q);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** Load one entry by its `$slug` route segment (hydrated + localized), or null — the detail route's payload. */
|
|
333
|
+
export async function getItemBySlug(
|
|
334
|
+
ctx: RequestContext,
|
|
335
|
+
collection: string,
|
|
336
|
+
slug: string,
|
|
337
|
+
): Promise<CollectionItemRecord | null> {
|
|
338
|
+
const def = requireCollection(collection);
|
|
339
|
+
const [row] = await db(ctx)
|
|
340
|
+
.select()
|
|
341
|
+
.from(contentCollectionItems)
|
|
342
|
+
.where(
|
|
343
|
+
and(eq(contentCollectionItems.collection, collection), eq(contentCollectionItems.slug, slug)),
|
|
344
|
+
)
|
|
345
|
+
.limit(1);
|
|
346
|
+
if (!row) return null;
|
|
347
|
+
const [item] = await hydrateRows(ctx, def, collection, [row]);
|
|
348
|
+
return item ?? null;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** Load one entry by id for the ADMIN editor: the storage shape (list item ids preserved) with text filled
|
|
352
|
+
* from the editing locale — symmetric with getSectionForEdit. */
|
|
353
|
+
export async function getItemForEdit(
|
|
354
|
+
ctx: RequestContext,
|
|
355
|
+
collection: string,
|
|
356
|
+
id: string,
|
|
357
|
+
): Promise<CollectionItemRecord | null> {
|
|
358
|
+
const def = requireCollection(collection);
|
|
359
|
+
const [row] = await db(ctx)
|
|
360
|
+
.select()
|
|
361
|
+
.from(contentCollectionItems)
|
|
362
|
+
.where(
|
|
363
|
+
and(eq(contentCollectionItems.collection, collection), eq(contentCollectionItems.id, id)),
|
|
364
|
+
)
|
|
365
|
+
.limit(1);
|
|
366
|
+
if (!row) return null;
|
|
367
|
+
const prefix = collectionItemPrefix(collection, id);
|
|
368
|
+
const keys = sectionLeaves(def.schema, row.config, prefix).map((l) => l.key);
|
|
369
|
+
const localized = dropMissing(await ctx.i18n.localizeMany(keys, ctx.locale));
|
|
370
|
+
return {
|
|
371
|
+
id: row.id,
|
|
372
|
+
slug: row.slug,
|
|
373
|
+
value: fillSection(def.schema, row.config, localized, prefix),
|
|
374
|
+
};
|
|
375
|
+
}
|