Claude
Skills
Sign in
Back

storage-ui

Included with Lifetime
$97 forever

File management UI components — dropzone upload, file list with grid/list toggle, file preview modal, and upload progress tracking. Built on the storage skill's API routes.

Design

What this skill does


# Storage UI Skill

React components for file upload, browsing, preview, and management. Connects to the `/api/storage` routes from the `storage` skill.

## Prerequisites

- `storage` skill installed (provides `/api/storage` endpoints)
- shadcn/ui initialized with `button`, `dialog`, `alert-dialog`, `progress` components
- `@phosphor-icons/react` installed (installed by `add-shadcn`)

## Installation

```bash
bunx shadcn@latest add button dialog alert-dialog progress
```

## What Gets Created

```
src/
└── components/
    └── storage/
        ├── storage-provider.tsx   # React context + useStorage hook
        ├── dropzone.tsx           # Drag-and-drop file upload area
        ├── file-list.tsx          # File grid/list with actions
        ├── file-preview.tsx       # Modal file previewer
        └── file-manager.tsx       # Complete file management UI (combines all)
```

## Setup Steps

### Step 1: Create `src/components/storage/storage-provider.tsx`

```tsx
"use client";

import {
  createContext,
  useCallback,
  useContext,
  useId,
  useState,
  type ReactNode,
} from "react";

type StorageFile = {
  key: string;
  name: string;
  size: number;
  contentType: string;
  url: string;
  lastModified: string;
};

type UploadProgress = {
  id: string;
  fileName: string;
  progress: number;
  status: "pending" | "uploading" | "complete" | "error";
  error?: string;
};

type StorageContextValue = {
  files: StorageFile[];
  isLoading: boolean;
  error: string | null;
  uploads: Map<string, UploadProgress>;
  uploadFiles: (files: File[]) => Promise<StorageFile[]>;
  deleteFile: (key: string) => Promise<void>;
  refresh: () => Promise<void>;
  clearError: () => void;
};

const StorageContext = createContext<StorageContextValue | null>(null);

type StorageProviderProps = {
  children: ReactNode;
  apiBase?: string;
};

export function StorageProvider({
  children,
  apiBase = "/api/storage",
}: StorageProviderProps) {
  const [files, setFiles] = useState<StorageFile[]>([]);
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const [uploads, setUploads] = useState<Map<string, UploadProgress>>(new Map());
  const uploadIdPrefix = useId();

  const clearError = useCallback(() => setError(null), []);

  const refresh = useCallback(async () => {
    setIsLoading(true);
    setError(null);
    try {
      const response = await fetch(apiBase);
      if (!response.ok) throw new Error("Failed to fetch files");
      const data = await response.json();
      setFiles(data.files ?? []);
    } catch (err) {
      setError(err instanceof Error ? err.message : "Failed to fetch files");
    } finally {
      setIsLoading(false);
    }
  }, [apiBase]);

  const uploadFiles = useCallback(
    async (filesToUpload: File[]): Promise<StorageFile[]> => {
      const results: StorageFile[] = [];

      for (let i = 0; i < filesToUpload.length; i++) {
        const file = filesToUpload[i];
        const uploadId = `${uploadIdPrefix}-${Date.now()}-${i}`;

        setUploads((prev) => {
          const next = new Map(prev);
          next.set(uploadId, {
            id: uploadId,
            fileName: file.name,
            progress: 0,
            status: "uploading",
          });
          return next;
        });

        try {
          const formData = new FormData();
          formData.append("file", file);

          const response = await fetch(`${apiBase}/upload`, {
            method: "POST",
            body: formData,
          });

          if (!response.ok) {
            const errData = await response.json().catch(() => ({}));
            throw new Error(errData.error || `Failed to upload ${file.name}`);
          }

          const data = await response.json();
          results.push(data.file);

          setUploads((prev) => {
            const next = new Map(prev);
            next.set(uploadId, {
              id: uploadId,
              fileName: file.name,
              progress: 100,
              status: "complete",
            });
            return next;
          });

          setTimeout(() => {
            setUploads((prev) => {
              const next = new Map(prev);
              next.delete(uploadId);
              return next;
            });
          }, 2000);
        } catch (err) {
          const message = err instanceof Error ? err.message : "Upload failed";
          setUploads((prev) => {
            const next = new Map(prev);
            next.set(uploadId, {
              id: uploadId,
              fileName: file.name,
              progress: 0,
              status: "error",
              error: message,
            });
            return next;
          });

          setTimeout(() => {
            setUploads((prev) => {
              const next = new Map(prev);
              next.delete(uploadId);
              return next;
            });
          }, 5000);
        }
      }

      if (results.length > 0) {
        setFiles((prev) => [...results, ...prev]);
      }

      return results;
    },
    [apiBase, uploadIdPrefix]
  );

  const deleteFile = useCallback(
    async (key: string) => {
      setError(null);
      try {
        const response = await fetch(`${apiBase}/delete`, {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ key }),
        });
        if (!response.ok) throw new Error("Failed to delete file");
        setFiles((prev) => prev.filter((f) => f.key !== key));
      } catch (err) {
        const message = err instanceof Error ? err.message : "Failed to delete";
        setError(message);
        throw err;
      }
    },
    [apiBase]
  );

  return (
    <StorageContext.Provider
      value={{ files, isLoading, error, uploads, uploadFiles, deleteFile, refresh, clearError }}
    >
      {children}
    </StorageContext.Provider>
  );
}

export function useStorage(): StorageContextValue {
  const context = useContext(StorageContext);
  if (!context) throw new Error("useStorage must be used within a StorageProvider");
  return context;
}

export type { StorageFile, UploadProgress };
```

