@quoin-cms/admin 0.2.0 → 0.3.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/package.json +4 -1
- package/src/AdminRoot.svelte +5 -1
- package/src/lib/api/auth.ts +3 -6
- package/src/lib/api/files.ts +3 -2
- package/src/lib/components/AdminSidebar.svelte +23 -2
- package/src/lib/components/DocumentEditLayout.svelte +17 -0
- package/src/lib/components/DynamicForm.svelte +3 -3
- package/src/lib/components/MediaLibrary.svelte +55 -7
- package/src/lib/components/UploadCreateView.svelte +173 -0
- package/src/lib/components/doc/ApiView.svelte +95 -103
- package/src/lib/components/doc/RenderJson.svelte +93 -0
- package/src/lib/components/fields/RichTextField.svelte +5 -0
- package/src/lib/components/fields/UploadField.svelte +26 -34
- package/src/lib/components/fields/UploadGalleryField.svelte +28 -37
- package/src/lib/components/lexical/BlockField.svelte +41 -0
- package/src/lib/components/lexical/BlockHost.svelte +85 -0
- package/src/lib/components/lexical/BlockNode.ts +102 -0
- package/src/lib/components/lexical/block-defaults.ts +40 -0
- package/src/lib/components/lexical/lexical-helpers.ts +3 -0
- package/src/lib/components/lexical/nodes.ts +2 -0
- package/src/lib/components/lexical/toolbar/InsertBlockDropdown.svelte +27 -2
- package/src/lib/context.svelte.ts +1 -0
- package/src/lib/types/schema.ts +5 -0
- package/src/views/CollectionNewView.svelte +3 -0
- package/src/views/LoginView.svelte +47 -23
- package/biome.json +0 -62
- package/dist/assets/index-BaOy5Of3.js +0 -32
- package/dist/assets/index-DINUk481.css +0 -1
- package/dist/index.html +0 -20
- package/index.html +0 -19
- package/tsconfig.json +0 -25
- package/vite.config.ts +0 -80
package/package.json
CHANGED
package/src/AdminRoot.svelte
CHANGED
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
import GlobalEditView from './views/GlobalEditView.svelte'
|
|
34
34
|
import CustomPageView from './views/CustomPageView.svelte'
|
|
35
35
|
import NotFoundView from './views/NotFoundView.svelte'
|
|
36
|
-
import { seedBrandingFromConfig } from './lib/stores/branding.svelte.js'
|
|
36
|
+
import { seedBrandingFromConfig, branding } from './lib/stores/branding.svelte.js'
|
|
37
37
|
|
|
38
38
|
let {
|
|
39
39
|
config,
|
|
@@ -48,6 +48,10 @@
|
|
|
48
48
|
// Later, site-settings loadBranding() can override with operator-edited values.
|
|
49
49
|
seedBrandingFromConfig()
|
|
50
50
|
|
|
51
|
+
$effect(() => {
|
|
52
|
+
document.title = branding.siteName
|
|
53
|
+
})
|
|
54
|
+
|
|
51
55
|
// Routes are matched in order — most specific patterns first.
|
|
52
56
|
const routes: RoutePattern[] = [
|
|
53
57
|
{ pattern: '/login' },
|
package/src/lib/api/auth.ts
CHANGED
|
@@ -16,11 +16,8 @@ export function getMe(): Promise<ApiResult<AuthUser>> {
|
|
|
16
16
|
|
|
17
17
|
export function register(
|
|
18
18
|
username: string,
|
|
19
|
-
password: string
|
|
19
|
+
password: string,
|
|
20
|
+
name: string
|
|
20
21
|
): Promise<ApiResult<{ message: string }>> {
|
|
21
|
-
return post<{ message: string }>('/auth/register', { username, password })
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
export function getSetupStatus(): Promise<ApiResult<{ needsSetup: boolean }>> {
|
|
25
|
-
return get<{ needsSetup: boolean }>('/auth/setup-status')
|
|
22
|
+
return post<{ message: string }>('/auth/register', { username, password, name })
|
|
26
23
|
}
|
package/src/lib/api/files.ts
CHANGED
|
@@ -33,7 +33,7 @@ export interface UploadRecord {
|
|
|
33
33
|
|
|
34
34
|
export interface UploadToCollectionOptions {
|
|
35
35
|
/** Optional user-metadata form fields to include alongside `file`. */
|
|
36
|
-
extra?: Record<string,
|
|
36
|
+
extra?: Record<string, unknown>
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
/**
|
|
@@ -50,7 +50,8 @@ export function uploadToCollection(
|
|
|
50
50
|
const fd = new FormData()
|
|
51
51
|
fd.append('file', file)
|
|
52
52
|
for (const [k, v] of Object.entries(opts.extra ?? {})) {
|
|
53
|
-
|
|
53
|
+
if (v === undefined || v === null || v === '') continue
|
|
54
|
+
fd.append(k, typeof v === 'object' ? JSON.stringify(v) : String(v))
|
|
54
55
|
}
|
|
55
56
|
return upload<UploadRecord>(`/upload/${encodeURIComponent(collection)}`, fd)
|
|
56
57
|
}
|
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
FileText, LogOut, Settings, PanelLeftClose, PanelLeftOpen, BookOpen,
|
|
13
13
|
PenLine, Users, FolderTree, Tag, BookCopy, Trophy, Megaphone,
|
|
14
14
|
Mail, StickyNote, Menu, Image, FolderOpen, Tags,
|
|
15
|
-
MessageSquare, BarChart3, MousePointerClick, Puzzle, type Icon
|
|
15
|
+
MessageSquare, BarChart3, MousePointerClick, Puzzle, LayoutDashboard, User, type Icon
|
|
16
16
|
} from 'lucide-svelte'
|
|
17
17
|
import type { Component } from 'svelte'
|
|
18
18
|
|
|
@@ -134,6 +134,23 @@ async function handleLogout() {
|
|
|
134
134
|
|
|
135
135
|
<!-- Navigation -->
|
|
136
136
|
<nav class="flex-1 overflow-y-auto px-3 py-4 scrollbar-hide">
|
|
137
|
+
<div class="mb-5">
|
|
138
|
+
<ul class="space-y-0.5">
|
|
139
|
+
<li>
|
|
140
|
+
<a
|
|
141
|
+
href={resolve('/')}
|
|
142
|
+
class="group flex items-center gap-2.5 rounded-lg px-3 py-2 text-[13px] transition-all duration-150
|
|
143
|
+
{currentPath === resolve('/')
|
|
144
|
+
? 'bg-sidebar-accent text-sidebar-accent-foreground font-medium shadow-sm'
|
|
145
|
+
: 'text-sidebar-foreground hover:bg-white/[0.04] hover:text-white'}"
|
|
146
|
+
>
|
|
147
|
+
<LayoutDashboard class="h-4 w-4 shrink-0 opacity-60 group-hover:opacity-100 {currentPath === resolve('/') ? 'opacity-100' : ''}" />
|
|
148
|
+
Dashboard
|
|
149
|
+
</a>
|
|
150
|
+
</li>
|
|
151
|
+
</ul>
|
|
152
|
+
</div>
|
|
153
|
+
|
|
137
154
|
{#each groupNames as group}
|
|
138
155
|
<div class="mb-5">
|
|
139
156
|
<p class="mb-2 px-3 text-[10px] font-semibold uppercase tracking-[0.15em] text-sidebar-muted">
|
|
@@ -216,7 +233,11 @@ async function handleLogout() {
|
|
|
216
233
|
<div class="flex items-center justify-between">
|
|
217
234
|
<div class="flex items-center gap-2.5">
|
|
218
235
|
<div class="flex h-7 w-7 items-center justify-center rounded-full bg-sidebar-accent text-[11px] font-semibold text-sidebar-accent-foreground">
|
|
219
|
-
{user?.username
|
|
236
|
+
{#if user?.username}
|
|
237
|
+
{user.username.charAt(0).toUpperCase()}
|
|
238
|
+
{:else}
|
|
239
|
+
<User class="h-3.5 w-3.5" />
|
|
240
|
+
{/if}
|
|
220
241
|
</div>
|
|
221
242
|
<span class="text-xs text-sidebar-foreground">{user?.username}</span>
|
|
222
243
|
</div>
|
|
@@ -6,6 +6,7 @@ import VersionHistory from './doc/VersionHistory.svelte'
|
|
|
6
6
|
import ScheduleModal from './doc/ScheduleModal.svelte'
|
|
7
7
|
import DynamicForm from './DynamicForm.svelte'
|
|
8
8
|
import { resolveFieldComponent } from './fields/index.js'
|
|
9
|
+
import { formatFileSize } from '$lib/utils/format.js'
|
|
9
10
|
import { useDirtyState } from '$lib/utils/dirty.svelte.js'
|
|
10
11
|
import { unpublishRecord } from '$lib/api/records.js'
|
|
11
12
|
import { toast } from 'svelte-sonner'
|
|
@@ -231,6 +232,22 @@ function handleSchedule(isoDate: string) {
|
|
|
231
232
|
{:else}
|
|
232
233
|
<div class="grid gap-9 px-7 py-8 {sideFields.length > 0 || (collection.versions && mode === 'edit' && record?.id) ? 'lg:grid-cols-[1fr_320px]' : ''}">
|
|
233
234
|
<div class="min-w-0">
|
|
235
|
+
{#if collection.upload && record?.url}
|
|
236
|
+
<div class="mb-6 flex items-start gap-4 rounded-lg border bg-muted/30 p-4">
|
|
237
|
+
{#if String(record.mimeType ?? '').startsWith('image/')}
|
|
238
|
+
<img src={record.url} alt={record.filename ?? ''} class="h-24 w-24 shrink-0 rounded object-cover" />
|
|
239
|
+
{:else}
|
|
240
|
+
<div class="flex h-24 w-24 shrink-0 items-center justify-center rounded bg-muted text-2xl">📄</div>
|
|
241
|
+
{/if}
|
|
242
|
+
<div class="min-w-0 space-y-1">
|
|
243
|
+
<p class="truncate font-medium">{record.filename}</p>
|
|
244
|
+
<p class="text-sm text-muted-foreground">
|
|
245
|
+
{formatFileSize(record.size ?? 0)}{#if record.width}{' · '}{record.width}×{record.height}{/if}{' · '}{record.mimeType}
|
|
246
|
+
</p>
|
|
247
|
+
<a href={record.url} target="_blank" rel="noopener" class="inline-block text-sm text-primary hover:underline">Open file</a>
|
|
248
|
+
</div>
|
|
249
|
+
</div>
|
|
250
|
+
{/if}
|
|
234
251
|
<DynamicForm
|
|
235
252
|
fields={collection.fields}
|
|
236
253
|
initialData={record ?? {}}
|
|
@@ -22,7 +22,7 @@ let {
|
|
|
22
22
|
|
|
23
23
|
let sidebarFieldSet = $derived(new Set(sidebarFields))
|
|
24
24
|
let mainFields = $derived(
|
|
25
|
-
fields.filter((f) => !f.hidden && !sidebarFieldSet.has(f.name))
|
|
25
|
+
fields.filter((f) => !f.hidden && !f.adminHidden && !sidebarFieldSet.has(f.name))
|
|
26
26
|
)
|
|
27
27
|
|
|
28
28
|
// formData is owned by the parent (DocumentEditLayout) and bound in.
|
|
@@ -52,7 +52,7 @@ let mergedErrors = $derived({ ...errors, ...localErrors })
|
|
|
52
52
|
{#each (tabsField.tabs[activeTab].fields ?? []) as cf (cf.name)}
|
|
53
53
|
{#if cf.type === 'row'}
|
|
54
54
|
<div class="grid gap-4" style="grid-template-columns: repeat({(cf.fields ?? []).length}, minmax(0, 1fr));">
|
|
55
|
-
{#each (cf.fields ?? []).filter((x: FieldSchema) => !x.hidden) as rf (rf.name)}
|
|
55
|
+
{#each (cf.fields ?? []).filter((x: FieldSchema) => !x.hidden && !x.adminHidden) as rf (rf.name)}
|
|
56
56
|
{@render fieldRenderer(rf)}
|
|
57
57
|
{/each}
|
|
58
58
|
</div>
|
|
@@ -63,7 +63,7 @@ let mergedErrors = $derived({ ...errors, ...localErrors })
|
|
|
63
63
|
{/if}
|
|
64
64
|
{:else if f.type === 'row'}
|
|
65
65
|
<div class="grid gap-4" style="grid-template-columns: repeat({(f.fields ?? []).length}, minmax(0, 1fr));">
|
|
66
|
-
{#each (f.fields ?? []).filter((x: FieldSchema) => !x.hidden) as rf (rf.name)}
|
|
66
|
+
{#each (f.fields ?? []).filter((x: FieldSchema) => !x.hidden && !x.adminHidden) as rf (rf.name)}
|
|
67
67
|
{@render fieldRenderer(rf)}
|
|
68
68
|
{/each}
|
|
69
69
|
</div>
|
|
@@ -3,9 +3,10 @@ import { listRecords } from '$lib/api/records.js';
|
|
|
3
3
|
import { uploadFile } from '$lib/api/files.js';
|
|
4
4
|
import { createRecord } from '$lib/api/records.js';
|
|
5
5
|
import { formatFileSize } from '$lib/utils/format.js';
|
|
6
|
-
import { X, Upload, Search, FolderOpen, FileText } from 'lucide-svelte';
|
|
6
|
+
import { X, Upload, Search, FolderOpen, FileText, Check } from 'lucide-svelte';
|
|
7
7
|
|
|
8
8
|
interface MediaItem {
|
|
9
|
+
id: string;
|
|
9
10
|
url: string;
|
|
10
11
|
alt: string;
|
|
11
12
|
filename: string;
|
|
@@ -16,13 +17,23 @@ interface MediaItem {
|
|
|
16
17
|
let {
|
|
17
18
|
open = $bindable(false),
|
|
18
19
|
accept = ['image/*'],
|
|
20
|
+
multiple = false,
|
|
21
|
+
collection = 'media',
|
|
19
22
|
onSelect,
|
|
20
23
|
}: {
|
|
21
24
|
open: boolean;
|
|
22
25
|
accept?: string[];
|
|
26
|
+
/** When true, items toggle into a selection the user confirms via "Add". */
|
|
27
|
+
multiple?: boolean;
|
|
28
|
+
/** Upload collection slug to browse (and its `<collection>_folders`). */
|
|
29
|
+
collection?: string;
|
|
23
30
|
onSelect: (media: MediaItem) => void;
|
|
24
31
|
} = $props();
|
|
25
32
|
|
|
33
|
+
// In multi-select mode we accumulate chosen items here (keyed by id) so the
|
|
34
|
+
// selection survives pagination, then flush them on confirm.
|
|
35
|
+
let selected = $state<Map<string, MediaItem>>(new Map());
|
|
36
|
+
|
|
26
37
|
let items = $state<any[]>([]);
|
|
27
38
|
let folders = $state<any[]>([]);
|
|
28
39
|
let search = $state('');
|
|
@@ -48,7 +59,7 @@ async function loadMedia() {
|
|
|
48
59
|
if (selectedFolder) {
|
|
49
60
|
params.filter = `folder="${selectedFolder}"`;
|
|
50
61
|
}
|
|
51
|
-
const result = await listRecords(
|
|
62
|
+
const result = await listRecords(collection, params);
|
|
52
63
|
if (result.ok) {
|
|
53
64
|
items = result.data.records;
|
|
54
65
|
totalDocs = result.data.totalRecords;
|
|
@@ -58,21 +69,37 @@ async function loadMedia() {
|
|
|
58
69
|
}
|
|
59
70
|
|
|
60
71
|
async function loadFolders() {
|
|
61
|
-
const result = await listRecords(
|
|
72
|
+
const result = await listRecords(`${collection}_folders`, { perPage: 100, sort: 'name' });
|
|
62
73
|
if (result.ok) {
|
|
63
74
|
folders = result.data.records;
|
|
64
75
|
}
|
|
65
76
|
}
|
|
66
77
|
|
|
67
|
-
function
|
|
68
|
-
|
|
78
|
+
function buildMediaItem(item: any): MediaItem {
|
|
79
|
+
return {
|
|
80
|
+
id: item.id,
|
|
69
81
|
url: item.file?.url || item.url || '',
|
|
70
82
|
alt: item.alt || '',
|
|
71
83
|
filename: item.filename || item.file?.filename || '',
|
|
72
84
|
mimeType: item.mimeType || item.file?.mimeType || '',
|
|
73
85
|
size: item.size || item.file?.size || 0,
|
|
74
86
|
};
|
|
75
|
-
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function handleSelect(item: any) {
|
|
90
|
+
if (multiple) {
|
|
91
|
+
const next = new Map(selected);
|
|
92
|
+
if (next.has(item.id)) next.delete(item.id);
|
|
93
|
+
else next.set(item.id, buildMediaItem(item));
|
|
94
|
+
selected = next;
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
onSelect(buildMediaItem(item));
|
|
98
|
+
open = false;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function confirmSelection() {
|
|
102
|
+
for (const media of selected.values()) onSelect(media);
|
|
76
103
|
open = false;
|
|
77
104
|
}
|
|
78
105
|
|
|
@@ -144,6 +171,7 @@ $effect(() => {
|
|
|
144
171
|
page = 1;
|
|
145
172
|
search = '';
|
|
146
173
|
selectedFolder = null;
|
|
174
|
+
selected = new Map();
|
|
147
175
|
loadMedia();
|
|
148
176
|
loadFolders();
|
|
149
177
|
}
|
|
@@ -246,9 +274,14 @@ $effect(() => {
|
|
|
246
274
|
{#each items as item}
|
|
247
275
|
<button
|
|
248
276
|
type="button"
|
|
249
|
-
class="group flex flex-col overflow-hidden rounded-md border hover:border-primary hover:
|
|
277
|
+
class="group relative flex flex-col overflow-hidden rounded-md border hover:shadow-sm transition-all {multiple && selected.has(item.id) ? 'border-primary ring-2 ring-primary' : 'hover:border-primary'}"
|
|
250
278
|
onclick={() => handleSelect(item)}
|
|
251
279
|
>
|
|
280
|
+
{#if multiple && selected.has(item.id)}
|
|
281
|
+
<div class="absolute right-1.5 top-1.5 z-10 flex h-5 w-5 items-center justify-center rounded-full bg-primary text-primary-foreground">
|
|
282
|
+
<Check class="h-3.5 w-3.5" />
|
|
283
|
+
</div>
|
|
284
|
+
{/if}
|
|
252
285
|
<div class="aspect-square w-full overflow-hidden bg-muted">
|
|
253
286
|
{#if isImage(item.mimeType || item.file?.mimeType || '')}
|
|
254
287
|
<img
|
|
@@ -273,6 +306,21 @@ $effect(() => {
|
|
|
273
306
|
</div>
|
|
274
307
|
</div>
|
|
275
308
|
|
|
309
|
+
<!-- Multi-select confirm bar -->
|
|
310
|
+
{#if multiple}
|
|
311
|
+
<div class="flex items-center justify-between border-t px-4 py-2">
|
|
312
|
+
<p class="text-xs text-muted-foreground">{selected.size} selected</p>
|
|
313
|
+
<button
|
|
314
|
+
type="button"
|
|
315
|
+
disabled={selected.size === 0}
|
|
316
|
+
onclick={confirmSelection}
|
|
317
|
+
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"
|
|
318
|
+
>
|
|
319
|
+
Add {selected.size} {selected.size === 1 ? 'item' : 'items'}
|
|
320
|
+
</button>
|
|
321
|
+
</div>
|
|
322
|
+
{/if}
|
|
323
|
+
|
|
276
324
|
<!-- Pagination -->
|
|
277
325
|
{#if totalPages > 1}
|
|
278
326
|
<div class="flex items-center justify-between border-t px-4 py-2">
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { goto, resolve } from '$lib/router/index.svelte.js'
|
|
3
|
+
import { uploadToCollection } from '$lib/api/files.js'
|
|
4
|
+
import DynamicForm from './DynamicForm.svelte'
|
|
5
|
+
import type { CollectionSchema, FieldSchema } from '$lib/types/schema.js'
|
|
6
|
+
import { toast } from 'svelte-sonner'
|
|
7
|
+
import { Upload, X } from 'lucide-svelte'
|
|
8
|
+
|
|
9
|
+
let {
|
|
10
|
+
collection,
|
|
11
|
+
}: {
|
|
12
|
+
collection: CollectionSchema
|
|
13
|
+
} = $props()
|
|
14
|
+
|
|
15
|
+
let isUploading = $state(false)
|
|
16
|
+
let isDragging = $state(false)
|
|
17
|
+
let selectedFile = $state<File | null>(null)
|
|
18
|
+
let fileInput: HTMLInputElement
|
|
19
|
+
|
|
20
|
+
let accept = $derived(collection.upload?.accept?.join(',') ?? '')
|
|
21
|
+
|
|
22
|
+
function flatten(flds: FieldSchema[]): FieldSchema[] {
|
|
23
|
+
const out: FieldSchema[] = []
|
|
24
|
+
for (const f of flds) {
|
|
25
|
+
if (f.type === 'tabs') {
|
|
26
|
+
for (const t of f.tabs ?? []) out.push(...flatten(t.fields ?? []))
|
|
27
|
+
continue
|
|
28
|
+
}
|
|
29
|
+
if (f.type === 'row') {
|
|
30
|
+
out.push(...flatten(f.fields ?? []))
|
|
31
|
+
continue
|
|
32
|
+
}
|
|
33
|
+
out.push(f)
|
|
34
|
+
}
|
|
35
|
+
return out
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function buildInitial(flds: FieldSchema[]): Record<string, any> {
|
|
39
|
+
const data: Record<string, any> = {}
|
|
40
|
+
for (const f of flatten(flds)) {
|
|
41
|
+
if ((f as any).defaultValue !== undefined) {
|
|
42
|
+
data[f.name] = (f as any).defaultValue
|
|
43
|
+
} else if (f.type === 'checkbox') {
|
|
44
|
+
data[f.name] = false
|
|
45
|
+
} else if (f.type === 'tags') {
|
|
46
|
+
data[f.name] = []
|
|
47
|
+
} else if (f.type === 'number' || f.type === 'file' || f.type === 'relationship' || f.type === 'richtext') {
|
|
48
|
+
data[f.name] = null
|
|
49
|
+
} else {
|
|
50
|
+
data[f.name] = ''
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return data
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// svelte-ignore state_referenced_locally
|
|
57
|
+
let formData = $state<Record<string, any>>(buildInitial(collection.fields))
|
|
58
|
+
let userMetadataFields = $derived(flatten(collection.fields).filter((f) => !f.hidden && !f.adminHidden))
|
|
59
|
+
let hasUserMetadata = $derived(userMetadataFields.length > 0)
|
|
60
|
+
|
|
61
|
+
function buildExtra(): Record<string, unknown> {
|
|
62
|
+
const out: Record<string, unknown> = {}
|
|
63
|
+
for (const f of userMetadataFields) {
|
|
64
|
+
out[f.name] = formData[f.name]
|
|
65
|
+
}
|
|
66
|
+
return out
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function handleFiles(files: FileList | null) {
|
|
70
|
+
if (!files?.length || isUploading) return
|
|
71
|
+
selectedFile = files[0]
|
|
72
|
+
if (fileInput) fileInput.value = ''
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function clearFile() {
|
|
76
|
+
selectedFile = null
|
|
77
|
+
if (fileInput) fileInput.value = ''
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function submitUpload() {
|
|
81
|
+
if (!selectedFile || isUploading) return
|
|
82
|
+
isUploading = true
|
|
83
|
+
const result = await uploadToCollection(collection.key, selectedFile, { extra: buildExtra() })
|
|
84
|
+
isUploading = false
|
|
85
|
+
if (result.ok && result.data.id) {
|
|
86
|
+
toast.success(`Uploaded ${selectedFile.name}`)
|
|
87
|
+
await goto(resolve(`/${collection.key}/${result.data.id}`))
|
|
88
|
+
} else {
|
|
89
|
+
toast.error(!result.ok ? result.error : 'Upload failed')
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function onDrop(e: DragEvent) {
|
|
94
|
+
e.preventDefault()
|
|
95
|
+
isDragging = false
|
|
96
|
+
handleFiles(e.dataTransfer?.files ?? null)
|
|
97
|
+
}
|
|
98
|
+
</script>
|
|
99
|
+
|
|
100
|
+
<div class="px-7 py-8">
|
|
101
|
+
<h1 class="mb-6 text-2xl font-semibold">New {collection.label}</h1>
|
|
102
|
+
|
|
103
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
104
|
+
<div
|
|
105
|
+
class="flex flex-col items-center justify-center gap-3 rounded-lg border-2 border-dashed p-12 text-center transition-colors {isDragging ? 'border-primary bg-accent/40' : 'border-border'}"
|
|
106
|
+
ondragover={(e) => { e.preventDefault(); isDragging = true }}
|
|
107
|
+
ondragleave={() => (isDragging = false)}
|
|
108
|
+
ondrop={onDrop}
|
|
109
|
+
>
|
|
110
|
+
<Upload class="h-8 w-8 text-muted-foreground" />
|
|
111
|
+
<div class="flex items-center gap-2">
|
|
112
|
+
<button
|
|
113
|
+
type="button"
|
|
114
|
+
onclick={() => fileInput.click()}
|
|
115
|
+
disabled={isUploading}
|
|
116
|
+
class="inline-flex h-10 items-center gap-2 rounded-md border bg-background px-4 text-sm hover:bg-accent disabled:opacity-50"
|
|
117
|
+
>
|
|
118
|
+
{selectedFile ? 'Change file' : 'Select a file'}
|
|
119
|
+
</button>
|
|
120
|
+
<span class="text-sm text-muted-foreground">or drag and drop a file</span>
|
|
121
|
+
</div>
|
|
122
|
+
{#if selectedFile}
|
|
123
|
+
<div class="inline-flex max-w-full items-center gap-2 rounded-md border bg-background px-3 py-2 text-sm">
|
|
124
|
+
<span class="truncate">{selectedFile.name}</span>
|
|
125
|
+
<button
|
|
126
|
+
type="button"
|
|
127
|
+
onclick={clearFile}
|
|
128
|
+
disabled={isUploading}
|
|
129
|
+
class="text-muted-foreground hover:text-foreground disabled:opacity-50"
|
|
130
|
+
aria-label="Remove selected file"
|
|
131
|
+
>
|
|
132
|
+
<X class="h-4 w-4" />
|
|
133
|
+
</button>
|
|
134
|
+
</div>
|
|
135
|
+
{/if}
|
|
136
|
+
{#if accept}
|
|
137
|
+
<p class="text-xs text-muted-foreground">Accepted: {accept}</p>
|
|
138
|
+
{/if}
|
|
139
|
+
</div>
|
|
140
|
+
|
|
141
|
+
{#if hasUserMetadata}
|
|
142
|
+
<div class="mt-8 max-w-3xl">
|
|
143
|
+
<DynamicForm fields={collection.fields} bind:formData />
|
|
144
|
+
</div>
|
|
145
|
+
{/if}
|
|
146
|
+
|
|
147
|
+
<div class="mt-8 flex items-center gap-3">
|
|
148
|
+
<button
|
|
149
|
+
type="button"
|
|
150
|
+
onclick={submitUpload}
|
|
151
|
+
disabled={!selectedFile || isUploading}
|
|
152
|
+
class="inline-flex h-10 items-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
|
153
|
+
>
|
|
154
|
+
{isUploading ? 'Uploading…' : 'Upload'}
|
|
155
|
+
</button>
|
|
156
|
+
<button
|
|
157
|
+
type="button"
|
|
158
|
+
onclick={() => goto(resolve(`/${collection.key}`))}
|
|
159
|
+
disabled={isUploading}
|
|
160
|
+
class="inline-flex h-10 items-center rounded-md border bg-background px-4 text-sm hover:bg-accent disabled:opacity-50"
|
|
161
|
+
>
|
|
162
|
+
Cancel
|
|
163
|
+
</button>
|
|
164
|
+
</div>
|
|
165
|
+
|
|
166
|
+
<input
|
|
167
|
+
bind:this={fileInput}
|
|
168
|
+
type="file"
|
|
169
|
+
class="hidden"
|
|
170
|
+
{accept}
|
|
171
|
+
onchange={(e) => handleFiles((e.target as HTMLInputElement).files)}
|
|
172
|
+
/>
|
|
173
|
+
</div>
|