diff --git a/packages/admin/src/components/AdminLayout.tsx b/packages/admin/src/components/AdminLayout.tsx index 2c76e2b..671a14c 100644 --- a/packages/admin/src/components/AdminLayout.tsx +++ b/packages/admin/src/components/AdminLayout.tsx @@ -52,6 +52,7 @@ const navItems: NavItem[] = [ { path: '/design/theme', label: 'Theme' }, { path: '/design/templates', label: 'Templates' }, { path: '/design/gallery', label: 'Gallery' }, + { path: '/design/media', label: 'Media Library' }, ], }, { diff --git a/packages/admin/src/components/MediaPicker.tsx b/packages/admin/src/components/MediaPicker.tsx new file mode 100644 index 0000000..71220b7 --- /dev/null +++ b/packages/admin/src/components/MediaPicker.tsx @@ -0,0 +1,142 @@ +import { useEffect, useState } from 'react'; + +export interface MediaAsset { + id: string; + filename: string; + originalName: string; + url: string; + mimeType: string; + size: number; + createdAt: string; + uploadedBy?: { id: string; name: string } | null; +} + +export function MediaPickerModal({ + open, + onClose, + onPick, +}: { + open: boolean; + onClose: () => void; + onPick: (asset: MediaAsset) => void; +}) { + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [uploading, setUploading] = useState(false); + + const token = localStorage.getItem('token') || ''; + + async function load() { + setLoading(true); + setError(''); + try { + const res = await fetch('/api/media?limit=100', { + headers: { Authorization: `Bearer ${token}` }, + }); + const result = await res.json(); + if (!res.ok) throw new Error(result.error || 'Failed to load'); + setItems(result.data ?? []); + } catch (e: any) { + setError(e.message); + } finally { + setLoading(false); + } + } + + useEffect(() => { + if (open) load(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]); + + async function handleUpload(e: React.ChangeEvent) { + const file = e.target.files?.[0]; + if (!file) return; + setUploading(true); + setError(''); + try { + const formData = new FormData(); + formData.append('file', file); + const res = await fetch('/api/media/upload', { + method: 'POST', + headers: { Authorization: `Bearer ${token}` }, + body: formData, + }); + const result = await res.json(); + if (!res.ok) throw new Error(result.error || 'Upload failed'); + await load(); + } catch (e: any) { + setError(e.message); + } finally { + setUploading(false); + e.target.value = ''; + } + } + + if (!open) return null; + + return ( +
+
e.stopPropagation()} + > +
+

Pick from Media Library

+
+ + +
+
+ +
+ {error &&
{error}
} + {loading ? ( +
+
+
+ ) : items.length === 0 ? ( +

+ No uploads yet. Click "Upload New" to add an image. +

+ ) : ( +
+ {items.map((item) => ( + + ))} +
+ )} +
+
+
+ ); +} diff --git a/packages/admin/src/main.tsx b/packages/admin/src/main.tsx index 5e98c54..2130dd4 100644 --- a/packages/admin/src/main.tsx +++ b/packages/admin/src/main.tsx @@ -35,6 +35,7 @@ import DesignBranding from './pages/DesignBranding.js'; import DesignTheme from './pages/DesignTheme.js'; import DesignTemplates from './pages/DesignTemplates.js'; import DesignGallery from './pages/DesignGallery.js'; +import DesignMedia from './pages/DesignMedia.js'; import StaffList from './pages/StaffList.js'; import StaffInvite from './pages/StaffInvite.js'; import StaffEdit from './pages/StaffEdit.js'; @@ -110,6 +111,7 @@ function AppRoutes() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/packages/admin/src/pages/DesignMedia.tsx b/packages/admin/src/pages/DesignMedia.tsx new file mode 100644 index 0000000..f6df73c --- /dev/null +++ b/packages/admin/src/pages/DesignMedia.tsx @@ -0,0 +1,184 @@ +import { useEffect, useRef, useState } from 'react'; +import type { MediaAsset } from '../components/MediaPicker.js'; + +function formatSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / 1024 / 1024).toFixed(1)} MB`; +} + +function formatDate(iso: string): string { + return new Date(iso).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }); +} + +export default function DesignMedia() { + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(true); + const [uploading, setUploading] = useState(false); + const [error, setError] = useState(''); + const [copiedId, setCopiedId] = useState(null); + const [dragOver, setDragOver] = useState(false); + const fileInputRef = useRef(null); + + const token = localStorage.getItem('token') || ''; + + async function load() { + setLoading(true); + setError(''); + try { + const res = await fetch('/api/media?limit=100', { + headers: { Authorization: `Bearer ${token}` }, + }); + const result = await res.json(); + if (!res.ok) throw new Error(result.error || 'Failed to load media'); + setItems(result.data ?? []); + } catch (e: any) { + setError(e.message); + } finally { + setLoading(false); + } + } + + useEffect(() => { + load(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + async function uploadFiles(files: FileList | null) { + if (!files || files.length === 0) return; + setUploading(true); + setError(''); + try { + for (const file of Array.from(files)) { + const formData = new FormData(); + formData.append('file', file); + const res = await fetch('/api/media/upload', { + method: 'POST', + headers: { Authorization: `Bearer ${token}` }, + body: formData, + }); + if (!res.ok) { + const result = await res.json().catch(() => ({})); + throw new Error(result.error || `Upload failed for ${file.name}`); + } + } + await load(); + } catch (e: any) { + setError(e.message); + } finally { + setUploading(false); + if (fileInputRef.current) fileInputRef.current.value = ''; + } + } + + async function remove(item: MediaAsset) { + if (!window.confirm(`Delete "${item.originalName}"? This will remove the file from storage.`)) return; + const res = await fetch(`/api/media/${item.id}`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${token}` }, + }); + if (res.ok) await load(); + else setError('Delete failed'); + } + + async function copyUrl(item: MediaAsset) { + const fullUrl = `${window.location.origin}${item.url}`; + try { + await navigator.clipboard.writeText(fullUrl); + setCopiedId(item.id); + setTimeout(() => setCopiedId((id) => (id === item.id ? null : id)), 1500); + } catch { + setError('Could not copy to clipboard'); + } + } + + function onDrop(e: React.DragEvent) { + e.preventDefault(); + setDragOver(false); + uploadFiles(e.dataTransfer.files); + } + + return ( +
+
+
+

Media Library

+

Upload and manage images used across your storefront

+
+ + uploadFiles(e.target.files)} + className="hidden" + /> +
+ + {/* Drop zone */} +
{ e.preventDefault(); setDragOver(true); }} + onDragLeave={() => setDragOver(false)} + onDrop={onDrop} + className={`mb-6 border-2 border-dashed rounded-xl p-8 text-center transition-colors ${ + dragOver ? 'border-primary-500 bg-primary-50' : 'border-gray-300 bg-gray-50' + }`} + > +

