@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.
- package/LICENSE +661 -0
- package/biome.json +62 -0
- package/dist/assets/index-C9Y5-AKj.js +33 -0
- package/dist/assets/index-uVdiUjty.css +1 -0
- package/dist/index.html +20 -0
- package/index.html +19 -0
- package/package.json +43 -0
- package/src/AdminRoot.svelte +98 -0
- package/src/app.css +211 -0
- package/src/lib/Slot.svelte +65 -0
- package/src/lib/api/auth.ts +26 -0
- package/src/lib/api/client.ts +73 -0
- package/src/lib/api/files.ts +56 -0
- package/src/lib/api/globals.ts +13 -0
- package/src/lib/api/records.ts +102 -0
- package/src/lib/api/schema.ts +7 -0
- package/src/lib/api/versions.ts +40 -0
- package/src/lib/components/AdminHeader.svelte +107 -0
- package/src/lib/components/AdminSidebar.svelte +262 -0
- package/src/lib/components/DeleteDialog.svelte +58 -0
- package/src/lib/components/DocumentEditLayout.svelte +263 -0
- package/src/lib/components/DynamicForm.svelte +74 -0
- package/src/lib/components/KpiCard.svelte +75 -0
- package/src/lib/components/MediaLibrary.svelte +311 -0
- package/src/lib/components/Pagination.svelte +78 -0
- package/src/lib/components/RangeFilter.svelte +41 -0
- package/src/lib/components/RecordGrid.svelte +123 -0
- package/src/lib/components/RecordTable.svelte +156 -0
- package/src/lib/components/cells/CheckboxCell.svelte +10 -0
- package/src/lib/components/cells/ColorCell.svelte +15 -0
- package/src/lib/components/cells/DateCell.svelte +8 -0
- package/src/lib/components/cells/RelationshipCell.svelte +20 -0
- package/src/lib/components/cells/RichTextCell.svelte +21 -0
- package/src/lib/components/cells/SelectCell.svelte +26 -0
- package/src/lib/components/cells/TextCell.svelte +8 -0
- package/src/lib/components/cells/UploadCell.svelte +34 -0
- package/src/lib/components/cells/index.ts +28 -0
- package/src/lib/components/charts/TimeSeriesChart.svelte +184 -0
- package/src/lib/components/doc/ApiView.svelte +181 -0
- package/src/lib/components/doc/Autosave.svelte +102 -0
- package/src/lib/components/doc/DocHeader.svelte +86 -0
- package/src/lib/components/doc/DocMetaStrip.svelte +103 -0
- package/src/lib/components/doc/DocTabBar.svelte +26 -0
- package/src/lib/components/doc/HeaderModeSwitch.svelte +32 -0
- package/src/lib/components/doc/PublishButton.svelte +114 -0
- package/src/lib/components/doc/ScheduleModal.svelte +110 -0
- package/src/lib/components/doc/VersionHistory.svelte +20 -0
- package/src/lib/components/fields/ArrayFieldEditor.svelte +62 -0
- package/src/lib/components/fields/BlockCard.svelte +63 -0
- package/src/lib/components/fields/BlocksFieldEditor.svelte +83 -0
- package/src/lib/components/fields/CheckboxField.svelte +27 -0
- package/src/lib/components/fields/ColorField.svelte +46 -0
- package/src/lib/components/fields/DateField.svelte +52 -0
- package/src/lib/components/fields/EmailField.svelte +30 -0
- package/src/lib/components/fields/FileField.svelte +280 -0
- package/src/lib/components/fields/JsonField.svelte +145 -0
- package/src/lib/components/fields/NumberField.svelte +44 -0
- package/src/lib/components/fields/PasswordField.svelte +38 -0
- package/src/lib/components/fields/RelationshipField.svelte +271 -0
- package/src/lib/components/fields/RichTextField.svelte +139 -0
- package/src/lib/components/fields/SelectField.svelte +33 -0
- package/src/lib/components/fields/SlugField.svelte +70 -0
- package/src/lib/components/fields/TabsField.svelte +56 -0
- package/src/lib/components/fields/TagsField.svelte +85 -0
- package/src/lib/components/fields/TextField.svelte +36 -0
- package/src/lib/components/fields/TextareaField.svelte +32 -0
- package/src/lib/components/fields/UploadField.svelte +166 -0
- package/src/lib/components/fields/UploadFieldDispatch.svelte +21 -0
- package/src/lib/components/fields/UploadGalleryField.svelte +166 -0
- package/src/lib/components/fields/index.ts +22 -0
- package/src/lib/components/fields/registry.ts +58 -0
- package/src/lib/components/lexical/CustomHTMLComponent.svelte +52 -0
- package/src/lib/components/lexical/CustomHTMLNode.ts +94 -0
- package/src/lib/components/lexical/PullQuoteComponent.svelte +73 -0
- package/src/lib/components/lexical/PullQuoteNode.ts +112 -0
- package/src/lib/components/lexical/lexical-helpers.ts +24 -0
- package/src/lib/components/lexical/nodes.ts +8 -0
- package/src/lib/components/lexical/toolbar/EditorToolbar.svelte +159 -0
- package/src/lib/components/lexical/toolbar/InsertBlockDropdown.svelte +278 -0
- package/src/lib/components/versions/CompareSelector.svelte +31 -0
- package/src/lib/components/versions/FieldDiff.svelte +141 -0
- package/src/lib/components/versions/RestoreModal.svelte +67 -0
- package/src/lib/components/versions/StatusPill.svelte +21 -0
- package/src/lib/context.svelte.ts +156 -0
- package/src/lib/router/index.svelte.ts +282 -0
- package/src/lib/router/matcher.ts +52 -0
- package/src/lib/stores/branding.svelte.ts +74 -0
- package/src/lib/stores/schema.svelte.ts +17 -0
- package/src/lib/types/schema.ts +126 -0
- package/src/lib/utils/cn.ts +6 -0
- package/src/lib/utils/diff.ts +112 -0
- package/src/lib/utils/dirty.svelte.ts +50 -0
- package/src/lib/utils/format.ts +28 -0
- package/src/lib/utils/json-highlight.ts +34 -0
- package/src/lib/utils/slug.ts +8 -0
- package/src/main.ts +32 -0
- package/src/views/AdminLayout.svelte +73 -0
- package/src/views/AdsAnalyticsView.svelte +152 -0
- package/src/views/CollectionEditView.svelte +117 -0
- package/src/views/CollectionListView.svelte +347 -0
- package/src/views/CollectionNewView.svelte +68 -0
- package/src/views/CustomPageView.svelte +59 -0
- package/src/views/DashboardView.svelte +370 -0
- package/src/views/GlobalEditView.svelte +100 -0
- package/src/views/LoginView.svelte +231 -0
- package/src/views/NotFoundView.svelte +9 -0
- package/src/views/VersionDetailView.svelte +307 -0
- package/src/views/VersionsListView.svelte +201 -0
- package/tsconfig.json +25 -0
- 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}
|