feat(parent,auth,onboarding,files,notifications,adaptive-practice,ai): add module updates

parent:

- Add parent-student-attendance-detail component

auth:

- Add actions, data-access, schema, services, types

onboarding:

- Add parent-children-form and hooks directory

files:

- Add actions, schema, hooks directory

notifications:

- Add schema and schema test

adaptive-practice:

- Add answer-input, answer-result, practice-result-view, practice-starter-with-nav

- Add question-card, question-content, lib and services directories

ai:

- Add context/create-ai-client-service, hooks/use-drag-position, hooks/use-position-persistence
This commit is contained in:
SpecialX
2026-07-03 10:26:12 +08:00
parent f3c223d914
commit e9a5264fe7
84 changed files with 6060 additions and 2530 deletions

View File

@@ -1,12 +1,13 @@
"use client"
import { useMemo, useState } from "react"
import { useTranslations } from "next-intl"
import { useRouter } from "next/navigation"
import { Files, Search, Trash2, HardDrive, FileWarning } from "lucide-react"
import { toast } from "sonner"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
import { Checkbox } from "@/shared/components/ui/checkbox"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { Input } from "@/shared/components/ui/input"
import {
Select,
@@ -15,13 +16,14 @@ import {
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select"
import { Badge } from "@/shared/components/ui/badge"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { WidgetBoundary } from "@/shared/components/widget-boundary"
import { formatDate } from "@/shared/lib/utils"
import { formatFileSize } from "@/shared/lib/file-storage"
import { FileIcon } from "./file-icon"
import { FileUpload } from "./file-upload"
import { FilePreviewDialog } from "./file-preview-dialog"
import { FileUpload } from "./file-upload"
import { useFileBatchOperations } from "../hooks/use-file-batch-operations"
import type { FileAttachment, FileStats } from "../types"
interface AdminFilesViewProps {
@@ -29,174 +31,159 @@ interface AdminFilesViewProps {
stats: FileStats
}
// 文件类型分组选项
const TYPE_OPTIONS: Array<{ value: string; label: string }> = [
{ value: "all", label: "All Types" },
{ value: "image/", label: "Images" },
{ value: "application/pdf", label: "PDF" },
{ value: "application/msword", label: "Word" },
{ value: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", label: "Word (docx)" },
{ value: "application/vnd.ms-excel", label: "Excel" },
{ value: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", label: "Excel (xlsx)" },
{ value: "application/vnd.ms-powerpoint", label: "PowerPoint" },
{ value: "application/vnd.openxmlformats-officedocument.presentationml.presentation", label: "PowerPoint (pptx)" },
{ value: "text/", label: "Text" },
{ value: "application/zip", label: "ZIP" },
]
interface TypeOption {
value: string
labelKey:
| "allTypes"
| "images"
| "pdf"
| "word"
| "wordDocx"
| "excel"
| "excelXlsx"
| "powerpoint"
| "powerpointPptx"
| "text"
| "zip"
}
export function AdminFilesView({ files, stats }: AdminFilesViewProps) {
const TYPE_OPTIONS = [
{ value: "all", labelKey: "allTypes" },
{ value: "image/", labelKey: "images" },
{ value: "application/pdf", labelKey: "pdf" },
{ value: "application/msword", labelKey: "word" },
{ value: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", labelKey: "wordDocx" },
{ value: "application/vnd.ms-excel", labelKey: "excel" },
{ value: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", labelKey: "excelXlsx" },
{ value: "application/vnd.ms-powerpoint", labelKey: "powerpoint" },
{ value: "application/vnd.openxmlformats-officedocument.presentationml.presentation", labelKey: "powerpointPptx" },
{ value: "text/", labelKey: "text" },
{ value: "application/zip", labelKey: "zip" },
] as const satisfies TypeOption[]
type TypeLabelKey = TypeOption["labelKey"]
export function AdminFilesView({
files,
stats,
}: AdminFilesViewProps): React.ReactElement {
const t = useTranslations("files.admin")
const router = useRouter()
const [typeFilter, setTypeFilter] = useState<string>("all")
const [search, setSearch] = useState<string>("")
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [deleting, setDeleting] = useState(false)
// 客户端二次筛选(与 server 端筛选互补,提升交互即时性)
const filteredFiles = useMemo(() => {
return files.filter((f) => {
if (typeFilter !== "all") {
if (typeFilter.endsWith("/")) {
if (!f.mimeType.startsWith(typeFilter)) return false
} else if (f.mimeType !== typeFilter) {
return false
}
}
if (search.trim()) {
const kw = search.trim().toLowerCase()
if (
!f.originalName.toLowerCase().includes(kw) &&
!f.filename.toLowerCase().includes(kw)
) {
return false
}
}
return true
})
}, [files, typeFilter, search])
const allSelected = filteredFiles.length > 0 && selectedIds.size === filteredFiles.length
const someSelected = selectedIds.size > 0 && !allSelected
const toggleAll = () => {
if (allSelected) {
setSelectedIds(new Set())
} else {
setSelectedIds(new Set(filteredFiles.map((f) => f.id)))
const renderTypeLabel = (key: TypeLabelKey): string => {
switch (key) {
case "allTypes": return t("filter.allTypes")
case "images": return t("filter.images")
case "pdf": return t("filter.pdf")
case "word": return t("filter.word")
case "wordDocx": return t("filter.wordDocx")
case "excel": return t("filter.excel")
case "excelXlsx": return t("filter.excelXlsx")
case "powerpoint": return t("filter.powerpoint")
case "powerpointPptx": return t("filter.powerpointPptx")
case "text": return t("filter.text")
case "zip": return t("filter.zip")
}
}
const toggleOne = (id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
const handleUploaded = () => {
router.refresh()
}
const handleDeleted = () => {
router.refresh()
setSelectedIds(new Set())
}
const handleBatchDelete = async () => {
if (selectedIds.size === 0) return
const ids = Array.from(selectedIds)
setDeleting(true)
try {
const res = await fetch("/api/files/batch-delete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ids }),
})
const body = await res.json().catch(() => null)
if (!res.ok || !body?.success) {
toast.error(body?.message || "Failed to delete files")
return
}
toast.success(`Deleted ${body.deletedCount} file(s)`)
handleDeleted()
} catch {
toast.error("Failed to delete files")
} finally {
setDeleting(false)
}
}
const {
selectedIds,
typeFilter,
search,
deleting,
setTypeFilter,
setSearch,
filteredFiles,
allSelected,
someSelected,
toggleAll,
toggleOne,
handleBatchDelete,
} = useFileBatchOperations({
files,
onAfterDelete: () => router.refresh(),
})
return (
<div className="flex h-full flex-col space-y-6 p-8">
<div className="space-y-1">
<h2 className="flex items-center gap-2 text-2xl font-bold tracking-tight">
<Files className="h-6 w-6" />
Files
<Files className="h-6 w-6" aria-hidden="true" />
{t("title")}
</h2>
<p className="text-muted-foreground">
Upload and manage all files in the system.
</p>
<p className="text-muted-foreground">{t("subtitle")}</p>
</div>
{/* 统计卡片 */}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div className="rounded-md border p-4">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Files className="h-3.5 w-3.5" />
Total Files
</div>
<p className="mt-1 text-2xl font-bold">{stats.totalCount}</p>
</div>
<div className="rounded-md border p-4">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<HardDrive className="h-3.5 w-3.5" />
Total Size
</div>
<p className="mt-1 text-2xl font-bold">{formatFileSize(stats.totalSize)}</p>
</div>
{stats.byType.slice(0, 2).map((t) => (
<div key={t.mimeType} className="rounded-md border p-4">
<WidgetBoundary title={t("stats.totalFiles")} skeletonHeight={120}>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div className="rounded-md border p-4">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<FileIcon mimeType={t.mimeType} className="h-3.5 w-3.5" />
<span className="truncate" title={t.mimeType}>{t.mimeType}</span>
<Files className="h-3.5 w-3.5" aria-hidden="true" />
{t("stats.totalFiles")}
</div>
<p className="mt-1 text-2xl font-bold">{t.count}</p>
<p className="text-xs text-muted-foreground">{formatFileSize(t.size)}</p>
<p className="mt-1 text-2xl font-bold">{stats.totalCount}</p>
</div>
))}
</div>
<div className="rounded-md border p-4">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<HardDrive className="h-3.5 w-3.5" aria-hidden="true" />
{t("stats.totalSize")}
</div>
<p className="mt-1 text-2xl font-bold">{formatFileSize(stats.totalSize)}</p>
</div>
{stats.byType.slice(0, 2).map((typeStat) => (
<div key={typeStat.mimeType} className="rounded-md border p-4">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<FileIcon mimeType={typeStat.mimeType} className="h-3.5 w-3.5" />
<span className="truncate" title={typeStat.mimeType}>
{typeStat.mimeType}
</span>
</div>
<p className="mt-1 text-2xl font-bold">{typeStat.count}</p>
<p className="text-xs text-muted-foreground">
{formatFileSize(typeStat.size)}
</p>
</div>
))}
</div>
</WidgetBoundary>
<FileUpload onUploaded={handleUploaded} />
<WidgetBoundary title={t("title")} skeletonHeight={160}>
<FileUpload onUploaded={() => router.refresh()} />
</WidgetBoundary>
{/* 筛选与批量操作工具栏 */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-1 flex-col gap-2 sm:flex-row sm:items-center">
<Select value={typeFilter} onValueChange={setTypeFilter}>
<SelectTrigger className="w-full sm:w-[200px]">
<SelectValue placeholder="Filter by type" />
<SelectValue placeholder={t("filter.byType")} />
</SelectTrigger>
<SelectContent>
{TYPE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
{renderTypeLabel(opt.labelKey)}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="relative flex-1">
<Search className="absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Search
className="absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground"
aria-hidden="true"
/>
<Input
placeholder="Search by file name..."
placeholder={t("filter.search")}
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-8"
aria-label={t("filter.search")}
/>
</div>
</div>
{selectedIds.size > 0 ? (
<div className="flex items-center gap-2">
<Badge variant="secondary">{selectedIds.size} selected</Badge>
<Badge variant="secondary">
{t("selection.selected", { count: selectedIds.size })}
</Badge>
<Button
type="button"
variant="destructive"
@@ -204,86 +191,91 @@ export function AdminFilesView({ files, stats }: AdminFilesViewProps) {
disabled={deleting}
onClick={() => void handleBatchDelete()}
>
<Trash2 className="mr-2 h-4 w-4" />
{deleting ? "Deleting..." : "Delete Selected"}
<Trash2 className="mr-2 h-4 w-4" aria-hidden="true" />
{deleting ? t("selection.deleting") : t("selection.deleteSelected")}
</Button>
</div>
) : null}
</div>
{/* 文件列表 */}
{filteredFiles.length === 0 ? (
<EmptyState
title="No files found"
description="Try adjusting your filters or upload a new file."
icon={FileWarning}
className="h-auto border-none shadow-none"
/>
) : (
<div className="rounded-md border">
<div className="flex items-center gap-3 border-b bg-muted/40 px-3 py-2 text-xs font-medium text-muted-foreground">
<Checkbox
checked={allSelected ? true : someSelected ? "indeterminate" : false}
onCheckedChange={toggleAll}
aria-label="Select all"
/>
<span className="flex-1">File</span>
<span className="hidden w-24 sm:block">Size</span>
<span className="hidden w-32 md:block">Type</span>
<span className="hidden w-32 md:block">Uploaded</span>
<span className="w-24 text-right">Actions</span>
</div>
<ul className="divide-y">
{filteredFiles.map((file) => {
const checked = selectedIds.has(file.id)
return (
<li
key={file.id}
className="flex items-center gap-3 p-3 transition-colors hover:bg-accent/40"
>
<Checkbox
checked={checked}
onCheckedChange={() => toggleOne(file.id)}
aria-label={`Select ${file.originalName}`}
/>
<FileIcon mimeType={file.mimeType} className="h-6 w-6" />
<div className="min-w-0 flex-1">
<a
href={file.url ?? "#"}
target="_blank"
rel="noopener noreferrer"
className="truncate text-sm font-medium hover:underline"
title={file.originalName}
>
{file.originalName}
</a>
<p className="mt-0.5 text-xs text-muted-foreground sm:hidden">
{formatFileSize(file.size)} · {formatDate(file.createdAt, "zh-CN")}
</p>
</div>
<span className="hidden w-24 shrink-0 text-xs text-muted-foreground sm:block">
{formatFileSize(file.size)}
</span>
<span className="hidden w-32 shrink-0 truncate text-xs text-muted-foreground md:block" title={file.mimeType}>
{file.mimeType}
</span>
<span className="hidden w-32 shrink-0 text-xs text-muted-foreground md:block">
{formatDate(file.createdAt, "zh-CN")}
</span>
<div className="flex w-24 shrink-0 justify-end gap-1">
<FilePreviewDialog
file={file}
triggerLabel=""
triggerVariant="ghost"
triggerSize="icon"
<WidgetBoundary title={t("title")} skeletonHeight={400}>
{filteredFiles.length === 0 ? (
<EmptyState
title={t("empty.title")}
description={t("empty.description")}
icon={FileWarning}
className="h-auto border-none shadow-none"
/>
) : (
<div className="rounded-md border">
<div className="flex items-center gap-3 border-b bg-muted/40 px-3 py-2 text-xs font-medium text-muted-foreground">
<Checkbox
checked={allSelected ? true : someSelected ? "indeterminate" : false}
onCheckedChange={toggleAll}
aria-label={t("columns.file")}
/>
<span className="flex-1">{t("columns.file")}</span>
<span className="hidden w-24 sm:block">{t("columns.size")}</span>
<span className="hidden w-32 md:block">{t("columns.type")}</span>
<span className="hidden w-32 md:block">{t("columns.uploaded")}</span>
<span className="w-24 text-right">{t("columns.actions")}</span>
</div>
<ul className="divide-y">
{filteredFiles.map((file) => {
const checked = selectedIds.has(file.id)
return (
<li
key={file.id}
className="flex items-center gap-3 p-3 transition-colors hover:bg-accent/40"
>
<Checkbox
checked={checked}
onCheckedChange={() => toggleOne(file.id)}
aria-label={`Select ${file.originalName}`}
/>
</div>
</li>
)
})}
</ul>
</div>
)}
<FileIcon mimeType={file.mimeType} className="h-6 w-6" />
<div className="min-w-0 flex-1">
<a
href={file.url ?? "#"}
target="_blank"
rel="noopener noreferrer"
className="truncate text-sm font-medium hover:underline"
title={file.originalName}
aria-label={`${t("columns.file")}: ${file.originalName}`}
>
{file.originalName}
</a>
<p className="mt-0.5 text-xs text-muted-foreground sm:hidden">
{formatFileSize(file.size)} · {formatDate(file.createdAt, "zh-CN")}
</p>
</div>
<span className="hidden w-24 shrink-0 text-xs text-muted-foreground sm:block">
{formatFileSize(file.size)}
</span>
<span
className="hidden w-32 shrink-0 truncate text-xs text-muted-foreground md:block"
title={file.mimeType}
>
{file.mimeType}
</span>
<span className="hidden w-32 shrink-0 text-xs text-muted-foreground md:block">
{formatDate(file.createdAt, "zh-CN")}
</span>
<div className="flex w-24 shrink-0 justify-end gap-1">
<FilePreviewDialog
file={file}
triggerLabel=""
triggerVariant="ghost"
triggerSize="icon"
/>
</div>
</li>
)
})}
</ul>
</div>
)}
</WidgetBoundary>
</div>
)
}

View File

@@ -1,126 +0,0 @@
"use client"
import { useState } from "react"
import { Download, Trash2, FileWarning } from "lucide-react"
import { toast } from "sonner"
import { Button } from "@/shared/components/ui/button"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { formatDate } from "@/shared/lib/utils"
import { formatFileSize } from "@/shared/lib/file-storage"
import { FileIcon } from "./file-icon"
import type { FileAttachment } from "../types"
interface FileListProps {
files: FileAttachment[]
canDelete?: boolean
onDeleted?: (id: string) => void
emptyTitle?: string
emptyDescription?: string
}
export function FileList({
files,
canDelete = false,
onDeleted,
emptyTitle = "No files",
emptyDescription = "There are no files yet.",
}: FileListProps) {
const [deletingId, setDeletingId] = useState<string | null>(null)
const handleDelete = async (file: FileAttachment) => {
setDeletingId(file.id)
try {
const res = await fetch(`/api/files/${file.id}`, { method: "DELETE" })
const body = await res.json().catch(() => null)
if (!res.ok || !body?.success) {
toast.error(body?.message || "Failed to delete file")
return
}
toast.success("File deleted")
onDeleted?.(file.id)
} catch {
toast.error("Failed to delete file")
} finally {
setDeletingId(null)
}
}
if (files.length === 0) {
return (
<EmptyState
title={emptyTitle}
description={emptyDescription}
icon={FileWarning}
className="h-auto border-none shadow-none"
/>
)
}
return (
<ul className="divide-y rounded-md border">
{files.map((file) => (
<li
key={file.id}
className="flex items-center gap-3 p-3 transition-colors hover:bg-accent/40"
>
<FileIcon mimeType={file.mimeType} className="h-6 w-6" />
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2">
<a
href={file.url ?? "#"}
target="_blank"
rel="noopener noreferrer"
className="truncate text-sm font-medium hover:underline"
title={file.originalName}
>
{file.originalName}
</a>
<span className="shrink-0 text-xs text-muted-foreground">
{formatFileSize(file.size)}
</span>
</div>
<div className="mt-0.5 flex items-center gap-2 text-xs text-muted-foreground">
<span className="font-mono">{file.mimeType}</span>
<span>·</span>
<span>{formatDate(file.createdAt, "zh-CN")}</span>
</div>
</div>
<div className="flex shrink-0 items-center gap-1">
<Button
asChild
variant="ghost"
size="icon"
className="h-8 w-8"
title="Download"
>
<a
href={file.url ?? "#"}
download={file.originalName}
target="_blank"
rel="noopener noreferrer"
>
<Download className="h-4 w-4" />
<span className="sr-only">Download</span>
</a>
</Button>
{canDelete ? (
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive"
title="Delete"
disabled={deletingId === file.id}
onClick={() => void handleDelete(file)}
>
<Trash2 className="h-4 w-4" />
<span className="sr-only">Delete</span>
</Button>
) : null}
</div>
</li>
))}
</ul>
)
}

View File

@@ -1,6 +1,7 @@
"use client"
import * as React from "react"
import { useTranslations } from "next-intl"
import { Eye } from "lucide-react"
import {
@@ -26,17 +27,26 @@ interface FilePreviewDialogProps {
export function FilePreviewDialog({
file,
trigger,
triggerLabel = "Preview",
triggerLabel,
triggerVariant = "outline",
triggerSize = "sm",
}: FilePreviewDialogProps) {
}: FilePreviewDialogProps): React.ReactElement {
const t = useTranslations("files.preview")
return (
<Dialog>
<DialogTrigger asChild>
{trigger ?? (
<Button type="button" variant={triggerVariant} size={triggerSize}>
<Eye className={triggerLabel ? "mr-2 h-4 w-4" : "h-4 w-4"} />
{triggerLabel ? <span>{triggerLabel}</span> : <span className="sr-only">Preview</span>}
<Eye
className={triggerLabel ? "mr-2 h-4 w-4" : "h-4 w-4"}
aria-hidden="true"
/>
{triggerLabel ? (
<span>{triggerLabel}</span>
) : (
<span className="sr-only">{t("trigger")}</span>
)}
</Button>
)}
</DialogTrigger>
@@ -44,7 +54,7 @@ export function FilePreviewDialog({
<DialogHeader>
<DialogTitle className="truncate">{file.originalName}</DialogTitle>
<DialogDescription>
File preview · {file.mimeType}
{t("title")} · {file.mimeType}
</DialogDescription>
</DialogHeader>
<div className="max-h-[75vh] overflow-auto">

View File

@@ -1,11 +1,12 @@
"use client"
import { useState } from "react"
import { useTranslations } from "next-intl"
import { Download, ZoomIn, ZoomOut, FileText } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
import { FileIcon } from "./file-icon"
import { formatFileSize } from "@/shared/lib/file-storage"
import { useFilePreview, useImageZoom } from "../hooks/use-file-preview"
import type { FileAttachment } from "../types"
interface FilePreviewProps {
@@ -42,7 +43,8 @@ function classify(mimeType: string): PreviewKind {
return "other"
}
export function FilePreview({ file, className }: FilePreviewProps) {
export function FilePreview({ file, className }: FilePreviewProps): React.ReactElement {
const t = useTranslations("files.preview")
const kind = classify(file.mimeType)
const url = file.url ?? "#"
@@ -66,9 +68,10 @@ export function FilePreview({ file, className }: FilePreviewProps) {
download={file.originalName}
target="_blank"
rel="noopener noreferrer"
aria-label={`${t("download")} ${file.originalName}`}
>
<Download className="mr-2 h-4 w-4" />
Download
<Download className="mr-2 h-4 w-4" aria-hidden="true" />
{t("download")}
</a>
</Button>
</div>
@@ -86,7 +89,7 @@ function PreviewBody({
kind: PreviewKind
file: FileAttachment
url: string
}) {
}): React.ReactElement {
if (kind === "image") {
return <ImagePreview url={url} alt={file.originalName} />
}
@@ -96,6 +99,7 @@ function PreviewBody({
<iframe
src={url}
title={file.originalName}
aria-label={`PDF preview: ${file.originalName}`}
className="h-[70vh] w-full rounded-md border"
/>
)
@@ -105,35 +109,12 @@ function PreviewBody({
return <TextPreview url={url} />
}
// Office / other: show info card + download button
return (
<div className="flex flex-col items-center justify-center rounded-md border border-dashed p-12 text-center">
<FileIcon mimeType={file.mimeType} className="h-12 w-12" />
<p className="mt-3 text-sm font-medium">
{kind === "office" ? "Office file preview not available" : "Preview not available"}
</p>
<p className="mt-1 text-xs text-muted-foreground">
{kind === "office"
? "Download the file to view its contents in your Office application."
: "Download the file to view its contents."}
</p>
<Button asChild variant="outline" size="sm" className="mt-4">
<a
href={url}
download={file.originalName}
target="_blank"
rel="noopener noreferrer"
>
<Download className="mr-2 h-4 w-4" />
Download
</a>
</Button>
</div>
)
return <OtherPreview kind={kind} file={file} url={url} />
}
function ImagePreview({ url, alt }: { url: string; alt: string }) {
const [zoom, setZoom] = useState(1)
function ImagePreview({ url, alt }: { url: string; alt: string }): React.ReactElement {
const t = useTranslations("files.preview")
const { zoom, zoomIn, zoomOut, canZoomIn, canZoomOut } = useImageZoom()
return (
<div className="space-y-2">
@@ -143,13 +124,13 @@ function ImagePreview({ url, alt }: { url: string; alt: string }) {
variant="outline"
size="icon"
className="h-8 w-8"
onClick={() => setZoom((z) => Math.max(0.25, z - 0.25))}
disabled={zoom <= 0.25}
onClick={zoomOut}
disabled={!canZoomOut}
aria-label={t("zoomOut")}
>
<ZoomOut className="h-4 w-4" />
<span className="sr-only">Zoom out</span>
<ZoomOut className="h-4 w-4" aria-hidden="true" />
</Button>
<span className="text-xs text-muted-foreground w-12 text-center">
<span className="w-12 text-center text-xs text-muted-foreground" aria-live="polite">
{Math.round(zoom * 100)}%
</span>
<Button
@@ -157,14 +138,17 @@ function ImagePreview({ url, alt }: { url: string; alt: string }) {
variant="outline"
size="icon"
className="h-8 w-8"
onClick={() => setZoom((z) => Math.min(4, z + 0.25))}
disabled={zoom >= 4}
onClick={zoomIn}
disabled={!canZoomIn}
aria-label={t("zoomIn")}
>
<ZoomIn className="h-4 w-4" />
<span className="sr-only">Zoom in</span>
<ZoomIn className="h-4 w-4" aria-hidden="true" />
</Button>
</div>
<div className="overflow-auto rounded-md border bg-muted/30 p-2" style={{ maxHeight: "70vh" }}>
<div
className="overflow-auto rounded-md border bg-muted/30 p-2"
style={{ maxHeight: "70vh" }}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={url}
@@ -177,34 +161,23 @@ function ImagePreview({ url, alt }: { url: string; alt: string }) {
)
}
function TextPreview({ url }: { url: string }) {
const [content, setContent] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const load = async () => {
setLoading(true)
setError(null)
try {
const res = await fetch(url)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const text = await res.text()
setContent(text)
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to load text")
} finally {
setLoading(false)
}
}
function TextPreview({ url }: { url: string }): React.ReactElement {
const t = useTranslations("files.preview.text")
const { content, error, loading, load } = useFilePreview()
if (content === null && !error && !loading) {
return (
<div className="flex flex-col items-center justify-center rounded-md border border-dashed p-12 text-center">
<FileText className="h-12 w-12 text-muted-foreground" />
<p className="mt-3 text-sm font-medium">Text file</p>
<p className="mt-1 text-xs text-muted-foreground">Click below to load the content.</p>
<Button variant="outline" size="sm" className="mt-4" onClick={() => void load()}>
Load preview
<FileText className="h-12 w-12 text-muted-foreground" aria-hidden="true" />
<p className="mt-3 text-sm font-medium">{t("title")}</p>
<p className="mt-1 text-xs text-muted-foreground">{t("hint")}</p>
<Button
variant="outline"
size="sm"
className="mt-4"
onClick={() => void load(url)}
>
{t("load")}
</Button>
</div>
)
@@ -212,16 +185,29 @@ function TextPreview({ url }: { url: string }) {
if (loading) {
return (
<div className="rounded-md border bg-muted/30 p-12 text-center text-sm text-muted-foreground">
Loading...
<div
role="status"
className="rounded-md border bg-muted/30 p-12 text-center text-sm text-muted-foreground"
>
{t("loading")}
</div>
)
}
if (error) {
return (
<div className="rounded-md border border-destructive/40 bg-destructive/10 p-4 text-sm text-destructive">
Failed to load text: {error}
<div
role="alert"
className="flex flex-col items-center justify-center gap-3 rounded-md border border-destructive/40 bg-destructive/10 p-4 text-sm text-destructive"
>
<span>{t("error", { message: error })}</span>
<Button
variant="outline"
size="sm"
onClick={() => void load(url)}
>
{t("load")}
</Button>
</div>
)
}
@@ -232,3 +218,38 @@ function TextPreview({ url }: { url: string }) {
</pre>
)
}
function OtherPreview({
kind,
file,
url,
}: {
kind: PreviewKind
file: FileAttachment
url: string
}): React.ReactElement {
const t = useTranslations("files.preview")
const isOffice = kind === "office"
const title = isOffice ? t("office.title") : t("other.title")
const hint = isOffice ? t("office.hint") : t("other.hint")
return (
<div className="flex flex-col items-center justify-center rounded-md border border-dashed p-12 text-center">
<FileIcon mimeType={file.mimeType} className="h-12 w-12" />
<p className="mt-3 text-sm font-medium">{title}</p>
<p className="mt-1 text-xs text-muted-foreground">{hint}</p>
<Button asChild variant="outline" size="sm" className="mt-4">
<a
href={url}
download={file.originalName}
target="_blank"
rel="noopener noreferrer"
aria-label={`${t("download")} ${file.originalName}`}
>
<Download className="mr-2 h-4 w-4" aria-hidden="true" />
{t("download")}
</a>
</Button>
</div>
)
}

View File

@@ -1,18 +1,15 @@
"use client"
import { useCallback, useRef, useState } from "react"
import { useTranslations } from "next-intl"
import { UploadCloud, X } from "lucide-react"
import { toast } from "sonner"
import { Button } from "@/shared/components/ui/button"
import { Progress } from "@/shared/components/ui/progress"
import { cn } from "@/shared/lib/utils"
import {
ALLOWED_MIME_TYPES,
formatFileSize,
MAX_FILE_SIZE,
} from "@/shared/lib/file-storage"
import { formatFileSize } from "@/shared/lib/file-storage"
import { FileIcon } from "./file-icon"
import { useFileUpload } from "../hooks/use-file-upload"
import type { FileTargetType, FileUploadResult } from "../types"
interface FileUploadProps {
@@ -23,146 +20,31 @@ interface FileUploadProps {
className?: string
}
interface UploadTask {
file: File
progress: number
status: "uploading" | "success" | "error"
message?: string
result?: FileUploadResult
}
const ACCEPT_ATTR = (ALLOWED_MIME_TYPES as readonly string[]).join(",")
export function FileUpload({
targetType,
targetId,
onUploaded,
multiple = true,
className,
}: FileUploadProps) {
const inputRef = useRef<HTMLInputElement>(null)
const [isDragging, setIsDragging] = useState(false)
const [tasks, setTasks] = useState<UploadTask[]>([])
const validateFile = (file: File): string | null => {
if (file.size === 0) return "File is empty"
if (file.size > MAX_FILE_SIZE) return "File size exceeds 10MB limit"
if (!(ALLOWED_MIME_TYPES as readonly string[]).includes(file.type)) {
return `File type ${file.type || "unknown"} is not allowed`
}
return null
}
const uploadOne = useCallback(
async (file: File): Promise<void> => {
const taskId = `${file.name}-${file.size}-${Date.now()}`
setTasks((prev) => [
...prev,
{ file, progress: 0, status: "uploading" },
])
const validationError = validateFile(file)
if (validationError) {
setTasks((prev) =>
prev.map((t) =>
t.file === file
? { ...t, status: "error", message: validationError, progress: 100 }
: t
)
)
toast.error(`${file.name}: ${validationError}`)
return
}
try {
const formData = new FormData()
formData.append("file", file)
if (targetType) formData.append("targetType", targetType)
if (targetId) formData.append("targetId", targetId)
const xhr = new XMLHttpRequest()
const result = await new Promise<FileUploadResult>((resolve, reject) => {
xhr.open("POST", "/api/upload")
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) {
const pct = Math.round((e.loaded / e.total) * 100)
setTasks((prev) =>
prev.map((t) =>
t.file === file ? { ...t, progress: pct } : t
)
)
}
}
xhr.onload = () => {
try {
const body = JSON.parse(xhr.responseText)
if (xhr.status >= 200 && xhr.status < 300 && body.success) {
resolve(body as FileUploadResult)
} else {
reject(new Error(body.message || "Upload failed"))
}
} catch {
reject(new Error("Invalid response"))
}
}
xhr.onerror = () => reject(new Error("Network error"))
xhr.send(formData)
})
setTasks((prev) =>
prev.map((t) =>
t.file === file
? { ...t, status: "success", progress: 100, result }
: t
)
)
onUploaded?.(result)
toast.success(`${file.name} uploaded`)
void taskId
} catch (e) {
const message = e instanceof Error ? e.message : "Upload failed"
setTasks((prev) =>
prev.map((t) =>
t.file === file ? { ...t, status: "error", message } : t
)
)
toast.error(`${file.name}: ${message}`)
}
},
[targetType, targetId, onUploaded]
)
const handleFiles = useCallback(
(fileList: FileList | null) => {
if (!fileList || fileList.length === 0) return
const files = Array.from(fileList)
if (!multiple) {
void uploadOne(files[0])
} else {
files.forEach((f) => void uploadOne(f))
}
},
[uploadOne, multiple]
)
const handleDrop = useCallback(
(e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault()
setIsDragging(false)
handleFiles(e.dataTransfer.files)
},
[handleFiles]
)
const removeTask = (task: UploadTask) => {
setTasks((prev) => prev.filter((t) => t !== task))
}
}: FileUploadProps): React.ReactElement {
const t = useTranslations("files.upload")
const {
tasks,
isDragging,
inputRef,
setIsDragging,
handleFiles,
removeTask,
acceptAttr,
maxFileSize,
} = useFileUpload({ targetType, targetId, multiple, onUploaded })
return (
<div className={cn("space-y-4", className)}>
<div
role="button"
tabIndex={0}
aria-describedby="file-upload-hint"
onClick={() => inputRef.current?.click()}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
@@ -175,7 +57,11 @@ export function FileUpload({
setIsDragging(true)
}}
onDragLeave={() => setIsDragging(false)}
onDrop={handleDrop}
onDrop={(e) => {
e.preventDefault()
setIsDragging(false)
handleFiles(e.dataTransfer.files)
}}
className={cn(
"flex cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed p-8 text-center transition-colors",
isDragging
@@ -183,19 +69,18 @@ export function FileUpload({
: "border-input hover:border-primary/50 hover:bg-accent/50"
)}
>
<UploadCloud className="h-10 w-10 text-muted-foreground" />
<p className="mt-2 text-sm font-medium">
Click to upload or drag and drop
</p>
<p className="mt-1 text-xs text-muted-foreground">
Images, PDF, Word, Excel, PPT, Text, ZIP / RAR · up to {formatFileSize(MAX_FILE_SIZE)}
<UploadCloud className="h-10 w-10 text-muted-foreground" aria-hidden="true" />
<p className="mt-2 text-sm font-medium">{t("title")}</p>
<p id="file-upload-hint" className="mt-1 text-xs text-muted-foreground">
{t("hint", { size: formatFileSize(maxFileSize) })}
</p>
<input
ref={inputRef}
type="file"
className="hidden"
accept={ACCEPT_ATTR}
accept={acceptAttr}
multiple={multiple}
aria-label={t("title")}
onChange={(e) => {
handleFiles(e.target.files)
e.target.value = ""
@@ -204,7 +89,7 @@ export function FileUpload({
</div>
{tasks.length > 0 ? (
<ul className="space-y-2">
<ul className="space-y-2" aria-live="polite">
{tasks.map((task, idx) => (
<li
key={`${task.file.name}-${idx}`}
@@ -221,13 +106,17 @@ export function FileUpload({
</span>
</div>
{task.status === "uploading" ? (
<Progress value={task.progress} className="h-1.5" />
<Progress
value={task.progress}
className="h-1.5"
aria-label={`Uploading ${task.file.name}`}
/>
) : null}
{task.status === "error" ? (
<p className="text-xs text-destructive">{task.message}</p>
) : null}
{task.status === "success" ? (
<p className="text-xs text-green-600">Uploaded</p>
<p className="text-xs text-green-600">{t("uploaded")}</p>
) : null}
</div>
<Button
@@ -236,9 +125,9 @@ export function FileUpload({
size="icon"
className="h-7 w-7"
onClick={() => removeTask(task)}
aria-label="Remove"
aria-label={t("remove")}
>
<X className="h-4 w-4" />
<X className="h-4 w-4" aria-hidden="true" />
</Button>
</li>
))}