first commit

Made-with: Cursor
This commit is contained in:
Michilis
2026-04-01 02:46:53 +00:00
commit 76210db03d
126 changed files with 20208 additions and 0 deletions

View 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} &middot; {formatFileSize(editingItem.size)} &middot; {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>
);
}