### Step 2: Create `src/components/storage/dropzone.tsx`

```tsx
"use client";

import { useCallback, useId, useState, type DragEvent } from "react";
import { cn } from "@/lib/utils";
import { UploadSimple, File, CircleNotch, WarningCircle } from "@phosphor-icons/react";

type AcceptCategory = "images" | "audio" | "video" | "pdf" | "text" | "archives";

const MIME_MAP: Record<AcceptCategory, string[]> = {
  images: ["image/jpeg", "image/png", "image/gif", "image/webp", "image/svg+xml"],
  audio: ["audio/mpeg", "audio/wav", "audio/ogg", "audio/mp4"],
  video: ["video/mp4", "video/webm", "video/quicktime"],
  pdf: ["application/pdf"],
  text: ["text/plain", "text/csv", "application/json"],
  archives: ["application/zip", "application/gzip", "application/x-tar"],
};

const LABELS: Record<AcceptCategory, string> = {
  images: "Images (JPEG, PNG, GIF, WebP, SVG)",
  audio: "Audio (MP3, WAV, OGG)",
  video: "Video (MP4, WebM, MOV)",
  pdf: "PDF",
  text: "Text (TXT, CSV, JSON)",
  archives: "Archives (ZIP, TAR, GZ)",
};

type DropzoneProps = {
  onFilesSelected: (files: File[]) => void;
  accept?: AcceptCategory[];
  multiple?: boolean;
  disabled?: boolean;
  isUploading?: boolean;
  maxSize?: number;
  className?: string;
};

export function Dropzone({
  onFilesSelected,
  accept = ["images", "audio", "video", "pdf", "text", "archives"],
  multiple = true,
  disabled = false,
  isUploading = false,
  maxSize,
  className,
}: DropzoneProps) {
  const inputId = useId();
  const [isDragOver, setIsDragOver] = useState(false);
  const [error, setError] = useState<string | null>(null);

  const acceptedMimeTypes = accept.flatMap((cat) => MIME_MAP[cat]);
  const acceptString = acceptedMimeTypes.join(",");
  const acceptedLabels = accept.map((cat) => LABELS[cat]);

  const validateFiles = useCallback(
    
Files: 1
Size: 31.1 KB
Complexity: 38/100
Category: Design

Related in Design