@agent-native/core-corpus 0.213.1 → 0.214.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/corpus/README.md +1 -1
- package/corpus/templates/community/qr/package.json +1 -1
- package/corpus/templates/content/.agents/skills/document-editing/references/databases.md +15 -1
- package/corpus/templates/content/CHANGELOG.md +5 -0
- package/corpus/templates/content/actions/_database-view-setup.ts +305 -11
- package/corpus/templates/content/actions/_property-utils.ts +3 -0
- package/corpus/templates/content/actions/update-content-database-view.ts +18 -2
- package/corpus/templates/content/actions/view-screen.ts +12 -0
- package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +91 -16
- package/corpus/templates/content/app/components/editor/database/ListItemLayout.tsx +410 -0
- package/corpus/templates/content/app/components/editor/database/ListItemRow.tsx +426 -0
- package/corpus/templates/content/app/components/editor/database/ListView.tsx +136 -18
- package/corpus/templates/content/app/components/editor/database/navigation-state.ts +3 -0
- package/corpus/templates/content/app/components/editor/database/settings.tsx +3 -3
- package/corpus/templates/content/app/components/editor/database/view-config.ts +4 -0
- package/corpus/templates/content/app/global.css +13 -0
- package/corpus/templates/content/app/i18n-data.ts +32 -0
- package/corpus/templates/content/changelog/2026-10-19-list-rows-can-be-customized.md +6 -0
- package/corpus/templates/content/changelog/2026-10-19-list-views-fit-phone-screens.md +6 -0
- package/corpus/templates/content/shared/api.ts +18 -0
- package/corpus/templates/content/shared/database-item-layout.ts +591 -0
- package/package.json +1 -1
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ContentDatabaseItemLayout,
|
|
3
|
+
ContentDatabaseItemLeadingKind,
|
|
4
|
+
ContentDatabaseItemTitleWeight,
|
|
5
|
+
DocumentProperty,
|
|
6
|
+
} from "@shared/api";
|
|
7
|
+
import {
|
|
8
|
+
CONTENT_DATABASE_ITEM_LAYOUT_SLOTS,
|
|
9
|
+
contentDatabaseItemLayoutPresets,
|
|
10
|
+
itemLayoutLeadingCandidates,
|
|
11
|
+
itemLayoutLine2Candidates,
|
|
12
|
+
itemLayoutMetaCandidates,
|
|
13
|
+
matchingContentDatabaseItemLayoutPreset,
|
|
14
|
+
MAX_ITEM_LAYOUT_META_PROPERTIES,
|
|
15
|
+
startingContentDatabaseItemLayout,
|
|
16
|
+
toggleContentDatabaseItemLayoutMetaProperty,
|
|
17
|
+
type ContentDatabaseItemLayoutPresetId,
|
|
18
|
+
type ContentDatabaseItemLayoutSlot,
|
|
19
|
+
} from "@shared/database-item-layout";
|
|
20
|
+
import { IconCheck, IconPencil } from "@tabler/icons-react";
|
|
21
|
+
|
|
22
|
+
import { cn } from "@/lib/utils";
|
|
23
|
+
|
|
24
|
+
import { dbText } from "./DatabaseView";
|
|
25
|
+
|
|
26
|
+
const SLOT_LABEL_KEYS = {
|
|
27
|
+
leading: "listRowsStart",
|
|
28
|
+
title: "listRowsTitle",
|
|
29
|
+
line2: "listRowsLine2",
|
|
30
|
+
line3: "listRowsLine3",
|
|
31
|
+
trailing: "listRowsEnd",
|
|
32
|
+
} as const satisfies Record<
|
|
33
|
+
ContentDatabaseItemLayoutSlot,
|
|
34
|
+
Parameters<typeof dbText>[0]
|
|
35
|
+
>;
|
|
36
|
+
|
|
37
|
+
const PRESET_LABEL_KEYS = {
|
|
38
|
+
default: "listRowsPresetDefault",
|
|
39
|
+
checklist: "listRowsPresetChecklist",
|
|
40
|
+
reminders: "listRowsPresetReminders",
|
|
41
|
+
tracker: "listRowsPresetTracker",
|
|
42
|
+
digest: "listRowsPresetDigest",
|
|
43
|
+
} as const satisfies Record<
|
|
44
|
+
ContentDatabaseItemLayoutPresetId,
|
|
45
|
+
Parameters<typeof dbText>[0]
|
|
46
|
+
>;
|
|
47
|
+
|
|
48
|
+
export function listItemLayoutSlotLabel(slot: ContentDatabaseItemLayoutSlot) {
|
|
49
|
+
return dbText(SLOT_LABEL_KEYS[slot]);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function Segmented<T extends string | number>({
|
|
53
|
+
label,
|
|
54
|
+
value,
|
|
55
|
+
options,
|
|
56
|
+
onChange,
|
|
57
|
+
}: {
|
|
58
|
+
label: string;
|
|
59
|
+
value: T;
|
|
60
|
+
options: Array<{ value: T; label: string; disabled?: boolean }>;
|
|
61
|
+
onChange: (value: T) => void;
|
|
62
|
+
}) {
|
|
63
|
+
return (
|
|
64
|
+
<div role="group" aria-label={label} className="flex flex-wrap gap-1">
|
|
65
|
+
{options.map((option) => (
|
|
66
|
+
<button
|
|
67
|
+
key={String(option.value)}
|
|
68
|
+
type="button"
|
|
69
|
+
disabled={option.disabled}
|
|
70
|
+
aria-pressed={value === option.value}
|
|
71
|
+
className={cn(
|
|
72
|
+
"h-7 rounded-md border px-2 text-xs transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-40",
|
|
73
|
+
value === option.value
|
|
74
|
+
? "border-database-blue bg-database-blue/5 text-database-blue"
|
|
75
|
+
: "border-border text-muted-foreground hover:bg-muted hover:text-foreground",
|
|
76
|
+
)}
|
|
77
|
+
// Pressing the current choice again changes nothing, so Start keeps
|
|
78
|
+
// the checkbox or status field it shows.
|
|
79
|
+
onClick={() => {
|
|
80
|
+
if (option.value !== value) onChange(option.value);
|
|
81
|
+
}}
|
|
82
|
+
>
|
|
83
|
+
{option.label}
|
|
84
|
+
</button>
|
|
85
|
+
))}
|
|
86
|
+
</div>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function PropertyChoices({
|
|
91
|
+
label,
|
|
92
|
+
properties,
|
|
93
|
+
selectedIds: savedIds,
|
|
94
|
+
multiple,
|
|
95
|
+
noneLabel,
|
|
96
|
+
onChange,
|
|
97
|
+
}: {
|
|
98
|
+
label: string;
|
|
99
|
+
properties: DocumentProperty[];
|
|
100
|
+
selectedIds: string[];
|
|
101
|
+
multiple: boolean;
|
|
102
|
+
noneLabel?: string;
|
|
103
|
+
onChange: (ids: string[]) => void;
|
|
104
|
+
}) {
|
|
105
|
+
// A saved slot can name a property this menu doesn't offer: one hidden, or
|
|
106
|
+
// one since deleted. It isn't shown, so it doesn't count toward the limit.
|
|
107
|
+
// In Line 3 and End it stays saved for when it's shown again.
|
|
108
|
+
const offeredIds = properties.map((property) => property.definition.id);
|
|
109
|
+
const selectedIds = savedIds.filter((id) => offeredIds.includes(id));
|
|
110
|
+
const rows = [
|
|
111
|
+
...(noneLabel ? [{ id: "", name: noneLabel }] : []),
|
|
112
|
+
...properties.map((property) => ({
|
|
113
|
+
id: property.definition.id,
|
|
114
|
+
name: property.definition.name,
|
|
115
|
+
})),
|
|
116
|
+
];
|
|
117
|
+
return (
|
|
118
|
+
<div
|
|
119
|
+
role="group"
|
|
120
|
+
aria-label={label}
|
|
121
|
+
className="grid max-h-56 gap-0.5 overflow-y-auto"
|
|
122
|
+
>
|
|
123
|
+
{rows.map((row) => {
|
|
124
|
+
const selected = row.id
|
|
125
|
+
? selectedIds.includes(row.id)
|
|
126
|
+
: selectedIds.length === 0;
|
|
127
|
+
const position = row.id ? selectedIds.indexOf(row.id) : -1;
|
|
128
|
+
const full =
|
|
129
|
+
multiple &&
|
|
130
|
+
!selected &&
|
|
131
|
+
!!row.id &&
|
|
132
|
+
selectedIds.length >= MAX_ITEM_LAYOUT_META_PROPERTIES;
|
|
133
|
+
return (
|
|
134
|
+
<button
|
|
135
|
+
key={row.id || "none"}
|
|
136
|
+
type="button"
|
|
137
|
+
aria-pressed={selected}
|
|
138
|
+
disabled={full}
|
|
139
|
+
className="flex h-8 items-center gap-2 rounded px-2 text-left text-sm enabled:hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
|
|
140
|
+
onClick={() => {
|
|
141
|
+
if (!multiple) return onChange(row.id ? [row.id] : []);
|
|
142
|
+
onChange(
|
|
143
|
+
toggleContentDatabaseItemLayoutMetaProperty(
|
|
144
|
+
savedIds,
|
|
145
|
+
offeredIds,
|
|
146
|
+
row.id || null,
|
|
147
|
+
),
|
|
148
|
+
);
|
|
149
|
+
}}
|
|
150
|
+
>
|
|
151
|
+
<span className="flex size-4 shrink-0 items-center justify-center">
|
|
152
|
+
{selected ? (
|
|
153
|
+
<IconCheck className="size-4 text-database-blue" />
|
|
154
|
+
) : null}
|
|
155
|
+
</span>
|
|
156
|
+
<span className="min-w-0 flex-1 truncate">{row.name}</span>
|
|
157
|
+
{multiple && position >= 0 ? (
|
|
158
|
+
<span className="text-xs text-muted-foreground">
|
|
159
|
+
{position + 1}
|
|
160
|
+
</span>
|
|
161
|
+
) : null}
|
|
162
|
+
</button>
|
|
163
|
+
);
|
|
164
|
+
})}
|
|
165
|
+
</div>
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** The controls for one slot, shared by the Layout panel and the row popovers. */
|
|
170
|
+
export function ListItemLayoutSlotEditor({
|
|
171
|
+
slot,
|
|
172
|
+
layout,
|
|
173
|
+
properties,
|
|
174
|
+
onChange,
|
|
175
|
+
}: {
|
|
176
|
+
slot: ContentDatabaseItemLayoutSlot;
|
|
177
|
+
layout: ContentDatabaseItemLayout;
|
|
178
|
+
properties: DocumentProperty[];
|
|
179
|
+
onChange: (layout: ContentDatabaseItemLayout) => void;
|
|
180
|
+
}) {
|
|
181
|
+
const label = listItemLayoutSlotLabel(slot);
|
|
182
|
+
if (slot === "leading") {
|
|
183
|
+
const checkboxes = itemLayoutLeadingCandidates(properties, "checkbox");
|
|
184
|
+
const statuses = itemLayoutLeadingCandidates(properties, "status");
|
|
185
|
+
const kind = layout.leading.kind;
|
|
186
|
+
const candidates =
|
|
187
|
+
kind === "checkbox" ? checkboxes : kind === "status" ? statuses : [];
|
|
188
|
+
return (
|
|
189
|
+
<div className="grid gap-2">
|
|
190
|
+
<Segmented<ContentDatabaseItemLeadingKind>
|
|
191
|
+
label={label}
|
|
192
|
+
value={kind}
|
|
193
|
+
options={[
|
|
194
|
+
{ value: "none", label: dbText("none") },
|
|
195
|
+
{ value: "page_icon", label: dbText("listRowsPageIcon") },
|
|
196
|
+
{
|
|
197
|
+
value: "checkbox",
|
|
198
|
+
label: dbText("listRowsCheckbox"),
|
|
199
|
+
disabled: checkboxes.length === 0,
|
|
200
|
+
},
|
|
201
|
+
{
|
|
202
|
+
value: "status",
|
|
203
|
+
label: dbText("listRowsStatus"),
|
|
204
|
+
disabled: statuses.length === 0,
|
|
205
|
+
},
|
|
206
|
+
]}
|
|
207
|
+
onChange={(next) =>
|
|
208
|
+
onChange({
|
|
209
|
+
...layout,
|
|
210
|
+
leading: {
|
|
211
|
+
kind: next,
|
|
212
|
+
propertyId:
|
|
213
|
+
next === "checkbox"
|
|
214
|
+
? (checkboxes[0]?.definition.id ?? null)
|
|
215
|
+
: next === "status"
|
|
216
|
+
? (statuses[0]?.definition.id ?? null)
|
|
217
|
+
: null,
|
|
218
|
+
},
|
|
219
|
+
})
|
|
220
|
+
}
|
|
221
|
+
/>
|
|
222
|
+
{candidates.length > 1 ? (
|
|
223
|
+
<PropertyChoices
|
|
224
|
+
label={label}
|
|
225
|
+
properties={candidates}
|
|
226
|
+
selectedIds={
|
|
227
|
+
layout.leading.propertyId ? [layout.leading.propertyId] : []
|
|
228
|
+
}
|
|
229
|
+
multiple={false}
|
|
230
|
+
onChange={([id]) =>
|
|
231
|
+
onChange({
|
|
232
|
+
...layout,
|
|
233
|
+
leading: { kind, propertyId: id ?? null },
|
|
234
|
+
})
|
|
235
|
+
}
|
|
236
|
+
/>
|
|
237
|
+
) : null}
|
|
238
|
+
</div>
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
if (slot === "title") {
|
|
242
|
+
return (
|
|
243
|
+
<Segmented<ContentDatabaseItemTitleWeight>
|
|
244
|
+
label={label}
|
|
245
|
+
value={layout.titleWeight}
|
|
246
|
+
options={[
|
|
247
|
+
{ value: "regular", label: dbText("listRowsRegular") },
|
|
248
|
+
{ value: "medium", label: dbText("listRowsMedium") },
|
|
249
|
+
{ value: "bold", label: dbText("listRowsBold") },
|
|
250
|
+
]}
|
|
251
|
+
onChange={(titleWeight) => onChange({ ...layout, titleWeight })}
|
|
252
|
+
/>
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
if (slot === "line2") {
|
|
256
|
+
return (
|
|
257
|
+
<div className="grid gap-2">
|
|
258
|
+
<PropertyChoices
|
|
259
|
+
label={label}
|
|
260
|
+
properties={itemLayoutLine2Candidates(properties)}
|
|
261
|
+
selectedIds={layout.line2.propertyId ? [layout.line2.propertyId] : []}
|
|
262
|
+
multiple={false}
|
|
263
|
+
noneLabel={dbText("listRowsNothing")}
|
|
264
|
+
onChange={([id]) =>
|
|
265
|
+
onChange({
|
|
266
|
+
...layout,
|
|
267
|
+
line2: { ...layout.line2, propertyId: id ?? null },
|
|
268
|
+
})
|
|
269
|
+
}
|
|
270
|
+
/>
|
|
271
|
+
{layout.line2.propertyId ? (
|
|
272
|
+
<Segmented<1 | 2 | 3>
|
|
273
|
+
label={label}
|
|
274
|
+
value={layout.line2.lines}
|
|
275
|
+
options={[
|
|
276
|
+
{ value: 1, label: dbText("listRowsOneLine") },
|
|
277
|
+
{ value: 2, label: dbText("listRowsLineCount", { count: 2 }) },
|
|
278
|
+
{ value: 3, label: dbText("listRowsLineCount", { count: 3 }) },
|
|
279
|
+
]}
|
|
280
|
+
onChange={(lines) =>
|
|
281
|
+
onChange({ ...layout, line2: { ...layout.line2, lines } })
|
|
282
|
+
}
|
|
283
|
+
/>
|
|
284
|
+
) : null}
|
|
285
|
+
</div>
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
return (
|
|
289
|
+
<div className="grid gap-1.5">
|
|
290
|
+
<PropertyChoices
|
|
291
|
+
label={label}
|
|
292
|
+
properties={itemLayoutMetaCandidates(properties)}
|
|
293
|
+
selectedIds={layout[slot]}
|
|
294
|
+
multiple
|
|
295
|
+
noneLabel={dbText("listRowsNothing")}
|
|
296
|
+
onChange={(ids) => onChange({ ...layout, [slot]: ids })}
|
|
297
|
+
/>
|
|
298
|
+
{slot === "trailing" ? (
|
|
299
|
+
<div className="text-xs text-muted-foreground">
|
|
300
|
+
{dbText("listRowsEndHint")}
|
|
301
|
+
</div>
|
|
302
|
+
) : null}
|
|
303
|
+
</div>
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export function ListItemLayoutPresets({
|
|
308
|
+
layout,
|
|
309
|
+
properties,
|
|
310
|
+
onChange,
|
|
311
|
+
}: {
|
|
312
|
+
layout: ContentDatabaseItemLayout | null;
|
|
313
|
+
properties: DocumentProperty[];
|
|
314
|
+
onChange: (layout: ContentDatabaseItemLayout | null) => void;
|
|
315
|
+
}) {
|
|
316
|
+
const active = matchingContentDatabaseItemLayoutPreset(layout, properties);
|
|
317
|
+
return (
|
|
318
|
+
<div className="flex flex-wrap gap-1.5">
|
|
319
|
+
{contentDatabaseItemLayoutPresets(properties).map((preset) => {
|
|
320
|
+
const unavailable = preset.missingPropertyType
|
|
321
|
+
? dbText(
|
|
322
|
+
preset.missingPropertyType === "checkbox"
|
|
323
|
+
? "listRowsNeedsCheckbox"
|
|
324
|
+
: "listRowsNeedsStatus",
|
|
325
|
+
)
|
|
326
|
+
: undefined;
|
|
327
|
+
return (
|
|
328
|
+
<button
|
|
329
|
+
key={preset.id}
|
|
330
|
+
type="button"
|
|
331
|
+
disabled={!!unavailable}
|
|
332
|
+
title={unavailable}
|
|
333
|
+
aria-pressed={active === preset.id}
|
|
334
|
+
className={cn(
|
|
335
|
+
"h-7 rounded-full border px-3 text-xs transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-40",
|
|
336
|
+
active === preset.id
|
|
337
|
+
? "border-database-blue bg-database-blue text-database-blue-foreground"
|
|
338
|
+
: "border-border text-foreground hover:bg-muted",
|
|
339
|
+
)}
|
|
340
|
+
onClick={() => onChange(preset.layout)}
|
|
341
|
+
>
|
|
342
|
+
{dbText(PRESET_LABEL_KEYS[preset.id])}
|
|
343
|
+
</button>
|
|
344
|
+
);
|
|
345
|
+
})}
|
|
346
|
+
</div>
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** The Rows section of a list view's Layout panel. */
|
|
351
|
+
export function DatabaseListRowsSetting({
|
|
352
|
+
itemLayout,
|
|
353
|
+
properties,
|
|
354
|
+
visibleProperties,
|
|
355
|
+
editingOnList,
|
|
356
|
+
onChange,
|
|
357
|
+
onEditOnList,
|
|
358
|
+
}: {
|
|
359
|
+
itemLayout: ContentDatabaseItemLayout | null;
|
|
360
|
+
properties: DocumentProperty[];
|
|
361
|
+
visibleProperties: DocumentProperty[];
|
|
362
|
+
editingOnList: boolean;
|
|
363
|
+
onChange: (layout: ContentDatabaseItemLayout | null) => void;
|
|
364
|
+
onEditOnList: () => void;
|
|
365
|
+
}) {
|
|
366
|
+
const working =
|
|
367
|
+
itemLayout ?? startingContentDatabaseItemLayout(visibleProperties);
|
|
368
|
+
return (
|
|
369
|
+
<div className="grid gap-3 border-t border-border pt-4">
|
|
370
|
+
<div className="flex items-start justify-between gap-2">
|
|
371
|
+
<div className="grid gap-0.5">
|
|
372
|
+
<div className="text-sm font-medium text-foreground">
|
|
373
|
+
{dbText("listRows")}
|
|
374
|
+
</div>
|
|
375
|
+
<div className="text-xs text-muted-foreground">
|
|
376
|
+
{dbText("listRowsDescription")}
|
|
377
|
+
</div>
|
|
378
|
+
</div>
|
|
379
|
+
{editingOnList ? null : (
|
|
380
|
+
<button
|
|
381
|
+
type="button"
|
|
382
|
+
className="flex h-7 shrink-0 items-center gap-1.5 rounded-md px-2 text-xs text-database-blue hover:bg-database-blue/5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
383
|
+
onClick={onEditOnList}
|
|
384
|
+
>
|
|
385
|
+
<IconPencil className="size-3.5" />
|
|
386
|
+
{dbText("listRowsEditOnList")}
|
|
387
|
+
</button>
|
|
388
|
+
)}
|
|
389
|
+
</div>
|
|
390
|
+
<ListItemLayoutPresets
|
|
391
|
+
layout={itemLayout}
|
|
392
|
+
properties={properties}
|
|
393
|
+
onChange={onChange}
|
|
394
|
+
/>
|
|
395
|
+
{CONTENT_DATABASE_ITEM_LAYOUT_SLOTS.map((slot) => (
|
|
396
|
+
<div key={slot} className="grid gap-1.5">
|
|
397
|
+
<div className="text-xs text-muted-foreground">
|
|
398
|
+
{listItemLayoutSlotLabel(slot)}
|
|
399
|
+
</div>
|
|
400
|
+
<ListItemLayoutSlotEditor
|
|
401
|
+
slot={slot}
|
|
402
|
+
layout={working}
|
|
403
|
+
properties={properties}
|
|
404
|
+
onChange={onChange}
|
|
405
|
+
/>
|
|
406
|
+
</div>
|
|
407
|
+
))}
|
|
408
|
+
</div>
|
|
409
|
+
);
|
|
410
|
+
}
|