.claude/skills/media-library/SKILL.md
WordPress-style media management system for this Next.js application. Covers MediaService CRUD, file upload, tag system, duplicate detection, MediaLibrary modal, MediaSelector form field, and block editor integration. Use this skill when working with media uploads, browsing, or selection.
npx skillsauth add NextSpark-js/nextspark media-libraryInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
WordPress-style media management system with full CRUD API, reusable modal, form field component, and block editor integration.
MEDIA LIBRARY SYSTEM:
Core Layer (packages/core/):
├── src/lib/permissions/system.ts # media.read/upload/update/delete (core system permissions)
├── src/
│ ├── components/media/
│ │ ├── MediaLibrary.tsx # Main modal (browse, upload, select)
│ │ ├── MediaGrid.tsx # Grid view with thumbnails
│ │ ├── MediaList.tsx # List/table view
│ │ ├── MediaCard.tsx # Individual media card
│ │ ├── MediaToolbar.tsx # Search, filter, sort, view toggle
│ │ ├── MediaDetailPanel.tsx # Right sidebar detail/edit panel
│ │ ├── MediaUploadZone.tsx # Drag & drop upload area
│ │ ├── MediaSelector.tsx # Form field component for entities
│ │ ├── MediaTagFilter.tsx # Tag filter chips
│ │ └── index.ts # Re-exports
│ ├── hooks/
│ │ ├── useMedia.ts # TanStack Query hooks (CRUD + tags)
│ │ └── useMediaUpload.ts # Upload hook with progress tracking
│ ├── lib/
│ │ ├── media/
│ │ │ ├── types.ts # Media, MediaTag, MediaListOptions
│ │ │ ├── schemas.ts # Zod validation schemas
│ │ │ └── utils.ts # formatFileSize, getMediaType, etc.
│ │ └── services/
│ │ └── media.service.ts # MediaService (CRUD, tags, duplicates)
│ └── types/
│ └── blocks.ts # FieldType includes 'media-library'
│
├── migrations/
│ └── 021_media.sql # Media + media_tags + media_tag_relations
API Layer (apps/dev/app/api/v1/):
├── media/
│ ├── route.ts # GET (list), POST (create)
│ ├── upload/route.ts # POST (file upload)
│ ├── check-duplicates/route.ts # POST (hash check)
│ └── [id]/
│ ├── route.ts # GET, PATCH, DELETE
│ └── tags/route.ts # GET, POST, DELETE media tags
└── media-tags/
└── route.ts # GET all tags
Dashboard (apps/dev/app/dashboard/(main)/media/):
└── page.tsx # Dashboard media page
Flow:
Upload → API → MediaService → DB → TanStack Query Cache → UI
Context-Aware Paths: Core layer components and services are in
packages/core/. API routes are in the app layer. Dashboard page is theme-provided. Seecore-theme-responsibilitiesskill for complete rules.
type: 'media-library' fields to page builder blocksinterface Media {
id: string
userId: string
teamId: string
filename: string
originalFilename: string
mimeType: string
fileSize: number
url: string
thumbnailUrl?: string
width?: number
height?: number
title?: string
alt?: string
caption?: string
hash?: string
status: 'active' | 'archived'
metadata?: Record<string, unknown>
createdAt: string
updatedAt: string
}
interface MediaTag {
id: string
name: string
slug: string
createdAt: string
}
interface MediaListOptions {
limit?: number // Default: 50
offset?: number // Default: 0
orderBy?: string // Default: 'createdAt'
orderDir?: 'asc' | 'desc' // Default: 'desc'
type?: 'all' | 'image' | 'video'
search?: string // Searches filename, title, alt
tagIds?: string[] // Filter by tag IDs
}
| Method | Endpoint | Description | Auth Scope |
|--------|----------|-------------|------------|
| GET | /api/v1/media | List media (paginated, filterable) | media:read |
| POST | /api/v1/media | Create media record | media:write |
| POST | /api/v1/media/upload | Upload files | media:write |
| POST | /api/v1/media/check-duplicates | Check for duplicates by hash | media:read |
| GET | /api/v1/media/[id] | Get single media item | media:read |
| PATCH | /api/v1/media/[id] | Update media metadata | media:write |
| DELETE | /api/v1/media/[id] | Delete media item | media:delete |
| GET | /api/v1/media/[id]/tags | Get tags for a media item | media:read |
| POST | /api/v1/media/[id]/tags | Add tags to media | media:write |
| DELETE | /api/v1/media/[id]/tags | Remove tags from media | media:write |
| GET | /api/v1/media-tags | List all available tags | media:read |
All endpoints support dual authentication (session cookie + API key via x-api-key header).
| Param | Type | Default | Description |
|-------|------|---------|-------------|
| limit | number | 50 | Items per page |
| offset | number | 0 | Pagination offset |
| orderBy | string | createdAt | Sort field (createdAt, filename, fileSize, mimeType) |
| orderDir | string | desc | Sort direction (asc, desc) |
| type | string | all | Filter: all, image, video |
| search | string | - | Search in filename, title, alt |
| tagIds | string | - | Comma-separated tag IDs |
Static class following the service-layer pattern with RLS integration:
// core/lib/services/media.service.ts
export class MediaService {
// CRUD operations
static async getById(id: string, userId: string): Promise<Media | null>
static async list(userId: string, options?: MediaListOptions): Promise<ListResult<Media>>
static async create(userId: string, teamId: string, data: CreateMedia): Promise<Media>
static async update(id: string, userId: string, data: UpdateMedia): Promise<Media>
static async delete(id: string, userId: string): Promise<boolean>
// Tag operations
static async getTags(mediaId: string, userId: string): Promise<MediaTag[]>
static async addTag(mediaId: string, tagId: string, userId: string): Promise<void>
static async removeTag(mediaId: string, tagId: string, userId: string): Promise<void>
static async getAllTags(userId: string): Promise<MediaTag[]>
// Duplicate detection
static async checkDuplicates(hashes: string[], userId: string): Promise<Media[]>
}
import {
useMediaList,
useMediaItem,
useMediaTags,
useMediaItemTags,
} from '@/core/hooks/useMedia'
// List media with filters (paginated)
const { data, isLoading, error } = useMediaList({
limit: 50,
offset: 0,
type: 'image',
search: 'hero',
orderBy: 'createdAt',
orderDir: 'desc',
tagIds: ['tag-1', 'tag-2'],
})
// Single media item (enabled when id is truthy)
const { data: media } = useMediaItem(selectedId)
// All available tags
const { data: tags } = useMediaTags()
// Tags for a specific media item
const { data: mediaTags } = useMediaItemTags(mediaId)
import {
useCreateMedia,
useUpdateMedia,
useDeleteMedia,
useAddMediaTag,
useRemoveMediaTag,
} from '@/core/hooks/useMedia'
// Create media record
const createMutation = useCreateMedia()
await createMutation.mutateAsync({ filename, url, mimeType, fileSize })
// Update media metadata
const updateMutation = useUpdateMedia()
await updateMutation.mutateAsync({ id: 'media-123', title: 'New Title', alt: 'Alt text' })
// Delete media
const deleteMutation = useDeleteMedia()
await deleteMutation.mutateAsync('media-123')
// Tag management
const addTagMutation = useAddMediaTag()
await addTagMutation.mutateAsync({ mediaId: 'media-123', tagId: 'tag-456' })
const removeTagMutation = useRemoveMediaTag()
await removeTagMutation.mutateAsync({ mediaId: 'media-123', tagId: 'tag-456' })
import { useMediaUpload } from '@/core/hooks/useMediaUpload'
const { upload, progress, isUploading, error } = useMediaUpload()
// Upload a file with progress tracking
const media = await upload(file)
console.log(`Upload progress: ${progress}%`)
// Media query keys follow the standard pattern:
['media', filters] // Media list with filters
['media', id] // Single media item
['media-tags'] // All tags
['media', mediaId, 'tags'] // Tags for specific media
Full-featured modal for browsing, uploading, and selecting media:
import { MediaLibrary } from '@/core/components/media'
// Single selection mode
<MediaLibrary
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
onSelect={(media: Media) => {
setSelectedImage(media.url)
}}
mode="single"
allowedTypes={['image']}
/>
// Multiple selection mode
<MediaLibrary
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
onSelect={(mediaItems: Media[]) => {
setGalleryImages(mediaItems.map(m => m.url))
}}
mode="multiple"
allowedTypes={['image', 'video']}
maxSelections={10}
/>
MediaLibrary Props:
| Prop | Type | Required | Description |
|------|------|----------|-------------|
| isOpen | boolean | Yes | Controls modal visibility |
| onClose | () => void | Yes | Callback when modal closes |
| onSelect | (media: Media \| Media[]) => void | Yes | Callback with selected media |
| mode | 'single' \| 'multiple' | No | Selection mode (default: 'single') |
| allowedTypes | string[] | No | Restrict to ['image'], ['video'], or both |
| maxSelections | number | No | Max items in multiple mode |
Drop-in form component for entity fields:
import { MediaSelector } from '@/core/components/media'
// In an entity form
<MediaSelector
value={formData.featuredImageId}
onChange={(id) => setFormData({ ...formData, featuredImageId: id })}
type="image"
/>
// For video selection
<MediaSelector
value={formData.videoId}
onChange={(id) => setFormData({ ...formData, videoId: id })}
type="video"
/>
MediaSelector Props:
| Prop | Type | Required | Description |
|------|------|----------|-------------|
| value | string \| null | Yes | Current media ID |
| onChange | (id: string \| null) => void | Yes | Callback when selection changes |
| type | 'image' \| 'video' | No | Restrict selectable media type |
In block field definitions, use type: 'media-library' to open the full MediaLibrary modal instead of a simple file input:
// contents/themes/{theme}/blocks/hero/fields.ts
import type { FieldDefinition } from '@/core/types/blocks'
const customDesignFields: FieldDefinition[] = [
{
name: 'backgroundImage',
label: 'Background Image',
type: 'media-library', // Opens MediaLibrary modal
tab: 'design',
},
]
The MediaLibraryField component renders based on state:
MediaLibrary modalMedia library fields also work inside array (repeatable) items:
// Array field with media sub-field
{
name: 'slides',
label: 'Slides',
type: 'array',
tab: 'content',
itemFields: [
{ name: 'image', label: 'Slide Image', type: 'media-library', tab: 'content' },
{ name: 'title', label: 'Title', type: 'text', tab: 'content' },
{ name: 'description', label: 'Description', type: 'textarea', tab: 'content' },
],
}
// contents/themes/{theme}/blocks/hero/schema.ts
import { z } from 'zod'
import { baseBlockSchema } from '@/core/types/blocks'
export const schema = baseBlockSchema.merge(z.object({
backgroundImage: z.string().url().optional(), // URL from MediaLibrary
overlayOpacity: z.number().min(0).max(100).default(50),
}))
// contents/themes/{theme}/config/app.config.ts
export const appConfig = {
// ... other config
media: {
maxSizeMB: 10, // General max size
maxSizeImageMB: 10, // Image-specific max
maxSizeVideoMB: 50, // Video-specific max
acceptedTypes: ['image/*', 'video/*'],
allowedMimeTypes: [
'image/jpeg',
'image/png',
'image/gif',
'image/webp',
'image/svg+xml',
'video/mp4',
'video/webm',
],
},
}
1. User drops file on MediaUploadZone (or clicks to browse)
2. Client-side validation: file size, MIME type
3. Client computes file hash (SHA-256)
4. POST /api/v1/media/check-duplicates with hash
5. If duplicate found: prompt user (skip/replace/upload anyway)
6. POST /api/v1/media/upload with FormData
7. Server validates, stores file, creates DB record
8. TanStack Query cache invalidated, UI updates
-- Media items table
CREATE TABLE IF NOT EXISTS "media" (
"id" TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
"userId" TEXT NOT NULL REFERENCES "user"(id),
"teamId" TEXT NOT NULL REFERENCES "team"(id),
"filename" VARCHAR(500) NOT NULL,
"originalFilename" VARCHAR(500) NOT NULL,
"mimeType" VARCHAR(100) NOT NULL,
"fileSize" INTEGER NOT NULL,
"url" TEXT NOT NULL,
"thumbnailUrl" TEXT,
"width" INTEGER,
"height" INTEGER,
"title" VARCHAR(500),
"alt" VARCHAR(500),
"caption" TEXT,
"hash" VARCHAR(128),
"status" VARCHAR(20) NOT NULL DEFAULT 'active',
"metadata" JSONB DEFAULT '{}'::jsonb,
"createdAt" TIMESTAMPTZ NOT NULL DEFAULT now(),
"updatedAt" TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Media tags table
CREATE TABLE IF NOT EXISTS "media_tags" (
"id" TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
"name" VARCHAR(100) NOT NULL,
"slug" VARCHAR(100) NOT NULL UNIQUE,
"createdAt" TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Many-to-many relation: media <-> tags
CREATE TABLE IF NOT EXISTS "media_tag_relations" (
"mediaId" TEXT NOT NULL REFERENCES "media"(id) ON DELETE CASCADE,
"tagId" TEXT NOT NULL REFERENCES "media_tags"(id) ON DELETE CASCADE,
PRIMARY KEY ("mediaId", "tagId")
);
-- Indexes
CREATE INDEX "idx_media_userId" ON "media"("userId");
CREATE INDEX "idx_media_teamId" ON "media"("teamId");
CREATE INDEX "idx_media_hash" ON "media"("hash");
CREATE INDEX "idx_media_status" ON "media"("status");
CREATE INDEX "idx_media_mimeType" ON "media"("mimeType");
CREATE INDEX "idx_media_tag_relations_tagId" ON "media_tag_relations"("tagId");
media60+ translation keys in en and es locales.
// Usage in components
const t = useTranslations('media')
t('title') // "Media Library"
t('upload') // "Upload"
t('dragDrop') // "Drag & drop files here"
t('noResults') // "No media found"
t('confirmDelete') // "Are you sure you want to delete this media?"
t('filters.allTypes') // "All Types"
t('filters.images') // "Images"
t('filters.videos') // "Videos"
t('detail.title') // "Title"
t('detail.alt') // "Alt Text"
t('detail.caption') // "Caption"
t('detail.fileSize') // "File Size"
t('detail.dimensions') // "Dimensions"
t('detail.uploadedAt') // "Uploaded At"
t('tags.addTag') // "Add Tag"
t('tags.removeTag') // "Remove Tag"
// Namespace: admin.blockEditor.form
t('changeImage') // "Change"
t('removeImage') // "Remove"
t('browseMedia') // "Browse Media Library"
File: media-crud.cy.ts (20+ test cases)
// Tests cover:
// - List media with pagination
// - List media with type filter (image, video)
// - List media with search
// - List media with tag filter
// - List media with sorting
// - Create media record
// - Upload file
// - Get single media
// - Update media metadata (title, alt, caption)
// - Delete media
// - Duplicate detection via hash
// - Add/remove tags
// - Permission checks (media:read, media:write, media:delete)
// - Dual auth (session + API key)
media.service.test.ts # Service layer unit tests
schemas.test.ts # Zod schema validation tests
utils.test.ts # Utility function tests (formatFileSize, getMediaType, etc.)
Defined in media.selectors.ts and block-editor.selectors.ts:
// media.selectors.ts
export const MEDIA_SELECTORS = {
library: {
modal: 'media-library-modal',
grid: 'media-grid',
list: 'media-list',
card: 'media-card',
toolbar: 'media-toolbar',
searchInput: 'media-search-input',
typeFilter: 'media-type-filter',
sortSelect: 'media-sort-select',
viewToggle: 'media-view-toggle',
uploadZone: 'media-upload-zone',
detailPanel: 'media-detail-panel',
selectBtn: 'media-select-btn',
deleteBtn: 'media-delete-btn',
},
selector: {
container: 'media-selector',
preview: 'media-selector-preview',
browseBtn: 'media-selector-browse',
removeBtn: 'media-selector-remove',
},
tags: {
filter: 'media-tag-filter',
chip: 'media-tag-chip',
addBtn: 'media-tag-add',
removeBtn: 'media-tag-remove',
},
} as const
// block-editor.selectors.ts (additions)
export const BLOCK_EDITOR_SELECTORS = {
// ... existing selectors
mediaField: {
container: 'block-media-field',
preview: 'block-media-preview',
browseBtn: 'block-media-browse',
changeBtn: 'block-media-change',
removeBtn: 'block-media-remove',
},
} as const
| File | Purpose |
|------|---------|
| core/src/components/media/MediaLibrary.tsx | Main modal component |
| core/src/components/media/MediaSelector.tsx | Form field component |
| core/src/components/media/MediaUploadZone.tsx | Drag & drop upload |
| core/src/hooks/useMedia.ts | TanStack Query hooks (CRUD + tags) |
| core/src/hooks/useMediaUpload.ts | Upload with progress |
| core/src/lib/media/types.ts | TypeScript type definitions |
| core/src/lib/media/schemas.ts | Zod validation schemas |
| core/src/lib/media/utils.ts | Utility functions |
| core/src/lib/services/media.service.ts | MediaService (CRUD, tags, duplicates) |
| core/src/types/blocks.ts | FieldType with 'media-library' |
| core/migrations/021_media.sql | Database migration |
| apps/dev/app/api/v1/media/route.ts | List + create endpoints |
| apps/dev/app/api/v1/media/upload/route.ts | File upload endpoint |
| apps/dev/app/api/v1/media/[id]/route.ts | Single item CRUD |
| apps/dev/app/api/v1/media/[id]/tags/route.ts | Media tag management |
| apps/dev/app/api/v1/media-tags/route.ts | All tags endpoint |
| apps/dev/app/dashboard/(main)/media/page.tsx | Dashboard page |
// NEVER: Use type 'image' in block fields when you want the media library
{
name: 'backgroundImage',
type: 'image', // Opens basic file input
}
// CORRECT: Use type 'media-library' for full media library experience
{
name: 'backgroundImage',
type: 'media-library', // Opens MediaLibrary modal
}
// NEVER: Store media ID in block data (blocks store URLs)
onSelect={(media) => {
updateBlockData({ backgroundImage: media.id }) // WRONG
}}
// CORRECT: Store URL in block data
onSelect={(media) => {
updateBlockData({ backgroundImage: media.url }) // Correct
}}
// NEVER: Bypass MediaService for direct DB queries
const media = await queryWithRLS('SELECT * FROM "media" WHERE id = $1', [id], userId)
// CORRECT: Use MediaService
const media = await MediaService.getById(id, userId)
// NEVER: Skip duplicate check before upload
const result = await upload(file) // Might create duplicates
// CORRECT: Check for duplicates first (useMediaUpload handles this)
const { upload } = useMediaUpload() // Built-in duplicate detection
const result = await upload(file)
// NEVER: Hardcode file size limits
if (file.size > 10 * 1024 * 1024) { ... }
// CORRECT: Read from app.config.ts
import { appConfig } from '@/contents/themes/{theme}/config/app.config'
if (file.size > appConfig.media.maxSizeImageMB * 1024 * 1024) { ... }
// NEVER: Skip i18n for media UI strings
<Button>Upload</Button>
// CORRECT: Use translation keys
<Button>{t('upload')}</Button>
// NEVER: Forget data-cy selectors on interactive elements
<Button onClick={handleUpload}>Upload</Button>
// CORRECT: Include data-cy
<Button data-cy={sel(MEDIA_SELECTORS.library.uploadZone)} onClick={handleUpload}>
{t('upload')}
</Button>
MediaSelector from @/core/components/mediavalue and onChangedata-cy selector for the media fieldtype: 'media-library' in field definition (not type: 'image').url().optional() Zod schema field for the URLuseMedia.ts (not raw fetch)app.config.tsmedia i18n namespacedata-cy selectorsmedia:read, media:write, media:delete scopesFormData (not JSON){ data, total, limit, offset } formatpage-builder-blocks - Block field type 'media-library' integrationentity-system - MediaSelector for entity formstanstack-query - useMedia hooks follow TanStack Query patternscypress-api - Media API test patternshow-to:handle-file-uploads - Upload component and API guideservice-layer - MediaService follows static class pattern with RLSi18n-nextintl - media namespace translationscypress-selectors - MEDIA_SELECTORS and BLOCK_EDITOR_SELECTORSzod-validation - Media schema validation patternsdatabase-migrations - 021_media.sql migration structuredevelopment
Zod validation patterns for this Next.js application. Covers schema definition, API validation, form integration, error formatting, and type inference. Use this skill when implementing validation for APIs, forms, or entity schemas.
development
Review UI code for Web Interface Guidelines compliance. Use when asked to "review my UI", "check accessibility", "audit design", "review UX", or "check my site against best practices".
testing
Test coverage metrics and registry system for this Next.js application. Covers FEATURE_REGISTRY, FLOW_REGISTRY, TAGS_REGISTRY, and coverage metrics interpretation. Use this skill when evaluating test coverage, identifying gaps, or planning testing priorities.
development
TanStack Query (React Query) patterns for data fetching in this Next.js application. Covers useQuery, useMutation, optimistic updates, cache invalidation, and anti-patterns. Use this skill when implementing data fetching or state management with server data.