first commit
Made-with: Cursor
This commit is contained in:
325
frontend/app/admin/gallery/page.tsx
Normal file
325
frontend/app/admin/gallery/page.tsx
Normal file
@@ -0,0 +1,325 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user