@lovett/ui 0.0.1
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 +28 -0
- package/package.json +53 -0
- package/src/accordion.tsx +153 -0
- package/src/atoms.tsx +197 -0
- package/src/badge.tsx +91 -0
- package/src/brand-logo-tile.tsx +200 -0
- package/src/bulk-action-bar.tsx +104 -0
- package/src/button-group.tsx +46 -0
- package/src/button.tsx +93 -0
- package/src/calculator-shell-v2.tsx +380 -0
- package/src/calculator-shell.tsx +310 -0
- package/src/card.tsx +369 -0
- package/src/checkbox.tsx +114 -0
- package/src/chip-nav.tsx +347 -0
- package/src/choropleth.tsx +62 -0
- package/src/code-block.tsx +235 -0
- package/src/completion-ring.tsx +119 -0
- package/src/copy-field.tsx +169 -0
- package/src/data-grid/column-options-menu.tsx +127 -0
- package/src/data-grid/data-grid.tsx +391 -0
- package/src/data-grid/drawer-panel.tsx +146 -0
- package/src/data-grid/index.ts +29 -0
- package/src/data-grid/sortable-parts.tsx +215 -0
- package/src/data-grid/table-body.tsx +238 -0
- package/src/data-grid/table-elements.tsx +135 -0
- package/src/data-grid/table-header.tsx +95 -0
- package/src/data-grid/table-overlays.tsx +78 -0
- package/src/data-grid/table-parts.tsx +15 -0
- package/src/data-grid/table-summary-footer.tsx +101 -0
- package/src/data-grid/table-view.tsx +176 -0
- package/src/data-grid/types.ts +141 -0
- package/src/data-grid/use-grid-columns.ts +311 -0
- package/src/data-grid/use-grid-editing.ts +154 -0
- package/src/device-frame.tsx +274 -0
- package/src/dropdown-button.tsx +221 -0
- package/src/dropdown-menu.tsx +450 -0
- package/src/empty-state.tsx +85 -0
- package/src/file-upload/file-drop-zone.tsx +156 -0
- package/src/file-upload/file-preview-grid.tsx +39 -0
- package/src/file-upload/file-preview-item.tsx +175 -0
- package/src/file-upload/file-thumbnail.tsx +98 -0
- package/src/file-upload/file-upload-button.tsx +95 -0
- package/src/file-upload/index.ts +13 -0
- package/src/file-upload/use-file-upload.ts +371 -0
- package/src/filter-bar.tsx +125 -0
- package/src/filter-dropdown.tsx +289 -0
- package/src/floating-drawer.tsx +420 -0
- package/src/folder-card.tsx +188 -0
- package/src/folder-tree-picker.tsx +511 -0
- package/src/format.ts +117 -0
- package/src/frame-stack.tsx +73 -0
- package/src/hero-form-card.tsx +471 -0
- package/src/index.ts +372 -0
- package/src/input.tsx +130 -0
- package/src/kbd.tsx +41 -0
- package/src/lib/layout.ts +22 -0
- package/src/lib/utils.ts +6 -0
- package/src/markdown.tsx +214 -0
- package/src/menu-button.tsx +142 -0
- package/src/meta-previews/CreativeMedia.tsx +119 -0
- package/src/meta-previews/InstagramExplorePreview.tsx +340 -0
- package/src/meta-previews/InstagramFeedCarousel.tsx +245 -0
- package/src/meta-previews/InstagramFeedPreview.tsx +259 -0
- package/src/meta-previews/MetaFeedCarousel.tsx +228 -0
- package/src/meta-previews/MetaFeedPreview.tsx +294 -0
- package/src/meta-previews/MetaInstreamPreview.tsx +135 -0
- package/src/meta-previews/MetaMessengerPreview.tsx +795 -0
- package/src/meta-previews/MetaReelPreview.tsx +750 -0
- package/src/meta-previews/MetaRightColumnPreview.tsx +245 -0
- package/src/meta-previews/MetaSearchPreview.tsx +113 -0
- package/src/meta-previews/MetaStoryPreview.tsx +1132 -0
- package/src/meta-previews/ProfileAvatar.tsx +79 -0
- package/src/meta-previews/defaults.ts +27 -0
- package/src/meta-previews/index.ts +37 -0
- package/src/meta-previews/types.ts +219 -0
- package/src/metric-card.tsx +117 -0
- package/src/modal.tsx +131 -0
- package/src/option-tile.tsx +252 -0
- package/src/page-hero.tsx +78 -0
- package/src/page-shell.tsx +440 -0
- package/src/pagination.tsx +86 -0
- package/src/pill-button.tsx +103 -0
- package/src/profile-section.tsx +389 -0
- package/src/progress-bar.tsx +84 -0
- package/src/radio.tsx +172 -0
- package/src/range-slider.tsx +161 -0
- package/src/resizable.tsx +282 -0
- package/src/search-bar.tsx +81 -0
- package/src/segmented-pill.tsx +143 -0
- package/src/shell.tsx +464 -0
- package/src/slider.tsx +107 -0
- package/src/sortable-table.tsx +198 -0
- package/src/sortable.tsx +137 -0
- package/src/stat-row.tsx +132 -0
- package/src/stats-grid.tsx +38 -0
- package/src/step-loader.tsx +141 -0
- package/src/styles.css +562 -0
- package/src/tabs.tsx +143 -0
- package/src/tag-chip-input.tsx +394 -0
- package/src/textarea.tsx +39 -0
- package/src/toast.tsx +59 -0
- package/src/tokens.css +579 -0
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FilePreviewGrid — responsive grid of FilePreviewItem tiles, sized by
|
|
3
|
+
* file count (1 → single col, 2 → 2-col, 3-4 → 4-col, 5+ → 5-col).
|
|
4
|
+
*
|
|
5
|
+
* Promoted from brand-ai-extension (ADR-036 D20).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { cn } from '../lib/utils'
|
|
9
|
+
import { FilePreviewItem } from './file-preview-item'
|
|
10
|
+
import type { PendingFile } from './use-file-upload'
|
|
11
|
+
|
|
12
|
+
interface FilePreviewGridProps {
|
|
13
|
+
files: PendingFile[]
|
|
14
|
+
onRemove: (id: string) => void
|
|
15
|
+
className?: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function FilePreviewGrid({ files, onRemove, className }: FilePreviewGridProps) {
|
|
19
|
+
if (files.length === 0) return null
|
|
20
|
+
|
|
21
|
+
const gridCols =
|
|
22
|
+
files.length === 1
|
|
23
|
+
? 'grid-cols-1 max-w-[120px]'
|
|
24
|
+
: files.length === 2
|
|
25
|
+
? 'grid-cols-2 max-w-[248px]'
|
|
26
|
+
: files.length <= 4
|
|
27
|
+
? 'grid-cols-4'
|
|
28
|
+
: 'grid-cols-5'
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<div className={cn('px-3 pt-3', className)}>
|
|
32
|
+
<div className={cn('grid gap-2', gridCols)}>
|
|
33
|
+
{files.map((file) => (
|
|
34
|
+
<FilePreviewItem key={file.id} pendingFile={file} onRemove={onRemove} />
|
|
35
|
+
))}
|
|
36
|
+
</div>
|
|
37
|
+
</div>
|
|
38
|
+
)
|
|
39
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FilePreviewItem — single file card showing thumbnail, name, size, upload
|
|
3
|
+
* progress, error state, and a remove button.
|
|
4
|
+
*
|
|
5
|
+
* Promoted from brand-ai-extension (ADR-036 D20).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { X, AlertCircle, Loader2 } from 'lucide-react'
|
|
9
|
+
import { cn } from '../lib/utils'
|
|
10
|
+
import { FileThumbnail } from './file-thumbnail'
|
|
11
|
+
import type { PendingFile } from './use-file-upload'
|
|
12
|
+
|
|
13
|
+
interface FilePreviewItemProps {
|
|
14
|
+
pendingFile: PendingFile
|
|
15
|
+
onRemove: (id: string) => void
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function formatFileSize(bytes: number): string {
|
|
19
|
+
if (bytes < 1024) return `${bytes} B`
|
|
20
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
|
21
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function truncateFilename(name: string, maxLength = 20): string {
|
|
25
|
+
if (name.length <= maxLength) return name
|
|
26
|
+
|
|
27
|
+
const ext = name.includes('.') ? name.split('.').pop() : ''
|
|
28
|
+
const baseName = ext ? name.slice(0, -(ext.length + 1)) : name
|
|
29
|
+
const available = maxLength - (ext ? ext.length + 4 : 3)
|
|
30
|
+
|
|
31
|
+
if (available <= 0) return name.slice(0, maxLength - 3) + '...'
|
|
32
|
+
|
|
33
|
+
return `${baseName.slice(0, available)}...${ext ? `.${ext}` : ''}`
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function FilePreviewItem({ pendingFile, onRemove }: FilePreviewItemProps) {
|
|
37
|
+
const { id, file, progress, status, error, previewUrl } = pendingFile
|
|
38
|
+
const isUploading = status === 'uploading'
|
|
39
|
+
const isError = status === 'error'
|
|
40
|
+
const isUploaded = status === 'uploaded'
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<div
|
|
44
|
+
className={cn(
|
|
45
|
+
'group relative flex flex-col gap-1 p-2',
|
|
46
|
+
'border bg-[rgb(var(--background))]',
|
|
47
|
+
)}
|
|
48
|
+
style={{
|
|
49
|
+
borderRadius: 'var(--radius-md)',
|
|
50
|
+
borderColor: isError
|
|
51
|
+
? 'rgb(var(--destructive) / 0.5)'
|
|
52
|
+
: isUploaded
|
|
53
|
+
? 'rgb(var(--success) / 0.5)'
|
|
54
|
+
: 'rgb(var(--border))',
|
|
55
|
+
background: isError
|
|
56
|
+
? 'rgb(var(--destructive) / 0.08)'
|
|
57
|
+
: isUploaded
|
|
58
|
+
? 'rgb(var(--success-bg))'
|
|
59
|
+
: 'rgb(var(--background))',
|
|
60
|
+
}}
|
|
61
|
+
>
|
|
62
|
+
{/* Thumbnail */}
|
|
63
|
+
<div
|
|
64
|
+
className="relative aspect-square w-full overflow-hidden"
|
|
65
|
+
style={{
|
|
66
|
+
borderRadius: 'var(--radius-sm)',
|
|
67
|
+
background: 'rgb(var(--muted))',
|
|
68
|
+
}}
|
|
69
|
+
>
|
|
70
|
+
<FileThumbnail
|
|
71
|
+
file={file}
|
|
72
|
+
className="h-full w-full"
|
|
73
|
+
{...(previewUrl !== undefined ? { previewUrl } : {})}
|
|
74
|
+
/>
|
|
75
|
+
|
|
76
|
+
{isUploading && (
|
|
77
|
+
<div
|
|
78
|
+
className="absolute inset-0 flex items-center justify-center"
|
|
79
|
+
style={{ background: 'rgb(var(--background) / 0.8)' }}
|
|
80
|
+
>
|
|
81
|
+
<Loader2
|
|
82
|
+
className="h-5 w-5 animate-spin"
|
|
83
|
+
style={{ color: 'rgb(var(--accent))' }}
|
|
84
|
+
/>
|
|
85
|
+
</div>
|
|
86
|
+
)}
|
|
87
|
+
|
|
88
|
+
{isError && (
|
|
89
|
+
<div
|
|
90
|
+
className="absolute inset-0 flex items-center justify-center"
|
|
91
|
+
style={{ background: 'rgb(var(--destructive) / 0.2)' }}
|
|
92
|
+
>
|
|
93
|
+
<AlertCircle
|
|
94
|
+
className="h-5 w-5"
|
|
95
|
+
style={{ color: 'rgb(var(--destructive))' }}
|
|
96
|
+
/>
|
|
97
|
+
</div>
|
|
98
|
+
)}
|
|
99
|
+
</div>
|
|
100
|
+
|
|
101
|
+
{/* File info */}
|
|
102
|
+
<div className="flex flex-col gap-0.5">
|
|
103
|
+
<span
|
|
104
|
+
className="truncate text-xs font-medium"
|
|
105
|
+
style={{ color: 'rgb(var(--foreground))' }}
|
|
106
|
+
title={file.name}
|
|
107
|
+
>
|
|
108
|
+
{truncateFilename(file.name)}
|
|
109
|
+
</span>
|
|
110
|
+
<span className="text-[10px]" style={{ color: 'rgb(var(--text-muted))' }}>
|
|
111
|
+
{formatFileSize(file.size)}
|
|
112
|
+
</span>
|
|
113
|
+
</div>
|
|
114
|
+
|
|
115
|
+
{/* Progress bar */}
|
|
116
|
+
{isUploading && (
|
|
117
|
+
<div
|
|
118
|
+
className="h-1 w-full overflow-hidden"
|
|
119
|
+
style={{
|
|
120
|
+
borderRadius: 'var(--radius-full)',
|
|
121
|
+
background: 'rgb(var(--muted))',
|
|
122
|
+
}}
|
|
123
|
+
>
|
|
124
|
+
<div
|
|
125
|
+
className="h-full transition-all duration-300"
|
|
126
|
+
style={{
|
|
127
|
+
width: `${progress}%`,
|
|
128
|
+
background: 'rgb(var(--accent))',
|
|
129
|
+
}}
|
|
130
|
+
/>
|
|
131
|
+
</div>
|
|
132
|
+
)}
|
|
133
|
+
|
|
134
|
+
{/* Error message */}
|
|
135
|
+
{isError && error && (
|
|
136
|
+
<span
|
|
137
|
+
className="truncate text-[10px]"
|
|
138
|
+
style={{ color: 'rgb(var(--destructive))' }}
|
|
139
|
+
title={error}
|
|
140
|
+
>
|
|
141
|
+
{error}
|
|
142
|
+
</span>
|
|
143
|
+
)}
|
|
144
|
+
|
|
145
|
+
{/* Remove button */}
|
|
146
|
+
<button
|
|
147
|
+
type="button"
|
|
148
|
+
onClick={() => onRemove(id)}
|
|
149
|
+
disabled={isUploading}
|
|
150
|
+
className={cn(
|
|
151
|
+
'absolute -right-2 -top-2 flex h-5 w-5 items-center justify-center',
|
|
152
|
+
'rounded-full border transition-all',
|
|
153
|
+
'shadow-[var(--shadow-sm)]',
|
|
154
|
+
isError
|
|
155
|
+
? 'opacity-100'
|
|
156
|
+
: 'opacity-0 group-hover:opacity-100',
|
|
157
|
+
isUploading && 'pointer-events-none',
|
|
158
|
+
)}
|
|
159
|
+
style={
|
|
160
|
+
isError
|
|
161
|
+
? {
|
|
162
|
+
background: 'rgb(var(--destructive))',
|
|
163
|
+
color: 'white',
|
|
164
|
+
borderColor: 'rgb(var(--destructive))',
|
|
165
|
+
}
|
|
166
|
+
: undefined
|
|
167
|
+
}
|
|
168
|
+
title={isError ? 'Remove failed upload' : 'Remove file'}
|
|
169
|
+
aria-label={isError ? 'Remove failed upload' : 'Remove file'}
|
|
170
|
+
>
|
|
171
|
+
<X className="h-3 w-3" />
|
|
172
|
+
</button>
|
|
173
|
+
</div>
|
|
174
|
+
)
|
|
175
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FileThumbnail — icon-based preview tile for non-image files; renders the
|
|
3
|
+
* actual image for image files when a preview URL is available.
|
|
4
|
+
*
|
|
5
|
+
* Promoted from brand-ai-extension (ADR-036 D20).
|
|
6
|
+
*
|
|
7
|
+
* Token-boundary note: the source used Tailwind color utilities (text-red-600,
|
|
8
|
+
* bg-blue-100, etc.) which are raw color literals. Rewritten to use inline
|
|
9
|
+
* style with CSS variables from the public token set where possible, and
|
|
10
|
+
* for the categorical file-type tints we use the --folder-* palette tokens
|
|
11
|
+
* (ADR-035) which are the closest semantic color set in the design system
|
|
12
|
+
* for categorical accent use.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { FileText, Table, Code, Image, FileType } from 'lucide-react'
|
|
16
|
+
import { cn } from '../lib/utils'
|
|
17
|
+
|
|
18
|
+
interface FileThumbnailProps {
|
|
19
|
+
file: File
|
|
20
|
+
previewUrl?: string
|
|
21
|
+
className?: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
type IconComponent = typeof FileText
|
|
25
|
+
|
|
26
|
+
interface ExtensionConfig {
|
|
27
|
+
icon: IconComponent
|
|
28
|
+
colorVar: string
|
|
29
|
+
bgVar: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Map file extensions to Lucide icons and design-system token pairs.
|
|
33
|
+
// Using --folder-* palette tokens (ADR-035) as categorical accents.
|
|
34
|
+
const EXTENSION_CONFIG: Record<string, ExtensionConfig> = {
|
|
35
|
+
pdf: { icon: FileText, colorVar: '--folder-red', bgVar: '--folder-red' },
|
|
36
|
+
docx: { icon: FileText, colorVar: '--folder-blue', bgVar: '--folder-blue' },
|
|
37
|
+
txt: { icon: FileText, colorVar: '--folder-slate', bgVar: '--folder-slate' },
|
|
38
|
+
csv: { icon: Table, colorVar: '--folder-emerald', bgVar: '--folder-emerald' },
|
|
39
|
+
xml: { icon: Code, colorVar: '--folder-amber', bgVar: '--folder-amber' },
|
|
40
|
+
html: { icon: Code, colorVar: '--folder-violet', bgVar: '--folder-violet' },
|
|
41
|
+
css: { icon: Code, colorVar: '--folder-blue', bgVar: '--folder-blue' },
|
|
42
|
+
js: { icon: Code, colorVar: '--folder-amber', bgVar: '--folder-amber' },
|
|
43
|
+
jpg: { icon: Image, colorVar: '--folder-pink', bgVar: '--folder-pink' },
|
|
44
|
+
jpeg: { icon: Image, colorVar: '--folder-pink', bgVar: '--folder-pink' },
|
|
45
|
+
png: { icon: Image, colorVar: '--folder-pink', bgVar: '--folder-pink' },
|
|
46
|
+
gif: { icon: Image, colorVar: '--folder-pink', bgVar: '--folder-pink' },
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const FALLBACK_CONFIG: ExtensionConfig = {
|
|
50
|
+
icon: FileType,
|
|
51
|
+
colorVar: '--folder-slate',
|
|
52
|
+
bgVar: '--folder-slate',
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function getExtension(filename: string): string {
|
|
56
|
+
const parts = filename.split('.')
|
|
57
|
+
return parts.length > 1 ? (parts[parts.length - 1] ?? '').toLowerCase() : ''
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function FileThumbnail({ file, previewUrl, className }: FileThumbnailProps) {
|
|
61
|
+
const extension = getExtension(file.name)
|
|
62
|
+
const isImage = file.type.startsWith('image/')
|
|
63
|
+
|
|
64
|
+
if (isImage && previewUrl) {
|
|
65
|
+
return (
|
|
66
|
+
<div className={cn('relative overflow-hidden rounded-md', className)}>
|
|
67
|
+
<img
|
|
68
|
+
src={previewUrl}
|
|
69
|
+
alt={file.name}
|
|
70
|
+
className="h-full w-full object-cover"
|
|
71
|
+
/>
|
|
72
|
+
</div>
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const config = EXTENSION_CONFIG[extension] ?? FALLBACK_CONFIG
|
|
77
|
+
const IconComponent = config.icon
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
<div
|
|
81
|
+
className={cn('flex items-center justify-center rounded-md', className)}
|
|
82
|
+
style={{ background: `rgb(var(${config.bgVar}) / 0.15)` }}
|
|
83
|
+
>
|
|
84
|
+
<div className="flex flex-col items-center gap-1">
|
|
85
|
+
<IconComponent
|
|
86
|
+
className="h-6 w-6"
|
|
87
|
+
style={{ color: `rgb(var(${config.colorVar}))` }}
|
|
88
|
+
/>
|
|
89
|
+
<span
|
|
90
|
+
className="text-[10px] font-medium uppercase"
|
|
91
|
+
style={{ color: `rgb(var(${config.colorVar}))` }}
|
|
92
|
+
>
|
|
93
|
+
{extension || '?'}
|
|
94
|
+
</span>
|
|
95
|
+
</div>
|
|
96
|
+
</div>
|
|
97
|
+
)
|
|
98
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FileUploadButton — paperclip trigger that opens a native file picker.
|
|
3
|
+
* Disables itself when the file limit is reached.
|
|
4
|
+
*
|
|
5
|
+
* Promoted from brand-ai-extension (ADR-036 D20).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { useRef } from 'react'
|
|
9
|
+
import { Paperclip } from 'lucide-react'
|
|
10
|
+
import { cn } from '../lib/utils'
|
|
11
|
+
|
|
12
|
+
const ACCEPTED_TYPES = [
|
|
13
|
+
'image/jpeg',
|
|
14
|
+
'image/png',
|
|
15
|
+
'image/gif',
|
|
16
|
+
'application/pdf',
|
|
17
|
+
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
18
|
+
'text/plain',
|
|
19
|
+
'text/csv',
|
|
20
|
+
'text/html',
|
|
21
|
+
'text/css',
|
|
22
|
+
'text/xml',
|
|
23
|
+
'application/xml',
|
|
24
|
+
'application/javascript',
|
|
25
|
+
'text/javascript',
|
|
26
|
+
].join(',')
|
|
27
|
+
|
|
28
|
+
interface FileUploadButtonProps {
|
|
29
|
+
onFilesSelected: (files: File[]) => void
|
|
30
|
+
disabled?: boolean
|
|
31
|
+
fileCount?: number
|
|
32
|
+
maxFiles?: number
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function FileUploadButton({
|
|
36
|
+
onFilesSelected,
|
|
37
|
+
disabled = false,
|
|
38
|
+
fileCount = 0,
|
|
39
|
+
maxFiles = 10,
|
|
40
|
+
}: FileUploadButtonProps) {
|
|
41
|
+
const inputRef = useRef<HTMLInputElement>(null)
|
|
42
|
+
const isAtLimit = fileCount >= maxFiles
|
|
43
|
+
|
|
44
|
+
const handleClick = () => {
|
|
45
|
+
inputRef.current?.click()
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
49
|
+
const files = e.target.files
|
|
50
|
+
if (files && files.length > 0) {
|
|
51
|
+
onFilesSelected(Array.from(files))
|
|
52
|
+
// Reset so the same file can be re-selected
|
|
53
|
+
e.target.value = ''
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<>
|
|
59
|
+
<input
|
|
60
|
+
ref={inputRef}
|
|
61
|
+
type="file"
|
|
62
|
+
multiple
|
|
63
|
+
accept={ACCEPTED_TYPES}
|
|
64
|
+
onChange={handleChange}
|
|
65
|
+
className="hidden"
|
|
66
|
+
disabled={disabled || isAtLimit}
|
|
67
|
+
/>
|
|
68
|
+
<button
|
|
69
|
+
type="button"
|
|
70
|
+
onClick={handleClick}
|
|
71
|
+
disabled={disabled || isAtLimit}
|
|
72
|
+
className={cn(
|
|
73
|
+
'flex h-8 w-8 items-center justify-center transition-colors',
|
|
74
|
+
(disabled || isAtLimit) && 'cursor-not-allowed opacity-50',
|
|
75
|
+
)}
|
|
76
|
+
style={{ borderRadius: 'var(--radius-sm)' }}
|
|
77
|
+
title={
|
|
78
|
+
isAtLimit
|
|
79
|
+
? `Maximum ${maxFiles} files`
|
|
80
|
+
: `Attach files (${fileCount}/${maxFiles})`
|
|
81
|
+
}
|
|
82
|
+
aria-label={
|
|
83
|
+
isAtLimit
|
|
84
|
+
? `Maximum ${maxFiles} files reached`
|
|
85
|
+
: `Attach files (${fileCount} of ${maxFiles})`
|
|
86
|
+
}
|
|
87
|
+
>
|
|
88
|
+
<Paperclip
|
|
89
|
+
className="h-4 w-4"
|
|
90
|
+
style={{ color: 'rgb(var(--muted-foreground))' }}
|
|
91
|
+
/>
|
|
92
|
+
</button>
|
|
93
|
+
</>
|
|
94
|
+
)
|
|
95
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* file-upload barrel — public surface for the @lovett/ui file-upload subtree.
|
|
3
|
+
*
|
|
4
|
+
* Promoted from brand-ai-extension (ADR-036 D20).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export { FileUploadButton } from './file-upload-button'
|
|
8
|
+
export { FileDropZone } from './file-drop-zone'
|
|
9
|
+
export { FilePreviewGrid } from './file-preview-grid'
|
|
10
|
+
export { FilePreviewItem } from './file-preview-item'
|
|
11
|
+
export { FileThumbnail } from './file-thumbnail'
|
|
12
|
+
export { useFileUpload } from './use-file-upload'
|
|
13
|
+
export type { PendingFile, UploadResult, UseFileUploadOptions } from './use-file-upload'
|