Files
memer/frontend/src/components/MemeDetail.tsx
2026-03-28 01:23:53 -05:00

286 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState } from 'react';
import { X, Minimize2, Trash2, Edit2, Check, Layers } from 'lucide-react';
import { useMeme, useDeleteMeme, useUpdateMeme } from '../hooks/useMemes';
import { useAuth } from '../hooks/useAuth';
import { SharePanel } from './SharePanel';
import { RescaleModal } from './RescaleModal';
import { api, type Meme } from '../api/client';
interface Props {
memeId: string;
onClose: () => void;
}
function formatBytes(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('en-US', {
year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit',
});
}
export function MemeDetail({ memeId, onClose }: Props) {
const { data, isLoading, refetch } = useMeme(memeId);
const deleteMeme = useDeleteMeme();
const updateMeme = useUpdateMeme();
const { data: auth } = useAuth();
const isAdmin = auth?.authenticated === true;
const [editing, setEditing] = useState(false);
const [editTitle, setEditTitle] = useState('');
const [editDesc, setEditDesc] = useState('');
const [editTags, setEditTags] = useState('');
const [showRescale, setShowRescale] = useState(false);
const [activeChild, setActiveChild] = useState<Meme | null>(null);
const meme = data;
const displayMeme = activeChild ?? meme;
function startEdit() {
if (!meme) return;
setEditTitle(meme.title);
setEditDesc(meme.description ?? '');
setEditTags(meme.tags.join(', '));
setEditing(true);
}
async function saveEdit() {
if (!meme) return;
await updateMeme.mutateAsync({
id: meme.id,
title: editTitle,
description: editDesc || undefined,
tags: editTags.split(',').map((t) => t.trim()).filter(Boolean),
});
setEditing(false);
}
async function handleDelete() {
if (!meme) return;
if (!confirm(`Delete "${meme.title}"? This also removes all rescaled copies.`)) return;
await deleteMeme.mutateAsync(meme.id);
onClose();
}
if (isLoading) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70">
<div className="w-10 h-10 border-2 border-accent border-t-transparent rounded-full animate-spin" />
</div>
);
}
if (!meme) return null;
return (
<>
<div
className="fixed inset-0 z-40 bg-black/80 animate-fade-in"
onClick={onClose}
/>
<div className="fixed inset-4 md:inset-8 z-50 flex flex-col bg-zinc-900 rounded-2xl shadow-2xl border border-zinc-800 overflow-hidden animate-scale-in">
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-zinc-800 flex-shrink-0">
{editing ? (
<input
autoFocus
value={editTitle}
onChange={(e) => setEditTitle(e.target.value)}
className="flex-1 bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-1.5 text-sm font-semibold focus:outline-none focus:border-accent mr-3"
/>
) : (
<h2 className="text-lg font-semibold truncate flex-1 mr-3">{meme.title}</h2>
)}
<div className="flex items-center gap-2 flex-shrink-0">
{isAdmin && (
editing ? (
<button
onClick={saveEdit}
disabled={updateMeme.isPending}
className="flex items-center gap-1 text-sm px-3 py-1.5 rounded-lg bg-accent hover:bg-accent-hover text-white transition-colors"
>
<Check size={14} /> Save
</button>
) : (
<button
onClick={startEdit}
className="text-zinc-500 hover:text-zinc-300 transition-colors p-1"
title="Edit"
>
<Edit2 size={16} />
</button>
)
)}
{isAdmin && !meme.parent_id && (
<button
onClick={() => setShowRescale(true)}
className="flex items-center gap-1 text-sm px-3 py-1.5 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-zinc-300 transition-colors"
title="Create rescaled copy"
>
<Minimize2 size={14} />
<span className="hidden sm:inline">Rescale</span>
</button>
)}
{isAdmin && (
<button
onClick={handleDelete}
disabled={deleteMeme.isPending}
className="text-zinc-500 hover:text-red-400 transition-colors p-1"
title="Delete"
>
<Trash2 size={16} />
</button>
)}
<button onClick={onClose} className="text-zinc-500 hover:text-zinc-300 transition-colors p-1">
<X size={20} />
</button>
</div>
</div>
{/* Body */}
<div className="flex flex-col md:flex-row flex-1 overflow-hidden">
{/* Image panel */}
<div className="flex-1 flex items-center justify-center bg-zinc-950 p-4 overflow-hidden">
{displayMeme && (
<img
key={displayMeme.id}
src={api.imageUrl(displayMeme.file_path)}
alt={displayMeme.title}
className="max-w-full max-h-full object-contain rounded-lg animate-fade-in"
/>
)}
</div>
{/* Sidebar */}
<div className="md:w-80 border-t md:border-t-0 md:border-l border-zinc-800 flex flex-col overflow-y-auto">
<div className="p-5 space-y-5 flex-1">
{/* Share */}
{displayMeme && (
<section>
<h3 className="text-xs font-semibold text-zinc-500 uppercase tracking-wider mb-2">Share</h3>
<SharePanel meme={displayMeme} />
</section>
)}
{/* Description */}
<section>
<h3 className="text-xs font-semibold text-zinc-500 uppercase tracking-wider mb-2">Description</h3>
{isAdmin && editing ? (
<textarea
value={editDesc}
onChange={(e) => setEditDesc(e.target.value)}
rows={3}
placeholder="Add a description…"
className="w-full bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-accent resize-none"
/>
) : (
<p className="text-sm text-zinc-400">{meme.description ?? 'No description.'}</p>
)}
</section>
{/* Tags */}
<section>
<h3 className="text-xs font-semibold text-zinc-500 uppercase tracking-wider mb-2">Tags</h3>
{isAdmin && editing ? (
<input
type="text"
value={editTags}
onChange={(e) => setEditTags(e.target.value)}
placeholder="funny, reaction, wholesome"
className="w-full bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-accent"
/>
) : (
<div className="flex flex-wrap gap-1.5">
{meme.tags.length > 0
? meme.tags.map((t) => (
<span key={t} className="text-xs px-2 py-0.5 rounded-full bg-accent/20 text-purple-300">
{t}
</span>
))
: <span className="text-sm text-zinc-600">No tags</span>
}
</div>
)}
</section>
{/* Metadata */}
<section>
<h3 className="text-xs font-semibold text-zinc-500 uppercase tracking-wider mb-2">Info</h3>
<dl className="space-y-1.5 text-sm">
<div className="flex justify-between">
<dt className="text-zinc-500">Dimensions</dt>
<dd className="text-zinc-300">{meme.width} × {meme.height}</dd>
</div>
<div className="flex justify-between">
<dt className="text-zinc-500">Size</dt>
<dd className="text-zinc-300">{formatBytes(meme.file_size)}</dd>
</div>
<div className="flex justify-between">
<dt className="text-zinc-500">Type</dt>
<dd className="text-zinc-300">{meme.mime_type.replace('image/', '')}</dd>
</div>
<div className="flex justify-between">
<dt className="text-zinc-500">Uploaded</dt>
<dd className="text-zinc-300 text-right text-xs">{formatDate(meme.created_at)}</dd>
</div>
</dl>
</section>
{/* Rescaled variants */}
{meme.children && meme.children.length > 0 && (
<section>
<h3 className="text-xs font-semibold text-zinc-500 uppercase tracking-wider mb-2 flex items-center gap-1">
<Layers size={12} /> Rescaled Copies ({meme.children.length})
</h3>
<div className="space-y-1.5">
<button
onClick={() => setActiveChild(null)}
className={`w-full text-left text-xs px-3 py-2 rounded-lg transition-colors ${
activeChild === null
? 'bg-accent/20 text-purple-300'
: 'bg-zinc-800 text-zinc-400 hover:bg-zinc-700'
}`}
>
Original {meme.width}×{meme.height} ({formatBytes(meme.file_size)})
</button>
{meme.children.map((child) => (
<button
key={child.id}
onClick={() => setActiveChild(activeChild?.id === child.id ? null : child)}
className={`w-full text-left text-xs px-3 py-2 rounded-lg transition-colors ${
activeChild?.id === child.id
? 'bg-accent/20 text-purple-300'
: 'bg-zinc-800 text-zinc-400 hover:bg-zinc-700'
}`}
>
{child.title.replace(meme.title, '').trim() || child.title} {child.width}×{child.height} ({formatBytes(child.file_size)})
</button>
))}
</div>
</section>
)}
</div>
</div>
</div>
</div>
{showRescale && (
<RescaleModal
meme={meme}
onClose={() => setShowRescale(false)}
onDone={() => {
setShowRescale(false);
refetch();
}}
/>
)}
</>
);
}