326 lines
12 KiB
TypeScript
326 lines
12 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useState, useRef } from "react";
|
|
import { api } from "@/lib/api";
|
|
import { cn } from "@/lib/utils";
|
|
import { Upload, Trash2, Copy, Film, Image as ImageIcon, Check, Pencil, X } from "lucide-react";
|
|
|
|
interface MediaItem {
|
|
id: string;
|
|
slug: string;
|
|
type: "image" | "video";
|
|
mimeType: string;
|
|
size: number;
|
|
originalFilename: string;
|
|
uploadedBy: string;
|
|
createdAt: string;
|
|
url: string;
|
|
title?: string;
|
|
description?: string;
|
|
altText?: string;
|
|
}
|
|
|
|
interface EditForm {
|
|
title: string;
|
|
description: string;
|
|
altText: string;
|
|
}
|
|
|
|
function formatFileSize(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`;
|
|
}
|
|
|
|
export default function GalleryPage() {
|
|
const [media, setMedia] = useState<MediaItem[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState("");
|
|
const [uploading, setUploading] = useState(false);
|
|
const [copiedId, setCopiedId] = useState<string | null>(null);
|
|
const [editingItem, setEditingItem] = useState<MediaItem | null>(null);
|
|
const [editForm, setEditForm] = useState<EditForm>({ title: "", description: "", altText: "" });
|
|
const [saving, setSaving] = useState(false);
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
const loadMedia = async () => {
|
|
try {
|
|
const data = await api.getMediaList();
|
|
setMedia(data);
|
|
} catch (err: any) {
|
|
setError(err.message);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
loadMedia();
|
|
}, []);
|
|
|
|
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const files = e.target.files;
|
|
if (!files || files.length === 0) return;
|
|
|
|
setUploading(true);
|
|
setError("");
|
|
|
|
try {
|
|
for (const file of Array.from(files)) {
|
|
await api.uploadMedia(file);
|
|
}
|
|
await loadMedia();
|
|
} catch (err: any) {
|
|
setError(err.message);
|
|
} finally {
|
|
setUploading(false);
|
|
if (fileInputRef.current) fileInputRef.current.value = "";
|
|
}
|
|
};
|
|
|
|
const handleDelete = async (id: string) => {
|
|
if (!confirm("Delete this media item? This cannot be undone.")) return;
|
|
try {
|
|
await api.deleteMedia(id);
|
|
await loadMedia();
|
|
} catch (err: any) {
|
|
setError(err.message);
|
|
}
|
|
};
|
|
|
|
const handleCopyUrl = async (item: MediaItem) => {
|
|
const url = `${window.location.origin}/media/${item.id}`;
|
|
await navigator.clipboard.writeText(url);
|
|
setCopiedId(item.id);
|
|
setTimeout(() => setCopiedId(null), 2000);
|
|
};
|
|
|
|
const openEdit = (item: MediaItem) => {
|
|
setEditingItem(item);
|
|
setEditForm({
|
|
title: item.title || "",
|
|
description: item.description || "",
|
|
altText: item.altText || "",
|
|
});
|
|
};
|
|
|
|
const handleSaveEdit = async () => {
|
|
if (!editingItem) return;
|
|
setSaving(true);
|
|
setError("");
|
|
try {
|
|
await api.updateMedia(editingItem.id, {
|
|
title: editForm.title,
|
|
description: editForm.description,
|
|
altText: editForm.altText,
|
|
});
|
|
setEditingItem(null);
|
|
await loadMedia();
|
|
} catch (err: any) {
|
|
setError(err.message);
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="flex items-center justify-center min-h-[60vh]">
|
|
<div className="text-on-surface/50">Loading gallery...</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="flex items-center justify-between">
|
|
<h1 className="text-2xl font-bold text-on-surface">Media Gallery</h1>
|
|
<div>
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
accept="image/*,video/*"
|
|
multiple
|
|
onChange={handleUpload}
|
|
className="hidden"
|
|
/>
|
|
<button
|
|
onClick={() => fileInputRef.current?.click()}
|
|
disabled={uploading}
|
|
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-gradient-to-r from-primary to-primary-container text-on-primary font-semibold text-sm hover:opacity-90 transition-opacity disabled:opacity-50"
|
|
>
|
|
<Upload size={16} />
|
|
{uploading ? "Uploading..." : "Upload Media"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{error && <p className="text-error text-sm">{error}</p>}
|
|
|
|
{media.length === 0 ? (
|
|
<div className="text-center py-20">
|
|
<ImageIcon size={48} className="mx-auto text-on-surface/20 mb-4" />
|
|
<p className="text-on-surface/50 text-sm">No media uploaded yet.</p>
|
|
<p className="text-on-surface/30 text-xs mt-1">
|
|
Upload images or videos to get started.
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
|
{media.map((item) => (
|
|
<div
|
|
key={item.id}
|
|
className="group bg-surface-container-low rounded-xl overflow-hidden"
|
|
>
|
|
<div className="relative aspect-square bg-surface-container-highest">
|
|
{item.type === "image" ? (
|
|
<img
|
|
src={`/media/${item.id}?w=300`}
|
|
alt={item.altText || item.title || item.originalFilename}
|
|
className="w-full h-full object-cover"
|
|
loading="lazy"
|
|
/>
|
|
) : (
|
|
<div className="w-full h-full flex items-center justify-center">
|
|
<Film size={40} className="text-on-surface/30" />
|
|
</div>
|
|
)}
|
|
|
|
<span
|
|
className={cn(
|
|
"absolute top-2 left-2 rounded-full px-2 py-0.5 text-[10px] font-bold uppercase",
|
|
item.type === "image"
|
|
? "bg-blue-900/60 text-blue-300"
|
|
: "bg-purple-900/60 text-purple-300"
|
|
)}
|
|
>
|
|
{item.type}
|
|
</span>
|
|
|
|
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/40 transition-colors flex items-center justify-center gap-2 opacity-0 group-hover:opacity-100">
|
|
<button
|
|
onClick={() => handleCopyUrl(item)}
|
|
className="p-2 rounded-lg bg-white/20 hover:bg-white/30 text-white transition-colors"
|
|
title="Copy Full URL"
|
|
>
|
|
{copiedId === item.id ? <Check size={16} /> : <Copy size={16} />}
|
|
</button>
|
|
<button
|
|
onClick={() => openEdit(item)}
|
|
className="p-2 rounded-lg bg-white/20 hover:bg-white/30 text-white transition-colors"
|
|
title="Edit Media"
|
|
>
|
|
<Pencil size={16} />
|
|
</button>
|
|
<button
|
|
onClick={() => handleDelete(item.id)}
|
|
className="p-2 rounded-lg bg-red-500/30 hover:bg-red-500/50 text-white transition-colors"
|
|
title="Delete"
|
|
>
|
|
<Trash2 size={16} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="p-3">
|
|
<p className="text-on-surface text-xs font-medium truncate" title={item.title || item.originalFilename}>
|
|
{item.title || item.originalFilename}
|
|
</p>
|
|
<p className="text-on-surface/40 text-[10px] mt-0.5">
|
|
{formatFileSize(item.size)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{editingItem && (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
|
<div className="absolute inset-0 bg-black/60" onClick={() => setEditingItem(null)} />
|
|
<div className="relative bg-surface-container-low rounded-2xl w-full max-w-lg overflow-hidden">
|
|
<div className="flex items-center justify-between p-5 border-b border-surface-container-highest">
|
|
<h2 className="text-lg font-semibold text-on-surface">Edit Media</h2>
|
|
<button
|
|
onClick={() => setEditingItem(null)}
|
|
className="text-on-surface/50 hover:text-on-surface transition-colors"
|
|
>
|
|
<X size={20} />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="p-5 space-y-4">
|
|
<div className="flex items-center gap-4 p-3 bg-surface-container-highest rounded-lg">
|
|
{editingItem.type === "image" ? (
|
|
<img
|
|
src={`/media/${editingItem.id}?w=100`}
|
|
alt=""
|
|
className="w-14 h-14 rounded-lg object-cover shrink-0"
|
|
/>
|
|
) : (
|
|
<div className="w-14 h-14 rounded-lg bg-surface-container flex items-center justify-center shrink-0">
|
|
<Film size={20} className="text-on-surface/30" />
|
|
</div>
|
|
)}
|
|
<div className="min-w-0">
|
|
<p className="text-on-surface text-sm font-medium truncate">{editingItem.originalFilename}</p>
|
|
<p className="text-on-surface/40 text-xs">
|
|
{editingItem.type} · {formatFileSize(editingItem.size)} · {editingItem.mimeType}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="text-on-surface/60 text-xs mb-1.5 block">Title</label>
|
|
<input
|
|
value={editForm.title}
|
|
onChange={(e) => setEditForm({ ...editForm, title: e.target.value })}
|
|
placeholder="SEO title for this media"
|
|
className="bg-surface-container-highest text-on-surface rounded-lg px-4 py-3 w-full focus:outline-none focus:ring-1 focus:ring-primary/40 text-sm"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="text-on-surface/60 text-xs mb-1.5 block">Description</label>
|
|
<textarea
|
|
value={editForm.description}
|
|
onChange={(e) => setEditForm({ ...editForm, description: e.target.value })}
|
|
placeholder="SEO description for this media"
|
|
rows={3}
|
|
className="bg-surface-container-highest text-on-surface rounded-lg px-4 py-3 w-full focus:outline-none focus:ring-1 focus:ring-primary/40 text-sm resize-none"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="text-on-surface/60 text-xs mb-1.5 block">Alt Text</label>
|
|
<input
|
|
value={editForm.altText}
|
|
onChange={(e) => setEditForm({ ...editForm, altText: e.target.value })}
|
|
placeholder="Accessible alt text for images"
|
|
className="bg-surface-container-highest text-on-surface rounded-lg px-4 py-3 w-full focus:outline-none focus:ring-1 focus:ring-primary/40 text-sm"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex justify-end gap-3 p-5 border-t border-surface-container-highest">
|
|
<button
|
|
onClick={() => setEditingItem(null)}
|
|
className="px-5 py-2 rounded-lg bg-surface-container-highest text-on-surface font-semibold text-sm hover:bg-surface-container-high transition-colors"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
onClick={handleSaveEdit}
|
|
disabled={saving}
|
|
className="px-5 py-2 rounded-lg bg-gradient-to-r from-primary to-primary-container text-on-primary font-semibold text-sm hover:opacity-90 transition-opacity disabled:opacity-50"
|
|
>
|
|
{saving ? "Saving..." : "Save"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|