@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,426 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ContentDatabaseItem,
|
|
3
|
+
ContentDatabaseItemLayout,
|
|
4
|
+
DocumentProperty,
|
|
5
|
+
} from "@shared/api";
|
|
6
|
+
import type {
|
|
7
|
+
ContentDatabaseItemLayoutSlot,
|
|
8
|
+
ResolvedContentDatabaseItemLayout,
|
|
9
|
+
} from "@shared/database-item-layout";
|
|
10
|
+
import { stripMarkdownForPreview } from "@shared/og-description";
|
|
11
|
+
import {
|
|
12
|
+
isEmptyPropertyValue,
|
|
13
|
+
type DocumentPropertyOptionColor,
|
|
14
|
+
} from "@shared/properties";
|
|
15
|
+
import { IconCheck } from "@tabler/icons-react";
|
|
16
|
+
import type { ReactNode } from "react";
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
Popover,
|
|
20
|
+
PopoverContent,
|
|
21
|
+
PopoverTrigger,
|
|
22
|
+
} from "@/components/ui/popover";
|
|
23
|
+
import { cn } from "@/lib/utils";
|
|
24
|
+
|
|
25
|
+
import { displayValue, PropertyValuePopover } from "../DocumentProperties";
|
|
26
|
+
import { DatabaseItemPageIcon, dbText } from "./DatabaseView";
|
|
27
|
+
import {
|
|
28
|
+
ListItemLayoutSlotEditor,
|
|
29
|
+
listItemLayoutSlotLabel,
|
|
30
|
+
} from "./ListItemLayout";
|
|
31
|
+
|
|
32
|
+
export interface ListItemRowLayout {
|
|
33
|
+
/** The layout being edited: the saved one or the default rows' equivalent. */
|
|
34
|
+
layout: ContentDatabaseItemLayout;
|
|
35
|
+
resolved: ResolvedContentDatabaseItemLayout<DocumentProperty>;
|
|
36
|
+
properties: DocumentProperty[];
|
|
37
|
+
editing: boolean;
|
|
38
|
+
onLayoutChange: (layout: ContentDatabaseItemLayout) => void;
|
|
39
|
+
onToggleCheckbox: (
|
|
40
|
+
item: ContentDatabaseItem,
|
|
41
|
+
property: DocumentProperty,
|
|
42
|
+
) => void;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const STATUS_DOT_CLASSES: Record<DocumentPropertyOptionColor, string> = {
|
|
46
|
+
gray: "border-muted-foreground/50 bg-muted-foreground/10 dark:border-muted-foreground dark:bg-muted-foreground/20",
|
|
47
|
+
brown: "border-amber-700 bg-amber-700/25",
|
|
48
|
+
orange: "border-orange-500 bg-orange-500/25",
|
|
49
|
+
yellow: "border-yellow-500 bg-yellow-500/30",
|
|
50
|
+
green: "border-emerald-500 bg-emerald-500",
|
|
51
|
+
blue: "border-sky-500 bg-sky-500/30",
|
|
52
|
+
purple: "border-violet-500 bg-violet-500/30",
|
|
53
|
+
pink: "border-pink-500 bg-pink-500/30",
|
|
54
|
+
red: "border-rose-500 bg-rose-500/30",
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const LINE_CLAMP_CLASSES = {
|
|
58
|
+
1: "line-clamp-1",
|
|
59
|
+
2: "line-clamp-2",
|
|
60
|
+
3: "line-clamp-3",
|
|
61
|
+
} as const;
|
|
62
|
+
|
|
63
|
+
const TITLE_WEIGHT_CLASSES = {
|
|
64
|
+
regular: "font-normal",
|
|
65
|
+
medium: "font-medium",
|
|
66
|
+
bold: "font-semibold",
|
|
67
|
+
} as const;
|
|
68
|
+
|
|
69
|
+
function itemValue(item: ContentDatabaseItem, property: DocumentProperty) {
|
|
70
|
+
return (
|
|
71
|
+
item.properties.find(
|
|
72
|
+
(candidate) => candidate.definition.id === property.definition.id,
|
|
73
|
+
) ?? { ...property, value: null }
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Values a row leaves out entirely, so empty slots take no space. An
|
|
79
|
+
* unchecked box is a checkbox's empty value; a checked one shows by name.
|
|
80
|
+
*/
|
|
81
|
+
function isBlankValue(property: DocumentProperty) {
|
|
82
|
+
return (
|
|
83
|
+
isEmptyPropertyValue(property.value) ||
|
|
84
|
+
(property.definition.type === "checkbox" && property.value !== true)
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function line2Text(property: DocumentProperty) {
|
|
89
|
+
const value = property.value;
|
|
90
|
+
if (typeof value !== "string") return "";
|
|
91
|
+
return property.definition.type === "blocks"
|
|
92
|
+
? stripMarkdownForPreview(value)
|
|
93
|
+
: value.trim();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The fields a slot shows, which can be set but blank on a given row. */
|
|
97
|
+
function slotFieldNames(
|
|
98
|
+
resolved: ResolvedContentDatabaseItemLayout<DocumentProperty>,
|
|
99
|
+
slot: ContentDatabaseItemLayoutSlot,
|
|
100
|
+
) {
|
|
101
|
+
const fields =
|
|
102
|
+
slot === "line2"
|
|
103
|
+
? resolved.line2.property
|
|
104
|
+
? [resolved.line2.property]
|
|
105
|
+
: []
|
|
106
|
+
: slot === "line3" || slot === "trailing"
|
|
107
|
+
? resolved[slot]
|
|
108
|
+
: [];
|
|
109
|
+
return fields.map((property) => property.definition.name);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function SlotZone({
|
|
113
|
+
slot,
|
|
114
|
+
row,
|
|
115
|
+
showPlaceholder,
|
|
116
|
+
empty,
|
|
117
|
+
className,
|
|
118
|
+
children,
|
|
119
|
+
}: {
|
|
120
|
+
slot: ContentDatabaseItemLayoutSlot;
|
|
121
|
+
row: ListItemRowLayout;
|
|
122
|
+
showPlaceholder: boolean;
|
|
123
|
+
empty: boolean;
|
|
124
|
+
className?: string;
|
|
125
|
+
children: ReactNode;
|
|
126
|
+
}) {
|
|
127
|
+
if (!row.editing) return empty ? null : <>{children}</>;
|
|
128
|
+
if (empty && !showPlaceholder) return null;
|
|
129
|
+
const label = listItemLayoutSlotLabel(slot);
|
|
130
|
+
const fields = slotFieldNames(row.resolved, slot);
|
|
131
|
+
const unset = empty && fields.length === 0;
|
|
132
|
+
const action = dbText(unset ? "listRowsAddSlot" : "listRowsChangeSlot", {
|
|
133
|
+
slot: label,
|
|
134
|
+
});
|
|
135
|
+
return (
|
|
136
|
+
<Popover>
|
|
137
|
+
<PopoverTrigger asChild>
|
|
138
|
+
<button
|
|
139
|
+
type="button"
|
|
140
|
+
aria-label={unset ? action : undefined}
|
|
141
|
+
className={cn(
|
|
142
|
+
"rounded-md border border-dashed border-database-blue/50 text-left transition-colors hover:border-database-blue hover:bg-database-blue/5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring data-[state=open]:border-database-blue data-[state=open]:bg-database-blue/10",
|
|
143
|
+
empty ? "px-2 py-0.5 text-xs text-database-blue/80" : "px-1 py-0.5",
|
|
144
|
+
className,
|
|
145
|
+
)}
|
|
146
|
+
>
|
|
147
|
+
{unset ? (
|
|
148
|
+
`+ ${label}`
|
|
149
|
+
) : (
|
|
150
|
+
<>
|
|
151
|
+
{/* Screen readers hear the action, then what the slot shows. */}
|
|
152
|
+
<span className="sr-only">{action}: </span>
|
|
153
|
+
{empty ? (
|
|
154
|
+
<>
|
|
155
|
+
{label}
|
|
156
|
+
<span className="ml-1 text-muted-foreground">
|
|
157
|
+
{fields.join(", ")}
|
|
158
|
+
</span>
|
|
159
|
+
</>
|
|
160
|
+
) : (
|
|
161
|
+
children
|
|
162
|
+
)}
|
|
163
|
+
</>
|
|
164
|
+
)}
|
|
165
|
+
</button>
|
|
166
|
+
</PopoverTrigger>
|
|
167
|
+
<PopoverContent align="start">
|
|
168
|
+
<div className="mb-2 text-xs font-medium text-muted-foreground">
|
|
169
|
+
{label}
|
|
170
|
+
</div>
|
|
171
|
+
<ListItemLayoutSlotEditor
|
|
172
|
+
slot={slot}
|
|
173
|
+
layout={row.layout}
|
|
174
|
+
properties={row.properties}
|
|
175
|
+
onChange={row.onLayoutChange}
|
|
176
|
+
/>
|
|
177
|
+
</PopoverContent>
|
|
178
|
+
</Popover>
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function MetaValues({ values }: { values: DocumentProperty[] }) {
|
|
183
|
+
return values.map((property) => (
|
|
184
|
+
<span
|
|
185
|
+
key={property.definition.id}
|
|
186
|
+
className="inline-flex max-w-48 min-w-0 items-center truncate"
|
|
187
|
+
>
|
|
188
|
+
{property.definition.type === "checkbox" ? (
|
|
189
|
+
<span className="inline-flex min-w-0 items-center gap-1 text-foreground">
|
|
190
|
+
<IconCheck className="size-3.5 shrink-0" />
|
|
191
|
+
<span className="truncate">{property.definition.name}</span>
|
|
192
|
+
</span>
|
|
193
|
+
) : (
|
|
194
|
+
displayValue(property)
|
|
195
|
+
)}
|
|
196
|
+
</span>
|
|
197
|
+
));
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** A list row whose parts follow the view's item layout. */
|
|
201
|
+
export function DatabaseLayoutListRow({
|
|
202
|
+
row,
|
|
203
|
+
isFirst,
|
|
204
|
+
item,
|
|
205
|
+
databaseDocumentId,
|
|
206
|
+
canEdit,
|
|
207
|
+
onPreview,
|
|
208
|
+
actions,
|
|
209
|
+
}: {
|
|
210
|
+
row: ListItemRowLayout;
|
|
211
|
+
isFirst: boolean;
|
|
212
|
+
item: ContentDatabaseItem;
|
|
213
|
+
databaseDocumentId: string;
|
|
214
|
+
canEdit: boolean;
|
|
215
|
+
onPreview: () => void;
|
|
216
|
+
actions: ReactNode;
|
|
217
|
+
}) {
|
|
218
|
+
const { resolved, editing } = row;
|
|
219
|
+
const title = item.document.title || "Untitled";
|
|
220
|
+
const leadingProperty = resolved.leading.property
|
|
221
|
+
? itemValue(item, resolved.leading.property)
|
|
222
|
+
: null;
|
|
223
|
+
const checked =
|
|
224
|
+
resolved.leading.kind === "checkbox" && leadingProperty?.value === true;
|
|
225
|
+
const interactive = canEdit && !editing && leadingProperty?.editable === true;
|
|
226
|
+
|
|
227
|
+
let leading: ReactNode = null;
|
|
228
|
+
if (resolved.leading.kind === "page_icon") {
|
|
229
|
+
leading = (
|
|
230
|
+
<DatabaseItemPageIcon
|
|
231
|
+
document={item.document}
|
|
232
|
+
className="size-4 text-sm"
|
|
233
|
+
fallbackClassName="size-4"
|
|
234
|
+
/>
|
|
235
|
+
);
|
|
236
|
+
} else if (resolved.leading.kind === "checkbox" && leadingProperty) {
|
|
237
|
+
const box = cn(
|
|
238
|
+
"flex size-4 items-center justify-center rounded border-2 transition-colors",
|
|
239
|
+
checked
|
|
240
|
+
? "border-database-blue bg-database-blue text-database-blue-foreground"
|
|
241
|
+
: "border-muted-foreground/60",
|
|
242
|
+
);
|
|
243
|
+
const mark = checked ? (
|
|
244
|
+
<IconCheck aria-hidden="true" className="size-3" stroke={3} />
|
|
245
|
+
) : null;
|
|
246
|
+
// In Edit rows the whole slot is a button, so the box is only a picture,
|
|
247
|
+
// with its field and state as text for screen readers.
|
|
248
|
+
leading = editing ? (
|
|
249
|
+
<span className={box}>
|
|
250
|
+
{mark}
|
|
251
|
+
<span className="sr-only">
|
|
252
|
+
{`${leadingProperty.definition.name}: ${dbText(checked ? "checked" : "unchecked")}`}
|
|
253
|
+
</span>
|
|
254
|
+
</span>
|
|
255
|
+
) : (
|
|
256
|
+
<button
|
|
257
|
+
type="button"
|
|
258
|
+
role="checkbox"
|
|
259
|
+
aria-checked={checked}
|
|
260
|
+
aria-label={`${leadingProperty.definition.name}: ${title}`}
|
|
261
|
+
disabled={!interactive}
|
|
262
|
+
className={cn(
|
|
263
|
+
box,
|
|
264
|
+
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-default",
|
|
265
|
+
!checked && "enabled:hover:border-foreground",
|
|
266
|
+
)}
|
|
267
|
+
onClick={() => row.onToggleCheckbox(item, leadingProperty)}
|
|
268
|
+
>
|
|
269
|
+
{mark}
|
|
270
|
+
</button>
|
|
271
|
+
);
|
|
272
|
+
} else if (resolved.leading.kind === "status" && leadingProperty) {
|
|
273
|
+
const option = (leadingProperty.definition.options.options ?? []).find(
|
|
274
|
+
(candidate) => candidate.id === leadingProperty.value,
|
|
275
|
+
);
|
|
276
|
+
const statusName = option?.name ?? dbText("listRowsNoStatus");
|
|
277
|
+
const dot = (
|
|
278
|
+
<span
|
|
279
|
+
title={statusName}
|
|
280
|
+
className={cn(
|
|
281
|
+
"block size-3.5 shrink-0 rounded-full border-2",
|
|
282
|
+
option
|
|
283
|
+
? STATUS_DOT_CLASSES[option.color]
|
|
284
|
+
: "border-dashed border-muted-foreground/60",
|
|
285
|
+
)}
|
|
286
|
+
>
|
|
287
|
+
<span className="sr-only">{statusName}</span>
|
|
288
|
+
</span>
|
|
289
|
+
);
|
|
290
|
+
leading = interactive ? (
|
|
291
|
+
<span className="flex w-6 justify-center">
|
|
292
|
+
<PropertyValuePopover
|
|
293
|
+
property={leadingProperty}
|
|
294
|
+
documentId={item.document.id}
|
|
295
|
+
databaseDocumentId={databaseDocumentId}
|
|
296
|
+
>
|
|
297
|
+
{dot}
|
|
298
|
+
</PropertyValuePopover>
|
|
299
|
+
</span>
|
|
300
|
+
) : (
|
|
301
|
+
dot
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const line2Property = resolved.line2.property
|
|
306
|
+
? itemValue(item, resolved.line2.property)
|
|
307
|
+
: null;
|
|
308
|
+
const line2 = line2Property ? line2Text(line2Property) : "";
|
|
309
|
+
const filled = (properties: DocumentProperty[]) =>
|
|
310
|
+
properties
|
|
311
|
+
.map((property) => itemValue(item, property))
|
|
312
|
+
.filter((property) => !isBlankValue(property));
|
|
313
|
+
const line3 = filled(resolved.line3);
|
|
314
|
+
const trailing = filled(resolved.trailing);
|
|
315
|
+
|
|
316
|
+
const body = (
|
|
317
|
+
<>
|
|
318
|
+
<SlotZone
|
|
319
|
+
slot="title"
|
|
320
|
+
row={row}
|
|
321
|
+
showPlaceholder={false}
|
|
322
|
+
empty={false}
|
|
323
|
+
className="block max-w-full"
|
|
324
|
+
>
|
|
325
|
+
<span
|
|
326
|
+
className={cn(
|
|
327
|
+
"block min-w-0 truncate text-sm",
|
|
328
|
+
TITLE_WEIGHT_CLASSES[resolved.titleWeight],
|
|
329
|
+
checked &&
|
|
330
|
+
"text-muted-foreground line-through decoration-muted-foreground/70",
|
|
331
|
+
)}
|
|
332
|
+
>
|
|
333
|
+
{title}
|
|
334
|
+
</span>
|
|
335
|
+
</SlotZone>
|
|
336
|
+
<SlotZone
|
|
337
|
+
slot="line2"
|
|
338
|
+
row={row}
|
|
339
|
+
showPlaceholder={isFirst}
|
|
340
|
+
empty={!line2}
|
|
341
|
+
className="mt-0.5 block w-full"
|
|
342
|
+
>
|
|
343
|
+
<span
|
|
344
|
+
className={cn(
|
|
345
|
+
"text-xs leading-relaxed whitespace-pre-line text-muted-foreground",
|
|
346
|
+
LINE_CLAMP_CLASSES[resolved.line2.lines],
|
|
347
|
+
)}
|
|
348
|
+
>
|
|
349
|
+
{line2}
|
|
350
|
+
</span>
|
|
351
|
+
</SlotZone>
|
|
352
|
+
<SlotZone
|
|
353
|
+
slot="line3"
|
|
354
|
+
row={row}
|
|
355
|
+
showPlaceholder={isFirst}
|
|
356
|
+
empty={line3.length === 0}
|
|
357
|
+
className="mt-1 inline-flex max-w-full"
|
|
358
|
+
>
|
|
359
|
+
<span className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground">
|
|
360
|
+
<MetaValues values={line3} />
|
|
361
|
+
</span>
|
|
362
|
+
</SlotZone>
|
|
363
|
+
{/* On phones End sits under the title, so it's edited there too. */}
|
|
364
|
+
<SlotZone
|
|
365
|
+
slot="trailing"
|
|
366
|
+
row={row}
|
|
367
|
+
showPlaceholder={isFirst}
|
|
368
|
+
empty={trailing.length === 0}
|
|
369
|
+
className="mt-1 inline-flex max-w-full sm:hidden"
|
|
370
|
+
>
|
|
371
|
+
<span
|
|
372
|
+
className={cn(
|
|
373
|
+
"flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground",
|
|
374
|
+
!editing && "mt-1 sm:hidden",
|
|
375
|
+
)}
|
|
376
|
+
>
|
|
377
|
+
<MetaValues values={trailing} />
|
|
378
|
+
</span>
|
|
379
|
+
</SlotZone>
|
|
380
|
+
</>
|
|
381
|
+
);
|
|
382
|
+
|
|
383
|
+
return (
|
|
384
|
+
<div
|
|
385
|
+
className={cn(
|
|
386
|
+
"group flex min-h-10 items-start gap-2 border-t border-border px-1 py-1.5",
|
|
387
|
+
!editing && "hover:bg-muted/40",
|
|
388
|
+
)}
|
|
389
|
+
>
|
|
390
|
+
<SlotZone
|
|
391
|
+
slot="leading"
|
|
392
|
+
row={row}
|
|
393
|
+
showPlaceholder={isFirst}
|
|
394
|
+
empty={!leading}
|
|
395
|
+
className="mt-1 shrink-0"
|
|
396
|
+
>
|
|
397
|
+
<span className="flex min-w-5 h-5 items-center justify-center">
|
|
398
|
+
{leading}
|
|
399
|
+
</span>
|
|
400
|
+
</SlotZone>
|
|
401
|
+
{editing ? (
|
|
402
|
+
<div className="min-w-0 flex-1 px-1.5 py-0.5">{body}</div>
|
|
403
|
+
) : (
|
|
404
|
+
<button
|
|
405
|
+
type="button"
|
|
406
|
+
className="block min-w-0 flex-1 rounded px-1.5 py-0.5 text-left hover:bg-accent/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
407
|
+
onClick={onPreview}
|
|
408
|
+
>
|
|
409
|
+
{body}
|
|
410
|
+
</button>
|
|
411
|
+
)}
|
|
412
|
+
<SlotZone
|
|
413
|
+
slot="trailing"
|
|
414
|
+
row={row}
|
|
415
|
+
showPlaceholder={isFirst}
|
|
416
|
+
empty={trailing.length === 0}
|
|
417
|
+
className="mt-0.5 hidden shrink-0 sm:inline-flex"
|
|
418
|
+
>
|
|
419
|
+
<span className="mt-1 hidden max-w-xs min-w-0 flex-wrap items-center justify-end gap-2 text-xs text-muted-foreground sm:flex">
|
|
420
|
+
<MetaValues values={trailing} />
|
|
421
|
+
</span>
|
|
422
|
+
</SlotZone>
|
|
423
|
+
{editing ? null : actions}
|
|
424
|
+
</div>
|
|
425
|
+
);
|
|
426
|
+
}
|
|
@@ -1,8 +1,19 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
|
|
1
|
+
import type {
|
|
2
|
+
ContentDatabaseItem,
|
|
3
|
+
ContentDatabaseItemLayout,
|
|
4
|
+
DocumentProperty,
|
|
5
|
+
} from "@shared/api";
|
|
6
|
+
import {
|
|
7
|
+
resolveContentDatabaseItemLayout,
|
|
8
|
+
startingContentDatabaseItemLayout,
|
|
9
|
+
} from "@shared/database-item-layout";
|
|
10
|
+
import { IconCheck, IconList, IconPencil, IconPlus } from "@tabler/icons-react";
|
|
3
11
|
import { useRef, useState } from "react";
|
|
12
|
+
import { toast } from "sonner";
|
|
4
13
|
|
|
5
14
|
import { Spinner } from "@/components/ui/spinner";
|
|
15
|
+
import { useSetDocumentProperty } from "@/hooks/use-document-properties";
|
|
16
|
+
import { cn } from "@/lib/utils";
|
|
6
17
|
|
|
7
18
|
import { displayValue } from "../DocumentProperties";
|
|
8
19
|
import {
|
|
@@ -20,12 +31,18 @@ import {
|
|
|
20
31
|
type DatabaseBoardGroup,
|
|
21
32
|
type DatabaseFilter,
|
|
22
33
|
} from "./DatabaseView";
|
|
34
|
+
import { ListItemLayoutPresets } from "./ListItemLayout";
|
|
35
|
+
import { DatabaseLayoutListRow, type ListItemRowLayout } from "./ListItemRow";
|
|
23
36
|
|
|
24
37
|
export function DatabaseListView({
|
|
25
38
|
properties,
|
|
26
39
|
groupableProperties,
|
|
40
|
+
layoutProperties,
|
|
27
41
|
items,
|
|
42
|
+
databaseId,
|
|
28
43
|
databaseDocumentId,
|
|
44
|
+
itemLayout,
|
|
45
|
+
editingRows,
|
|
29
46
|
canEdit,
|
|
30
47
|
canCreateItems,
|
|
31
48
|
isLoading,
|
|
@@ -43,11 +60,18 @@ export function DatabaseListView({
|
|
|
43
60
|
onPreview,
|
|
44
61
|
onDeletedPreviewItem,
|
|
45
62
|
onOpenPage,
|
|
63
|
+
onItemLayoutChange,
|
|
64
|
+
onEditingRowsChange,
|
|
46
65
|
}: {
|
|
47
66
|
properties: DocumentProperty[];
|
|
48
67
|
groupableProperties: DocumentProperty[];
|
|
68
|
+
/** What custom rows can show: properties not hidden in this view. */
|
|
69
|
+
layoutProperties: DocumentProperty[];
|
|
49
70
|
items: ContentDatabaseItem[];
|
|
71
|
+
databaseId: string;
|
|
50
72
|
databaseDocumentId: string;
|
|
73
|
+
itemLayout: ContentDatabaseItemLayout | null;
|
|
74
|
+
editingRows: boolean;
|
|
51
75
|
canEdit: boolean;
|
|
52
76
|
canCreateItems: boolean;
|
|
53
77
|
isLoading: boolean;
|
|
@@ -68,7 +92,44 @@ export function DatabaseListView({
|
|
|
68
92
|
onPreview: (item: ContentDatabaseItem) => void;
|
|
69
93
|
onDeletedPreviewItem: (item: ContentDatabaseItem) => boolean;
|
|
70
94
|
onOpenPage: (item: ContentDatabaseItem) => void;
|
|
95
|
+
onItemLayoutChange: (layout: ContentDatabaseItemLayout | null) => void;
|
|
96
|
+
onEditingRowsChange: (editing: boolean) => void;
|
|
71
97
|
}) {
|
|
98
|
+
const setProperty = useSetDocumentProperty(
|
|
99
|
+
databaseDocumentId,
|
|
100
|
+
databaseId,
|
|
101
|
+
databaseDocumentId,
|
|
102
|
+
{ errorNotification: "caller" },
|
|
103
|
+
);
|
|
104
|
+
const editing = canEdit && editingRows;
|
|
105
|
+
const layout =
|
|
106
|
+
itemLayout ??
|
|
107
|
+
(editing ? startingContentDatabaseItemLayout(properties) : null);
|
|
108
|
+
const layoutRow: ListItemRowLayout | null = layout
|
|
109
|
+
? {
|
|
110
|
+
layout,
|
|
111
|
+
resolved: resolveContentDatabaseItemLayout(layout, layoutProperties),
|
|
112
|
+
properties: layoutProperties,
|
|
113
|
+
editing,
|
|
114
|
+
onLayoutChange: onItemLayoutChange,
|
|
115
|
+
onToggleCheckbox: (item, property) => {
|
|
116
|
+
setProperty
|
|
117
|
+
.mutateAsync({
|
|
118
|
+
documentId: item.document.id,
|
|
119
|
+
propertyId: property.definition.id,
|
|
120
|
+
value: property.value !== true,
|
|
121
|
+
})
|
|
122
|
+
.catch((err: unknown) => {
|
|
123
|
+
toast.error(dbText("failedToUpdateCheckbox"), {
|
|
124
|
+
description:
|
|
125
|
+
err instanceof Error
|
|
126
|
+
? err.message
|
|
127
|
+
: dbText("somethingWentWrong"),
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
},
|
|
131
|
+
}
|
|
132
|
+
: null;
|
|
72
133
|
const groups = databaseVisibleGroups(
|
|
73
134
|
databaseViewItemGroups(items, groupableProperties, groupByPropertyId),
|
|
74
135
|
hideEmptyGroups,
|
|
@@ -83,14 +144,44 @@ export function DatabaseListView({
|
|
|
83
144
|
<div className="flex min-h-9 items-center gap-2 border-t border-border px-1 text-xs text-muted-foreground">
|
|
84
145
|
<IconList className="size-4 shrink-0" />
|
|
85
146
|
<span>List</span>
|
|
147
|
+
{canEdit ? (
|
|
148
|
+
<button
|
|
149
|
+
type="button"
|
|
150
|
+
aria-pressed={editing}
|
|
151
|
+
className={cn(
|
|
152
|
+
"ml-auto flex h-7 items-center gap-1.5 rounded-md px-2 text-xs transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
|
153
|
+
editing
|
|
154
|
+
? "bg-database-blue text-database-blue-foreground"
|
|
155
|
+
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
|
156
|
+
)}
|
|
157
|
+
onClick={() => onEditingRowsChange(!editing)}
|
|
158
|
+
>
|
|
159
|
+
{editing ? (
|
|
160
|
+
<IconCheck className="size-3.5" />
|
|
161
|
+
) : (
|
|
162
|
+
<IconPencil className="size-3.5" />
|
|
163
|
+
)}
|
|
164
|
+
{dbText(editing ? "listRowsDone" : "listRowsEdit")}
|
|
165
|
+
</button>
|
|
166
|
+
) : null}
|
|
86
167
|
</div>
|
|
168
|
+
{editing ? (
|
|
169
|
+
<div className="flex flex-wrap items-center gap-2 border-t border-dashed border-database-blue/40 bg-database-blue/5 px-2 py-2 text-xs text-muted-foreground">
|
|
170
|
+
<ListItemLayoutPresets
|
|
171
|
+
layout={itemLayout}
|
|
172
|
+
properties={layoutProperties}
|
|
173
|
+
onChange={onItemLayoutChange}
|
|
174
|
+
/>
|
|
175
|
+
<span>{dbText("listRowsEditHint")}</span>
|
|
176
|
+
</div>
|
|
177
|
+
) : null}
|
|
87
178
|
{isLoading ? (
|
|
88
179
|
<div className="flex h-16 items-center gap-2 px-2 text-sm text-muted-foreground">
|
|
89
180
|
<Spinner className="size-4" />
|
|
90
181
|
{dbText("loadingList")}
|
|
91
182
|
</div>
|
|
92
183
|
) : (
|
|
93
|
-
<div className="grid">
|
|
184
|
+
<div className="grid grid-cols-1">
|
|
94
185
|
{databaseViewHasNoMatchingPages(
|
|
95
186
|
items.length,
|
|
96
187
|
hasSearch,
|
|
@@ -102,6 +193,7 @@ export function DatabaseListView({
|
|
|
102
193
|
? groups.map((group) => (
|
|
103
194
|
<DatabaseGroupedListSection
|
|
104
195
|
key={group.id}
|
|
196
|
+
layoutRow={layoutRow}
|
|
105
197
|
group={group}
|
|
106
198
|
properties={properties}
|
|
107
199
|
databaseDocumentId={databaseDocumentId}
|
|
@@ -124,6 +216,8 @@ export function DatabaseListView({
|
|
|
124
216
|
: items.map((item, index) => (
|
|
125
217
|
<DatabaseListRow
|
|
126
218
|
key={item.id}
|
|
219
|
+
layoutRow={layoutRow}
|
|
220
|
+
isFirst={index === 0}
|
|
127
221
|
item={item}
|
|
128
222
|
properties={properties}
|
|
129
223
|
databaseDocumentId={databaseDocumentId}
|
|
@@ -140,7 +234,7 @@ export function DatabaseListView({
|
|
|
140
234
|
onOpenPage={() => onOpenPage(item)}
|
|
141
235
|
/>
|
|
142
236
|
))}
|
|
143
|
-
{canEdit && canCreateItems && !grouped ? (
|
|
237
|
+
{canEdit && canCreateItems && !grouped && !editing ? (
|
|
144
238
|
<NewListRow
|
|
145
239
|
disabled={isCreating}
|
|
146
240
|
isPending={isCreating}
|
|
@@ -154,6 +248,7 @@ export function DatabaseListView({
|
|
|
154
248
|
}
|
|
155
249
|
|
|
156
250
|
function DatabaseGroupedListSection({
|
|
251
|
+
layoutRow,
|
|
157
252
|
group,
|
|
158
253
|
properties,
|
|
159
254
|
databaseDocumentId,
|
|
@@ -167,6 +262,7 @@ function DatabaseGroupedListSection({
|
|
|
167
262
|
onDeletedPreviewItem,
|
|
168
263
|
onOpenPage,
|
|
169
264
|
}: {
|
|
265
|
+
layoutRow: ListItemRowLayout | null;
|
|
170
266
|
group: DatabaseBoardGroup;
|
|
171
267
|
properties: DocumentProperty[];
|
|
172
268
|
databaseDocumentId: string;
|
|
@@ -195,6 +291,8 @@ function DatabaseGroupedListSection({
|
|
|
195
291
|
{group.items.map((item, index) => (
|
|
196
292
|
<DatabaseListRow
|
|
197
293
|
key={`${group.id}-${item.id}`}
|
|
294
|
+
layoutRow={layoutRow}
|
|
295
|
+
isFirst={index === 0}
|
|
198
296
|
item={item}
|
|
199
297
|
properties={properties}
|
|
200
298
|
databaseDocumentId={databaseDocumentId}
|
|
@@ -209,7 +307,7 @@ function DatabaseGroupedListSection({
|
|
|
209
307
|
onOpenPage={() => onOpenPage(item)}
|
|
210
308
|
/>
|
|
211
309
|
))}
|
|
212
|
-
{canEdit && canCreateItems ? (
|
|
310
|
+
{canEdit && canCreateItems && !layoutRow?.editing ? (
|
|
213
311
|
<NewListRow
|
|
214
312
|
disabled={isCreating}
|
|
215
313
|
isPending={isCreating}
|
|
@@ -223,6 +321,8 @@ function DatabaseGroupedListSection({
|
|
|
223
321
|
}
|
|
224
322
|
|
|
225
323
|
function DatabaseListRow({
|
|
324
|
+
layoutRow,
|
|
325
|
+
isFirst,
|
|
226
326
|
item,
|
|
227
327
|
properties,
|
|
228
328
|
databaseDocumentId,
|
|
@@ -236,6 +336,8 @@ function DatabaseListRow({
|
|
|
236
336
|
onPreview,
|
|
237
337
|
onOpenPage,
|
|
238
338
|
}: {
|
|
339
|
+
layoutRow: ListItemRowLayout | null;
|
|
340
|
+
isFirst: boolean;
|
|
239
341
|
item: ContentDatabaseItem;
|
|
240
342
|
properties: DocumentProperty[];
|
|
241
343
|
databaseDocumentId: string;
|
|
@@ -249,6 +351,34 @@ function DatabaseListRow({
|
|
|
249
351
|
onPreview: () => void;
|
|
250
352
|
onOpenPage: () => void;
|
|
251
353
|
}) {
|
|
354
|
+
const actions = canEdit ? (
|
|
355
|
+
<RowActionsCell
|
|
356
|
+
item={item}
|
|
357
|
+
databaseDocumentId={databaseDocumentId}
|
|
358
|
+
rowIndex={rowIndex}
|
|
359
|
+
canReorder={canReorder}
|
|
360
|
+
canMoveUp={canMoveUp}
|
|
361
|
+
canMoveDown={canMoveDown}
|
|
362
|
+
onPreviewItem={onPreviewItem}
|
|
363
|
+
onDeletedPreviewItem={onDeletedPreviewItem}
|
|
364
|
+
onOpenPage={onOpenPage}
|
|
365
|
+
/>
|
|
366
|
+
) : null;
|
|
367
|
+
|
|
368
|
+
if (layoutRow) {
|
|
369
|
+
return (
|
|
370
|
+
<DatabaseLayoutListRow
|
|
371
|
+
row={layoutRow}
|
|
372
|
+
isFirst={isFirst}
|
|
373
|
+
item={item}
|
|
374
|
+
databaseDocumentId={databaseDocumentId}
|
|
375
|
+
canEdit={canEdit}
|
|
376
|
+
onPreview={onPreview}
|
|
377
|
+
actions={actions}
|
|
378
|
+
/>
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
|
|
252
382
|
const visibleProperties = properties.slice(0, 4);
|
|
253
383
|
|
|
254
384
|
return (
|
|
@@ -286,19 +416,7 @@ function DatabaseListRow({
|
|
|
286
416
|
</span>
|
|
287
417
|
) : null}
|
|
288
418
|
</button>
|
|
289
|
-
{
|
|
290
|
-
<RowActionsCell
|
|
291
|
-
item={item}
|
|
292
|
-
databaseDocumentId={databaseDocumentId}
|
|
293
|
-
rowIndex={rowIndex}
|
|
294
|
-
canReorder={canReorder}
|
|
295
|
-
canMoveUp={canMoveUp}
|
|
296
|
-
canMoveDown={canMoveDown}
|
|
297
|
-
onPreviewItem={onPreviewItem}
|
|
298
|
-
onDeletedPreviewItem={onDeletedPreviewItem}
|
|
299
|
-
onOpenPage={onOpenPage}
|
|
300
|
-
/>
|
|
301
|
-
) : null}
|
|
419
|
+
{actions}
|
|
302
420
|
</div>
|
|
303
421
|
);
|
|
304
422
|
}
|