Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/admin/src/components/AdminLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
],
},
{
Expand Down
142 changes: 142 additions & 0 deletions packages/admin/src/components/MediaPicker.tsx
Original file line number Diff line number Diff line change
@@ -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<MediaAsset[]>([]);
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<HTMLInputElement>) {
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 (
<div
className="fixed inset-0 z-50 bg-black/40 flex items-center justify-center p-4"
onClick={onClose}
role="dialog"
aria-modal="true"
aria-label="Pick from media library"
>
<div
className="bg-white rounded-xl shadow-xl max-w-4xl w-full max-h-[85vh] flex flex-col"
onClick={(e) => e.stopPropagation()}
>
<div className="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
<h3 className="text-lg font-semibold text-gray-900">Pick from Media Library</h3>
<div className="flex items-center gap-3">
<label className="px-3 py-1.5 text-sm bg-primary-600 text-white rounded-lg hover:bg-primary-700 cursor-pointer">
{uploading ? 'Uploading...' : '+ Upload New'}
<input type="file" accept="image/*" onChange={handleUpload} className="hidden" disabled={uploading} />
</label>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 p-1"
aria-label="Close"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>

<div className="flex-1 overflow-y-auto p-6">
{error && <div className="bg-red-50 text-red-700 p-3 rounded-lg mb-4 text-sm">{error}</div>}
{loading ? (
<div className="flex justify-center py-12">
<div className="w-8 h-8 border-4 border-primary-200 border-t-primary-600 rounded-full animate-spin" />
</div>
) : items.length === 0 ? (
<p className="text-center text-gray-500 py-12 text-sm">
No uploads yet. Click "Upload New" to add an image.
</p>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3">
{items.map((item) => (
<button
key={item.id}
onClick={() => onPick(item)}
className="group relative aspect-square bg-gray-100 rounded-lg overflow-hidden border-2 border-transparent hover:border-primary-500 focus:outline-none focus:border-primary-500"
title={item.originalName}
>
<img src={item.url} alt={item.originalName} className="w-full h-full object-cover" loading="lazy" />
<div className="absolute inset-0 bg-primary-600/0 group-hover:bg-primary-600/20 transition-colors flex items-center justify-center">
<span className="opacity-0 group-hover:opacity-100 bg-primary-600 text-white text-xs font-semibold px-2 py-1 rounded">
Select
</span>
</div>
</button>
))}
</div>
)}
</div>
</div>
</div>
);
}
2 changes: 2 additions & 0 deletions packages/admin/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -110,6 +111,7 @@ function AppRoutes() {
<Route path="/design/theme" element={<RequireRole roles={['SUPER_ADMIN', 'MANAGER']}><DesignTheme /></RequireRole>} />
<Route path="/design/templates" element={<RequireRole roles={['SUPER_ADMIN', 'MANAGER']}><DesignTemplates /></RequireRole>} />
<Route path="/design/gallery" element={<RequireRole roles={['SUPER_ADMIN', 'MANAGER']}><DesignGallery /></RequireRole>} />
<Route path="/design/media" element={<RequireRole roles={['SUPER_ADMIN', 'MANAGER']}><DesignMedia /></RequireRole>} />
<Route path="/legal" element={<RequireRole roles={['SUPER_ADMIN', 'MANAGER']}><Navigate to="/legal/pages" replace /></RequireRole>} />
<Route path="/legal/pages" element={<RequireRole roles={['SUPER_ADMIN', 'MANAGER']}><LegalPageList /></RequireRole>} />
<Route path="/legal/pages/:slug" element={<RequireRole roles={['SUPER_ADMIN', 'MANAGER']}><LegalPageForm /></RequireRole>} />
Expand Down
184 changes: 184 additions & 0 deletions packages/admin/src/pages/DesignMedia.tsx
Original file line number Diff line number Diff line change
@@ -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<MediaAsset[]>([]);
const [loading, setLoading] = useState(true);
const [uploading, setUploading] = useState(false);
const [error, setError] = useState('');
const [copiedId, setCopiedId] = useState<string | null>(null);
const [dragOver, setDragOver] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(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 (
<div>
<div className="flex items-center justify-between mb-6 flex-wrap gap-3">
<div>
<h2 className="text-2xl font-semibold text-gray-800">Media Library</h2>
<p className="text-sm text-gray-500 mt-1">Upload and manage images used across your storefront</p>
</div>
<button
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
className="bg-primary-600 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-primary-700 disabled:opacity-50 transition-colors"
>
{uploading ? 'Uploading...' : '+ Upload Image'}
</button>
<input
ref={fileInputRef}
type="file"
accept="image/*"
multiple
onChange={(e) => uploadFiles(e.target.files)}
className="hidden"
/>
</div>

{/* Drop zone */}
<div
onDragOver={(e) => { 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'
}`}
>
<p className="text-sm text-gray-600">
Drag and drop images here, or click <span className="font-semibold">Upload Image</span> above.
</p>
<p className="text-xs text-gray-400 mt-1">JPEG, PNG, WebP or GIF · max 5 MB each</p>
</div>

{error && <div className="bg-red-50 text-red-700 p-3 rounded-lg mb-4 text-sm">{error}</div>}

{loading ? (
<div className="flex justify-center py-12">
<div className="w-8 h-8 border-4 border-primary-200 border-t-primary-600 rounded-full animate-spin" />
</div>
) : items.length === 0 ? (
<p className="text-gray-500 text-sm py-8 text-center">No images yet. Upload one to get started.</p>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{items.map((item) => (
<div key={item.id} className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
<div className="aspect-square bg-gray-100 relative">
<img src={item.url} alt={item.originalName} className="w-full h-full object-cover" loading="lazy" />
</div>
<div className="p-3">
<p className="text-sm font-medium text-gray-900 truncate" title={item.originalName}>
{item.originalName}
</p>
<p className="text-xs text-gray-500 mt-0.5">
{formatSize(item.size)} · {formatDate(item.createdAt)}
</p>
<div className="flex gap-2 mt-3">
<button
onClick={() => copyUrl(item)}
className="flex-1 text-xs px-2 py-1.5 border border-gray-300 rounded hover:bg-gray-50"
>
{copiedId === item.id ? 'Copied!' : 'Copy URL'}
</button>
<button
onClick={() => remove(item)}
className="text-xs px-2 py-1.5 border border-red-200 text-red-600 rounded hover:bg-red-50"
aria-label={`Delete ${item.originalName}`}
>
</button>
</div>
</div>
</div>
))}
</div>
)}
</div>
);
}
Loading
Loading