Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
22 changes: 22 additions & 0 deletions app/_docs/demos/file-upload.tsx
Original file line number Diff line number Diff line change
@@ -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<File[]>([])

return (
<FileUpload
value={files}
onValueChange={setFiles}
accept=".pdf,image/*"
acceptLabel="PDF、JPG、PNG"
maxSize={10 * 1024 * 1024}
multiple
paste
className="w-full max-w-md"
/>
)
}
2 changes: 2 additions & 0 deletions app/_docs/demos/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -52,6 +53,7 @@ export const demos: Record<string, ComponentType> = {
"status-page": StatusPageDemo,
"date-picker": DatePickerDemo,
"action-panel": ActionPanelDemo,
"file-upload": FileUploadDemo,
button: Button,
badge: Badge,
input: Input,
Expand Down
7 changes: 7 additions & 0 deletions app/_docs/docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,13 @@ export const docs: Doc[] = [
"整頁只做一件事,例如開門:一顆大按鈕,執行中鎖住,成功後短暫變色。",
item: "action-panel",
},
{
group: "區塊",
slug: "file-upload",
title: "上傳檔案",
description: "拖放、貼上或點選檔案,送出前可以檢查和移除。",
item: "file-upload",
},
{
group: "元件",
slug: "button",
Expand Down
20 changes: 20 additions & 0 deletions registry.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
]
}
]
}
257 changes: 257 additions & 0 deletions registry/winlab/blocks/file-upload/file-upload.tsx
Original file line number Diff line number Diff line change
@@ -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<Rejected[]>([])
const formRef = React.useRef<HTMLInputElement>(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 (
<div
data-slot="file-upload"
className={cn("flex flex-col gap-3", className)}
>
<label
data-dragging={dragging || undefined}
data-disabled={disabled || undefined}
onDragOver={(event) => {
event.preventDefault()
if (!disabled) setDragging(true)
}}
onDragLeave={() => setDragging(false)}
onDrop={(event) => {
event.preventDefault()
setDragging(false)
add(Array.from(event.dataTransfer.files))
}}
className="flex cursor-pointer flex-col items-center gap-2 rounded-surface border border-dashed border-input p-6 text-center transition-colors duration-state hover:bg-muted/50 has-[input:focus-visible]:border-ring has-[input:focus-visible]:ring-3 has-[input:focus-visible]:ring-ring/50 data-dragging:border-ring data-dragging:bg-muted data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:size-4"
>
<UploadIcon className="text-muted-foreground" />
<span className="font-medium">
{paste ? "拖放、貼上檔案,或點這裡選擇" : "拖放檔案,或點這裡選擇"}
</span>
{hint && <span className="text-muted-foreground">{hint}</span>}
<input
id={id}
type="file"
accept={accept}
multiple={multiple}
disabled={disabled}
className="sr-only"
onChange={(event) => {
add(Array.from(event.target.files ?? []))
event.target.value = ""
}}
/>
</label>
{name && (
<input
ref={formRef}
type="file"
name={name}
multiple
hidden
tabIndex={-1}
/>
)}
{(value.length > 0 || rejected.length > 0) && (
<AttachmentGroup>
{value.map((file, index) => (
<Attachment key={`${file.name}-${file.lastModified}-${index}`}>
<FileMedia file={file} />
<AttachmentContent>
<AttachmentTitle>{file.name}</AttachmentTitle>
<AttachmentDescription>
{formatBytes(file.size)}
</AttachmentDescription>
</AttachmentContent>
<AttachmentActions>
<AttachmentAction
aria-label={`移除 ${file.name}`}
disabled={disabled}
onClick={() =>
onValueChange(value.filter((_, i) => i !== index))
}
>
<XIcon />
</AttachmentAction>
</AttachmentActions>
</Attachment>
))}
{rejected.map(({ file, reason }, index) => (
<Attachment key={`rejected-${file.name}-${index}`} state="error">
<FileMedia file={file} />
<AttachmentContent>
<AttachmentTitle>{file.name}</AttachmentTitle>
<AttachmentDescription>{reason}</AttachmentDescription>
</AttachmentContent>
<AttachmentActions>
<AttachmentAction
aria-label={`移除 ${file.name}`}
onClick={() =>
setRejected(rejected.filter((_, i) => i !== index))
}
>
<XIcon />
</AttachmentAction>
</AttachmentActions>
</Attachment>
))}
</AttachmentGroup>
)}
</div>
)
}

// A thumbnail for images, an icon by kind for the rest. The image's object
// URL lives exactly as long as the <img>.
function FileMedia({ file }: { file: File }) {
return (
<AttachmentMedia>
{file.type.startsWith("image/") ? (
// eslint-disable-next-line @next/next/no-img-element
<img
alt=""
ref={(img) => {
if (!img) return
const url = URL.createObjectURL(file)
img.src = url
return () => URL.revokeObjectURL(url)
}}
/>
) : file.type.startsWith("audio/") ? (
<FileAudioIcon />
) : file.type === "application/pdf" ? (
<FileTextIcon />
) : (
<FileIcon />
)}
</AttachmentMedia>
)
}

export { FileUpload, formatBytes }
1 change: 1 addition & 0 deletions registry/winlab/ui/calendar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
Loading