@svadmin/ui 0.64.0 → 0.65.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/dist/components/ActivityFeed.svelte +135 -0
- package/dist/components/ActivityFeed.svelte.d.ts +21 -0
- package/dist/components/ApprovalActionCard.svelte +248 -0
- package/dist/components/ApprovalActionCard.svelte.d.ts +22 -0
- package/dist/components/CanvasAnnotation.svelte +389 -0
- package/dist/components/CanvasAnnotation.svelte.d.ts +17 -0
- package/dist/components/ColumnHeaderFilter.svelte +178 -0
- package/dist/components/ColumnHeaderFilter.svelte.d.ts +12 -0
- package/dist/components/ColumnSettings.svelte +198 -0
- package/dist/components/ColumnSettings.svelte.d.ts +19 -0
- package/dist/components/DecisionTable.svelte +240 -0
- package/dist/components/DecisionTable.svelte.d.ts +22 -0
- package/dist/components/DraggableRowTable.svelte +117 -0
- package/dist/components/DraggableRowTable.svelte.d.ts +15 -0
- package/dist/components/DrawerForm.svelte +110 -26
- package/dist/components/DrawerForm.svelte.d.ts +20 -8
- package/dist/components/EditableTable.svelte +195 -0
- package/dist/components/EditableTable.svelte.d.ts +25 -0
- package/dist/components/GanttChart.svelte +144 -0
- package/dist/components/GanttChart.svelte.d.ts +20 -0
- package/dist/components/ImageCropper.svelte +122 -0
- package/dist/components/ImageCropper.svelte.d.ts +12 -0
- package/dist/components/ImportWizard.svelte +336 -0
- package/dist/components/ImportWizard.svelte.d.ts +12 -0
- package/dist/components/JsonSchemaForm.svelte +149 -0
- package/dist/components/JsonSchemaForm.svelte.d.ts +10 -0
- package/dist/components/KanbanBoard.svelte +212 -0
- package/dist/components/KanbanBoard.svelte.d.ts +30 -0
- package/dist/components/MasterDetailView.svelte +134 -0
- package/dist/components/MasterDetailView.svelte.d.ts +16 -0
- package/dist/components/MediaLibraryModal.svelte +195 -0
- package/dist/components/MediaLibraryModal.svelte.d.ts +22 -0
- package/dist/components/MentionsInput.svelte +174 -0
- package/dist/components/MentionsInput.svelte.d.ts +21 -0
- package/dist/components/ModalForm.svelte +113 -28
- package/dist/components/ModalForm.svelte.d.ts +20 -8
- package/dist/components/MultiTabKeepAlive.svelte +143 -0
- package/dist/components/MultiTabKeepAlive.svelte.d.ts +21 -0
- package/dist/components/OfflineSyncBanner.svelte +172 -0
- package/dist/components/OfflineSyncBanner.svelte.d.ts +19 -0
- package/dist/components/PdfDocumentViewer.svelte +255 -0
- package/dist/components/PdfDocumentViewer.svelte.d.ts +22 -0
- package/dist/components/PivotTable.svelte +140 -0
- package/dist/components/PivotTable.svelte.d.ts +16 -0
- package/dist/components/PresenceAvatarGroup.svelte +89 -0
- package/dist/components/PresenceAvatarGroup.svelte.d.ts +15 -0
- package/dist/components/PrintableBill.svelte +149 -0
- package/dist/components/PrintableBill.svelte.d.ts +24 -0
- package/dist/components/SensitiveDataMask.svelte +125 -0
- package/dist/components/SensitiveDataMask.svelte.d.ts +13 -0
- package/dist/components/SignaturePad.svelte +264 -0
- package/dist/components/SignaturePad.svelte.d.ts +17 -0
- package/dist/components/SplitPaneLayout.svelte +113 -0
- package/dist/components/SplitPaneLayout.svelte.d.ts +13 -0
- package/dist/components/SpreadsheetView.svelte +363 -0
- package/dist/components/SpreadsheetView.svelte.d.ts +18 -0
- package/dist/components/StepForm.svelte +180 -0
- package/dist/components/StepForm.svelte.d.ts +23 -0
- package/dist/components/TableSummary.svelte +110 -0
- package/dist/components/TableSummary.svelte.d.ts +20 -0
- package/dist/components/TreeTable.svelte +237 -0
- package/dist/components/TreeTable.svelte.d.ts +28 -0
- package/dist/components/UnsavedChangesPrompt.svelte +44 -0
- package/dist/components/UnsavedChangesPrompt.svelte.d.ts +7 -0
- package/dist/components/VersionDiffViewer.svelte +196 -0
- package/dist/components/VersionDiffViewer.svelte.d.ts +12 -0
- package/dist/components/VirtualTable.svelte +86 -54
- package/dist/components/VirtualTable.svelte.d.ts +14 -21
- package/dist/components/Watermark.svelte +166 -0
- package/dist/components/Watermark.svelte.d.ts +26 -0
- package/dist/index.d.ts +58 -3
- package/dist/index.js +35 -3
- package/package.json +2 -2
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { Button } from './ui/button/index.js';
|
|
3
|
+
import { Badge } from './ui/badge/index.js';
|
|
4
|
+
import { Plus, GripVertical } from '@lucide/svelte';
|
|
5
|
+
import { cn } from '../utils.js';
|
|
6
|
+
|
|
7
|
+
export interface KanbanCard {
|
|
8
|
+
id: string;
|
|
9
|
+
title: string;
|
|
10
|
+
description?: string;
|
|
11
|
+
columnId: string;
|
|
12
|
+
priority?: 'low' | 'medium' | 'high' | 'urgent';
|
|
13
|
+
tags?: string[];
|
|
14
|
+
assignee?: { name: string; avatar?: string };
|
|
15
|
+
dueDate?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface KanbanColumn {
|
|
19
|
+
id: string;
|
|
20
|
+
title: string;
|
|
21
|
+
colorBadge?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface Props {
|
|
25
|
+
columns: KanbanColumn[];
|
|
26
|
+
cards?: KanbanCard[];
|
|
27
|
+
allowAddCard?: boolean;
|
|
28
|
+
oncardmove?: (cardId: string, targetColumnId: string) => void;
|
|
29
|
+
oncardclick?: (card: KanbanCard) => void;
|
|
30
|
+
oncardadd?: (columnId: string, title: string) => void;
|
|
31
|
+
class?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
let {
|
|
35
|
+
columns = [],
|
|
36
|
+
cards = $bindable([]),
|
|
37
|
+
allowAddCard = true,
|
|
38
|
+
oncardmove,
|
|
39
|
+
oncardclick,
|
|
40
|
+
oncardadd,
|
|
41
|
+
class: className = '',
|
|
42
|
+
}: Props = $props();
|
|
43
|
+
|
|
44
|
+
let draggedCardId = $state<string | null>(null);
|
|
45
|
+
let addingColumnId = $state<string | null>(null);
|
|
46
|
+
let newCardTitle = $state('');
|
|
47
|
+
|
|
48
|
+
function handleDragStart(e: DragEvent, cardId: string) {
|
|
49
|
+
draggedCardId = cardId;
|
|
50
|
+
if (e.dataTransfer) {
|
|
51
|
+
e.dataTransfer.effectAllowed = 'move';
|
|
52
|
+
e.dataTransfer.setData('text/plain', cardId);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function handleDragOver(e: DragEvent) {
|
|
57
|
+
e.preventDefault();
|
|
58
|
+
if (e.dataTransfer) {
|
|
59
|
+
e.dataTransfer.dropEffect = 'move';
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function handleDrop(targetColId: string) {
|
|
64
|
+
if (!draggedCardId) return;
|
|
65
|
+
const card = cards.find((c) => c.id === draggedCardId);
|
|
66
|
+
if (card && card.columnId !== targetColId) {
|
|
67
|
+
cards = cards.map((c) =>
|
|
68
|
+
c.id === draggedCardId ? { ...c, columnId: targetColId } : c
|
|
69
|
+
);
|
|
70
|
+
oncardmove?.(draggedCardId, targetColId);
|
|
71
|
+
}
|
|
72
|
+
draggedCardId = null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function submitAddCard(columnId: string) {
|
|
76
|
+
if (!newCardTitle.trim()) {
|
|
77
|
+
addingColumnId = null;
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
const newCard: KanbanCard = {
|
|
81
|
+
id: `k_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,
|
|
82
|
+
title: newCardTitle.trim(),
|
|
83
|
+
columnId,
|
|
84
|
+
};
|
|
85
|
+
cards = [...cards, newCard];
|
|
86
|
+
oncardadd?.(columnId, newCardTitle.trim());
|
|
87
|
+
newCardTitle = '';
|
|
88
|
+
addingColumnId = null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function getPriorityClass(priority?: string) {
|
|
92
|
+
switch (priority) {
|
|
93
|
+
case 'urgent':
|
|
94
|
+
return 'bg-destructive/15 text-destructive border-destructive/30';
|
|
95
|
+
case 'high':
|
|
96
|
+
return 'bg-warning/15 text-warning border-warning/30';
|
|
97
|
+
case 'medium':
|
|
98
|
+
return 'bg-primary/10 text-primary border-primary/20';
|
|
99
|
+
default:
|
|
100
|
+
return 'bg-muted text-muted-foreground border-border/40';
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
</script>
|
|
104
|
+
|
|
105
|
+
<div class={cn('flex gap-4 overflow-x-auto pb-4 text-xs select-none', className)}>
|
|
106
|
+
{#each columns as col (col.id)}
|
|
107
|
+
{@const colCards = cards.filter((c) => c.columnId === col.id)}
|
|
108
|
+
<div
|
|
109
|
+
role="region"
|
|
110
|
+
aria-label={col.title}
|
|
111
|
+
class="flex flex-col w-72 shrink-0 rounded-xl border border-border/70 bg-card/60 shadow-xs max-h-[80vh]"
|
|
112
|
+
ondragover={handleDragOver}
|
|
113
|
+
ondrop={() => handleDrop(col.id)}
|
|
114
|
+
>
|
|
115
|
+
<!-- Column Header -->
|
|
116
|
+
<div class="flex items-center justify-between p-3 border-b border-border/50">
|
|
117
|
+
<div class="flex items-center gap-2 font-semibold text-foreground">
|
|
118
|
+
<span>{col.title}</span>
|
|
119
|
+
<span class="flex h-5 w-5 items-center justify-center rounded-full bg-muted text-[10px] font-mono text-muted-foreground">
|
|
120
|
+
{colCards.length}
|
|
121
|
+
</span>
|
|
122
|
+
</div>
|
|
123
|
+
|
|
124
|
+
{#if allowAddCard}
|
|
125
|
+
<Button
|
|
126
|
+
variant="ghost"
|
|
127
|
+
size="sm"
|
|
128
|
+
class="h-6 w-6 p-0 text-muted-foreground hover:text-foreground"
|
|
129
|
+
onclick={() => {
|
|
130
|
+
addingColumnId = col.id;
|
|
131
|
+
newCardTitle = '';
|
|
132
|
+
}}
|
|
133
|
+
>
|
|
134
|
+
<Plus class="h-3.5 w-3.5" />
|
|
135
|
+
</Button>
|
|
136
|
+
{/if}
|
|
137
|
+
</div>
|
|
138
|
+
|
|
139
|
+
<!-- Column Card List -->
|
|
140
|
+
<div class="flex-1 overflow-y-auto p-2 space-y-2 min-h-24">
|
|
141
|
+
{#each colCards as card (card.id)}
|
|
142
|
+
<div
|
|
143
|
+
role="button"
|
|
144
|
+
tabindex="0"
|
|
145
|
+
draggable="true"
|
|
146
|
+
class={cn(
|
|
147
|
+
'group relative flex flex-col gap-2 rounded-lg border border-border/60 bg-card p-3 shadow-xs transition-all cursor-grab active:cursor-grabbing hover:border-border hover:shadow-sm text-left',
|
|
148
|
+
draggedCardId === card.id ? 'opacity-40 ring-2 ring-primary/40' : ''
|
|
149
|
+
)}
|
|
150
|
+
ondragstart={(e) => handleDragStart(e, card.id)}
|
|
151
|
+
onclick={() => oncardclick?.(card)}
|
|
152
|
+
onkeydown={(e) => { if (e.key === 'Enter') oncardclick?.(card); }}
|
|
153
|
+
>
|
|
154
|
+
<div class="flex items-start justify-between gap-2">
|
|
155
|
+
<div class="font-medium text-foreground leading-snug">{card.title}</div>
|
|
156
|
+
<GripVertical class="h-3.5 w-3.5 text-muted-foreground/40 opacity-0 group-hover:opacity-100 transition-opacity shrink-0" />
|
|
157
|
+
</div>
|
|
158
|
+
|
|
159
|
+
{#if card.description}
|
|
160
|
+
<div class="text-[11px] text-muted-foreground line-clamp-2">{card.description}</div>
|
|
161
|
+
{/if}
|
|
162
|
+
|
|
163
|
+
<div class="flex flex-wrap items-center justify-between gap-1.5 pt-1 border-t border-border/40 text-[10px]">
|
|
164
|
+
<div class="flex flex-wrap items-center gap-1">
|
|
165
|
+
{#if card.priority}
|
|
166
|
+
<Badge variant="outline" class={cn('text-[9px] px-1 py-0 uppercase font-mono font-medium', getPriorityClass(card.priority))}>
|
|
167
|
+
{card.priority}
|
|
168
|
+
</Badge>
|
|
169
|
+
{/if}
|
|
170
|
+
{#each card.tags ?? [] as tag (tag)}
|
|
171
|
+
<span class="rounded bg-muted/60 px-1 py-0.5 text-muted-foreground">#{tag}</span>
|
|
172
|
+
{/each}
|
|
173
|
+
</div>
|
|
174
|
+
|
|
175
|
+
{#if card.assignee}
|
|
176
|
+
<div class="flex items-center gap-1 text-muted-foreground">
|
|
177
|
+
<div class="flex h-4 w-4 items-center justify-center rounded-full bg-primary/20 text-[9px] font-semibold text-primary">
|
|
178
|
+
{card.assignee.name.slice(0, 1).toUpperCase()}
|
|
179
|
+
</div>
|
|
180
|
+
<span class="truncate max-w-16">{card.assignee.name}</span>
|
|
181
|
+
</div>
|
|
182
|
+
{/if}
|
|
183
|
+
</div>
|
|
184
|
+
</div>
|
|
185
|
+
{/each}
|
|
186
|
+
|
|
187
|
+
{#if addingColumnId === col.id}
|
|
188
|
+
<div class="rounded-lg border border-primary/40 bg-card p-2.5 space-y-2 shadow-xs">
|
|
189
|
+
<textarea
|
|
190
|
+
bind:value={newCardTitle}
|
|
191
|
+
placeholder="Card title..."
|
|
192
|
+
rows={2}
|
|
193
|
+
class="w-full rounded border border-input bg-background p-1.5 text-xs text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring resize-none"
|
|
194
|
+
onkeydown={(e) => {
|
|
195
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
196
|
+
e.preventDefault();
|
|
197
|
+
submitAddCard(col.id);
|
|
198
|
+
} else if (e.key === 'Escape') {
|
|
199
|
+
addingColumnId = null;
|
|
200
|
+
}
|
|
201
|
+
}}
|
|
202
|
+
></textarea>
|
|
203
|
+
<div class="flex items-center justify-end gap-1.5">
|
|
204
|
+
<Button variant="ghost" size="sm" class="h-6 text-[11px] px-2" onclick={() => { addingColumnId = null; }}>Cancel</Button>
|
|
205
|
+
<Button size="sm" class="h-6 text-[11px] px-2" onclick={() => submitAddCard(col.id)}>Add</Button>
|
|
206
|
+
</div>
|
|
207
|
+
</div>
|
|
208
|
+
{/if}
|
|
209
|
+
</div>
|
|
210
|
+
</div>
|
|
211
|
+
{/each}
|
|
212
|
+
</div>
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export interface KanbanCard {
|
|
2
|
+
id: string;
|
|
3
|
+
title: string;
|
|
4
|
+
description?: string;
|
|
5
|
+
columnId: string;
|
|
6
|
+
priority?: 'low' | 'medium' | 'high' | 'urgent';
|
|
7
|
+
tags?: string[];
|
|
8
|
+
assignee?: {
|
|
9
|
+
name: string;
|
|
10
|
+
avatar?: string;
|
|
11
|
+
};
|
|
12
|
+
dueDate?: string;
|
|
13
|
+
}
|
|
14
|
+
export interface KanbanColumn {
|
|
15
|
+
id: string;
|
|
16
|
+
title: string;
|
|
17
|
+
colorBadge?: string;
|
|
18
|
+
}
|
|
19
|
+
interface Props {
|
|
20
|
+
columns: KanbanColumn[];
|
|
21
|
+
cards?: KanbanCard[];
|
|
22
|
+
allowAddCard?: boolean;
|
|
23
|
+
oncardmove?: (cardId: string, targetColumnId: string) => void;
|
|
24
|
+
oncardclick?: (card: KanbanCard) => void;
|
|
25
|
+
oncardadd?: (columnId: string, title: string) => void;
|
|
26
|
+
class?: string;
|
|
27
|
+
}
|
|
28
|
+
declare const KanbanBoard: import("svelte").Component<Props, {}, "cards">;
|
|
29
|
+
type KanbanBoard = ReturnType<typeof KanbanBoard>;
|
|
30
|
+
export default KanbanBoard;
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Snippet } from 'svelte';
|
|
3
|
+
import { Search } from '@lucide/svelte';
|
|
4
|
+
import { cn } from '../utils.js';
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
items: Record<string, unknown>[];
|
|
8
|
+
selectedId?: string | number;
|
|
9
|
+
idKey?: string;
|
|
10
|
+
titleKey?: string;
|
|
11
|
+
subtitleKey?: string;
|
|
12
|
+
listWidth?: string;
|
|
13
|
+
emptyText?: string;
|
|
14
|
+
itemSnippet?: Snippet<[Record<string, unknown>, boolean]>;
|
|
15
|
+
detailSnippet?: Snippet<[Record<string, unknown>]>;
|
|
16
|
+
class?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
let {
|
|
20
|
+
items = [],
|
|
21
|
+
selectedId = $bindable(items[0]?.id as (string | number | undefined)),
|
|
22
|
+
idKey = 'id',
|
|
23
|
+
titleKey = 'title',
|
|
24
|
+
subtitleKey = 'subtitle',
|
|
25
|
+
listWidth = 'w-72 sm:w-80',
|
|
26
|
+
emptyText = 'No items found',
|
|
27
|
+
itemSnippet,
|
|
28
|
+
detailSnippet,
|
|
29
|
+
class: className = '',
|
|
30
|
+
}: Props = $props();
|
|
31
|
+
|
|
32
|
+
let searchQuery = $state('');
|
|
33
|
+
|
|
34
|
+
const filteredItems = $derived(
|
|
35
|
+
items.filter((item) => {
|
|
36
|
+
if (!searchQuery.trim()) return true;
|
|
37
|
+
const title = String(item[titleKey] ?? '').toLowerCase();
|
|
38
|
+
const subtitle = String(item[subtitleKey] ?? '').toLowerCase();
|
|
39
|
+
const q = searchQuery.toLowerCase().trim();
|
|
40
|
+
return title.includes(q) || subtitle.includes(q);
|
|
41
|
+
})
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
const activeItem = $derived(
|
|
45
|
+
items.find((item) => String(item[idKey]) === String(selectedId)) ?? filteredItems[0]
|
|
46
|
+
);
|
|
47
|
+
</script>
|
|
48
|
+
|
|
49
|
+
<div class={cn('flex h-full min-h-[420px] rounded-xl border border-border bg-card shadow-xs overflow-hidden text-xs', className)}>
|
|
50
|
+
<!-- Master List Panel -->
|
|
51
|
+
<div class={cn('flex flex-col border-r border-border/60 bg-muted/15 shrink-0', listWidth)}>
|
|
52
|
+
<!-- Search Bar -->
|
|
53
|
+
<div class="p-3 border-b border-border/60">
|
|
54
|
+
<div class="relative">
|
|
55
|
+
<Search class="absolute left-2.5 top-2.5 h-3.5 w-3.5 text-muted-foreground" />
|
|
56
|
+
<input
|
|
57
|
+
type="text"
|
|
58
|
+
bind:value={searchQuery}
|
|
59
|
+
placeholder="Search items..."
|
|
60
|
+
class="h-8 w-full rounded-md border border-input bg-background pl-8 pr-2 text-xs focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
|
61
|
+
/>
|
|
62
|
+
</div>
|
|
63
|
+
</div>
|
|
64
|
+
|
|
65
|
+
<!-- Scrollable Items List -->
|
|
66
|
+
<div class="flex-1 overflow-y-auto divide-y divide-border/40">
|
|
67
|
+
{#each filteredItems as item (item[idKey])}
|
|
68
|
+
{@const isSelected = String(item[idKey]) === String(activeItem?.[idKey])}
|
|
69
|
+
<button
|
|
70
|
+
type="button"
|
|
71
|
+
class={cn(
|
|
72
|
+
'w-full text-left p-3 transition-colors cursor-pointer border-0 bg-transparent',
|
|
73
|
+
isSelected
|
|
74
|
+
? 'bg-primary/10 border-l-3 border-primary text-foreground font-medium'
|
|
75
|
+
: 'hover:bg-muted/40 text-muted-foreground'
|
|
76
|
+
)}
|
|
77
|
+
onclick={() => { selectedId = item[idKey] as (string | number); }}
|
|
78
|
+
>
|
|
79
|
+
{#if itemSnippet}
|
|
80
|
+
{@render itemSnippet(item, isSelected)}
|
|
81
|
+
{:else}
|
|
82
|
+
<div class={cn('font-semibold truncate', isSelected ? 'text-primary' : 'text-foreground')}>
|
|
83
|
+
{item[titleKey] ?? item.name ?? `Item #${item[idKey]}`}
|
|
84
|
+
</div>
|
|
85
|
+
{#if item[subtitleKey]}
|
|
86
|
+
<div class="text-[11px] text-muted-foreground truncate mt-0.5">
|
|
87
|
+
{item[subtitleKey]}
|
|
88
|
+
</div>
|
|
89
|
+
{/if}
|
|
90
|
+
{/if}
|
|
91
|
+
</button>
|
|
92
|
+
{/each}
|
|
93
|
+
|
|
94
|
+
{#if filteredItems.length === 0}
|
|
95
|
+
<div class="p-6 text-center text-muted-foreground">
|
|
96
|
+
{emptyText}
|
|
97
|
+
</div>
|
|
98
|
+
{/if}
|
|
99
|
+
</div>
|
|
100
|
+
</div>
|
|
101
|
+
|
|
102
|
+
<!-- Detail Content Panel -->
|
|
103
|
+
<div class="flex-1 flex flex-col overflow-y-auto p-6 bg-card">
|
|
104
|
+
{#if activeItem}
|
|
105
|
+
{#if detailSnippet}
|
|
106
|
+
{@render detailSnippet(activeItem)}
|
|
107
|
+
{:else}
|
|
108
|
+
<div class="space-y-4">
|
|
109
|
+
<div class="pb-3 border-b border-border/60">
|
|
110
|
+
<h3 class="text-base font-semibold text-foreground">
|
|
111
|
+
{activeItem[titleKey] ?? activeItem.name ?? `Item #${activeItem[idKey]}`}
|
|
112
|
+
</h3>
|
|
113
|
+
{#if activeItem[subtitleKey]}
|
|
114
|
+
<p class="text-xs text-muted-foreground mt-0.5">{activeItem[subtitleKey]}</p>
|
|
115
|
+
{/if}
|
|
116
|
+
</div>
|
|
117
|
+
|
|
118
|
+
<div class="grid grid-cols-2 gap-3 text-xs">
|
|
119
|
+
{#each Object.entries(activeItem) as [k, v] (k)}
|
|
120
|
+
<div class="p-2.5 rounded-lg bg-muted/20 border border-border/40">
|
|
121
|
+
<span class="text-muted-foreground font-medium block text-[11px] mb-0.5">{k}</span>
|
|
122
|
+
<span class="font-mono text-foreground break-words">{typeof v === 'object' ? JSON.stringify(v) : String(v ?? '—')}</span>
|
|
123
|
+
</div>
|
|
124
|
+
{/each}
|
|
125
|
+
</div>
|
|
126
|
+
</div>
|
|
127
|
+
{/if}
|
|
128
|
+
{:else}
|
|
129
|
+
<div class="flex-1 flex items-center justify-center text-muted-foreground">
|
|
130
|
+
Select an item from the list to view details
|
|
131
|
+
</div>
|
|
132
|
+
{/if}
|
|
133
|
+
</div>
|
|
134
|
+
</div>
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
interface Props {
|
|
3
|
+
items: Record<string, unknown>[];
|
|
4
|
+
selectedId?: string | number;
|
|
5
|
+
idKey?: string;
|
|
6
|
+
titleKey?: string;
|
|
7
|
+
subtitleKey?: string;
|
|
8
|
+
listWidth?: string;
|
|
9
|
+
emptyText?: string;
|
|
10
|
+
itemSnippet?: Snippet<[Record<string, unknown>, boolean]>;
|
|
11
|
+
detailSnippet?: Snippet<[Record<string, unknown>]>;
|
|
12
|
+
class?: string;
|
|
13
|
+
}
|
|
14
|
+
declare const MasterDetailView: import("svelte").Component<Props, {}, "selectedId">;
|
|
15
|
+
type MasterDetailView = ReturnType<typeof MasterDetailView>;
|
|
16
|
+
export default MasterDetailView;
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import * as Dialog from './ui/dialog/index.js';
|
|
3
|
+
import { Button } from './ui/button/index.js';
|
|
4
|
+
import { Badge } from './ui/badge/index.js';
|
|
5
|
+
import { Upload, Image as ImageIcon, Check, Search, Folder } from '@lucide/svelte';
|
|
6
|
+
import { cn } from '../utils.js';
|
|
7
|
+
|
|
8
|
+
export interface MediaItem {
|
|
9
|
+
id: string;
|
|
10
|
+
name: string;
|
|
11
|
+
url: string;
|
|
12
|
+
size?: string;
|
|
13
|
+
type?: string;
|
|
14
|
+
category?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface Props {
|
|
18
|
+
open?: boolean;
|
|
19
|
+
title?: string;
|
|
20
|
+
multiple?: boolean;
|
|
21
|
+
selectedUrls?: string[];
|
|
22
|
+
mediaItems?: MediaItem[];
|
|
23
|
+
categories?: string[];
|
|
24
|
+
onselect?: (urls: string[]) => void;
|
|
25
|
+
onupload?: (files: File[]) => void | Promise<void>;
|
|
26
|
+
class?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
let {
|
|
30
|
+
open = $bindable(false),
|
|
31
|
+
title = 'Media Asset Library',
|
|
32
|
+
multiple = false,
|
|
33
|
+
selectedUrls = $bindable([]),
|
|
34
|
+
mediaItems = [],
|
|
35
|
+
categories = ['All', 'Images', 'Documents', 'Banners'],
|
|
36
|
+
onselect,
|
|
37
|
+
onupload,
|
|
38
|
+
class: className = '',
|
|
39
|
+
}: Props = $props();
|
|
40
|
+
|
|
41
|
+
let activeCategory = $state('All');
|
|
42
|
+
let searchQuery = $state('');
|
|
43
|
+
let localSelected = $state<string[]>([...selectedUrls]);
|
|
44
|
+
|
|
45
|
+
const filteredMedia = $derived(
|
|
46
|
+
mediaItems.filter((item) => {
|
|
47
|
+
const matchCat = activeCategory === 'All' || item.category === activeCategory;
|
|
48
|
+
const matchSearch = !searchQuery.trim() || item.name.toLowerCase().includes(searchQuery.toLowerCase().trim());
|
|
49
|
+
return matchCat && matchSearch;
|
|
50
|
+
})
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
function toggleSelect(url: string) {
|
|
54
|
+
if (multiple) {
|
|
55
|
+
if (localSelected.includes(url)) {
|
|
56
|
+
localSelected = localSelected.filter((u) => u !== url);
|
|
57
|
+
} else {
|
|
58
|
+
localSelected = [...localSelected, url];
|
|
59
|
+
}
|
|
60
|
+
} else {
|
|
61
|
+
localSelected = [url];
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function handleConfirm() {
|
|
66
|
+
selectedUrls = [...localSelected];
|
|
67
|
+
onselect?.(selectedUrls);
|
|
68
|
+
open = false;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function handleFileInput(e: Event) {
|
|
72
|
+
const target = e.currentTarget as HTMLInputElement;
|
|
73
|
+
if (target.files?.length) {
|
|
74
|
+
onupload?.(Array.from(target.files));
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
</script>
|
|
78
|
+
|
|
79
|
+
<Dialog.Root bind:open>
|
|
80
|
+
<Dialog.Content class={cn('sm:max-w-3xl max-h-[85vh] flex flex-col p-0 overflow-hidden text-xs', className)}>
|
|
81
|
+
<!-- Header -->
|
|
82
|
+
<Dialog.Header class="p-4 border-b border-border/60">
|
|
83
|
+
<Dialog.Title class="flex items-center gap-2">
|
|
84
|
+
<ImageIcon class="h-4 w-4 text-primary" />
|
|
85
|
+
<span>{title}</span>
|
|
86
|
+
</Dialog.Title>
|
|
87
|
+
</Dialog.Header>
|
|
88
|
+
|
|
89
|
+
<!-- Toolbar: Search & Categories -->
|
|
90
|
+
<div class="flex flex-wrap items-center justify-between gap-3 p-3 bg-muted/20 border-b border-border/60">
|
|
91
|
+
<div class="flex items-center gap-1.5 overflow-x-auto">
|
|
92
|
+
{#each categories as cat (cat)}
|
|
93
|
+
<button
|
|
94
|
+
type="button"
|
|
95
|
+
class={cn(
|
|
96
|
+
'px-2.5 py-1 rounded-md text-xs font-medium transition-colors cursor-pointer border-0',
|
|
97
|
+
activeCategory === cat
|
|
98
|
+
? 'bg-primary text-primary-foreground'
|
|
99
|
+
: 'bg-card text-muted-foreground hover:text-foreground hover:bg-muted/60'
|
|
100
|
+
)}
|
|
101
|
+
onclick={() => { activeCategory = cat; }}
|
|
102
|
+
>
|
|
103
|
+
{cat}
|
|
104
|
+
</button>
|
|
105
|
+
{/each}
|
|
106
|
+
</div>
|
|
107
|
+
|
|
108
|
+
<div class="flex items-center gap-2">
|
|
109
|
+
<div class="relative w-48">
|
|
110
|
+
<Search class="absolute left-2.5 top-2 h-3.5 w-3.5 text-muted-foreground" />
|
|
111
|
+
<input
|
|
112
|
+
type="text"
|
|
113
|
+
bind:value={searchQuery}
|
|
114
|
+
placeholder="Search media..."
|
|
115
|
+
class="h-7.5 w-full rounded-md border border-input bg-background pl-7.5 pr-2 text-xs focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
|
116
|
+
/>
|
|
117
|
+
</div>
|
|
118
|
+
|
|
119
|
+
<label class="inline-flex">
|
|
120
|
+
<input type="file" multiple class="hidden" onchange={handleFileInput} />
|
|
121
|
+
<span class="inline-flex items-center gap-1 h-7.5 px-2.5 rounded-md bg-secondary text-secondary-foreground text-xs font-medium hover:bg-secondary/80 cursor-pointer">
|
|
122
|
+
<Upload class="h-3.5 w-3.5" />
|
|
123
|
+
Upload
|
|
124
|
+
</span>
|
|
125
|
+
</label>
|
|
126
|
+
</div>
|
|
127
|
+
</div>
|
|
128
|
+
|
|
129
|
+
<!-- Media Grid -->
|
|
130
|
+
<div class="flex-1 overflow-y-auto p-4 min-h-64">
|
|
131
|
+
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
|
132
|
+
{#each filteredMedia as item (item.id)}
|
|
133
|
+
{@const isChosen = localSelected.includes(item.url)}
|
|
134
|
+
<button
|
|
135
|
+
type="button"
|
|
136
|
+
class={cn(
|
|
137
|
+
'group relative flex flex-col rounded-lg border text-left overflow-hidden transition-all cursor-pointer p-0 bg-card',
|
|
138
|
+
isChosen
|
|
139
|
+
? 'border-primary ring-2 ring-primary/30 shadow-xs'
|
|
140
|
+
: 'border-border/60 hover:border-border hover:shadow-xs'
|
|
141
|
+
)}
|
|
142
|
+
onclick={() => toggleSelect(item.url)}
|
|
143
|
+
>
|
|
144
|
+
<!-- Thumbnail preview -->
|
|
145
|
+
<div class="relative h-28 w-full bg-muted/40 flex items-center justify-center overflow-hidden">
|
|
146
|
+
{#if item.type?.startsWith('image') || item.url.match(/\.(jpeg|jpg|gif|png|webp|svg)$/i)}
|
|
147
|
+
<img src={item.url} alt={item.name} class="h-full w-full object-cover group-hover:scale-105 transition-transform duration-200" />
|
|
148
|
+
{:else}
|
|
149
|
+
<Folder class="h-8 w-8 text-muted-foreground/60" />
|
|
150
|
+
{/if}
|
|
151
|
+
|
|
152
|
+
<!-- Select Checkmark Badge -->
|
|
153
|
+
{#if isChosen}
|
|
154
|
+
<div class="absolute top-1.5 right-1.5 flex h-5 w-5 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-xs">
|
|
155
|
+
<Check class="h-3 w-3" />
|
|
156
|
+
</div>
|
|
157
|
+
{/if}
|
|
158
|
+
</div>
|
|
159
|
+
|
|
160
|
+
<!-- Metadata Info -->
|
|
161
|
+
<div class="p-2 space-y-0.5">
|
|
162
|
+
<div class="font-medium text-foreground truncate">{item.name}</div>
|
|
163
|
+
<div class="flex items-center justify-between text-[10px] text-muted-foreground">
|
|
164
|
+
<span>{item.size ?? '—'}</span>
|
|
165
|
+
{#if item.category}
|
|
166
|
+
<Badge variant="secondary" class="text-[9px] px-1 py-0">{item.category}</Badge>
|
|
167
|
+
{/if}
|
|
168
|
+
</div>
|
|
169
|
+
</div>
|
|
170
|
+
</button>
|
|
171
|
+
{/each}
|
|
172
|
+
</div>
|
|
173
|
+
|
|
174
|
+
{#if filteredMedia.length === 0}
|
|
175
|
+
<div class="flex flex-col items-center justify-center py-16 text-muted-foreground text-center space-y-2">
|
|
176
|
+
<ImageIcon class="h-8 w-8 opacity-40" />
|
|
177
|
+
<p>No media files found</p>
|
|
178
|
+
</div>
|
|
179
|
+
{/if}
|
|
180
|
+
</div>
|
|
181
|
+
|
|
182
|
+
<!-- Footer -->
|
|
183
|
+
<Dialog.Footer class="p-3 border-t border-border/60 bg-muted/20 flex items-center justify-between">
|
|
184
|
+
<div class="text-muted-foreground">
|
|
185
|
+
Selected <strong class="text-foreground">{localSelected.length}</strong> file(s)
|
|
186
|
+
</div>
|
|
187
|
+
<div class="flex items-center gap-2">
|
|
188
|
+
<Button variant="outline" size="sm" onclick={() => { open = false; }}>Cancel</Button>
|
|
189
|
+
<Button size="sm" disabled={localSelected.length === 0} onclick={handleConfirm}>
|
|
190
|
+
Insert Selected
|
|
191
|
+
</Button>
|
|
192
|
+
</div>
|
|
193
|
+
</Dialog.Footer>
|
|
194
|
+
</Dialog.Content>
|
|
195
|
+
</Dialog.Root>
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export interface MediaItem {
|
|
2
|
+
id: string;
|
|
3
|
+
name: string;
|
|
4
|
+
url: string;
|
|
5
|
+
size?: string;
|
|
6
|
+
type?: string;
|
|
7
|
+
category?: string;
|
|
8
|
+
}
|
|
9
|
+
interface Props {
|
|
10
|
+
open?: boolean;
|
|
11
|
+
title?: string;
|
|
12
|
+
multiple?: boolean;
|
|
13
|
+
selectedUrls?: string[];
|
|
14
|
+
mediaItems?: MediaItem[];
|
|
15
|
+
categories?: string[];
|
|
16
|
+
onselect?: (urls: string[]) => void;
|
|
17
|
+
onupload?: (files: File[]) => void | Promise<void>;
|
|
18
|
+
class?: string;
|
|
19
|
+
}
|
|
20
|
+
declare const MediaLibraryModal: import("svelte").Component<Props, {}, "open" | "selectedUrls">;
|
|
21
|
+
type MediaLibraryModal = ReturnType<typeof MediaLibraryModal>;
|
|
22
|
+
export default MediaLibraryModal;
|