file-upload

Table file upload

Table file upload

Загрузка превью…

src/file-upload/c-file-upload-6.vue

<!-- Иконки/тип файла — путь lucide, идентичны react.tsx. -->
<script setup lang="ts">
import { ref } from "vue"
import { Badge } from "@/components/reui/badge"
import { Button } from "@/components/ui/button"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { formatBytes, useFileUpload, type FileMetadata, type FileWithPreview } from "@/composables/use-file-upload"
import ErrorAlert from "./_shared/ErrorAlert.vue"

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-doc-1", name: "document.pdf", size: 529254, type: "application/pdf", url: "/media/files/document.pdf" },
  { id: "default-doc-2", name: "intro.zip", size: 252846, type: "application/zip", url: "/media/files/intro.zip" },
  {
    id: "default-doc-3",
    name: "conclusion.xlsx",
    size: 353126,
    type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
    url: "/media/files/conclusion.xlsx",
  },
  { id: "default-doc-4", name: "package.json", size: 697, type: "application/json", url: "/media/files/package.json" },
]
const defaultUploadFiles: FileUploadItem[] = defaultFiles.map((file) => ({
  id: file.id,
  file: { name: file.name, size: file.size, type: file.type } as File,
  preview: file.url,
  progress: 100,
  status: "completed",
}))

const uploadFiles = ref<FileUploadItem[]>(defaultUploadFiles)

const [state, { removeFile, clearFiles, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, openFileDialog, handleFileChange }] =
  useFileUpload({
    maxFiles,
    maxSize,
    accept: "*",
    multiple: true,
    initialFiles: defaultFiles,
    onFilesChange: (newFiles) => {
      uploadFiles.value = newFiles.map((file) => {
        const existing = uploadFiles.value.find((f) => f.id === file.id)
        if (existing) return { ...existing, ...file }
        return { ...file, progress: 0, status: "uploading" as const }
      })
    },
  })

function removeUploadFile(fileId: string) {
  uploadFiles.value = uploadFiles.value.filter((f) => f.id !== fileId)
  removeFile(fileId)
}

function getFileIcon(file: File | FileMetadata) {
  const type = file.type
  if (type.startsWith("image/")) return "image"
  if (type.startsWith("video/")) return "video"
  if (type.startsWith("audio/")) return "headphones"
  if (type.includes("word") || type.includes("doc")) return "file-text"
  if (type.includes("excel") || type.includes("sheet")) return "file-spreadsheet"
  if (type.includes("zip") || type.includes("rar")) return "file-archive"
  return "file-text"
}

function getFileTypeLabel(file: File | FileMetadata) {
  const type = file.type
  if (type.startsWith("image/")) return "Image"
  if (type.startsWith("video/")) return "Video"
  if (type.startsWith("audio/")) return "Audio"
  if (type.includes("pdf")) return "PDF"
  if (type.includes("word") || type.includes("doc")) return "Word"
  if (type.includes("excel") || type.includes("sheet")) return "Excel"
  if (type.includes("zip") || type.includes("rar")) return "Archive"
  if (type.includes("json")) return "JSON"
  if (type.includes("text")) return "Text"
  return "File"
}
</script>

<template>
  <div class="w-full space-y-4">
    <div
      class="relative rounded-lg 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"
              class="h-4 w-4"
            ><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" @click="clearFiles">
            <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="h-4 w-4"
            ><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="rounded-lg border">
        <Table>
          <TableHeader>
            <TableRow class="text-xs">
              <TableHead class="h-9 ps-4">Name</TableHead>
              <TableHead class="h-9">Type</TableHead>
              <TableHead class="h-9">Size</TableHead>
              <TableHead class="h-9 w-[100px] ps-4">Actions</TableHead>
            </TableRow>
          </TableHeader>
          <TableBody>
            <TableRow v-for="fileItem in uploadFiles" :key="fileItem.id">
              <TableCell class="py-2 ps-1.5">
                <div class="flex items-center gap-1">
                  <div class="text-muted-foreground/80 relative flex size-8 shrink-0 items-center justify-center">
                    <div class="flex items-center justify-center">
                      <svg
                        v-if="getFileIcon(fileItem.file) === 'image'"
                        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"
                      ><rect width="18" height="18" x="3" y="3" rx="2" ry="2" /><circle cx="9" cy="9" r="2" /><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" /></svg>
                      <svg
                        v-else-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-spreadsheet'"
                        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="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="M8 13h2" /><path d="M14 13h2" /><path d="M8 17h2" /><path d="M14 17h2" /></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"
                        stroke-linecap="round"
                        stroke-linejoin="round"
                        class="size-4"
                      ><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>
                  <p class="flex items-center gap-1 truncate text-sm font-medium">{{ fileItem.file.name }}</p>
                </div>
              </TableCell>
              <TableCell class="py-2">
                <Badge variant="secondary" class="text-xs">{{ getFileTypeLabel(fileItem.file) }}</Badge>
              </TableCell>
              <TableCell class="text-muted-foreground py-2 text-sm">{{ formatBytes(fileItem.file.size) }}</TableCell>
              <TableCell class="py-2">
                <div class="flex items-center gap-1">
                  <Button v-if="fileItem.preview" size="icon" variant="ghost" class="size-8">
                    <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="M12 15V3" /><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><path d="m7 10 5 5 5-5" /></svg>
                  </Button>
                  <Button variant="ghost" size="icon" class="size-8" @click="removeUploadFile(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="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>
                  </Button>
                </div>
              </TableCell>
            </TableRow>
          </TableBody>
        </Table>
      </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-6.json

Зависимости реестра

Источник: порт из ReUI (Keenthemes, MIT)