diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a09c370..f8e70e1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -125,6 +125,8 @@ jobs: grep -q 'doneLabel' components/action-panel.tsx bunx shadcn@latest add http://localhost:8099/r/attachment.json -y -o grep -q 'rounded-menu' components/ui/attachment.tsx + bunx shadcn@latest add http://localhost:8099/r/file-upload.json -y -o + grep -q 'matchesAccept' components/file-upload.tsx bun run build - if: always() run: docker compose logs diff --git a/README.md b/README.md index 9580953..3699d5c 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,7 @@ npx shadcn@latest add @winlab/button | `status-page` | `registry:block` | A spotlight page that says what happened and the way out: not found, an error, no access; the site's own 404 uses it | | `date-picker` | `registry:block` | A date field with the same closed look as `select`; opens a `calendar`, closes on choice, can block days, and posts yyyy-mm-dd under `name` for `form-dialog` | | `action-panel` | `registry:block` | A page whose job is one action, such as opening the door: one 192px round button that locks while it runs, turns `success` for a moment, and says why when it cannot be pressed | +| `file-upload` | `registry:block` | Drop, paste or pick files; chosen files list as `attachment` rows to remove, files outside `accept` or `maxSize` show as errors with the reason, and `name` posts them inside `form-dialog` | | `badge` | `registry:ui` | Status label; variants `default`, `muted`, `outline`, `destructive`, `success`, `warning` | Text falls back in that order: `font-sans` is Inter, then Noto Sans JP, then Noto Sans TC. `font-mono` puts JetBrains Mono in front of the same CJK fonts. Use Inter's `tabular-nums` for amounts and table figures, not `font-mono`. diff --git a/app/_docs/demos/file-upload.tsx b/app/_docs/demos/file-upload.tsx new file mode 100644 index 0000000..f295d1d --- /dev/null +++ b/app/_docs/demos/file-upload.tsx @@ -0,0 +1,22 @@ +"use client" + +import * as React from "react" + +import { FileUpload } from "@/registry/winlab/blocks/file-upload/file-upload" + +export function Demo() { + const [files, setFiles] = React.useState([]) + + return ( + + ) +} diff --git a/app/_docs/demos/index.ts b/app/_docs/demos/index.ts index 9ab1d70..764df94 100644 --- a/app/_docs/demos/index.ts +++ b/app/_docs/demos/index.ts @@ -13,6 +13,7 @@ import { Demo as StatusPageDemo } from "@/app/_docs/demos/status-page" import { Demo as DatePickerDemo } from "@/app/_docs/demos/date-picker" import { Demo as Wide } from "@/app/_docs/demos/wide" import { Demo as ActionPanelDemo } from "@/app/_docs/demos/action-panel" +import { Demo as FileUploadDemo } from "@/app/_docs/demos/file-upload" import { Demo as Button } from "@/app/_docs/demos/button" import { Demo as Badge } from "@/app/_docs/demos/badge" import { Demo as Input } from "@/app/_docs/demos/input" @@ -52,6 +53,7 @@ export const demos: Record = { "status-page": StatusPageDemo, "date-picker": DatePickerDemo, "action-panel": ActionPanelDemo, + "file-upload": FileUploadDemo, button: Button, badge: Badge, input: Input, diff --git a/app/_docs/docs.ts b/app/_docs/docs.ts index 1486461..44afed0 100644 --- a/app/_docs/docs.ts +++ b/app/_docs/docs.ts @@ -104,6 +104,13 @@ export const docs: Doc[] = [ "整頁只做一件事,例如開門:一顆大按鈕,執行中鎖住,成功後短暫變色。", item: "action-panel", }, + { + group: "區塊", + slug: "file-upload", + title: "上傳檔案", + description: "拖放、貼上或點選檔案,送出前可以檢查和移除。", + item: "file-upload", + }, { group: "元件", slug: "button", diff --git a/registry.json b/registry.json index 0d46048..282f90f 100644 --- a/registry.json +++ b/registry.json @@ -862,6 +862,26 @@ "registryDependencies": [ "https://ui.winlab.tw/r/utils.json" ] + }, + { + "name": "file-upload", + "type": "registry:block", + "title": "File Upload", + "description": "A drop area that also opens the picker and takes pasted files, then the chosen files as attachments to check and remove.", + "dependencies": [ + "cn", + "lucide-react" + ], + "registryDependencies": [ + "https://ui.winlab.tw/r/utils.json", + "https://ui.winlab.tw/r/attachment.json" + ], + "files": [ + { + "path": "registry/winlab/blocks/file-upload/file-upload.tsx", + "type": "registry:component" + } + ] } ] } diff --git a/registry/winlab/blocks/file-upload/file-upload.tsx b/registry/winlab/blocks/file-upload/file-upload.tsx new file mode 100644 index 0000000..c2aab55 --- /dev/null +++ b/registry/winlab/blocks/file-upload/file-upload.tsx @@ -0,0 +1,257 @@ +"use client" + +import * as React from "react" +import { + FileAudioIcon, + FileIcon, + FileTextIcon, + UploadIcon, + XIcon, +} from "lucide-react" + +import { cn } from "@/registry/winlab/lib/utils" +import { + Attachment, + AttachmentAction, + AttachmentActions, + AttachmentContent, + AttachmentDescription, + AttachmentGroup, + AttachmentMedia, + AttachmentTitle, +} from "@/registry/winlab/ui/attachment" + +/** 240 KB, 3.2 MB. */ +function formatBytes(bytes: number) { + if (bytes < 1024 * 1024) return `${Math.max(1, Math.round(bytes / 1024))} KB` + return `${(bytes / 1024 / 1024).toFixed(1).replace(/\.0$/, "")} MB` +} + +// The same rules as the input's accept attribute: ".pdf", "image/*", +// "application/pdf". +function matchesAccept(file: File, accept?: string) { + if (!accept) return true + const name = file.name.toLowerCase() + return accept.split(",").some((token) => { + const rule = token.trim().toLowerCase() + if (rule.startsWith(".")) return name.endsWith(rule) + if (rule.endsWith("/*")) return file.type.startsWith(rule.slice(0, -1)) + return file.type === rule + }) +} + +type Rejected = { file: File; reason: string } + +type FileUploadProps = { + value: File[] + onValueChange: (files: File[]) => void + /** As on an input: ".pdf,image/*". */ + accept?: string + /** How accept reads to people: "PDF、JPG、PNG". */ + acceptLabel?: string + /** Bytes per file. */ + maxSize?: number + multiple?: boolean + /** Also take files pasted anywhere on the page (⌘V) while it is shown. */ + paste?: boolean + /** Posts the chosen files under this name, for FormDialog. */ + name?: string + disabled?: boolean + className?: string + id?: string +} + +// A drop area that also opens the file picker, then the chosen files as +// attachments to check and remove before sending. Files that do not fit +// accept or maxSize show as errors with the reason and are left out of value. +function FileUpload({ + value, + onValueChange, + accept, + acceptLabel, + maxSize, + multiple = false, + paste = false, + name, + disabled, + className, + id, +}: FileUploadProps) { + const [dragging, setDragging] = React.useState(false) + const [rejected, setRejected] = React.useState([]) + const formRef = React.useRef(null) + + const add = React.useCallback( + (incoming: File[]) => { + if (disabled || incoming.length === 0) return + const accepted: File[] = [] + const refused: Rejected[] = [] + for (const file of multiple ? incoming : incoming.slice(0, 1)) { + if (!matchesAccept(file, accept)) { + refused.push({ file, reason: "不支援這種檔案" }) + } else if (maxSize && file.size > maxSize) { + refused.push({ file, reason: `超過 ${formatBytes(maxSize)}` }) + } else { + accepted.push(file) + } + } + setRejected(refused) + if (accepted.length > 0) { + onValueChange(multiple ? [...value, ...accepted] : accepted) + } + }, + [accept, disabled, maxSize, multiple, onValueChange, value] + ) + + React.useEffect(() => { + if (!paste) return + function onPaste(event: ClipboardEvent) { + const files = Array.from(event.clipboardData?.files ?? []) + if (files.length === 0) return + event.preventDefault() + add(files) + } + document.addEventListener("paste", onPaste) + return () => document.removeEventListener("paste", onPaste) + }, [add, paste]) + + // Mirror value into a named file input so a surrounding form posts it. + React.useEffect(() => { + if (!formRef.current) return + const transfer = new DataTransfer() + for (const file of value) transfer.items.add(file) + formRef.current.files = transfer.files + }, [value]) + + const hint = [ + acceptLabel, + maxSize ? `每個 ${formatBytes(maxSize)} 以內` : undefined, + ] + .filter(Boolean) + .join(",") + + return ( +
+ + {name && ( + + )} + {(value.length > 0 || rejected.length > 0) && ( + + {value.map((file, index) => ( + + + + {file.name} + + {formatBytes(file.size)} + + + + + onValueChange(value.filter((_, i) => i !== index)) + } + > + + + + + ))} + {rejected.map(({ file, reason }, index) => ( + + + + {file.name} + {reason} + + + + setRejected(rejected.filter((_, i) => i !== index)) + } + > + + + + + ))} + + )} +
+ ) +} + +// A thumbnail for images, an icon by kind for the rest. The image's object +// URL lives exactly as long as the . +function FileMedia({ file }: { file: File }) { + return ( + + {file.type.startsWith("image/") ? ( + // eslint-disable-next-line @next/next/no-img-element + { + if (!img) return + const url = URL.createObjectURL(file) + img.src = url + return () => URL.revokeObjectURL(url) + }} + /> + ) : file.type.startsWith("audio/") ? ( + + ) : file.type === "application/pdf" ? ( + + ) : ( + + )} + + ) +} + +export { FileUpload, formatBytes } diff --git a/registry/winlab/ui/calendar.tsx b/registry/winlab/ui/calendar.tsx index bee6299..5eb0d3a 100644 --- a/registry/winlab/ui/calendar.tsx +++ b/registry/winlab/ui/calendar.tsx @@ -75,6 +75,7 @@ function CalendarDayButton({ ref={ref} variant="ghost" size="icon" + data-day={day.isoDate} data-selected={modifiers.selected || undefined} data-today={modifiers.today || undefined} data-outside={modifiers.outside || undefined}