@quoin-cms/admin 0.1.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 +7 -7
- package/src/lib/api/auth.ts +3 -6
- package/src/lib/api/files.ts +3 -2
- package/src/lib/components/AdminSidebar.svelte +23 -23
- 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/RecordTable.svelte +33 -21
- 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 +15 -0
- package/src/views/CollectionListView.svelte +63 -21
- package/src/views/CollectionNewView.svelte +3 -0
- package/src/views/DashboardSlot.svelte +46 -0
- package/src/views/DashboardView.svelte +78 -339
- package/src/views/LoginView.svelte +47 -23
- package/biome.json +0 -62
- package/dist/assets/index-C9Y5-AKj.js +0 -33
- package/dist/assets/index-uVdiUjty.css +0 -1
- package/dist/index.html +0 -20
- package/index.html +0 -19
- package/src/views/AdsAnalyticsView.svelte +0 -152
- package/tsconfig.json +0 -25
- package/vite.config.ts +0 -80
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Component } from 'svelte'
|
|
3
|
+
import { getContext } from 'svelte'
|
|
4
|
+
import { getEditor } from 'svelte-lexical'
|
|
5
|
+
import { getQuoinContext, loadComponent } from '$lib/context.svelte.js'
|
|
6
|
+
import { getNodeByKey, isBlockNode } from './lexical-helpers.js'
|
|
7
|
+
import BlockField from './BlockField.svelte'
|
|
8
|
+
import type { BlockDef } from './block-defaults.js'
|
|
9
|
+
|
|
10
|
+
let {
|
|
11
|
+
blockType = '',
|
|
12
|
+
data = {},
|
|
13
|
+
nodeKey = '',
|
|
14
|
+
}: { blockType?: string; data?: Record<string, unknown>; nodeKey?: string } = $props()
|
|
15
|
+
|
|
16
|
+
const editor = getEditor()
|
|
17
|
+
const ctx = getQuoinContext()
|
|
18
|
+
const blocks = getContext<BlockDef[]>('quoin.lexical.blocks') ?? []
|
|
19
|
+
|
|
20
|
+
const def = $derived(blocks.find((b) => b.slug === blockType))
|
|
21
|
+
const customPath = $derived(def ? ctx.config.editorBlocks?.[blockType] : undefined)
|
|
22
|
+
|
|
23
|
+
// svelte-ignore state_referenced_locally
|
|
24
|
+
let model = $state<Record<string, unknown>>(structuredClone($state.snapshot(data)) ?? {})
|
|
25
|
+
|
|
26
|
+
function commit(snapshot: Record<string, unknown>): void {
|
|
27
|
+
editor.update(() => {
|
|
28
|
+
const node = getNodeByKey(nodeKey)
|
|
29
|
+
if (isBlockNode(node)) node.setData(snapshot)
|
|
30
|
+
})
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
let primed = false
|
|
34
|
+
$effect(() => {
|
|
35
|
+
const snap = $state.snapshot(model)
|
|
36
|
+
if (!primed) {
|
|
37
|
+
primed = true
|
|
38
|
+
return
|
|
39
|
+
}
|
|
40
|
+
const id = setTimeout(() => commit(snap), 250)
|
|
41
|
+
return () => clearTimeout(id)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
let CustomComp = $state<Component<any> | null>(null)
|
|
45
|
+
let customError = $state(false)
|
|
46
|
+
$effect(() => {
|
|
47
|
+
if (customPath) {
|
|
48
|
+
customError = false
|
|
49
|
+
loadComponent(customPath, ctx.importMap)
|
|
50
|
+
.then((c) => (CustomComp = c))
|
|
51
|
+
.catch(() => {
|
|
52
|
+
CustomComp = null
|
|
53
|
+
customError = true
|
|
54
|
+
})
|
|
55
|
+
}
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
function setData(next: Record<string, unknown>): void {
|
|
59
|
+
model = next
|
|
60
|
+
}
|
|
61
|
+
</script>
|
|
62
|
+
|
|
63
|
+
<div class="my-3 rounded-md border bg-card p-3">
|
|
64
|
+
{#if !def}
|
|
65
|
+
<p class="text-sm text-destructive">Unknown block: {blockType}</p>
|
|
66
|
+
{:else}
|
|
67
|
+
<div class="mb-2 flex items-center gap-2 text-xs font-medium text-muted-foreground">
|
|
68
|
+
{def.label}
|
|
69
|
+
</div>
|
|
70
|
+
{#if customPath}
|
|
71
|
+
{#if CustomComp}
|
|
72
|
+
{@const C = CustomComp}
|
|
73
|
+
<C schema={def.fields} data={model} {setData} {BlockField} />
|
|
74
|
+
{:else if customError}
|
|
75
|
+
<p class="text-sm text-destructive">Failed to load editor component for block "{blockType}".</p>
|
|
76
|
+
{/if}
|
|
77
|
+
{:else}
|
|
78
|
+
<div class="space-y-2">
|
|
79
|
+
{#each def.fields as f (f.name)}
|
|
80
|
+
<BlockField field={f} bind:data={model} />
|
|
81
|
+
{/each}
|
|
82
|
+
</div>
|
|
83
|
+
{/if}
|
|
84
|
+
{/if}
|
|
85
|
+
</div>
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { DecoratorNode } from 'lexical';
|
|
2
|
+
import type {
|
|
3
|
+
EditorConfig,
|
|
4
|
+
LexicalEditor,
|
|
5
|
+
LexicalNode,
|
|
6
|
+
NodeKey,
|
|
7
|
+
SerializedLexicalNode,
|
|
8
|
+
Spread,
|
|
9
|
+
} from 'lexical';
|
|
10
|
+
import BlockHost from './BlockHost.svelte';
|
|
11
|
+
|
|
12
|
+
export type SerializedBlockNode = Spread<
|
|
13
|
+
{
|
|
14
|
+
type: 'block';
|
|
15
|
+
version: 1;
|
|
16
|
+
blockType: string;
|
|
17
|
+
data: Record<string, unknown>;
|
|
18
|
+
},
|
|
19
|
+
SerializedLexicalNode
|
|
20
|
+
>;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* BlockNode is the single generic decorator node for all custom blocks.
|
|
24
|
+
* __blockType holds the block's slug; the block schema + optional editor
|
|
25
|
+
* component are resolved at render time by BlockHost.
|
|
26
|
+
*/
|
|
27
|
+
export class BlockNode extends DecoratorNode<unknown> {
|
|
28
|
+
__blockType: string;
|
|
29
|
+
__data: Record<string, unknown>;
|
|
30
|
+
|
|
31
|
+
static getType(): string {
|
|
32
|
+
return 'block';
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
static clone(node: BlockNode): BlockNode {
|
|
36
|
+
return new BlockNode(node.__blockType, node.__data, node.__key);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
constructor(blockType: string, data: Record<string, unknown>, key?: NodeKey) {
|
|
40
|
+
super(key);
|
|
41
|
+
this.__blockType = blockType;
|
|
42
|
+
this.__data = data ?? {};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
createDOM(_config: EditorConfig): HTMLElement {
|
|
46
|
+
const div = document.createElement('div');
|
|
47
|
+
div.className = 'lexical-block';
|
|
48
|
+
div.setAttribute('contenteditable', 'false');
|
|
49
|
+
return div;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
updateDOM(): boolean {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
static importJSON(json: SerializedBlockNode): BlockNode {
|
|
57
|
+
return new BlockNode(json.blockType, json.data ?? {});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
exportJSON(): SerializedBlockNode {
|
|
61
|
+
return {
|
|
62
|
+
...super.exportJSON(),
|
|
63
|
+
type: 'block',
|
|
64
|
+
version: 1,
|
|
65
|
+
blockType: this.__blockType,
|
|
66
|
+
data: this.__data,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
decorate(_editor: LexicalEditor, _config: EditorConfig) {
|
|
71
|
+
return {
|
|
72
|
+
componentClass: BlockHost,
|
|
73
|
+
updateProps: (props: Record<string, unknown>) => {
|
|
74
|
+
props.blockType = this.__blockType;
|
|
75
|
+
props.data = this.__data;
|
|
76
|
+
props.nodeKey = this.__key;
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
isInline(): boolean {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
getData(): Record<string, unknown> {
|
|
86
|
+
return this.__data;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
setData(data: Record<string, unknown>): this {
|
|
90
|
+
const writable = this.getWritable();
|
|
91
|
+
writable.__data = data;
|
|
92
|
+
return writable;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function $createBlockNode(blockType: string, data: Record<string, unknown> = {}): BlockNode {
|
|
97
|
+
return new BlockNode(blockType, data);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function $isBlockNode(node: LexicalNode | null | undefined): node is BlockNode {
|
|
101
|
+
return node instanceof BlockNode;
|
|
102
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { FieldSchema } from '$lib/types/schema.js'
|
|
2
|
+
|
|
3
|
+
export interface BlockDef {
|
|
4
|
+
slug: string
|
|
5
|
+
label: string
|
|
6
|
+
icon?: string
|
|
7
|
+
fields: FieldSchema[]
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Build an empty data object for a block from its field schema. Each field
|
|
12
|
+
* gets a type-appropriate zero value (or its declared defaultValue). Used when
|
|
13
|
+
* inserting a fresh block node.
|
|
14
|
+
*/
|
|
15
|
+
export function defaultBlockData(fields: FieldSchema[]): Record<string, unknown> {
|
|
16
|
+
const out: Record<string, unknown> = {}
|
|
17
|
+
for (const f of fields) {
|
|
18
|
+
const dv = (f as { defaultValue?: unknown }).defaultValue
|
|
19
|
+
if (dv !== undefined) {
|
|
20
|
+
out[f.name] = dv
|
|
21
|
+
continue
|
|
22
|
+
}
|
|
23
|
+
switch (f.type as string) {
|
|
24
|
+
case 'checkbox':
|
|
25
|
+
out[f.name] = false
|
|
26
|
+
break
|
|
27
|
+
case 'number':
|
|
28
|
+
out[f.name] = null
|
|
29
|
+
break
|
|
30
|
+
case 'array':
|
|
31
|
+
case 'blocks':
|
|
32
|
+
case 'tags':
|
|
33
|
+
out[f.name] = []
|
|
34
|
+
break
|
|
35
|
+
default:
|
|
36
|
+
out[f.name] = ''
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return out
|
|
40
|
+
}
|
|
@@ -15,6 +15,9 @@ export { $isCustomHTMLNode as isCustomHTMLNode } from './CustomHTMLNode.js';
|
|
|
15
15
|
export { $createPullQuoteNode as createPullQuoteNode } from './PullQuoteNode.js';
|
|
16
16
|
export { $createCustomHTMLNode as createCustomHTMLNode } from './CustomHTMLNode.js';
|
|
17
17
|
|
|
18
|
+
export { $isBlockNode as isBlockNode } from './BlockNode.js';
|
|
19
|
+
export { $createBlockNode as createBlockNode } from './BlockNode.js';
|
|
20
|
+
|
|
18
21
|
export { $createYouTubeNode as createYouTubeNode } from 'svelte-lexical';
|
|
19
22
|
export { $createImageNode as createImageNode } from 'svelte-lexical';
|
|
20
23
|
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import type { Klass, LexicalNode } from 'lexical';
|
|
2
2
|
import { PullQuoteNode } from './PullQuoteNode.js';
|
|
3
3
|
import { CustomHTMLNode } from './CustomHTMLNode.js';
|
|
4
|
+
import { BlockNode } from './BlockNode.js';
|
|
4
5
|
|
|
5
6
|
export const customNodes: Array<Klass<LexicalNode>> = [
|
|
6
7
|
PullQuoteNode,
|
|
7
8
|
CustomHTMLNode,
|
|
9
|
+
BlockNode,
|
|
8
10
|
];
|
|
@@ -1,11 +1,26 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
+
import { getContext } from 'svelte';
|
|
2
3
|
import { getEditor } from 'svelte-lexical';
|
|
3
|
-
import { insertNodes, createParagraphNode, createPullQuoteNode, createCustomHTMLNode, createYouTubeNode, createImageNode } from '../lexical-helpers.js';
|
|
4
|
-
import {
|
|
4
|
+
import { insertNodes, createParagraphNode, createPullQuoteNode, createCustomHTMLNode, createYouTubeNode, createImageNode, createBlockNode } from '../lexical-helpers.js';
|
|
5
|
+
import { defaultBlockData } from '../block-defaults.js';
|
|
6
|
+
import type { BlockDef } from '../block-defaults.js';
|
|
7
|
+
import { Plus, Youtube, Quote, Code2, Image as ImageIcon, Blocks as BlocksIcon } from 'lucide-svelte';
|
|
5
8
|
import MediaLibrary from '../../MediaLibrary.svelte';
|
|
6
9
|
|
|
7
10
|
const editor = getEditor();
|
|
8
11
|
|
|
12
|
+
const customBlocks = getContext<BlockDef[]>('quoin.lexical.blocks') ?? [];
|
|
13
|
+
|
|
14
|
+
function insertBlock(b: BlockDef) {
|
|
15
|
+
editor.update(() => {
|
|
16
|
+
const node = createBlockNode(b.slug, defaultBlockData(b.fields));
|
|
17
|
+
insertNodes([node]);
|
|
18
|
+
const paragraph = createParagraphNode();
|
|
19
|
+
node.insertAfter(paragraph);
|
|
20
|
+
});
|
|
21
|
+
closeDropdown();
|
|
22
|
+
}
|
|
23
|
+
|
|
9
24
|
let isOpen = $state(false);
|
|
10
25
|
let showYoutubeDialog = $state(false);
|
|
11
26
|
let showImageDialog = $state(false);
|
|
@@ -188,6 +203,16 @@ function handleImageKeydown(e: KeyboardEvent) {
|
|
|
188
203
|
<Code2 class="h-4 w-4" />
|
|
189
204
|
Custom HTML
|
|
190
205
|
</button>
|
|
206
|
+
{#each customBlocks as b (b.slug)}
|
|
207
|
+
<button
|
|
208
|
+
type="button"
|
|
209
|
+
class="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm hover:bg-accent"
|
|
210
|
+
onclick={() => insertBlock(b)}
|
|
211
|
+
>
|
|
212
|
+
<BlocksIcon class="h-4 w-4" />
|
|
213
|
+
{b.label}
|
|
214
|
+
</button>
|
|
215
|
+
{/each}
|
|
191
216
|
</div>
|
|
192
217
|
{/if}
|
|
193
218
|
</div>
|
package/src/lib/types/schema.ts
CHANGED
|
@@ -25,6 +25,8 @@ export interface FieldSchema {
|
|
|
25
25
|
label: string
|
|
26
26
|
required: boolean
|
|
27
27
|
hidden?: boolean
|
|
28
|
+
/** Hides the field from admin edit/list views (API still returns it). */
|
|
29
|
+
adminHidden?: boolean
|
|
28
30
|
/** Sub-variant for text fields (e.g. "password"). Phase 21 D-06.
|
|
29
31
|
* Emitted by field.Text.ToJSON() when Text.Type != "". */
|
|
30
32
|
variant?: string
|
|
@@ -93,6 +95,9 @@ export interface CollectionSchema {
|
|
|
93
95
|
fields: FieldSchema[]
|
|
94
96
|
admin?: AdminConfig
|
|
95
97
|
versions?: VersionsSchema
|
|
98
|
+
/** Present when the collection is an upload collection (col.Upload != nil).
|
|
99
|
+
* Signals the admin to render a file dropzone on create/edit. */
|
|
100
|
+
upload?: { accept?: string[]; imageVariants?: boolean }
|
|
96
101
|
}
|
|
97
102
|
|
|
98
103
|
export interface GlobalSchema {
|
|
@@ -101,6 +106,16 @@ export interface GlobalSchema {
|
|
|
101
106
|
fields: FieldSchema[]
|
|
102
107
|
}
|
|
103
108
|
|
|
109
|
+
/**
|
|
110
|
+
* Auto-injected by the backend on every collection record. Not present in
|
|
111
|
+
* `collection.fields` (which only contains user-declared fields), so the
|
|
112
|
+
* admin synthesizes these entries when listing columns or rendering cells.
|
|
113
|
+
*/
|
|
114
|
+
export const SYSTEM_FIELDS: FieldSchema[] = [
|
|
115
|
+
{ name: 'createdAt', type: 'date', label: 'Created', required: false },
|
|
116
|
+
{ name: 'updatedAt', type: 'date', label: 'Updated', required: false },
|
|
117
|
+
]
|
|
118
|
+
|
|
104
119
|
export interface SchemaResponse {
|
|
105
120
|
collections: CollectionSchema[]
|
|
106
121
|
globals: GlobalSchema[]
|
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { page } from '$lib/router/index.svelte.js'
|
|
3
3
|
import { schema, getCollectionByKey } from '$lib/stores/schema.svelte.js'
|
|
4
|
+
import { SYSTEM_FIELDS } from '$lib/types/schema.js'
|
|
4
5
|
import { listRecords, deleteRecord } from '$lib/api/records.js'
|
|
5
6
|
import RecordTable from '$lib/components/RecordTable.svelte'
|
|
6
7
|
import RecordGrid from '$lib/components/RecordGrid.svelte'
|
|
7
8
|
import Pagination from '$lib/components/Pagination.svelte'
|
|
8
9
|
import DeleteDialog from '$lib/components/DeleteDialog.svelte'
|
|
9
10
|
import { toast } from 'svelte-sonner'
|
|
10
|
-
import { Search,
|
|
11
|
+
import { Search, Columns3, ChevronUp, ChevronDown, LayoutGrid, List } from 'lucide-svelte'
|
|
12
|
+
import Slot from '$lib/Slot.svelte'
|
|
11
13
|
|
|
12
14
|
type ViewMode = 'list' | 'grid'
|
|
13
15
|
|
|
@@ -32,6 +34,27 @@ let searchTimeout: ReturnType<typeof setTimeout>
|
|
|
32
34
|
let visibleColumns = $state<string[] | null>(null)
|
|
33
35
|
let loadedKey = $state<string | null>(null)
|
|
34
36
|
let columnPickerOpen = $state(false)
|
|
37
|
+
let columnPickerEl = $state<HTMLDivElement | null>(null)
|
|
38
|
+
|
|
39
|
+
function handleColumnPickerOutsideClick(e: MouseEvent) {
|
|
40
|
+
if (!columnPickerOpen) return
|
|
41
|
+
if (columnPickerEl && !columnPickerEl.contains(e.target as Node)) {
|
|
42
|
+
columnPickerOpen = false
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
$effect(() => {
|
|
47
|
+
if (!columnPickerOpen) return
|
|
48
|
+
// Defer attachment by one tick so the click that opened the popup cannot
|
|
49
|
+
// be caught by this listener (the same mouseup/click chain).
|
|
50
|
+
const id = setTimeout(() => {
|
|
51
|
+
document.addEventListener('mousedown', handleColumnPickerOutsideClick)
|
|
52
|
+
}, 0)
|
|
53
|
+
return () => {
|
|
54
|
+
clearTimeout(id)
|
|
55
|
+
document.removeEventListener('mousedown', handleColumnPickerOutsideClick)
|
|
56
|
+
}
|
|
57
|
+
})
|
|
35
58
|
let viewMode = $state<ViewMode>('list')
|
|
36
59
|
|
|
37
60
|
function hasImageField(flds: any[]): boolean {
|
|
@@ -68,22 +91,42 @@ function flattenFields(flds: any[]): any[] {
|
|
|
68
91
|
}
|
|
69
92
|
return out
|
|
70
93
|
}
|
|
71
|
-
let allFields = $derived
|
|
94
|
+
let allFields = $derived.by(() => {
|
|
95
|
+
if (!collection) return []
|
|
96
|
+
const userFields = flattenFields(collection.fields).filter((f: any) => !f.hidden)
|
|
97
|
+
const userNames = new Set(userFields.map((f: any) => f.name))
|
|
98
|
+
const systemExtras = SYSTEM_FIELDS.filter((f) => !userNames.has(f.name))
|
|
99
|
+
return [...userFields, ...systemExtras]
|
|
100
|
+
})
|
|
72
101
|
|
|
73
102
|
$effect(() => {
|
|
74
103
|
if (!collection) return
|
|
75
104
|
if (loadedKey === collectionKey) return
|
|
105
|
+
|
|
106
|
+
// Reset per-collection state — pagination, search, and sort from the
|
|
107
|
+
// previous collection are meaningless here and cause blank pages.
|
|
108
|
+
currentPage = 1
|
|
109
|
+
searchQuery = ''
|
|
110
|
+
sort = ''
|
|
111
|
+
|
|
112
|
+
// Compute next visibleColumns into a local, then assign unconditionally.
|
|
113
|
+
// The previous version mutated visibleColumns inside the storage branch
|
|
114
|
+
// and only fell back to defaults when the result was empty, so collections
|
|
115
|
+
// without a localStorage entry inherited the prior collection's columns.
|
|
116
|
+
let next: string[] | null = null
|
|
76
117
|
const stored = typeof localStorage !== 'undefined'
|
|
77
118
|
? localStorage.getItem(`cols:${collectionKey}`)
|
|
78
119
|
: null
|
|
79
120
|
if (stored) {
|
|
80
|
-
try {
|
|
121
|
+
try { next = JSON.parse(stored) } catch { next = null }
|
|
81
122
|
}
|
|
82
|
-
if (!
|
|
83
|
-
|
|
123
|
+
if (!next || !next.length) {
|
|
124
|
+
next = collection.admin?.defaultColumns?.length
|
|
84
125
|
? [...collection.admin.defaultColumns]
|
|
85
126
|
: flattenFields(collection.fields).filter((f: any) => !f.hidden).slice(0, 4).map((f: any) => f.name)
|
|
86
127
|
}
|
|
128
|
+
// Dedupe — guards against corrupt localStorage entries.
|
|
129
|
+
visibleColumns = Array.from(new Set(next))
|
|
87
130
|
loadedKey = collectionKey
|
|
88
131
|
})
|
|
89
132
|
|
|
@@ -110,10 +153,18 @@ function moveColumn(name: string, dir: -1 | 1) {
|
|
|
110
153
|
}
|
|
111
154
|
|
|
112
155
|
let orderedFields = $derived.by(() => {
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
const
|
|
156
|
+
// Dedupe by name. Stale localStorage or collisions can put the same
|
|
157
|
+
// column name in visibleColumns twice; keyed each blocks crash on dupes.
|
|
158
|
+
const seen = new Set<string>()
|
|
159
|
+
const selected: any[] = []
|
|
160
|
+
for (const n of visibleColumns ?? []) {
|
|
161
|
+
if (seen.has(n)) continue
|
|
162
|
+
const f = allFields.find((f: any) => f.name === n)
|
|
163
|
+
if (!f) continue
|
|
164
|
+
seen.add(n)
|
|
165
|
+
selected.push(f)
|
|
166
|
+
}
|
|
167
|
+
const rest = allFields.filter((f: any) => !seen.has(f.name))
|
|
117
168
|
return [...selected, ...rest]
|
|
118
169
|
})
|
|
119
170
|
|
|
@@ -228,7 +279,7 @@ async function confirmDelete() {
|
|
|
228
279
|
</button>
|
|
229
280
|
</div>
|
|
230
281
|
{/if}
|
|
231
|
-
<div class="relative">
|
|
282
|
+
<div class="relative" bind:this={columnPickerEl}>
|
|
232
283
|
<button
|
|
233
284
|
type="button"
|
|
234
285
|
onclick={() => (columnPickerOpen = !columnPickerOpen)}
|
|
@@ -240,7 +291,6 @@ async function confirmDelete() {
|
|
|
240
291
|
{#if columnPickerOpen}
|
|
241
292
|
<div
|
|
242
293
|
class="absolute right-0 z-20 mt-2 w-56 rounded-lg border border-border/80 bg-card p-2 shadow-lg"
|
|
243
|
-
onmouseleave={() => (columnPickerOpen = false)}
|
|
244
294
|
role="menu"
|
|
245
295
|
>
|
|
246
296
|
{#each orderedFields as f, idx (f.name)}
|
|
@@ -280,16 +330,8 @@ async function confirmDelete() {
|
|
|
280
330
|
</div>
|
|
281
331
|
{/if}
|
|
282
332
|
</div>
|
|
283
|
-
{
|
|
284
|
-
|
|
285
|
-
href="/api/subscribers/export.csv"
|
|
286
|
-
download
|
|
287
|
-
class="inline-flex items-center gap-2 rounded-lg bg-brand-red px-3 py-2 text-sm font-medium text-white shadow-sm transition-colors hover:opacity-90"
|
|
288
|
-
>
|
|
289
|
-
<Download class="h-4 w-4" />
|
|
290
|
-
Export CSV
|
|
291
|
-
</a>
|
|
292
|
-
{/if}
|
|
333
|
+
<Slot name="collection.{collectionKey}.list-actions" />
|
|
334
|
+
<Slot name="collection.*.list-actions" />
|
|
293
335
|
</div>
|
|
294
336
|
|
|
295
337
|
<!-- Top pagination -->
|
|
@@ -5,6 +5,7 @@ import { resolve } from '$lib/router/index.svelte.js'
|
|
|
5
5
|
import { schema, getCollectionByKey } from '$lib/stores/schema.svelte.js'
|
|
6
6
|
import { createRecord } from '$lib/api/records.js'
|
|
7
7
|
import DocumentEditLayout from '$lib/components/DocumentEditLayout.svelte'
|
|
8
|
+
import UploadCreateView from '$lib/components/UploadCreateView.svelte'
|
|
8
9
|
import { toast } from 'svelte-sonner'
|
|
9
10
|
import { onMount } from 'svelte'
|
|
10
11
|
|
|
@@ -57,6 +58,8 @@ async function handleSave(data: Record<string, any>) {
|
|
|
57
58
|
<p class="text-sm text-muted-foreground">Creating draft...</p>
|
|
58
59
|
</div>
|
|
59
60
|
</div>
|
|
61
|
+
{:else if collection.upload}
|
|
62
|
+
<UploadCreateView {collection} />
|
|
60
63
|
{:else}
|
|
61
64
|
<DocumentEditLayout
|
|
62
65
|
{collection}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Wrapper that renders either the consumer-supplied dashboard
|
|
4
|
+
* (via `views['dashboard.main']` in admin.config.ts) or the built-in
|
|
5
|
+
* generic DashboardView fallback.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { Component } from 'svelte'
|
|
9
|
+
import { getQuoinContext, loadComponent, resolveView } from '$lib/context.svelte.js'
|
|
10
|
+
import DashboardView from './DashboardView.svelte'
|
|
11
|
+
|
|
12
|
+
const ctx = getQuoinContext()
|
|
13
|
+
const overridePath = $derived(resolveView('dashboard.main', ctx.config))
|
|
14
|
+
|
|
15
|
+
let component = $state<Component<any> | null>(null)
|
|
16
|
+
let loadError = $state<string | null>(null)
|
|
17
|
+
|
|
18
|
+
$effect(() => {
|
|
19
|
+
component = null
|
|
20
|
+
loadError = null
|
|
21
|
+
if (!overridePath) return
|
|
22
|
+
|
|
23
|
+
loadComponent(overridePath, ctx.importMap)
|
|
24
|
+
.then((c) => {
|
|
25
|
+
component = c
|
|
26
|
+
})
|
|
27
|
+
.catch((err) => {
|
|
28
|
+
loadError = err instanceof Error ? err.message : String(err)
|
|
29
|
+
console.error('[quoin:DashboardSlot] Failed to load override:', err)
|
|
30
|
+
})
|
|
31
|
+
})
|
|
32
|
+
</script>
|
|
33
|
+
|
|
34
|
+
{#if !overridePath}
|
|
35
|
+
<DashboardView />
|
|
36
|
+
{:else if loadError}
|
|
37
|
+
<div class="p-6 text-red-600">
|
|
38
|
+
Failed to load dashboard override: {loadError}
|
|
39
|
+
</div>
|
|
40
|
+
<DashboardView />
|
|
41
|
+
{:else if component}
|
|
42
|
+
{@const Override = component}
|
|
43
|
+
<Override />
|
|
44
|
+
{:else}
|
|
45
|
+
<div class="p-6 text-muted-foreground">Loading dashboard…</div>
|
|
46
|
+
{/if}
|