@quoin-cms/admin 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.
Files changed (110) hide show
  1. package/LICENSE +661 -0
  2. package/biome.json +62 -0
  3. package/dist/assets/index-C9Y5-AKj.js +33 -0
  4. package/dist/assets/index-uVdiUjty.css +1 -0
  5. package/dist/index.html +20 -0
  6. package/index.html +19 -0
  7. package/package.json +43 -0
  8. package/src/AdminRoot.svelte +98 -0
  9. package/src/app.css +211 -0
  10. package/src/lib/Slot.svelte +65 -0
  11. package/src/lib/api/auth.ts +26 -0
  12. package/src/lib/api/client.ts +73 -0
  13. package/src/lib/api/files.ts +56 -0
  14. package/src/lib/api/globals.ts +13 -0
  15. package/src/lib/api/records.ts +102 -0
  16. package/src/lib/api/schema.ts +7 -0
  17. package/src/lib/api/versions.ts +40 -0
  18. package/src/lib/components/AdminHeader.svelte +107 -0
  19. package/src/lib/components/AdminSidebar.svelte +262 -0
  20. package/src/lib/components/DeleteDialog.svelte +58 -0
  21. package/src/lib/components/DocumentEditLayout.svelte +263 -0
  22. package/src/lib/components/DynamicForm.svelte +74 -0
  23. package/src/lib/components/KpiCard.svelte +75 -0
  24. package/src/lib/components/MediaLibrary.svelte +311 -0
  25. package/src/lib/components/Pagination.svelte +78 -0
  26. package/src/lib/components/RangeFilter.svelte +41 -0
  27. package/src/lib/components/RecordGrid.svelte +123 -0
  28. package/src/lib/components/RecordTable.svelte +156 -0
  29. package/src/lib/components/cells/CheckboxCell.svelte +10 -0
  30. package/src/lib/components/cells/ColorCell.svelte +15 -0
  31. package/src/lib/components/cells/DateCell.svelte +8 -0
  32. package/src/lib/components/cells/RelationshipCell.svelte +20 -0
  33. package/src/lib/components/cells/RichTextCell.svelte +21 -0
  34. package/src/lib/components/cells/SelectCell.svelte +26 -0
  35. package/src/lib/components/cells/TextCell.svelte +8 -0
  36. package/src/lib/components/cells/UploadCell.svelte +34 -0
  37. package/src/lib/components/cells/index.ts +28 -0
  38. package/src/lib/components/charts/TimeSeriesChart.svelte +184 -0
  39. package/src/lib/components/doc/ApiView.svelte +181 -0
  40. package/src/lib/components/doc/Autosave.svelte +102 -0
  41. package/src/lib/components/doc/DocHeader.svelte +86 -0
  42. package/src/lib/components/doc/DocMetaStrip.svelte +103 -0
  43. package/src/lib/components/doc/DocTabBar.svelte +26 -0
  44. package/src/lib/components/doc/HeaderModeSwitch.svelte +32 -0
  45. package/src/lib/components/doc/PublishButton.svelte +114 -0
  46. package/src/lib/components/doc/ScheduleModal.svelte +110 -0
  47. package/src/lib/components/doc/VersionHistory.svelte +20 -0
  48. package/src/lib/components/fields/ArrayFieldEditor.svelte +62 -0
  49. package/src/lib/components/fields/BlockCard.svelte +63 -0
  50. package/src/lib/components/fields/BlocksFieldEditor.svelte +83 -0
  51. package/src/lib/components/fields/CheckboxField.svelte +27 -0
  52. package/src/lib/components/fields/ColorField.svelte +46 -0
  53. package/src/lib/components/fields/DateField.svelte +52 -0
  54. package/src/lib/components/fields/EmailField.svelte +30 -0
  55. package/src/lib/components/fields/FileField.svelte +280 -0
  56. package/src/lib/components/fields/JsonField.svelte +145 -0
  57. package/src/lib/components/fields/NumberField.svelte +44 -0
  58. package/src/lib/components/fields/PasswordField.svelte +38 -0
  59. package/src/lib/components/fields/RelationshipField.svelte +271 -0
  60. package/src/lib/components/fields/RichTextField.svelte +139 -0
  61. package/src/lib/components/fields/SelectField.svelte +33 -0
  62. package/src/lib/components/fields/SlugField.svelte +70 -0
  63. package/src/lib/components/fields/TabsField.svelte +56 -0
  64. package/src/lib/components/fields/TagsField.svelte +85 -0
  65. package/src/lib/components/fields/TextField.svelte +36 -0
  66. package/src/lib/components/fields/TextareaField.svelte +32 -0
  67. package/src/lib/components/fields/UploadField.svelte +166 -0
  68. package/src/lib/components/fields/UploadFieldDispatch.svelte +21 -0
  69. package/src/lib/components/fields/UploadGalleryField.svelte +166 -0
  70. package/src/lib/components/fields/index.ts +22 -0
  71. package/src/lib/components/fields/registry.ts +58 -0
  72. package/src/lib/components/lexical/CustomHTMLComponent.svelte +52 -0
  73. package/src/lib/components/lexical/CustomHTMLNode.ts +94 -0
  74. package/src/lib/components/lexical/PullQuoteComponent.svelte +73 -0
  75. package/src/lib/components/lexical/PullQuoteNode.ts +112 -0
  76. package/src/lib/components/lexical/lexical-helpers.ts +24 -0
  77. package/src/lib/components/lexical/nodes.ts +8 -0
  78. package/src/lib/components/lexical/toolbar/EditorToolbar.svelte +159 -0
  79. package/src/lib/components/lexical/toolbar/InsertBlockDropdown.svelte +278 -0
  80. package/src/lib/components/versions/CompareSelector.svelte +31 -0
  81. package/src/lib/components/versions/FieldDiff.svelte +141 -0
  82. package/src/lib/components/versions/RestoreModal.svelte +67 -0
  83. package/src/lib/components/versions/StatusPill.svelte +21 -0
  84. package/src/lib/context.svelte.ts +156 -0
  85. package/src/lib/router/index.svelte.ts +282 -0
  86. package/src/lib/router/matcher.ts +52 -0
  87. package/src/lib/stores/branding.svelte.ts +74 -0
  88. package/src/lib/stores/schema.svelte.ts +17 -0
  89. package/src/lib/types/schema.ts +126 -0
  90. package/src/lib/utils/cn.ts +6 -0
  91. package/src/lib/utils/diff.ts +112 -0
  92. package/src/lib/utils/dirty.svelte.ts +50 -0
  93. package/src/lib/utils/format.ts +28 -0
  94. package/src/lib/utils/json-highlight.ts +34 -0
  95. package/src/lib/utils/slug.ts +8 -0
  96. package/src/main.ts +32 -0
  97. package/src/views/AdminLayout.svelte +73 -0
  98. package/src/views/AdsAnalyticsView.svelte +152 -0
  99. package/src/views/CollectionEditView.svelte +117 -0
  100. package/src/views/CollectionListView.svelte +347 -0
  101. package/src/views/CollectionNewView.svelte +68 -0
  102. package/src/views/CustomPageView.svelte +59 -0
  103. package/src/views/DashboardView.svelte +370 -0
  104. package/src/views/GlobalEditView.svelte +100 -0
  105. package/src/views/LoginView.svelte +231 -0
  106. package/src/views/NotFoundView.svelte +9 -0
  107. package/src/views/VersionDetailView.svelte +307 -0
  108. package/src/views/VersionsListView.svelte +201 -0
  109. package/tsconfig.json +25 -0
  110. package/vite.config.ts +80 -0
