file-upload
Card file upload.
Card file upload.
Загрузка превью…
src/file-upload/c-file-upload-9.vue
<!--
Иконки — путь lucide, идентичны react.tsx. `image-01.jpg` заменён PIXEL,
остальные default-файлы используют невалидируемый локальный путь
`/media/files/...` (не реальный сетевой запрос), как в оригинале.
-->
<script setup lang="ts">
import { ref } from "vue"
import { Button } from "@/components/ui/button"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
import { formatBytes, useFileUpload, type FileMetadata, type FileWithPreview } from "@/composables/use-file-upload"
import ErrorAlert from "./_shared/ErrorAlert.vue"
const PIXEL = "data:image/gif;base64,R0lGODlhAQABAIAAAP///wAAACwAAAAAAQABAAACAUwAOw=="
interface FileUploadItem extends FileWithPreview {
progress: number
status: "uploading" | "completed" | "error"
error?: string
}
const maxFiles = 10
const maxSize = 50 * 1024 * 1024
const defaultFiles: FileMetadata[] = [
{ id: "default-card-1", name: "intro.zip", size: 252846, type: "application/zip", url: "/media/files/intro.zip" },
{ id: "default-card-2", name: "image-01.jpg", size: 1536000, type: "image/jpeg", url: PIXEL },
{ id: "default-card-3", name: "audio.mp3", size: 1536000, type: "audio/mpeg", url: "/media/files/audio.mp3" },
]
const defaultUploadFiles: FileUploadItem[] = defaultFiles.map((file) => ({
id: file.id,
file: { id: file.id, name: file.name, size: file.size, type: file.type } as unknown as File,
preview: file.url,
progress: 100,
status: "completed",
}))
const uploadFiles = ref<FileUploadItem[]>(defaultUploadFiles)
const [state, { removeFile, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, openFileDialog, handleFileChange }] = useFileUpload({
maxFiles,
maxSize,
accept: "*",
multiple: true,
initialFiles: defaultFiles,
})
function getFileIcon(file: File | FileMetadata) {
const type = file.type
if (type.startsWith("video/")) return "video"
if (type.startsWith("audio/")) return "headphones"
if (type.includes("zip") || type.includes("rar")) return "file-archive"
return "file-text"
}
</script>
<template>
<div class="w-full space-y-4">
<div
class="rounded-lg relative border border-dashed p-6 text-center transition-colors"
:class="state.isDragging ? 'border-primary bg-primary/5' : 'border-muted-foreground/25 hover:border-muted-foreground/50'"
@dragenter="handleDragEnter"
@dragleave="handleDragLeave"
@dragover="handleDragOver"
@drop="handleDrop"
>
<input type="file" multiple class="sr-only" @change="handleFileChange" />
<div class="flex flex-col items-center gap-4">
<div
class="bg-muted flex h-12 w-12 items-center justify-center rounded-full transition-colors"
:class="state.isDragging ? 'border-primary bg-primary/10' : 'border-muted-foreground/25'"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="text-muted-foreground h-5 w-5"
><path d="M12 3v12" /><path d="m17 8-5-5-5 5" /><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /></svg>
</div>
<div class="space-y-2">
<p class="text-sm font-medium">
Drop files here or
<button type="button" class="text-primary cursor-pointer underline-offset-4 hover:underline" @click="openFileDialog">
browse files
</button>
</p>
<p class="text-muted-foreground text-xs">{{ `Maximum file size: ${formatBytes(maxSize)} • Maximum files: ${maxFiles}` }}</p>
</div>
</div>
</div>
<div v-if="uploadFiles.length > 0" class="space-y-4">
<div class="flex items-center justify-between">
<h3 class="text-sm font-medium">{{ `Files (${uploadFiles.length})` }}</h3>
<div class="flex gap-2">
<Button variant="outline" size="sm" @click="openFileDialog">
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
><path d="M12 13v8" /><path d="M4 14.899A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 2.5 8.242" /><path d="m8 17 4-4 4 4" /></svg>
Add files
</Button>
<Button variant="outline" size="sm">
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
><path d="M10 11v6" /><path d="M14 11v6" /><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" /><path d="M3 6h18" /><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" /></svg>
Remove all
</Button>
</div>
</div>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-4 lg:grid-cols-6">
<div v-for="fileItem in uploadFiles" :key="fileItem.id" class="group/item relative">
<Button
variant="outline"
size="icon"
class="absolute -end-2 -top-2 z-10 size-6 rounded-full opacity-0 transition-opacity group-hover/item:opacity-100 dark:bg-zinc-800 hover:dark:bg-zinc-700"
@click="removeFile(fileItem.id)"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="size-3.5"
><path d="M18 6 6 18" /><path d="m6 6 12 12" /></svg>
</Button>
<div class="bg-card rounded-lg relative overflow-hidden border transition-colors">
<div class="bg-muted border-border relative aspect-square border-b">
<img
v-if="fileItem.file.type.startsWith('image/') && fileItem.preview"
:src="fileItem.preview"
:alt="fileItem.file.name"
class="h-full w-full object-cover"
/>
<div v-else class="text-muted-foreground/80 flex h-full items-center justify-center">
<div class="text-4xl">
<svg
v-if="getFileIcon(fileItem.file) === 'video'"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="size-4"
><path d="m16 13 5.223 3.482a.5.5 0 0 0 .777-.416V7.87a.5.5 0 0 0-.752-.432L16 10.5" /><rect x="2" y="6" width="14" height="12" rx="2" /></svg>
<svg
v-else-if="getFileIcon(fileItem.file) === 'headphones'"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="size-4"
><path d="M3 14h3a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-7a9 9 0 0 1 18 0v7a2 2 0 0 1-2 2h-1a2 2 0 0 1-2-2v-3a2 2 0 0 1 2-2h3" /></svg>
<svg
v-else-if="getFileIcon(fileItem.file) === 'file-archive'"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="size-4"
><path d="M10 12v-1" /><path d="M10 18v-2" /><path d="M10 7V6" /><path d="M14 2v4a2 2 0 0 0 2 2h4" /><path d="M15.5 22H18a2 2 0 0 0 2-2V7l-5-5H6a2 2 0 0 0-2 2v16a2 2 0 0 0 .274 1.01" /><circle cx="10" cy="20" r="2" /></svg>
<svg
v-else
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
class="size-4"
stroke-linecap="round"
stroke-linejoin="round"
><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" /><path d="M14 2v4a2 2 0 0 0 2 2h4" /><path d="M10 9H8" /><path d="M16 13H8" /><path d="M16 17H8" /></svg>
</div>
</div>
</div>
<div class="p-3">
<div class="space-y-1">
<p class="truncate text-sm font-medium">{{ fileItem.file.name }}</p>
<div class="relative flex items-center justify-between gap-2">
<span class="text-muted-foreground text-xs">{{ formatBytes(fileItem.file.size) }}</span>
<TooltipProvider v-if="fileItem.status === 'error' && fileItem.error">
<Tooltip>
<TooltipTrigger as-child>
<Button
variant="ghost"
size="icon"
class="text-destructive hover:bg-destructive/10 hover:text-destructive absolute end-0 -top-1.25 size-6"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="size-3 opacity-100"
><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8" /><path d="M21 3v5h-5" /><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16" /><path d="M8 16H3v5" /></svg>
</Button>
</TooltipTrigger>
<TooltipContent>Upload failed. Retry</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<ErrorAlert :errors="state.errors" />
</div>
</template>
src/file-upload/_shared/ErrorAlert.vue
<!--
Общий блок "File upload error(s)" (`registry-reui/bases/radix/hooks/
use-file-upload.ts` пробрасывает `errors: string[]`), повторяется во всех
паттернах категории с одинаковой разметкой. Деталь реализации — не
экспортируется из index.ts (тот же приём, что `calendar/_shared/
CCalendar.vue`). В проверяемом (пустом/дефолтном) состоянии `errors`
всегда пуст, поэтому этот компонент никогда не монтируется на диф.
-->
<script setup lang="ts">
import { Alert, AlertDescription, AlertTitle } from "@/components/reui/alert"
defineProps<{ errors: string[] }>()
</script>
<template>
<Alert v-if="errors.length > 0" variant="destructive" class="mt-5">
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
><circle cx="12" cy="12" r="10" /><line x1="12" x2="12" y1="8" y2="12" /><line x1="12" x2="12.01" y1="16" y2="16" /></svg>
<AlertTitle>File upload error(s)</AlertTitle>
<AlertDescription>
<p v-for="(error, index) in errors" :key="index" class="last:mb-0">{{ error }}</p>
</AlertDescription>
</Alert>
</template>
Установка
npx shadcn-vue@latest add https://revueui.rootapi.dev/r/c-file-upload-9.jsonЗависимости реестра
Источник: порт из ReUI (Keenthemes, MIT)