file-upload

Gallery file upload

Gallery file upload

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

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

<!--
  Иконки "image"/"upload"/"x" — путь lucide, идентичны react.tsx.
  `https://picsum.photos/...` заменён локальным 1x1 data-URI (PIXEL),
  как в блоках avatar/badge/card.
-->
<script setup lang="ts">
import { ref } from "vue"
import { Button } from "@/components/ui/button"
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Spinner } from "@/components/ui/spinner"
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=="

const selectedImage = ref<string | null>(null)
const isPreviewLoading = ref(false)
const maxFiles = 10
const maxSize = 5 * 1024 * 1024

const defaultImages: FileMetadata[] = [
  { id: "default-1", name: "avatar-1.png", size: 44608, type: "image/png", url: PIXEL },
  { id: "default-2", name: "avatar-2.png", size: 42144, type: "image/png", url: PIXEL },
  { id: "default-3", name: "avatar-2.png", size: 42144, type: "image/png", url: PIXEL },
]

const [state, { removeFile, clearFiles, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, openFileDialog, handleFileChange }] =
  useFileUpload({
    maxFiles,
    maxSize,
    accept: "image/*",
    multiple: true,
    initialFiles: defaultImages,
  })

const isImage = (file: File | FileMetadata) => file.type.startsWith("image/")

function onView(fileItem: FileWithPreview) {
  selectedImage.value = fileItem.preview ?? null
  isPreviewLoading.value = true
}
</script>

<template>
  <div class="w-full max-w-4xl">
    <div
      class="rounded-lg relative border border-dashed p-8 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" accept="image/*" multiple class="sr-only" @change="handleFileChange" />

      <div class="flex flex-col items-center gap-4">
        <div class="flex h-16 w-16 items-center justify-center rounded-full" :class="state.isDragging ? 'bg-primary/10' : 'bg-muted'">
          <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-5 w-5"
            :class="state.isDragging ? 'text-primary' : 'text-muted-foreground'"
          ><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>
        </div>

        <div class="space-y-2">
          <h3 class="text-lg font-semibold">Upload images to gallery</h3>
          <p class="text-muted-foreground text-sm">Drag and drop images here or click to browse</p>
          <p class="text-muted-foreground text-xs">
            {{ `PNG, JPG, GIF up to ${formatBytes(maxSize)} each (max ${maxFiles} files)` }}
          </p>
        </div>

        <Button @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 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>
          Select images
        </Button>
      </div>
    </div>

    <div v-if="state.files.length > 0" class="mt-6 flex items-center justify-between">
      <div class="flex items-center gap-4">
        <h4 class="text-sm font-medium">{{ `Gallery (${state.files.length}/${maxFiles})` }}</h4>
        <div class="text-muted-foreground text-xs">
          {{ `Total: ${formatBytes(state.files.reduce((acc, file) => acc + file.file.size, 0))}` }}
        </div>
      </div>
      <Button variant="outline" size="sm" @click="clearFiles">Clear all</Button>
    </div>

    <div v-if="state.files.length > 0" class="mt-4 grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4">
      <div v-for="fileItem in state.files" :key="fileItem.id" class="group/item relative aspect-square">
        <img
          v-if="isImage(fileItem.file) && fileItem.preview"
          :src="fileItem.preview"
          :alt="fileItem.file.name"
          class="rounded-lg h-full w-full border object-cover transition-all group-hover/item:scale-105"
        />
        <div v-else class="bg-muted rounded-lg flex h-full w-full items-center justify-center border">
          <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-8 w-8"
          ><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>
        </div>

        <div class="bg-black/50 absolute inset-0 flex items-center justify-center gap-2 opacity-0 transition-opacity group-hover/item:opacity-100">
          <Button v-if="fileItem.preview" variant="secondary" size="icon" class="size-7" @click="onView(fileItem)">
            <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="opacity-100/80"
            ><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>
          </Button>

          <Button variant="secondary" size="icon" class="size-7" @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="opacity-100/8"
            ><path d="M18 6 6 18" /><path d="m6 6 12 12" /></svg>
          </Button>
        </div>

        <div class="rounded-b-lg absolute right-0 bottom-0 left-0 bg-black/70 p-2 text-white opacity-0 transition-opacity group-hover:opacity-100">
          <p class="truncate text-xs font-medium">{{ fileItem.file.name }}</p>
          <p class="text-xs text-gray-300">{{ formatBytes(fileItem.file.size) }}</p>
        </div>
      </div>
    </div>

    <ErrorAlert :errors="state.errors" />

    <Dialog :open="!!selectedImage" @update:open="(open: boolean) => !open && (selectedImage = null)">
      <DialogContent class="w-full border-none bg-transparent p-0 shadow-none sm:max-w-xl">
        <DialogHeader class="sr-only">
          <DialogTitle>Image Preview</DialogTitle>
        </DialogHeader>
        <div class="flex items-center justify-center">
          <template v-if="selectedImage">
            <div v-if="isPreviewLoading" class="absolute inset-0 flex items-center justify-center">
              <Spinner class="size-8 text-white" />
            </div>
            <img
              :src="selectedImage"
              alt="Preview"
              class="rounded-lg h-full w-auto object-contain transition-opacity duration-300"
              :class="isPreviewLoading ? 'opacity-0' : 'opacity-100'"
              @load="isPreviewLoading = false"
            />
          </template>
        </div>
      </DialogContent>
    </Dialog>
  </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-4.json

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

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