@classic-homes/theme-svelte 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/README.md +305 -0
- package/dist/lib/components/Alert.svelte +51 -0
- package/dist/lib/components/Alert.svelte.d.ts +9 -0
- package/dist/lib/components/AlertDescription.svelte +16 -0
- package/dist/lib/components/AlertDescription.svelte.d.ts +9 -0
- package/dist/lib/components/AlertDialog.svelte +136 -0
- package/dist/lib/components/AlertDialog.svelte.d.ts +79 -0
- package/dist/lib/components/AlertTitle.svelte +16 -0
- package/dist/lib/components/AlertTitle.svelte.d.ts +9 -0
- package/dist/lib/components/Avatar.svelte +56 -0
- package/dist/lib/components/Avatar.svelte.d.ts +26 -0
- package/dist/lib/components/AvatarFallback.svelte +31 -0
- package/dist/lib/components/AvatarFallback.svelte.d.ts +17 -0
- package/dist/lib/components/AvatarImage.svelte +29 -0
- package/dist/lib/components/AvatarImage.svelte.d.ts +12 -0
- package/dist/lib/components/Badge.svelte +73 -0
- package/dist/lib/components/Badge.svelte.d.ts +11 -0
- package/dist/lib/components/Button.svelte +130 -0
- package/dist/lib/components/Button.svelte.d.ts +17 -0
- package/dist/lib/components/Card.svelte +58 -0
- package/dist/lib/components/Card.svelte.d.ts +26 -0
- package/dist/lib/components/CardContent.svelte +16 -0
- package/dist/lib/components/CardContent.svelte.d.ts +9 -0
- package/dist/lib/components/CardDescription.svelte +16 -0
- package/dist/lib/components/CardDescription.svelte.d.ts +9 -0
- package/dist/lib/components/CardFooter.svelte +16 -0
- package/dist/lib/components/CardFooter.svelte.d.ts +9 -0
- package/dist/lib/components/CardHeader.svelte +16 -0
- package/dist/lib/components/CardHeader.svelte.d.ts +9 -0
- package/dist/lib/components/CardTitle.svelte +16 -0
- package/dist/lib/components/CardTitle.svelte.d.ts +9 -0
- package/dist/lib/components/Checkbox.svelte +65 -0
- package/dist/lib/components/Checkbox.svelte.d.ts +14 -0
- package/dist/lib/components/DataTable.svelte +334 -0
- package/dist/lib/components/DataTable.svelte.d.ts +103 -0
- package/dist/lib/components/Dialog.svelte +111 -0
- package/dist/lib/components/Dialog.svelte.d.ts +22 -0
- package/dist/lib/components/DropdownMenu.svelte +135 -0
- package/dist/lib/components/DropdownMenu.svelte.d.ts +33 -0
- package/dist/lib/components/FileUpload.svelte +448 -0
- package/dist/lib/components/FileUpload.svelte.d.ts +42 -0
- package/dist/lib/components/FormField.svelte +134 -0
- package/dist/lib/components/FormField.svelte.d.ts +37 -0
- package/dist/lib/components/Input.svelte +61 -0
- package/dist/lib/components/Input.svelte.d.ts +19 -0
- package/dist/lib/components/Label.svelte +33 -0
- package/dist/lib/components/Label.svelte.d.ts +11 -0
- package/dist/lib/components/LoadingLogo.svelte +124 -0
- package/dist/lib/components/LoadingLogo.svelte.d.ts +16 -0
- package/dist/lib/components/LogoMain.svelte +237 -0
- package/dist/lib/components/LogoMain.svelte.d.ts +20 -0
- package/dist/lib/components/PageHeader.svelte +90 -0
- package/dist/lib/components/PageHeader.svelte.d.ts +28 -0
- package/dist/lib/components/Section.svelte +44 -0
- package/dist/lib/components/Section.svelte.d.ts +28 -0
- package/dist/lib/components/Select.svelte +174 -0
- package/dist/lib/components/Select.svelte.d.ts +32 -0
- package/dist/lib/components/Separator.svelte +29 -0
- package/dist/lib/components/Separator.svelte.d.ts +9 -0
- package/dist/lib/components/Skeleton.svelte +35 -0
- package/dist/lib/components/Skeleton.svelte.d.ts +7 -0
- package/dist/lib/components/Spinner.svelte +50 -0
- package/dist/lib/components/Spinner.svelte.d.ts +8 -0
- package/dist/lib/components/Switch.svelte +56 -0
- package/dist/lib/components/Switch.svelte.d.ts +14 -0
- package/dist/lib/components/TabPanel.svelte +44 -0
- package/dist/lib/components/TabPanel.svelte.d.ts +12 -0
- package/dist/lib/components/Tabs.svelte +125 -0
- package/dist/lib/components/Tabs.svelte.d.ts +19 -0
- package/dist/lib/components/Textarea.svelte +54 -0
- package/dist/lib/components/Textarea.svelte.d.ts +16 -0
- package/dist/lib/components/Toast.svelte +116 -0
- package/dist/lib/components/Toast.svelte.d.ts +12 -0
- package/dist/lib/components/ToastContainer.svelte +56 -0
- package/dist/lib/components/ToastContainer.svelte.d.ts +8 -0
- package/dist/lib/components/Tooltip.svelte +55 -0
- package/dist/lib/components/Tooltip.svelte.d.ts +18 -0
- package/dist/lib/components/layout/AppShell.svelte +82 -0
- package/dist/lib/components/layout/AppShell.svelte.d.ts +44 -0
- package/dist/lib/components/layout/DashboardLayout.svelte +248 -0
- package/dist/lib/components/layout/DashboardLayout.svelte.d.ts +62 -0
- package/dist/lib/components/layout/Footer.svelte +130 -0
- package/dist/lib/components/layout/Footer.svelte.d.ts +32 -0
- package/dist/lib/components/layout/FormPageLayout.svelte +92 -0
- package/dist/lib/components/layout/FormPageLayout.svelte.d.ts +33 -0
- package/dist/lib/components/layout/Header.svelte +94 -0
- package/dist/lib/components/layout/Header.svelte.d.ts +30 -0
- package/dist/lib/components/layout/PublicLayout.svelte +180 -0
- package/dist/lib/components/layout/PublicLayout.svelte.d.ts +39 -0
- package/dist/lib/components/layout/QuickLinks.svelte +112 -0
- package/dist/lib/components/layout/QuickLinks.svelte.d.ts +27 -0
- package/dist/lib/components/layout/Sidebar.svelte +243 -0
- package/dist/lib/components/layout/Sidebar.svelte.d.ts +48 -0
- package/dist/lib/composables/index.d.ts +8 -0
- package/dist/lib/composables/index.js +10 -0
- package/dist/lib/composables/useAsync.svelte.d.ts +102 -0
- package/dist/lib/composables/useAsync.svelte.js +210 -0
- package/dist/lib/composables/useForm.svelte.d.ts +123 -0
- package/dist/lib/composables/useForm.svelte.js +245 -0
- package/dist/lib/index.d.ts +65 -0
- package/dist/lib/index.js +83 -0
- package/dist/lib/performance.d.ts +79 -0
- package/dist/lib/performance.js +170 -0
- package/dist/lib/schemas/auth.d.ts +410 -0
- package/dist/lib/schemas/auth.js +216 -0
- package/dist/lib/schemas/common.d.ts +267 -0
- package/dist/lib/schemas/common.js +268 -0
- package/dist/lib/schemas/index.d.ts +24 -0
- package/dist/lib/schemas/index.js +32 -0
- package/dist/lib/stores/sidebar.svelte.d.ts +25 -0
- package/dist/lib/stores/sidebar.svelte.js +38 -0
- package/dist/lib/stores/theme.svelte.d.ts +72 -0
- package/dist/lib/stores/theme.svelte.js +150 -0
- package/dist/lib/stores/toast.svelte.d.ts +62 -0
- package/dist/lib/stores/toast.svelte.js +93 -0
- package/dist/lib/types/components.d.ts +85 -0
- package/dist/lib/types/components.js +7 -0
- package/dist/lib/types/layout.d.ts +258 -0
- package/dist/lib/types/layout.js +7 -0
- package/dist/lib/utils.d.ts +6 -0
- package/dist/lib/utils.js +9 -0
- package/dist/lib/validation.d.ts +101 -0
- package/dist/lib/validation.js +170 -0
- package/package.json +56 -0
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
|
|
3
|
+
import { cn } from '../utils.js';
|
|
4
|
+
import type { Snippet } from 'svelte';
|
|
5
|
+
|
|
6
|
+
export interface DropdownMenuItem {
|
|
7
|
+
type?: 'item' | 'separator' | 'label';
|
|
8
|
+
label?: string;
|
|
9
|
+
value?: string;
|
|
10
|
+
disabled?: boolean;
|
|
11
|
+
onSelect?: () => void;
|
|
12
|
+
destructive?: boolean;
|
|
13
|
+
shortcut?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface DropdownMenuGroup {
|
|
17
|
+
label?: string;
|
|
18
|
+
items: DropdownMenuItem[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface Props {
|
|
22
|
+
/** Menu items */
|
|
23
|
+
items: (DropdownMenuItem | DropdownMenuGroup)[];
|
|
24
|
+
/** Trigger element */
|
|
25
|
+
trigger: Snippet;
|
|
26
|
+
/** Side of trigger to show menu */
|
|
27
|
+
side?: 'top' | 'right' | 'bottom' | 'left';
|
|
28
|
+
/** Alignment of menu relative to trigger */
|
|
29
|
+
align?: 'start' | 'center' | 'end';
|
|
30
|
+
/** Whether menu is open (controlled) */
|
|
31
|
+
open?: boolean;
|
|
32
|
+
/** Callback when open state changes */
|
|
33
|
+
onOpenChange?: (open: boolean) => void;
|
|
34
|
+
/** Additional class for menu content */
|
|
35
|
+
class?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
let {
|
|
39
|
+
items,
|
|
40
|
+
trigger,
|
|
41
|
+
side = 'bottom',
|
|
42
|
+
align = 'start',
|
|
43
|
+
open = $bindable(false),
|
|
44
|
+
onOpenChange,
|
|
45
|
+
class: className,
|
|
46
|
+
}: Props = $props();
|
|
47
|
+
|
|
48
|
+
function handleOpenChange(newOpen: boolean) {
|
|
49
|
+
open = newOpen;
|
|
50
|
+
onOpenChange?.(newOpen);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function isGroup(item: DropdownMenuItem | DropdownMenuGroup): item is DropdownMenuGroup {
|
|
54
|
+
return 'items' in item;
|
|
55
|
+
}
|
|
56
|
+
</script>
|
|
57
|
+
|
|
58
|
+
<DropdownMenuPrimitive.Root bind:open onOpenChange={handleOpenChange}>
|
|
59
|
+
<DropdownMenuPrimitive.Trigger asChild>
|
|
60
|
+
{#snippet child({ props })}
|
|
61
|
+
<span {...props}>
|
|
62
|
+
{@render trigger()}
|
|
63
|
+
</span>
|
|
64
|
+
{/snippet}
|
|
65
|
+
</DropdownMenuPrimitive.Trigger>
|
|
66
|
+
|
|
67
|
+
<DropdownMenuPrimitive.Content
|
|
68
|
+
{side}
|
|
69
|
+
{align}
|
|
70
|
+
sideOffset={4}
|
|
71
|
+
class={cn(
|
|
72
|
+
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
|
73
|
+
className
|
|
74
|
+
)}
|
|
75
|
+
>
|
|
76
|
+
{#each items as item}
|
|
77
|
+
{#if isGroup(item)}
|
|
78
|
+
<DropdownMenuPrimitive.Group>
|
|
79
|
+
{#if item.label}
|
|
80
|
+
<div class="px-2 py-1.5 text-sm font-semibold">
|
|
81
|
+
{item.label}
|
|
82
|
+
</div>
|
|
83
|
+
{/if}
|
|
84
|
+
{#each item.items as subItem}
|
|
85
|
+
{#if subItem.type === 'separator'}
|
|
86
|
+
<DropdownMenuPrimitive.Separator class="-mx-1 my-1 h-px bg-muted" />
|
|
87
|
+
{:else if subItem.type === 'label'}
|
|
88
|
+
<div class="px-2 py-1.5 text-sm font-semibold">
|
|
89
|
+
{subItem.label}
|
|
90
|
+
</div>
|
|
91
|
+
{:else}
|
|
92
|
+
<DropdownMenuPrimitive.Item
|
|
93
|
+
disabled={subItem.disabled}
|
|
94
|
+
onSelect={() => subItem.onSelect?.()}
|
|
95
|
+
class={cn(
|
|
96
|
+
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
|
97
|
+
subItem.destructive && 'text-destructive focus:text-destructive'
|
|
98
|
+
)}
|
|
99
|
+
>
|
|
100
|
+
{subItem.label}
|
|
101
|
+
{#if subItem.shortcut}
|
|
102
|
+
<span class="ml-auto text-xs tracking-widest opacity-60">
|
|
103
|
+
{subItem.shortcut}
|
|
104
|
+
</span>
|
|
105
|
+
{/if}
|
|
106
|
+
</DropdownMenuPrimitive.Item>
|
|
107
|
+
{/if}
|
|
108
|
+
{/each}
|
|
109
|
+
</DropdownMenuPrimitive.Group>
|
|
110
|
+
{:else if item.type === 'separator'}
|
|
111
|
+
<DropdownMenuPrimitive.Separator class="-mx-1 my-1 h-px bg-muted" />
|
|
112
|
+
{:else if item.type === 'label'}
|
|
113
|
+
<div class="px-2 py-1.5 text-sm font-semibold">
|
|
114
|
+
{item.label}
|
|
115
|
+
</div>
|
|
116
|
+
{:else}
|
|
117
|
+
<DropdownMenuPrimitive.Item
|
|
118
|
+
disabled={item.disabled}
|
|
119
|
+
onSelect={() => item.onSelect?.()}
|
|
120
|
+
class={cn(
|
|
121
|
+
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
|
122
|
+
item.destructive && 'text-destructive focus:text-destructive'
|
|
123
|
+
)}
|
|
124
|
+
>
|
|
125
|
+
{item.label}
|
|
126
|
+
{#if item.shortcut}
|
|
127
|
+
<span class="ml-auto text-xs tracking-widest opacity-60">
|
|
128
|
+
{item.shortcut}
|
|
129
|
+
</span>
|
|
130
|
+
{/if}
|
|
131
|
+
</DropdownMenuPrimitive.Item>
|
|
132
|
+
{/if}
|
|
133
|
+
{/each}
|
|
134
|
+
</DropdownMenuPrimitive.Content>
|
|
135
|
+
</DropdownMenuPrimitive.Root>
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
export interface DropdownMenuItem {
|
|
3
|
+
type?: 'item' | 'separator' | 'label';
|
|
4
|
+
label?: string;
|
|
5
|
+
value?: string;
|
|
6
|
+
disabled?: boolean;
|
|
7
|
+
onSelect?: () => void;
|
|
8
|
+
destructive?: boolean;
|
|
9
|
+
shortcut?: string;
|
|
10
|
+
}
|
|
11
|
+
export interface DropdownMenuGroup {
|
|
12
|
+
label?: string;
|
|
13
|
+
items: DropdownMenuItem[];
|
|
14
|
+
}
|
|
15
|
+
interface Props {
|
|
16
|
+
/** Menu items */
|
|
17
|
+
items: (DropdownMenuItem | DropdownMenuGroup)[];
|
|
18
|
+
/** Trigger element */
|
|
19
|
+
trigger: Snippet;
|
|
20
|
+
/** Side of trigger to show menu */
|
|
21
|
+
side?: 'top' | 'right' | 'bottom' | 'left';
|
|
22
|
+
/** Alignment of menu relative to trigger */
|
|
23
|
+
align?: 'start' | 'center' | 'end';
|
|
24
|
+
/** Whether menu is open (controlled) */
|
|
25
|
+
open?: boolean;
|
|
26
|
+
/** Callback when open state changes */
|
|
27
|
+
onOpenChange?: (open: boolean) => void;
|
|
28
|
+
/** Additional class for menu content */
|
|
29
|
+
class?: string;
|
|
30
|
+
}
|
|
31
|
+
declare const DropdownMenu: import("svelte").Component<Props, {}, "open">;
|
|
32
|
+
type DropdownMenu = ReturnType<typeof DropdownMenu>;
|
|
33
|
+
export default DropdownMenu;
|
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* FileUpload - Drag-and-drop file upload component
|
|
4
|
+
*
|
|
5
|
+
* Features:
|
|
6
|
+
* - Drag and drop zone with visual feedback
|
|
7
|
+
* - Click to browse files
|
|
8
|
+
* - File preview (thumbnails for images, icons for other types)
|
|
9
|
+
* - Size and type validation
|
|
10
|
+
* - Multiple file support
|
|
11
|
+
* - Remove file action
|
|
12
|
+
* - Accessible with keyboard navigation
|
|
13
|
+
*/
|
|
14
|
+
import type { FileMetadata } from '../types/components.js';
|
|
15
|
+
import { cn } from '../utils.js';
|
|
16
|
+
import Button from './Button.svelte';
|
|
17
|
+
|
|
18
|
+
interface Props {
|
|
19
|
+
/** Input ID */
|
|
20
|
+
id?: string;
|
|
21
|
+
/** Array of uploaded files (bindable) */
|
|
22
|
+
files?: FileMetadata[];
|
|
23
|
+
/** Maximum number of files allowed */
|
|
24
|
+
maxFiles?: number;
|
|
25
|
+
/** Maximum file size in bytes (default: 10MB) */
|
|
26
|
+
maxSizeBytes?: number;
|
|
27
|
+
/** Accepted MIME types */
|
|
28
|
+
acceptedTypes?: string[];
|
|
29
|
+
/** Whether upload is disabled */
|
|
30
|
+
disabled?: boolean;
|
|
31
|
+
/** External error message */
|
|
32
|
+
error?: string;
|
|
33
|
+
/** Callback when files are selected */
|
|
34
|
+
onFilesChange?: (files: FileMetadata[]) => void;
|
|
35
|
+
/** Callback to handle file upload (optional - if not provided, files are stored locally) */
|
|
36
|
+
onUpload?: (file: File) => Promise<FileMetadata>;
|
|
37
|
+
/** Label text */
|
|
38
|
+
label?: string;
|
|
39
|
+
/** Hint text shown below the drop zone */
|
|
40
|
+
hint?: string;
|
|
41
|
+
/** Additional classes */
|
|
42
|
+
class?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
let {
|
|
46
|
+
id = `file-upload-${Math.random().toString(36).slice(2, 9)}`,
|
|
47
|
+
files = $bindable([]),
|
|
48
|
+
maxFiles = 10,
|
|
49
|
+
maxSizeBytes = 10 * 1024 * 1024, // 10MB
|
|
50
|
+
acceptedTypes = ['image/*', 'application/pdf'],
|
|
51
|
+
disabled = false,
|
|
52
|
+
error: externalError,
|
|
53
|
+
onFilesChange,
|
|
54
|
+
onUpload,
|
|
55
|
+
label,
|
|
56
|
+
hint,
|
|
57
|
+
class: className,
|
|
58
|
+
}: Props = $props();
|
|
59
|
+
|
|
60
|
+
let isDragging = $state(false);
|
|
61
|
+
let internalError = $state<string | undefined>(undefined);
|
|
62
|
+
let fileInput: HTMLInputElement;
|
|
63
|
+
|
|
64
|
+
/** Map of file IDs to their AbortControllers for cancellation */
|
|
65
|
+
const uploadControllers = new Map<string, AbortController>();
|
|
66
|
+
|
|
67
|
+
const displayError = $derived(externalError || internalError);
|
|
68
|
+
|
|
69
|
+
// Helper to generate unique ID
|
|
70
|
+
function generateId(): string {
|
|
71
|
+
return `file-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Validate file
|
|
75
|
+
function validateFile(file: File): string | null {
|
|
76
|
+
if (file.size > maxSizeBytes) {
|
|
77
|
+
const maxMB = Math.round(maxSizeBytes / (1024 * 1024));
|
|
78
|
+
return `File "${file.name}" exceeds maximum size of ${maxMB}MB`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Check if file type is accepted
|
|
82
|
+
const isAccepted = acceptedTypes.some((type) => {
|
|
83
|
+
if (type.endsWith('/*')) {
|
|
84
|
+
const category = type.slice(0, -2);
|
|
85
|
+
return file.type.startsWith(category);
|
|
86
|
+
}
|
|
87
|
+
return file.type === type;
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
if (!isAccepted) {
|
|
91
|
+
return `File type "${file.type}" is not allowed`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Format file size
|
|
98
|
+
function formatSize(bytes: number): string {
|
|
99
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
100
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
101
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Get file icon based on type
|
|
105
|
+
function getFileIcon(contentType: string): string {
|
|
106
|
+
if (contentType.startsWith('image/')) {
|
|
107
|
+
return 'M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z';
|
|
108
|
+
}
|
|
109
|
+
if (contentType === 'application/pdf') {
|
|
110
|
+
return 'M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z';
|
|
111
|
+
}
|
|
112
|
+
return 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z';
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Create FileMetadata from File
|
|
116
|
+
async function createFileMetadata(file: File): Promise<FileMetadata> {
|
|
117
|
+
const metadata: FileMetadata = {
|
|
118
|
+
id: generateId(),
|
|
119
|
+
filename: file.name,
|
|
120
|
+
contentType: file.type,
|
|
121
|
+
size: file.size,
|
|
122
|
+
uploading: !!onUpload,
|
|
123
|
+
progress: 0,
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
// Generate preview for images
|
|
127
|
+
if (file.type.startsWith('image/')) {
|
|
128
|
+
metadata.url = await new Promise<string>((resolve) => {
|
|
129
|
+
const reader = new FileReader();
|
|
130
|
+
reader.onload = () => resolve(reader.result as string);
|
|
131
|
+
reader.readAsDataURL(file);
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return metadata;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Atomically update a specific file by ID
|
|
140
|
+
* This prevents race conditions when multiple files upload simultaneously
|
|
141
|
+
*/
|
|
142
|
+
function updateFileById(id: string, updates: Partial<FileMetadata>): void {
|
|
143
|
+
files = files.map((f) => (f.id === id ? { ...f, ...updates } : f));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Process selected files with proper async handling
|
|
148
|
+
* Files are added immediately, then uploads happen in parallel with atomic state updates
|
|
149
|
+
*/
|
|
150
|
+
async function processFiles(selectedFiles: FileList | File[]): Promise<void> {
|
|
151
|
+
if (disabled) return;
|
|
152
|
+
|
|
153
|
+
internalError = undefined;
|
|
154
|
+
const fileArray = Array.from(selectedFiles);
|
|
155
|
+
|
|
156
|
+
// Check max files limit
|
|
157
|
+
if (files.length + fileArray.length > maxFiles) {
|
|
158
|
+
internalError = `Maximum ${maxFiles} files allowed`;
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Track upload promises for waiting on completion
|
|
163
|
+
const uploadPromises: Promise<void>[] = [];
|
|
164
|
+
|
|
165
|
+
for (const file of fileArray) {
|
|
166
|
+
const validationError = validateFile(file);
|
|
167
|
+
if (validationError) {
|
|
168
|
+
internalError = validationError;
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const metadata = await createFileMetadata(file);
|
|
173
|
+
|
|
174
|
+
// Add file to list immediately with uploading state
|
|
175
|
+
files = [...files, metadata];
|
|
176
|
+
|
|
177
|
+
// Handle upload if handler provided
|
|
178
|
+
if (onUpload) {
|
|
179
|
+
const controller = new AbortController();
|
|
180
|
+
uploadControllers.set(metadata.id, controller);
|
|
181
|
+
|
|
182
|
+
const uploadPromise = (async () => {
|
|
183
|
+
try {
|
|
184
|
+
const result = await onUpload(file);
|
|
185
|
+
|
|
186
|
+
// Check if upload was cancelled (file removed during upload)
|
|
187
|
+
if (controller.signal.aborted) return;
|
|
188
|
+
|
|
189
|
+
// Atomic update on completion
|
|
190
|
+
updateFileById(metadata.id, {
|
|
191
|
+
...result,
|
|
192
|
+
uploading: false,
|
|
193
|
+
progress: 100,
|
|
194
|
+
});
|
|
195
|
+
} catch (err) {
|
|
196
|
+
// Check if error is from abort (file removed during upload)
|
|
197
|
+
if (controller.signal.aborted) return;
|
|
198
|
+
|
|
199
|
+
// Atomic update on error
|
|
200
|
+
updateFileById(metadata.id, {
|
|
201
|
+
uploading: false,
|
|
202
|
+
error: err instanceof Error ? err.message : 'Upload failed',
|
|
203
|
+
});
|
|
204
|
+
} finally {
|
|
205
|
+
uploadControllers.delete(metadata.id);
|
|
206
|
+
}
|
|
207
|
+
})();
|
|
208
|
+
|
|
209
|
+
uploadPromises.push(uploadPromise);
|
|
210
|
+
} else {
|
|
211
|
+
// No upload handler - mark as complete immediately
|
|
212
|
+
updateFileById(metadata.id, {
|
|
213
|
+
uploading: false,
|
|
214
|
+
progress: 100,
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Wait for all uploads to complete before notifying
|
|
220
|
+
if (uploadPromises.length > 0) {
|
|
221
|
+
await Promise.allSettled(uploadPromises);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
onFilesChange?.(files);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Remove a file by ID and cancel any pending upload
|
|
229
|
+
*/
|
|
230
|
+
function removeFile(fileId: string): void {
|
|
231
|
+
// Cancel pending upload if exists
|
|
232
|
+
const controller = uploadControllers.get(fileId);
|
|
233
|
+
if (controller) {
|
|
234
|
+
controller.abort();
|
|
235
|
+
uploadControllers.delete(fileId);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
files = files.filter((f) => f.id !== fileId);
|
|
239
|
+
onFilesChange?.(files);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Handle drag events
|
|
243
|
+
function handleDragEnter(e: DragEvent) {
|
|
244
|
+
e.preventDefault();
|
|
245
|
+
if (!disabled) isDragging = true;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function handleDragLeave(e: DragEvent) {
|
|
249
|
+
e.preventDefault();
|
|
250
|
+
isDragging = false;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function handleDragOver(e: DragEvent) {
|
|
254
|
+
e.preventDefault();
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function handleDrop(e: DragEvent) {
|
|
258
|
+
e.preventDefault();
|
|
259
|
+
isDragging = false;
|
|
260
|
+
if (e.dataTransfer?.files) {
|
|
261
|
+
processFiles(e.dataTransfer.files);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Handle click to browse
|
|
266
|
+
function handleClick() {
|
|
267
|
+
if (!disabled) {
|
|
268
|
+
fileInput?.click();
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Handle file input change
|
|
273
|
+
function handleInputChange(e: Event) {
|
|
274
|
+
const input = e.target as HTMLInputElement;
|
|
275
|
+
if (input.files) {
|
|
276
|
+
processFiles(input.files);
|
|
277
|
+
// Reset input so same file can be selected again
|
|
278
|
+
input.value = '';
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Handle keyboard activation
|
|
283
|
+
function handleKeyDown(e: KeyboardEvent) {
|
|
284
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
285
|
+
e.preventDefault();
|
|
286
|
+
handleClick();
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
</script>
|
|
290
|
+
|
|
291
|
+
<div class={cn('space-y-2', className)}>
|
|
292
|
+
{#if label}
|
|
293
|
+
<label for={id} class="block text-sm font-medium text-foreground">
|
|
294
|
+
{label}
|
|
295
|
+
</label>
|
|
296
|
+
{/if}
|
|
297
|
+
|
|
298
|
+
<!-- Drop Zone -->
|
|
299
|
+
<div
|
|
300
|
+
role="button"
|
|
301
|
+
tabindex={disabled ? -1 : 0}
|
|
302
|
+
aria-disabled={disabled}
|
|
303
|
+
aria-describedby={displayError ? `${id}-error` : undefined}
|
|
304
|
+
class={cn(
|
|
305
|
+
'relative flex flex-col items-center justify-center rounded-lg border-2 border-dashed p-6 transition-colors',
|
|
306
|
+
'cursor-pointer focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
|
|
307
|
+
isDragging && !disabled && 'border-primary bg-primary/5',
|
|
308
|
+
disabled && 'cursor-not-allowed opacity-50',
|
|
309
|
+
displayError && 'border-destructive',
|
|
310
|
+
!isDragging &&
|
|
311
|
+
!disabled &&
|
|
312
|
+
!displayError &&
|
|
313
|
+
'border-muted-foreground/25 hover:border-muted-foreground/50'
|
|
314
|
+
)}
|
|
315
|
+
ondragenter={handleDragEnter}
|
|
316
|
+
ondragleave={handleDragLeave}
|
|
317
|
+
ondragover={handleDragOver}
|
|
318
|
+
ondrop={handleDrop}
|
|
319
|
+
onclick={handleClick}
|
|
320
|
+
onkeydown={handleKeyDown}
|
|
321
|
+
>
|
|
322
|
+
<!-- Cloud Upload Icon -->
|
|
323
|
+
<svg
|
|
324
|
+
class={cn('mb-3 h-10 w-10', isDragging ? 'text-primary' : 'text-muted-foreground')}
|
|
325
|
+
fill="none"
|
|
326
|
+
viewBox="0 0 24 24"
|
|
327
|
+
stroke="currentColor"
|
|
328
|
+
stroke-width="1.5"
|
|
329
|
+
aria-hidden="true"
|
|
330
|
+
>
|
|
331
|
+
<path
|
|
332
|
+
stroke-linecap="round"
|
|
333
|
+
stroke-linejoin="round"
|
|
334
|
+
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
|
|
335
|
+
/>
|
|
336
|
+
</svg>
|
|
337
|
+
|
|
338
|
+
<p class="text-sm text-muted-foreground">
|
|
339
|
+
<span class="font-medium text-foreground">Click to upload</span> or drag and drop
|
|
340
|
+
</p>
|
|
341
|
+
<p class="mt-1 text-xs text-muted-foreground">
|
|
342
|
+
{acceptedTypes.join(', ')} (max {formatSize(maxSizeBytes)})
|
|
343
|
+
</p>
|
|
344
|
+
|
|
345
|
+
<!-- Hidden file input -->
|
|
346
|
+
<input
|
|
347
|
+
bind:this={fileInput}
|
|
348
|
+
type="file"
|
|
349
|
+
{id}
|
|
350
|
+
multiple={maxFiles > 1}
|
|
351
|
+
accept={acceptedTypes.join(',')}
|
|
352
|
+
{disabled}
|
|
353
|
+
class="sr-only"
|
|
354
|
+
onchange={handleInputChange}
|
|
355
|
+
aria-label="File upload"
|
|
356
|
+
/>
|
|
357
|
+
</div>
|
|
358
|
+
|
|
359
|
+
<!-- Hint text -->
|
|
360
|
+
{#if hint && !displayError}
|
|
361
|
+
<p class="text-sm text-muted-foreground">{hint}</p>
|
|
362
|
+
{/if}
|
|
363
|
+
|
|
364
|
+
<!-- Error message -->
|
|
365
|
+
{#if displayError}
|
|
366
|
+
<p id={`${id}-error`} class="text-sm text-destructive" role="alert">
|
|
367
|
+
{displayError}
|
|
368
|
+
</p>
|
|
369
|
+
{/if}
|
|
370
|
+
|
|
371
|
+
<!-- File List -->
|
|
372
|
+
{#if files.length > 0}
|
|
373
|
+
<ul class="mt-4 space-y-2" aria-label="Uploaded files">
|
|
374
|
+
{#each files as file (file.id)}
|
|
375
|
+
<li
|
|
376
|
+
class={cn(
|
|
377
|
+
'flex items-center gap-3 rounded-lg border p-3',
|
|
378
|
+
file.error && 'border-destructive bg-destructive/5'
|
|
379
|
+
)}
|
|
380
|
+
>
|
|
381
|
+
<!-- Preview/Icon -->
|
|
382
|
+
<div class="flex-shrink-0">
|
|
383
|
+
{#if file.url && file.contentType.startsWith('image/')}
|
|
384
|
+
<img src={file.url} alt={file.filename} class="h-10 w-10 rounded object-cover" />
|
|
385
|
+
{:else}
|
|
386
|
+
<div class="flex h-10 w-10 items-center justify-center rounded bg-muted">
|
|
387
|
+
<svg
|
|
388
|
+
class="h-5 w-5 text-muted-foreground"
|
|
389
|
+
fill="none"
|
|
390
|
+
viewBox="0 0 24 24"
|
|
391
|
+
stroke="currentColor"
|
|
392
|
+
stroke-width="2"
|
|
393
|
+
>
|
|
394
|
+
<path
|
|
395
|
+
stroke-linecap="round"
|
|
396
|
+
stroke-linejoin="round"
|
|
397
|
+
d={getFileIcon(file.contentType)}
|
|
398
|
+
/>
|
|
399
|
+
</svg>
|
|
400
|
+
</div>
|
|
401
|
+
{/if}
|
|
402
|
+
</div>
|
|
403
|
+
|
|
404
|
+
<!-- File Info -->
|
|
405
|
+
<div class="min-w-0 flex-1">
|
|
406
|
+
<p class="truncate text-sm font-medium">{file.filename}</p>
|
|
407
|
+
<p class="text-xs text-muted-foreground">
|
|
408
|
+
{formatSize(file.size)}
|
|
409
|
+
{#if file.error}
|
|
410
|
+
<span class="text-destructive"> - {file.error}</span>
|
|
411
|
+
{/if}
|
|
412
|
+
</p>
|
|
413
|
+
|
|
414
|
+
<!-- Progress bar -->
|
|
415
|
+
{#if file.uploading}
|
|
416
|
+
<div class="mt-1 h-1 w-full overflow-hidden rounded-full bg-muted">
|
|
417
|
+
<div
|
|
418
|
+
class="h-full bg-primary transition-all duration-300"
|
|
419
|
+
style="width: {file.progress || 0}%"
|
|
420
|
+
></div>
|
|
421
|
+
</div>
|
|
422
|
+
{/if}
|
|
423
|
+
</div>
|
|
424
|
+
|
|
425
|
+
<!-- Remove button -->
|
|
426
|
+
<Button
|
|
427
|
+
variant="ghost"
|
|
428
|
+
size="icon"
|
|
429
|
+
class="h-8 w-8 flex-shrink-0"
|
|
430
|
+
onclick={() => removeFile(file.id)}
|
|
431
|
+
aria-label={`Remove ${file.filename}`}
|
|
432
|
+
disabled={file.uploading}
|
|
433
|
+
>
|
|
434
|
+
<svg
|
|
435
|
+
class="h-4 w-4"
|
|
436
|
+
fill="none"
|
|
437
|
+
viewBox="0 0 24 24"
|
|
438
|
+
stroke="currentColor"
|
|
439
|
+
stroke-width="2"
|
|
440
|
+
>
|
|
441
|
+
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
|
442
|
+
</svg>
|
|
443
|
+
</Button>
|
|
444
|
+
</li>
|
|
445
|
+
{/each}
|
|
446
|
+
</ul>
|
|
447
|
+
{/if}
|
|
448
|
+
</div>
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FileUpload - Drag-and-drop file upload component
|
|
3
|
+
*
|
|
4
|
+
* Features:
|
|
5
|
+
* - Drag and drop zone with visual feedback
|
|
6
|
+
* - Click to browse files
|
|
7
|
+
* - File preview (thumbnails for images, icons for other types)
|
|
8
|
+
* - Size and type validation
|
|
9
|
+
* - Multiple file support
|
|
10
|
+
* - Remove file action
|
|
11
|
+
* - Accessible with keyboard navigation
|
|
12
|
+
*/
|
|
13
|
+
import type { FileMetadata } from '../types/components.js';
|
|
14
|
+
interface Props {
|
|
15
|
+
/** Input ID */
|
|
16
|
+
id?: string;
|
|
17
|
+
/** Array of uploaded files (bindable) */
|
|
18
|
+
files?: FileMetadata[];
|
|
19
|
+
/** Maximum number of files allowed */
|
|
20
|
+
maxFiles?: number;
|
|
21
|
+
/** Maximum file size in bytes (default: 10MB) */
|
|
22
|
+
maxSizeBytes?: number;
|
|
23
|
+
/** Accepted MIME types */
|
|
24
|
+
acceptedTypes?: string[];
|
|
25
|
+
/** Whether upload is disabled */
|
|
26
|
+
disabled?: boolean;
|
|
27
|
+
/** External error message */
|
|
28
|
+
error?: string;
|
|
29
|
+
/** Callback when files are selected */
|
|
30
|
+
onFilesChange?: (files: FileMetadata[]) => void;
|
|
31
|
+
/** Callback to handle file upload (optional - if not provided, files are stored locally) */
|
|
32
|
+
onUpload?: (file: File) => Promise<FileMetadata>;
|
|
33
|
+
/** Label text */
|
|
34
|
+
label?: string;
|
|
35
|
+
/** Hint text shown below the drop zone */
|
|
36
|
+
hint?: string;
|
|
37
|
+
/** Additional classes */
|
|
38
|
+
class?: string;
|
|
39
|
+
}
|
|
40
|
+
declare const FileUpload: import("svelte").Component<Props, {}, "files">;
|
|
41
|
+
type FileUpload = ReturnType<typeof FileUpload>;
|
|
42
|
+
export default FileUpload;
|