@@ -0,0 +1,311 @@
1
+ <script lang="ts">
2
+ import { listRecords } from '$lib/api/records.js';
3
+ import { uploadFile } from '$lib/api/files.js';
4
+ import { createRecord } from '$lib/api/records.js';
5
+ import { formatFileSize } from '$lib/utils/format.js';
6
+ import { X, Upload, Search, FolderOpen, FileText } from 'lucide-svelte';
7
+
8
+ interface MediaItem {
9
+ url: string;
10
+ alt: string;
11
+ filename: string;
12
+ mimeType: string;
13
+ size: number;
14
+ }
15
+
16
+ let {
17
+ open = $bindable(false),
18
+ accept = ['image/*'],
19
+ onSelect,
20
+ }: {
21
+ open: boolean;
22
+ accept?: string[];
23
+ onSelect: (media: MediaItem) => void;
24
+ } = $props();
25
+
26
+ let items = $state<any[]>([]);
27
+ let folders = $state<any[]>([]);
28
+ let search = $state('');
29
+ let selectedFolder = $state<string | null>(null);
30
+ let isLoading = $state(false);
31
+ let isUploading = $state(false);
32
+ let totalDocs = $state(0);
33
+ let totalPages = $state(0);
34
+ let page = $state(1);
35
+ let fileInput: HTMLInputElement;
36
+ let searchTimeout: ReturnType<typeof setTimeout>;
37
+
38
+ async function loadMedia() {
39
+ isLoading = true;
40
+ const params: Record<string, any> = {
41
+ perPage: 24,
42
+ sort: '-createdAt',
43
+ page,
44
+ };
45
+ if (search) {
46
+ params.search = search;
47
+ }
48
+ if (selectedFolder) {
49
+ params.filter = `folder="${selectedFolder}"`;
50
+ }
51
+ const result = await listRecords('media', params);
52
+ if (result.ok) {
53
+ items = result.data.records;
54
+ totalDocs = result.data.totalRecords;
55
+ totalPages = result.data.totalPages;
56
+ }
57
+ isLoading = false;
58
+ }
59
+
60
+ async function loadFolders() {
61
+ const result = await listRecords('media_folders', { perPage: 100, sort: 'name' });
62
+ if (result.ok) {
63
+ folders = result.data.records;
64
+ }
65
+ }
66
+
67
+ function handleSelect(item: any) {
68
+ const media: MediaItem = {
69
+ url: item.file?.url || item.url || '',
70
+ alt: item.alt || '',
71
+ filename: item.filename || item.file?.filename || '',
72
+ mimeType: item.mimeType || item.file?.mimeType || '',
73
+ size: item.size || item.file?.size || 0,
74
+ };
75
+ onSelect(media);
76
+ open = false;
77
+ }
78
+
79
+ async function handleUpload(e: Event) {
80
+ const target = e.target as HTMLInputElement;
81
+ const files = target.files;
82
+ if (!files?.length) return;
83
+
84
+ isUploading = true;
85
+ for (const file of files) {
86
+ // uploadFile (POST /api/upload/media) already creates the media record
87
+ // with auto-injected metadata (url, filename, mimeType, size, width, height).
88
+ // Don't double-create here.
89
+ await uploadFile(file);
90
+ }
91
+ isUploading = false;
92
+ target.value = '';
93
+ await loadMedia();
94
+ }
95
+
96
+ function handleSearchInput(e: Event) {
97
+ const val = (e.target as HTMLInputElement).value;
98
+ search = val;
99
+ clearTimeout(searchTimeout);
100
+ searchTimeout = setTimeout(() => {
101
+ page = 1;
102
+ loadMedia();
103
+ }, 300);
104
+ }
105
+
106
+ function selectFolder(folderId: string | null) {
107
+ selectedFolder = folderId;
108
+ page = 1;
109
+ loadMedia();
110
+ }
111
+
112
+ function prevPage() {
113
+ if (page > 1) {
114
+ page--;
115
+ loadMedia();
116
+ }
117
+ }
118
+
119
+ function nextPage() {
120
+ if (page < totalPages) {
121
+ page++;
122
+ loadMedia();
123
+ }
124
+ }
125
+
126
+ function close() {
127
+ open = false;
128
+ }
129
+
130
+ function handleKeydown(e: KeyboardEvent) {
131
+ if (e.key === 'Escape') close();
132
+ }
133
+
134
+ function isImage(mimeType: string): boolean {
135
+ return mimeType?.startsWith('image/') ?? false;
136
+ }
137
+
138
+ function getThumbnailUrl(item: any): string {
139
+ return item.file?.url || item.url || '';
140
+ }
141
+
142
+ $effect(() => {
143
+ if (open) {
144
+ page = 1;
145
+ search = '';
146
+ selectedFolder = null;
147
+ loadMedia();
148
+ loadFolders();
149
+ }
150
+ });
151
+ </script>
152
+
153
+ <svelte:window onkeydown={handleKeydown} />
154
+
155
+ {#if open}
156
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
157
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
158
+ <div
159
+ class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
160
+ onclick={(e) => { if (e.target === e.currentTarget) close(); }}
161
+ >
162
+ <div class="flex h-[85vh] w-full max-w-5xl flex-col rounded-lg border bg-background shadow-xl">
163
+ <!-- Header -->
164
+ <div class="flex items-center justify-between border-b px-4 py-3">
165
+ <h2 class="text-lg font-semibold">Media Library</h2>
166
+ <div class="flex items-center gap-2">
167
+ <button
168
+ type="button"
169
+ onclick={() => fileInput.click()}
170
+ disabled={isUploading}
171
+ class="inline-flex h-9 items-center gap-2 rounded-md bg-primary px-3 text-sm text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
172
+ >
173
+ <Upload class="h-4 w-4" />
174
+ {isUploading ? 'Uploading...' : 'Upload New'}
175
+ </button>
176
+ <button
177
+ type="button"
178
+ onclick={close}
179
+ class="rounded p-1.5 hover:bg-accent"
180
+ >
181
+ <X class="h-5 w-5" />
182
+ </button>
183
+ </div>
184
+ </div>
185
+
186
+ <!-- Search bar -->
187
+ <div class="border-b px-4 py-2">
188
+ <div class="relative">
189
+ <Search class="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
190
+ <input
191
+ type="text"
192
+ placeholder="Search media..."
193
+ value={search}
194
+ oninput={handleSearchInput}
195
+ class="h-9 w-full rounded-md border bg-background pl-9 pr-3 text-sm focus:outline-none focus:ring-1 focus:ring-primary"
196
+ />
197
+ </div>
198
+ </div>
199
+
200
+ <!-- Body: sidebar + grid -->
201
+ <div class="flex flex-1 overflow-hidden">
202
+ <!-- Folder sidebar -->
203
+ {#if folders.length > 0}
204
+ <div class="w-48 shrink-0 overflow-y-auto border-r p-2">
205
+ <button
206
+ type="button"
207
+ class="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm {selectedFolder === null ? 'bg-accent font-medium' : 'hover:bg-accent'}"
208
+ onclick={() => selectFolder(null)}
209
+ >
210
+ <FolderOpen class="h-4 w-4" />
211
+ All Media
212
+ </button>
213
+ {#each folders as folder}
214
+ <button
215
+ type="button"
216
+ class="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm {selectedFolder === folder.id ? 'bg-accent font-medium' : 'hover:bg-accent'}"
217
+ onclick={() => selectFolder(folder.id)}
218
+ >
219
+ <FolderOpen class="h-4 w-4" />
220
+ {folder.name || folder.title || 'Untitled'}
221
+ </button>
222
+ {/each}
223
+ </div>
224
+ {/if}
225
+
226
+ <!-- Grid area -->
227
+ <div class="flex-1 overflow-y-auto p-4">
228
+ {#if isLoading}
229
+ <div class="flex h-full items-center justify-center">
230
+ <p class="text-sm text-muted-foreground">Loading...</p>
231
+ </div>
232
+ {:else if items.length === 0}
233
+ <div class="flex h-full flex-col items-center justify-center gap-2">
234
+ <p class="text-sm text-muted-foreground">No media found</p>
235
+ <button
236
+ type="button"
237
+ onclick={() => fileInput.click()}
238
+ class="inline-flex h-9 items-center gap-2 rounded-md border px-3 text-sm hover:bg-accent"
239
+ >
240
+ <Upload class="h-4 w-4" />
241
+ Upload a file
242
+ </button>
243
+ </div>
244
+ {:else}
245
+ <div class="grid grid-cols-2 gap-3 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
246
+ {#each items as item}
247
+ <button
248
+ type="button"
249
+ class="group flex flex-col overflow-hidden rounded-md border hover:border-primary hover:shadow-sm transition-all"
250
+ onclick={() => handleSelect(item)}
251
+ >
252
+ <div class="aspect-square w-full overflow-hidden bg-muted">
253
+ {#if isImage(item.mimeType || item.file?.mimeType || '')}
254
+ <img
255
+ src={getThumbnailUrl(item)}
256
+ alt={item.alt || item.filename || ''}
257
+ class="h-full w-full object-cover group-hover:scale-105 transition-transform"
258
+ />
259
+ {:else}
260
+ <div class="flex h-full w-full items-center justify-center">
261
+ <FileText class="h-10 w-10 text-muted-foreground" />
262
+ </div>
263
+ {/if}
264
+ </div>
265
+ <div class="p-2">
266
+ <p class="truncate text-xs font-medium">{item.title || item.filename || 'Untitled'}</p>
267
+ <p class="text-xs text-muted-foreground">{formatFileSize(item.size || item.file?.size || 0)}</p>
268
+ </div>
269
+ </button>
270
+ {/each}
271
+ </div>
272
+ {/if}
273
+ </div>
274
+ </div>
275
+
276
+ <!-- Pagination -->
277
+ {#if totalPages > 1}
278
+ <div class="flex items-center justify-between border-t px-4 py-2">
279
+ <p class="text-xs text-muted-foreground">{totalDocs} items</p>
280
+ <div class="flex items-center gap-2">
281
+ <button
282
+ type="button"
283
+ disabled={page <= 1}
284
+ onclick={prevPage}
285
+ class="rounded-md border px-3 py-1 text-sm hover:bg-accent disabled:opacity-50"
286
+ >
287
+ Previous
288
+ </button>
289
+ <span class="text-sm">Page {page} of {totalPages}</span>
290
+ <button
291
+ type="button"
292
+ disabled={page >= totalPages}
293
+ onclick={nextPage}
294
+ class="rounded-md border px-3 py-1 text-sm hover:bg-accent disabled:opacity-50"
295
+ >
296
+ Next
297
+ </button>
298
+ </div>
299
+ </div>
300
+ {/if}
301
+ </div>
302
+ </div>
303
+
304
+ <input
305
+ bind:this={fileInput}
306
+ type="file"
307
+ class="hidden"
308
+ onchange={handleUpload}
309
+ accept={accept.join(',')}
310
+ />
311
+ {/if}
@@ -0,0 +1,78 @@
1
+ <script lang="ts">
2
+ import { ChevronLeft, ChevronRight } from 'lucide-svelte'
3
+
4
+ let {
5
+ page = 1,
6
+ totalPages = 1,
7
+ totalRecords = 0,
8
+ perPage = 20,
9
+ onPageChange,
10
+ class: className = 'border-t border-border/60',
11
+ }: {
12
+ page: number
13
+ totalPages: number
14
+ totalRecords: number
15
+ perPage: number
16
+ onPageChange: (page: number) => void
17
+ class?: string
18
+ } = $props()
19
+
20
+ let startRecord = $derived((page - 1) * perPage + 1)
21
+ let endRecord = $derived(Math.min(page * perPage, totalRecords))
22
+ </script>
23
+
24
+ {#if totalPages > 1}
25
+ <div class="flex items-center justify-between px-1 py-3 {className}">
26
+ <p class="text-[13px] text-muted-foreground">
27
+ <span class="font-medium text-foreground">{startRecord}-{endRecord}</span> of {totalRecords}
28
+ </p>
29
+ <div class="flex items-center gap-1.5">
30
+ <button
31
+ disabled={page <= 1}
32
+ onclick={() => onPageChange(page - 1)}
33
+ class="inline-flex h-8 w-8 items-center justify-center rounded-lg border border-border/60 bg-card text-sm shadow-sm transition-all hover:bg-secondary disabled:opacity-40 disabled:shadow-none"
34
+ >
35
+ <ChevronLeft class="h-4 w-4" />
36
+ </button>
37
+
38
+ {#each Array.from({ length: Math.min(totalPages, 7) }, (_, i) => {
39
+ if (totalPages <= 7) return i + 1;
40
+ if (i === 0) return 1;
41
+ if (i === 6) return totalPages;
42
+ if (page <= 3) {
43
+ if (i === 5) return -1;
44
+ return i + 1;
45
+ }
46
+ if (page >= totalPages - 2) {
47
+ if (i === 1) return -1;
48
+ return totalPages - 5 + i;
49
+ }
50
+ if (i === 1) return -1;
51
+ if (i === 5) return -1;
52
+ return page - 3 + i;
53
+ }) as p}
54
+ {#if p === -1}
55
+ <span class="px-1 text-xs text-muted-foreground/50">...</span>
56
+ {:else}
57
+ <button
58
+ onclick={() => onPageChange(p)}
59
+ class="inline-flex h-8 w-8 items-center justify-center rounded-lg text-[13px] transition-all
60
+ {p === page
61
+ ? 'bg-primary font-medium text-primary-foreground shadow-sm'
62
+ : 'border border-border/60 bg-card shadow-sm hover:bg-secondary'}"
63
+ >
64
+ {p}
65
+ </button>
66
+ {/if}
67
+ {/each}
68
+
69
+ <button
70
+ disabled={page >= totalPages}
71
+ onclick={() => onPageChange(page + 1)}
72
+ class="inline-flex h-8 w-8 items-center justify-center rounded-lg border border-border/60 bg-card text-sm shadow-sm transition-all hover:bg-secondary disabled:opacity-40 disabled:shadow-none"
73
+ >
74
+ <ChevronRight class="h-4 w-4" />
75
+ </button>
76
+ </div>
77
+ </div>
78
+ {/if}
@@ -0,0 +1,41 @@
1
+ <script lang="ts">
2
+ type Range = '24h' | '7d' | '30d'
3
+
4
+ let {
5
+ value = $bindable<Range>('7d'),
6
+ }: {
7
+ value?: Range
8
+ } = $props()
9
+
10
+ const options: Range[] = ['24h', '7d', '30d']
11
+
12
+ // Read persisted choice on mount.
13
+ $effect(() => {
14
+ if (typeof localStorage === 'undefined') return
15
+ const stored = localStorage.getItem('dashboard.range')
16
+ if (stored === '24h' || stored === '7d' || stored === '30d') {
17
+ value = stored
18
+ }
19
+ })
20
+
21
+ function select(next: Range) {
22
+ value = next
23
+ if (typeof localStorage !== 'undefined') {
24
+ localStorage.setItem('dashboard.range', next)
25
+ }
26
+ }
27
+ </script>
28
+
29
+ <div class="inline-flex items-center gap-1 rounded-lg border border-border/60 bg-card p-1 shadow-sm">
30
+ {#each options as opt (opt)}
31
+ <button
32
+ type="button"
33
+ onclick={() => select(opt)}
34
+ class="rounded-md px-3 py-1 text-sm font-medium transition-colors {value === opt
35
+ ? 'bg-primary text-primary-foreground shadow-sm'
36
+ : 'bg-transparent text-foreground hover:bg-secondary'}"
37
+ >
38
+ {opt}
39
+ </button>
40
+ {/each}
41
+ </div>
@@ -0,0 +1,123 @@
1
+ <script lang="ts">
2
+ import { resolve } from '$lib/router/index.svelte.js'
3
+ import type { CollectionSchema, FieldSchema } from '$lib/types/schema.js'
4
+ import { Trash2, FileText } from 'lucide-svelte'
5
+
6
+ let {
7
+ collection,
8
+ records,
9
+ onDelete,
10
+ }: {
11
+ collection: CollectionSchema
12
+ records: Record<string, any>[]
13
+ onDelete: (id: string) => void
14
+ } = $props()
15
+
16
+ function flatten(flds: FieldSchema[]): FieldSchema[] {
17
+ const out: FieldSchema[] = []
18
+ for (const f of flds) {
19
+ if ((f as any).type === 'tabs') {
20
+ for (const t of ((f as any).tabs ?? [])) out.push(...flatten(t.fields ?? []))
21
+ continue
22
+ }
23
+ if ((f as any).type === 'row') {
24
+ out.push(...flatten((f as any).fields ?? []))
25
+ continue
26
+ }
27
+ out.push(f)
28
+ }
29
+ return out
30
+ }
31
+ let flatFields = $derived(flatten(collection.fields))
32
+
33
+ // First file/upload field drives the thumbnail
34
+ let fileField = $derived(flatFields.find((f: any) => f.type === 'file' || f.type === 'upload'))
35
+ let titleField = $derived(collection.admin?.useAsTitle || flatFields[0]?.name || 'id')
36
+
37
+ function parseFileValue(raw: any): { url?: string; mimeType?: string; filename?: string } | null {
38
+ if (!raw) return null
39
+ let v = raw
40
+ if (typeof v === 'string') {
41
+ if (v.startsWith('{')) {
42
+ try { v = JSON.parse(v) } catch { return null }
43
+ } else {
44
+ return { url: v }
45
+ }
46
+ }
47
+ if (typeof v === 'object' && v !== null) return v
48
+ return null
49
+ }
50
+
51
+ function getThumbUrl(record: Record<string, any>): string | null {
52
+ // Upload collections expose url/mimeType as auto-injected top-level fields.
53
+ if (typeof record.url === 'string' && record.url) {
54
+ const mt = typeof record.mimeType === 'string' ? record.mimeType : ''
55
+ if (mt && !mt.startsWith('image/')) return null
56
+ return record.url
57
+ }
58
+ if (!fileField) return null
59
+ const parsed = parseFileValue(record[fileField.name])
60
+ if (!parsed?.url) return null
61
+ if (parsed.mimeType && !parsed.mimeType.startsWith('image/')) return null
62
+ return parsed.url
63
+ }
64
+
65
+ function getTitle(record: Record<string, any>): string {
66
+ const v = record[titleField]
67
+ if (v && typeof v === 'string') return v
68
+ if (v && typeof v === 'object' && 'filename' in v) return v.filename
69
+ return record.id || 'Untitled'
70
+ }
71
+
72
+ function handleDelete(e: Event, id: string) {
73
+ e.stopPropagation()
74
+ onDelete(id)
75
+ }
76
+ </script>
77
+
78
+ {#if records.length === 0}
79
+ <div class="rounded-lg border border-dashed border-border/60 bg-card py-16 text-center text-sm text-muted-foreground">
80
+ No records yet
81
+ </div>
82
+ {:else}
83
+ <div class="grid gap-3" style="grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));">
84
+ {#each records as record (record.id)}
85
+ {@const thumbUrl = getThumbUrl(record)}
86
+ {@const title = getTitle(record)}
87
+ <div
88
+ class="group relative flex flex-col overflow-hidden rounded-lg border border-border/80 bg-card text-left shadow-sm transition-all hover:border-primary/40 hover:shadow-md"
89
+ >
90
+ <a
91
+ href={resolve(`/${collection.key}/${record.id}`)}
92
+ class="flex flex-col"
93
+ >
94
+ <div class="relative aspect-square w-full overflow-hidden bg-muted">
95
+ {#if thumbUrl}
96
+ <img
97
+ src={thumbUrl}
98
+ alt={title}
99
+ class="h-full w-full object-cover transition-transform group-hover:scale-105"
100
+ loading="lazy"
101
+ />
102
+ {:else}
103
+ <div class="flex h-full w-full items-center justify-center">
104
+ <FileText class="h-10 w-10 text-muted-foreground/40" />
105
+ </div>
106
+ {/if}
107
+ </div>
108
+ <div class="px-3 py-2">
109
+ <p class="truncate text-xs font-medium text-foreground">{title}</p>
110
+ </div>
111
+ </a>
112
+ <button
113
+ type="button"
114
+ onclick={(e) => handleDelete(e, record.id)}
115
+ class="absolute right-2 top-2 inline-flex h-7 w-7 items-center justify-center rounded-md bg-background/90 text-destructive opacity-0 shadow-sm backdrop-blur transition-opacity hover:bg-destructive hover:text-white group-hover:opacity-100"
116
+ title="Delete"
117
+ >
118
+ <Trash2 class="h-3.5 w-3.5" />
119
+ </button>
120
+ </div>
121
+ {/each}
122
+ </div>
123
+ {/if}