+ Drag and drop images here, or click Upload Image above. +

+

JPEG, PNG, WebP or GIF · max 5 MB each

+
+ + {error &&
{error}
} + + {loading ? ( +
+
+
+ ) : items.length === 0 ? ( +

No images yet. Upload one to get started.

+ ) : ( +
+ {items.map((item) => ( +
+
+ {item.originalName} +
+
+

+ {item.originalName} +

+

+ {formatSize(item.size)} · {formatDate(item.createdAt)} +

+
+ + +
+
+
+ ))} +
+ )} +
+ ); +} diff --git a/packages/server/src/__tests__/integration/media.test.ts b/packages/server/src/__tests__/integration/media.test.ts new file mode 100644 index 0000000..bce1270 --- /dev/null +++ b/packages/server/src/__tests__/integration/media.test.ts @@ -0,0 +1,141 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import request from 'supertest'; +import { createApp } from '../../app.js'; +import { generateToken } from '../../middleware/auth.js'; + +vi.mock('../../lib/db.js', () => { + const mockPrisma = { + mediaAsset: { findMany: vi.fn(), findUnique: vi.fn(), create: vi.fn(), delete: vi.fn(), count: vi.fn() }, + user: { findUnique: vi.fn() }, + customer: { findUnique: vi.fn() }, + }; + return { default: mockPrisma, prisma: mockPrisma }; +}); + +vi.mock('../../lib/stripe.js', () => ({ + default: { + paymentIntents: { create: vi.fn() }, + webhooks: { constructEvent: vi.fn() }, + }, +})); + +// Silence the fs.unlink call in deleteMedia — file may not exist in tests +vi.mock('fs', async () => { + const actual = await vi.importActual('fs'); + return { + ...actual, + promises: { + ...actual.promises, + unlink: vi.fn().mockResolvedValue(undefined), + }, + }; +}); + +import prisma from '../../lib/db.js'; +const mockedPrisma = vi.mocked(prisma); + +const app = createApp(); + +const staffToken = generateToken({ id: 'user-1', email: 'admin@test.com', type: 'staff', role: 'SUPER_ADMIN' }); +const customerToken = generateToken({ id: 'cust-1', email: 'customer@test.com', type: 'customer' }); + +const sampleAsset = { + id: 'media-1', + filename: 'abc.jpg', + originalName: 'photo.jpg', + url: '/uploads/abc.jpg', + mimeType: 'image/jpeg', + size: 12345, + uploadedById: 'user-1', + createdAt: new Date(), + uploadedBy: { id: 'user-1', name: 'Admin' }, +}; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('Media API', () => { + describe('GET /api/media', () => { + it('returns 401 without auth', async () => { + const res = await request(app).get('/api/media'); + expect(res.status).toBe(401); + }); + + it('returns 403 for non-staff', async () => { + const res = await request(app) + .get('/api/media') + .set('Authorization', `Bearer ${customerToken}`); + expect(res.status).toBe(403); + }); + + it('returns paginated list for staff', async () => { + mockedPrisma.mediaAsset.findMany.mockResolvedValueOnce([sampleAsset] as any); + mockedPrisma.mediaAsset.count.mockResolvedValueOnce(1); + const res = await request(app) + .get('/api/media') + .set('Authorization', `Bearer ${staffToken}`); + expect(res.status).toBe(200); + expect(res.body.data).toHaveLength(1); + expect(res.body.data[0].url).toBe('/uploads/abc.jpg'); + expect(res.body.pagination).toMatchObject({ page: 1, total: 1, totalPages: 1 }); + }); + }); + + describe('POST /api/media/upload', () => { + it('returns 401 without auth', async () => { + const res = await request(app) + .post('/api/media/upload') + .attach('file', Buffer.from('fake-png-bytes'), { filename: 'test.png', contentType: 'image/png' }); + expect(res.status).toBe(401); + }); + + it('returns 403 for non-staff', async () => { + const res = await request(app) + .post('/api/media/upload') + .set('Authorization', `Bearer ${customerToken}`) + .attach('file', Buffer.from('fake-png-bytes'), { filename: 'test.png', contentType: 'image/png' }); + expect(res.status).toBe(403); + }); + + it('returns 400 when no file is attached', async () => { + const res = await request(app) + .post('/api/media/upload') + .set('Authorization', `Bearer ${staffToken}`); + expect(res.status).toBe(400); + expect(res.body.error).toBeDefined(); + }); + }); + + describe('DELETE /api/media/:id', () => { + it('returns 401 without auth', async () => { + const res = await request(app).delete('/api/media/media-1'); + expect(res.status).toBe(401); + }); + + it('returns 403 for non-staff', async () => { + const res = await request(app) + .delete('/api/media/media-1') + .set('Authorization', `Bearer ${customerToken}`); + expect(res.status).toBe(403); + }); + + it('returns 404 when not found', async () => { + mockedPrisma.mediaAsset.findUnique.mockResolvedValueOnce(null); + const res = await request(app) + .delete('/api/media/bad-id') + .set('Authorization', `Bearer ${staffToken}`); + expect(res.status).toBe(404); + }); + + it('deletes asset successfully', async () => { + mockedPrisma.mediaAsset.findUnique.mockResolvedValueOnce(sampleAsset as any); + mockedPrisma.mediaAsset.delete.mockResolvedValueOnce(sampleAsset as any); + const res = await request(app) + .delete('/api/media/media-1') + .set('Authorization', `Bearer ${staffToken}`); + expect(res.status).toBe(200); + expect(mockedPrisma.mediaAsset.delete).toHaveBeenCalledWith({ where: { id: 'media-1' } }); + }); + }); +}); diff --git a/packages/server/src/app.ts b/packages/server/src/app.ts index 97a20e6..1f88441 100644 --- a/packages/server/src/app.ts +++ b/packages/server/src/app.ts @@ -22,6 +22,7 @@ import settingsRoutes from './routes/settings.routes.js'; import staffRoutes from './routes/staff.routes.js'; import developerRoutes from './routes/developer.routes.js'; import galleryRoutes from './routes/gallery.routes.js'; +import mediaRoutes from './routes/media.routes.js'; import { openApiSpec } from './lib/openapi.js'; import { initPassport } from './lib/passport.js'; import passport from 'passport'; @@ -119,6 +120,7 @@ export function createApp() { app.use('/api/staff', staffRoutes); app.use('/api/developer', developerRoutes); app.use('/api/gallery', galleryRoutes); + app.use('/api/media', mediaRoutes); // 404 handler app.use((_req, res) => { diff --git a/packages/server/src/controllers/media.controller.ts b/packages/server/src/controllers/media.controller.ts new file mode 100644 index 0000000..d43ed30 --- /dev/null +++ b/packages/server/src/controllers/media.controller.ts @@ -0,0 +1,68 @@ +import { Request, Response } from 'express'; +import path from 'path'; +import { promises as fs } from 'fs'; +import prisma from '../lib/db.js'; + +const UPLOADS_DIR = path.resolve(process.cwd(), 'uploads'); + +export async function uploadMedia(req: Request, res: Response): Promise { + if (!req.file) { + res.status(400).json({ success: false, error: 'No file provided' }); + return; + } + + const userId = (req as any).user?.id ?? null; + + const asset = await prisma.mediaAsset.create({ + data: { + filename: req.file.filename, + originalName: req.file.originalname, + url: `/uploads/${req.file.filename}`, + mimeType: req.file.mimetype, + size: req.file.size, + uploadedById: userId, + }, + }); + + res.status(201).json({ success: true, data: asset }); +} + +export async function listMedia(req: Request, res: Response): Promise { + const page = Math.max(1, parseInt(req.query.page as string) || 1); + const limit = Math.min(100, Math.max(1, parseInt(req.query.limit as string) || 50)); + const skip = (page - 1) * limit; + + const [items, total] = await Promise.all([ + prisma.mediaAsset.findMany({ + skip, + take: limit, + orderBy: { createdAt: 'desc' }, + include: { uploadedBy: { select: { id: true, name: true } } }, + }), + prisma.mediaAsset.count(), + ]); + + res.json({ + success: true, + data: items, + pagination: { page, limit, total, totalPages: Math.ceil(total / limit) }, + }); +} + +export async function deleteMedia(req: Request<{ id: string }>, res: Response): Promise { + const asset = await prisma.mediaAsset.findUnique({ where: { id: req.params.id } }); + if (!asset) { + res.status(404).json({ success: false, error: 'Media not found' }); + return; + } + + const filePath = path.join(UPLOADS_DIR, asset.filename); + try { + await fs.unlink(filePath); + } catch { + // file may already be gone — keep going to remove the DB row + } + + await prisma.mediaAsset.delete({ where: { id: req.params.id } }); + res.json({ success: true, message: 'Media deleted' }); +} diff --git a/packages/server/src/routes/media.routes.ts b/packages/server/src/routes/media.routes.ts new file mode 100644 index 0000000..a9ed6d7 --- /dev/null +++ b/packages/server/src/routes/media.routes.ts @@ -0,0 +1,12 @@ +import { Router } from 'express'; +import { authenticate, requireStaff } from '../middleware/auth.js'; +import { upload } from '../middleware/upload.js'; +import { uploadMedia, listMedia, deleteMedia } from '../controllers/media.controller.js'; + +const router = Router(); + +router.get('/', authenticate, requireStaff, listMedia); +router.post('/upload', authenticate, requireStaff, upload.single('file'), uploadMedia); +router.delete('/:id', authenticate, requireStaff, deleteMedia); + +export default router; diff --git a/prisma/migrations/20260514101500_add_media_assets/migration.sql b/prisma/migrations/20260514101500_add_media_assets/migration.sql new file mode 100644 index 0000000..1c576c3 --- /dev/null +++ b/prisma/migrations/20260514101500_add_media_assets/migration.sql @@ -0,0 +1,19 @@ +-- CreateTable +CREATE TABLE "media_assets" ( + "id" TEXT NOT NULL, + "filename" TEXT NOT NULL, + "originalName" TEXT NOT NULL, + "url" TEXT NOT NULL, + "mimeType" TEXT NOT NULL, + "size" INTEGER NOT NULL, + "uploadedById" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "media_assets_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "media_assets_createdAt_idx" ON "media_assets"("createdAt"); + +-- AddForeignKey +ALTER TABLE "media_assets" ADD CONSTRAINT "media_assets_uploadedById_fkey" FOREIGN KEY ("uploadedById") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ee3aba7..081635e 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -28,6 +28,7 @@ model User { location Location? @relation(fields: [locationId], references: [id]) assignedOrders Order[] @relation("AssignedStaff") + mediaAssets MediaAsset[] @@map("users") } @@ -627,6 +628,21 @@ model GalleryImage { @@map("gallery_images") } +model MediaAsset { + id String @id @default(cuid()) + filename String + originalName String + url String + mimeType String + size Int + uploadedById String? + uploadedBy User? @relation(fields: [uploadedById], references: [id], onDelete: SetNull) + createdAt DateTime @default(now()) + + @@index([createdAt]) + @@map("media_assets") +} + // ============================================================ // AUTOMATION // ============================================================