first commit
Made-with: Cursor
This commit is contained in:
844
frontend/app/admin/events/page.tsx
Normal file
844
frontend/app/admin/events/page.tsx
Normal file
@@ -0,0 +1,844 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import { formatDate } from "@/lib/utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Plus,
|
||||
Pencil,
|
||||
Trash2,
|
||||
X,
|
||||
Image as ImageIcon,
|
||||
Copy,
|
||||
MoreHorizontal,
|
||||
Star,
|
||||
Eye,
|
||||
EyeOff,
|
||||
CheckSquare,
|
||||
Square,
|
||||
ChevronUp,
|
||||
ChevronDown,
|
||||
Link as LinkIcon,
|
||||
Check,
|
||||
} from "lucide-react";
|
||||
import { MediaPickerModal } from "@/components/admin/MediaPickerModal";
|
||||
|
||||
interface Meetup {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
date: string;
|
||||
time: string;
|
||||
location: string;
|
||||
link?: string;
|
||||
imageId?: string;
|
||||
status: string;
|
||||
featured: boolean;
|
||||
visibility: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface MeetupForm {
|
||||
title: string;
|
||||
description: string;
|
||||
date: string;
|
||||
time: string;
|
||||
location: string;
|
||||
link: string;
|
||||
imageId: string;
|
||||
status: string;
|
||||
featured: boolean;
|
||||
visibility: string;
|
||||
}
|
||||
|
||||
const emptyForm: MeetupForm = {
|
||||
title: "",
|
||||
description: "",
|
||||
date: "",
|
||||
time: "",
|
||||
location: "",
|
||||
link: "",
|
||||
imageId: "",
|
||||
status: "DRAFT",
|
||||
featured: false,
|
||||
visibility: "PUBLIC",
|
||||
};
|
||||
|
||||
// Statuses that can be manually set by an admin
|
||||
const EDITABLE_STATUS_OPTIONS = ["DRAFT", "PUBLISHED", "CANCELLED"] as const;
|
||||
type EditableStatus = (typeof EDITABLE_STATUS_OPTIONS)[number];
|
||||
|
||||
// Display statuses (includes computed Upcoming/Past from PUBLISHED + date)
|
||||
type DisplayStatus = "DRAFT" | "UPCOMING" | "PAST" | "CANCELLED";
|
||||
|
||||
function getDisplayStatus(meetup: { status: string; date: string }): DisplayStatus {
|
||||
if (meetup.status === "CANCELLED") return "CANCELLED";
|
||||
if (meetup.status === "DRAFT") return "DRAFT";
|
||||
// PUBLISHED (or legacy UPCOMING/PAST values) → derive from date
|
||||
if (!meetup.date) return "DRAFT";
|
||||
return new Date(meetup.date) > new Date() ? "UPCOMING" : "PAST";
|
||||
}
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
DRAFT: "Draft",
|
||||
PUBLISHED: "Published",
|
||||
UPCOMING: "Upcoming",
|
||||
PAST: "Past",
|
||||
CANCELLED: "Cancelled",
|
||||
};
|
||||
|
||||
// Badge styles use the computed display status
|
||||
const DISPLAY_STATUS_STYLES: Record<DisplayStatus, string> = {
|
||||
DRAFT: "bg-surface-container-highest text-on-surface/60",
|
||||
UPCOMING: "bg-green-900/40 text-green-400",
|
||||
PAST: "bg-surface-container-highest text-on-surface/40",
|
||||
CANCELLED: "bg-red-900/30 text-red-400",
|
||||
};
|
||||
|
||||
function useOutsideClick(ref: React.RefObject<HTMLElement | null>, callback: () => void) {
|
||||
useEffect(() => {
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
callback();
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClick);
|
||||
return () => document.removeEventListener("mousedown", handleClick);
|
||||
}, [ref, callback]);
|
||||
}
|
||||
|
||||
function MoreMenu({
|
||||
meetup,
|
||||
onCopyUrl,
|
||||
}: {
|
||||
meetup: Meetup;
|
||||
onCopyUrl: () => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useOutsideClick(ref, () => setOpen(false));
|
||||
|
||||
const handleCopy = () => {
|
||||
onCopyUrl();
|
||||
setCopied(true);
|
||||
setTimeout(() => {
|
||||
setCopied(false);
|
||||
setOpen(false);
|
||||
}, 1500);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative" ref={ref}>
|
||||
<button
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="p-2 rounded-lg hover:bg-surface-container-high text-on-surface/60 hover:text-on-surface transition-colors"
|
||||
title="More options"
|
||||
>
|
||||
<MoreHorizontal size={16} />
|
||||
</button>
|
||||
{open && (
|
||||
<div className="absolute right-0 top-full mt-1 z-50 w-48 bg-surface-container-low border border-surface-container-highest rounded-xl shadow-lg overflow-hidden">
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="flex items-center gap-2 w-full px-4 py-3 text-sm text-on-surface/80 hover:bg-surface-container-high hover:text-on-surface transition-colors"
|
||||
>
|
||||
{copied ? <Check size={14} className="text-green-400" /> : <LinkIcon size={14} />}
|
||||
{copied ? "Copied!" : "Copy Event URL"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusDropdown({
|
||||
meetup,
|
||||
onChange,
|
||||
}: {
|
||||
meetup: { status: string; date: string };
|
||||
onChange: (v: string) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useOutsideClick(ref, () => setOpen(false));
|
||||
|
||||
const displayStatus = getDisplayStatus(meetup);
|
||||
|
||||
return (
|
||||
<div className="relative" ref={ref}>
|
||||
<button
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className={cn(
|
||||
"rounded-full px-3 py-1 text-xs font-bold cursor-pointer hover:opacity-80 transition-opacity",
|
||||
DISPLAY_STATUS_STYLES[displayStatus]
|
||||
)}
|
||||
>
|
||||
{STATUS_LABELS[displayStatus]}
|
||||
</button>
|
||||
{open && (
|
||||
<div className="absolute left-0 top-full mt-1 z-50 w-36 bg-surface-container-low border border-surface-container-highest rounded-xl shadow-lg overflow-hidden">
|
||||
{EDITABLE_STATUS_OPTIONS.map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => {
|
||||
onChange(s);
|
||||
setOpen(false);
|
||||
}}
|
||||
className={cn(
|
||||
"flex items-center gap-2 w-full px-3 py-2 text-xs font-bold transition-colors hover:bg-surface-container-high",
|
||||
meetup.status === s ? "text-on-surface" : "text-on-surface/60"
|
||||
)}
|
||||
>
|
||||
<span className={cn("w-2 h-2 rounded-full", {
|
||||
"bg-on-surface/40": s === "DRAFT",
|
||||
"bg-green-400": s === "PUBLISHED",
|
||||
"bg-red-400": s === "CANCELLED",
|
||||
})} />
|
||||
{STATUS_LABELS[s]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function EventsPage() {
|
||||
const [meetups, setMeetups] = useState<Meetup[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [form, setForm] = useState<MeetupForm>(emptyForm);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [showMediaPicker, setShowMediaPicker] = useState(false);
|
||||
const formRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Filters
|
||||
const [filterStatus, setFilterStatus] = useState("ALL");
|
||||
const [filterCity, setFilterCity] = useState("ALL");
|
||||
const [sortDir, setSortDir] = useState<"asc" | "desc">("asc");
|
||||
|
||||
// Bulk selection
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [bulkLoading, setBulkLoading] = useState(false);
|
||||
|
||||
const loadMeetups = async () => {
|
||||
try {
|
||||
const data = await api.getMeetups({ admin: true });
|
||||
setMeetups(data as Meetup[]);
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadMeetups();
|
||||
}, []);
|
||||
|
||||
const openCreate = () => {
|
||||
setForm(emptyForm);
|
||||
setEditingId(null);
|
||||
setShowForm(true);
|
||||
setTimeout(() => formRef.current?.scrollIntoView({ behavior: "smooth", block: "start" }), 50);
|
||||
};
|
||||
|
||||
const openEdit = (meetup: Meetup) => {
|
||||
setForm({
|
||||
title: meetup.title,
|
||||
description: meetup.description || "",
|
||||
date: meetup.date?.split("T")[0] || meetup.date || "",
|
||||
time: meetup.time || "",
|
||||
location: meetup.location || "",
|
||||
link: meetup.link || "",
|
||||
imageId: meetup.imageId || "",
|
||||
status: meetup.status || "DRAFT",
|
||||
featured: meetup.featured || false,
|
||||
visibility: meetup.visibility || "PUBLIC",
|
||||
});
|
||||
setEditingId(meetup.id);
|
||||
setShowForm(true);
|
||||
setTimeout(() => formRef.current?.scrollIntoView({ behavior: "smooth", block: "start" }), 50);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
setError("");
|
||||
try {
|
||||
const payload = {
|
||||
title: form.title,
|
||||
description: form.description,
|
||||
date: form.date,
|
||||
time: form.time || "00:00",
|
||||
location: form.location,
|
||||
link: form.link,
|
||||
imageId: form.imageId || null,
|
||||
status: form.status,
|
||||
featured: form.featured,
|
||||
visibility: form.visibility,
|
||||
};
|
||||
|
||||
if (editingId) {
|
||||
const updated = await api.updateMeetup(editingId, payload);
|
||||
setMeetups((prev) => prev.map((m) => (m.id === editingId ? updated : m)));
|
||||
} else {
|
||||
const created = await api.createMeetup(payload);
|
||||
setMeetups((prev) => [...prev, created]);
|
||||
}
|
||||
setShowForm(false);
|
||||
setEditingId(null);
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm("Delete this meetup?")) return;
|
||||
setMeetups((prev) => prev.filter((m) => m.id !== id));
|
||||
try {
|
||||
await api.deleteMeetup(id);
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
await loadMeetups();
|
||||
}
|
||||
};
|
||||
|
||||
const handleDuplicate = async (id: string) => {
|
||||
try {
|
||||
const dup = await api.duplicateMeetup(id);
|
||||
setMeetups((prev) => [dup, ...prev]);
|
||||
openEdit(dup);
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePatch = async (id: string, patch: Partial<Meetup>) => {
|
||||
setMeetups((prev) => prev.map((m) => (m.id === id ? { ...m, ...patch } : m)));
|
||||
try {
|
||||
await api.updateMeetup(id, patch);
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
await loadMeetups();
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyUrl = (meetup: Meetup) => {
|
||||
const origin = typeof window !== "undefined" ? window.location.origin : "";
|
||||
navigator.clipboard.writeText(`${origin}/events/${meetup.id}`);
|
||||
};
|
||||
|
||||
const toggleSelect = (id: string) => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const selectAll = () => {
|
||||
if (selected.size === filtered.length) {
|
||||
setSelected(new Set());
|
||||
} else {
|
||||
setSelected(new Set(filtered.map((m) => m.id)));
|
||||
}
|
||||
};
|
||||
|
||||
const handleBulk = async (action: "delete" | "publish" | "duplicate") => {
|
||||
if (selected.size === 0) return;
|
||||
if (action === "delete" && !confirm(`Delete ${selected.size} meetup(s)?`)) return;
|
||||
setBulkLoading(true);
|
||||
try {
|
||||
const ids = Array.from(selected);
|
||||
if (action === "delete") {
|
||||
await api.bulkMeetupAction("delete", ids);
|
||||
setMeetups((prev) => prev.filter((m) => !ids.includes(m.id)));
|
||||
} else if (action === "publish") {
|
||||
await api.bulkMeetupAction("publish", ids);
|
||||
setMeetups((prev) => prev.map((m) => (ids.includes(m.id) ? { ...m, status: "PUBLISHED" } : m)));
|
||||
} else if (action === "duplicate") {
|
||||
const result = await api.bulkMeetupAction("duplicate", ids);
|
||||
setMeetups((prev) => [...(result as Meetup[]), ...prev]);
|
||||
}
|
||||
setSelected(new Set());
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
await loadMeetups();
|
||||
} finally {
|
||||
setBulkLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Derived: unique cities
|
||||
const cities = Array.from(new Set(meetups.map((m) => m.location).filter(Boolean))).sort();
|
||||
|
||||
// Filter tabs use computed display status
|
||||
const FILTER_STATUS_OPTIONS: Array<{ value: string; label: string }> = [
|
||||
{ value: "ALL", label: "All" },
|
||||
{ value: "UPCOMING", label: "Upcoming" },
|
||||
{ value: "PAST", label: "Past" },
|
||||
{ value: "DRAFT", label: "Draft" },
|
||||
{ value: "CANCELLED", label: "Cancelled" },
|
||||
];
|
||||
|
||||
// Filtered + sorted
|
||||
const filtered = meetups
|
||||
.filter((m) => {
|
||||
if (filterStatus !== "ALL" && getDisplayStatus(m) !== filterStatus) return false;
|
||||
if (filterCity !== "ALL" && m.location !== filterCity) return false;
|
||||
return true;
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const da = a.date || "";
|
||||
const db = b.date || "";
|
||||
return sortDir === "asc" ? da.localeCompare(db) : db.localeCompare(da);
|
||||
});
|
||||
|
||||
const allSelected = filtered.length > 0 && selected.size === filtered.length;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[60vh]">
|
||||
<div className="text-on-surface/50">Loading meetups...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-on-surface">Events</h1>
|
||||
<button
|
||||
onClick={openCreate}
|
||||
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"
|
||||
>
|
||||
<Plus size={16} />
|
||||
New Event
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-center justify-between bg-error-container/20 text-error text-sm px-4 py-3 rounded-lg">
|
||||
<span>{error}</span>
|
||||
<button onClick={() => setError("")}>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create / Edit Form */}
|
||||
{showForm && (
|
||||
<div ref={formRef} className="bg-surface-container-low rounded-xl p-6">
|
||||
<div className="flex items-center justify-between mb-5">
|
||||
<h2 className="text-lg font-semibold text-on-surface">
|
||||
{editingId ? "Edit Event" : "New Event"}
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => setShowForm(false)}
|
||||
className="text-on-surface/50 hover:text-on-surface"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<input
|
||||
placeholder="Title (e.g. #54 Belgian Bitcoin Embassy Meetup)"
|
||||
value={form.title}
|
||||
onChange={(e) => setForm({ ...form, title: e.target.value })}
|
||||
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 md:col-span-2"
|
||||
/>
|
||||
<input
|
||||
placeholder="Location"
|
||||
value={form.location}
|
||||
onChange={(e) => setForm({ ...form, location: e.target.value })}
|
||||
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"
|
||||
/>
|
||||
<input
|
||||
type="date"
|
||||
value={form.date}
|
||||
onChange={(e) => setForm({ ...form, date: e.target.value })}
|
||||
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"
|
||||
/>
|
||||
<input
|
||||
type="time"
|
||||
value={form.time}
|
||||
onChange={(e) => setForm({ ...form, time: e.target.value })}
|
||||
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"
|
||||
/>
|
||||
<div>
|
||||
<label className="text-on-surface/60 text-xs mb-2 block">Status</label>
|
||||
<select
|
||||
value={form.status}
|
||||
onChange={(e) => setForm({ ...form, status: e.target.value })}
|
||||
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"
|
||||
>
|
||||
{EDITABLE_STATUS_OPTIONS.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{STATUS_LABELS[s]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-on-surface/60 text-xs mb-2 block">Visibility</label>
|
||||
<select
|
||||
value={form.visibility}
|
||||
onChange={(e) => setForm({ ...form, visibility: e.target.value })}
|
||||
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"
|
||||
>
|
||||
<option value="PUBLIC">Public</option>
|
||||
<option value="HIDDEN">Hidden</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<label className="text-on-surface/60 text-xs mb-2 block">
|
||||
External registration link{" "}
|
||||
<span className="text-on-surface/40">(optional)</span>
|
||||
</label>
|
||||
<input
|
||||
placeholder="https://..."
|
||||
value={form.link}
|
||||
onChange={(e) => setForm({ ...form, link: e.target.value })}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<textarea
|
||||
placeholder="Description"
|
||||
value={form.description}
|
||||
onChange={(e) => setForm({ ...form, description: e.target.value })}
|
||||
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 md:col-span-2"
|
||||
/>
|
||||
<div className="md:col-span-2">
|
||||
<label className="text-on-surface/60 text-xs mb-2 block">
|
||||
Event image <span className="text-on-surface/40">(optional)</span>
|
||||
</label>
|
||||
<div className="flex items-center gap-3">
|
||||
{form.imageId && (
|
||||
<div className="relative w-20 h-20 rounded-lg overflow-hidden bg-surface-container-highest shrink-0">
|
||||
<img
|
||||
src={`/media/${form.imageId}?w=200`}
|
||||
alt="Selected"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowMediaPicker(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-surface-container-highest text-on-surface/70 hover:text-on-surface text-sm transition-colors"
|
||||
>
|
||||
<ImageIcon size={16} />
|
||||
{form.imageId ? "Change Image" : "Select Image"}
|
||||
</button>
|
||||
{form.imageId && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setForm({ ...form, imageId: "" })}
|
||||
className="px-3 py-2 rounded-lg text-error/70 hover:text-error text-sm transition-colors"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 mt-4 pt-4 border-t border-surface-container-highest">
|
||||
<label className="flex items-center gap-2 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.featured}
|
||||
onChange={(e) => setForm({ ...form, featured: e.target.checked })}
|
||||
className="hidden"
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 text-sm transition-colors",
|
||||
form.featured ? "text-primary" : "text-on-surface/50 hover:text-on-surface"
|
||||
)}
|
||||
>
|
||||
<Star size={15} className={form.featured ? "fill-primary" : ""} />
|
||||
Featured
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 mt-4">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving || !form.title || !form.date}
|
||||
className="px-6 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>
|
||||
<button
|
||||
onClick={() => setShowForm(false)}
|
||||
className="px-6 py-2 rounded-lg bg-surface-container-highest text-on-surface font-semibold text-sm hover:bg-surface-container-high transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showMediaPicker && (
|
||||
<MediaPickerModal
|
||||
selectedId={form.imageId || null}
|
||||
onSelect={(id) => {
|
||||
setForm({ ...form, imageId: id });
|
||||
setShowMediaPicker(false);
|
||||
}}
|
||||
onClose={() => setShowMediaPicker(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{/* Status filter */}
|
||||
<div className="flex items-center bg-surface-container-low rounded-lg overflow-hidden">
|
||||
{FILTER_STATUS_OPTIONS.map(({ value, label }) => (
|
||||
<button
|
||||
key={value}
|
||||
onClick={() => setFilterStatus(value)}
|
||||
className={cn(
|
||||
"px-3 py-1.5 text-xs font-semibold transition-colors",
|
||||
filterStatus === value
|
||||
? "bg-primary text-on-primary"
|
||||
: "text-on-surface/60 hover:text-on-surface hover:bg-surface-container-high"
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* City filter */}
|
||||
{cities.length > 1 && (
|
||||
<select
|
||||
value={filterCity}
|
||||
onChange={(e) => setFilterCity(e.target.value)}
|
||||
className="bg-surface-container-low text-on-surface/70 text-xs rounded-lg px-3 py-1.5 focus:outline-none focus:ring-1 focus:ring-primary/40"
|
||||
>
|
||||
<option value="ALL">All cities</option>
|
||||
{cities.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{c}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
|
||||
{/* Sort */}
|
||||
<button
|
||||
onClick={() => setSortDir((d) => (d === "asc" ? "desc" : "asc"))}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold text-on-surface/60 hover:text-on-surface bg-surface-container-low rounded-lg transition-colors"
|
||||
>
|
||||
{sortDir === "asc" ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
|
||||
Date
|
||||
</button>
|
||||
|
||||
<span className="ml-auto text-xs text-on-surface/40">
|
||||
{filtered.length} event{filtered.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Bulk action bar */}
|
||||
{selected.size > 0 && (
|
||||
<div className="flex items-center gap-3 bg-surface-container-low rounded-xl px-4 py-3 border border-primary/20">
|
||||
<span className="text-sm text-on-surface/70 font-medium">
|
||||
{selected.size} selected
|
||||
</span>
|
||||
<div className="flex items-center gap-2 ml-auto">
|
||||
<button
|
||||
onClick={() => handleBulk("duplicate")}
|
||||
disabled={bulkLoading}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold rounded-lg bg-surface-container-high text-on-surface/70 hover:text-on-surface transition-colors disabled:opacity-50"
|
||||
>
|
||||
<Copy size={12} />
|
||||
Duplicate
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleBulk("publish")}
|
||||
disabled={bulkLoading}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold rounded-lg bg-surface-container-high text-on-surface/70 hover:text-on-surface transition-colors disabled:opacity-50"
|
||||
>
|
||||
Publish
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleBulk("delete")}
|
||||
disabled={bulkLoading}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold rounded-lg bg-error-container/20 text-error/70 hover:text-error transition-colors disabled:opacity-50"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
Delete
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSelected(new Set())}
|
||||
className="ml-1 text-on-surface/40 hover:text-on-surface"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Event list */}
|
||||
<div className="space-y-2">
|
||||
{filtered.length === 0 ? (
|
||||
<p className="text-on-surface/50 text-sm py-8 text-center">No events found.</p>
|
||||
) : (
|
||||
<>
|
||||
{/* Select-all row */}
|
||||
<div className="flex items-center gap-2 px-2 pb-1">
|
||||
<button
|
||||
onClick={selectAll}
|
||||
className="text-on-surface/40 hover:text-on-surface transition-colors"
|
||||
title={allSelected ? "Deselect all" : "Select all"}
|
||||
>
|
||||
{allSelected ? <CheckSquare size={15} /> : <Square size={15} />}
|
||||
</button>
|
||||
<span className="text-xs text-on-surface/40">
|
||||
{allSelected ? "Deselect all" : "Select all"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{filtered.map((meetup) => (
|
||||
<div
|
||||
key={meetup.id}
|
||||
className={cn(
|
||||
"bg-surface-container-low rounded-xl p-4 flex items-center gap-3 transition-colors",
|
||||
selected.has(meetup.id) && "ring-1 ring-primary/30 bg-surface-container"
|
||||
)}
|
||||
>
|
||||
{/* Checkbox */}
|
||||
<button
|
||||
onClick={() => toggleSelect(meetup.id)}
|
||||
className="shrink-0 text-on-surface/40 hover:text-on-surface transition-colors"
|
||||
>
|
||||
{selected.has(meetup.id) ? (
|
||||
<CheckSquare size={16} className="text-primary" />
|
||||
) : (
|
||||
<Square size={16} />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Image */}
|
||||
{meetup.imageId ? (
|
||||
<div className="w-14 h-14 rounded-lg overflow-hidden bg-surface-container-highest shrink-0">
|
||||
<img
|
||||
src={`/media/${meetup.imageId}?w=100`}
|
||||
alt=""
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-14 h-14 rounded-lg bg-surface-container-highest shrink-0 flex items-center justify-center">
|
||||
<ImageIcon size={18} className="text-on-surface/20" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2 mb-1">
|
||||
<h3 className="text-on-surface font-semibold truncate">{meetup.title}</h3>
|
||||
{meetup.featured && (
|
||||
<Star size={12} className="text-primary fill-primary shrink-0" />
|
||||
)}
|
||||
{meetup.visibility === "HIDDEN" && (
|
||||
<EyeOff size={12} className="text-on-surface/40 shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<StatusDropdown
|
||||
meetup={meetup}
|
||||
onChange={(v) => handlePatch(meetup.id, { status: v })}
|
||||
/>
|
||||
<span className="text-on-surface/50 text-xs">
|
||||
{meetup.date ? formatDate(meetup.date) : "No date"}
|
||||
{meetup.location && ` · ${meetup.location}`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{/* Featured toggle */}
|
||||
<button
|
||||
onClick={() => handlePatch(meetup.id, { featured: !meetup.featured })}
|
||||
className={cn(
|
||||
"p-2 rounded-lg transition-colors",
|
||||
meetup.featured
|
||||
? "text-primary hover:text-primary/70"
|
||||
: "text-on-surface/30 hover:text-on-surface hover:bg-surface-container-high"
|
||||
)}
|
||||
title={meetup.featured ? "Unfeature" : "Feature"}
|
||||
>
|
||||
<Star size={15} className={meetup.featured ? "fill-primary" : ""} />
|
||||
</button>
|
||||
|
||||
{/* Visibility toggle */}
|
||||
<button
|
||||
onClick={() =>
|
||||
handlePatch(meetup.id, {
|
||||
visibility: meetup.visibility === "PUBLIC" ? "HIDDEN" : "PUBLIC",
|
||||
})
|
||||
}
|
||||
className={cn(
|
||||
"p-2 rounded-lg transition-colors",
|
||||
meetup.visibility === "HIDDEN"
|
||||
? "text-on-surface/30 hover:text-on-surface hover:bg-surface-container-high"
|
||||
: "text-on-surface/60 hover:text-on-surface hover:bg-surface-container-high"
|
||||
)}
|
||||
title={meetup.visibility === "PUBLIC" ? "Hide event" : "Make public"}
|
||||
>
|
||||
{meetup.visibility === "HIDDEN" ? <EyeOff size={15} /> : <Eye size={15} />}
|
||||
</button>
|
||||
|
||||
{/* Edit */}
|
||||
<button
|
||||
onClick={() => openEdit(meetup)}
|
||||
className="p-2 rounded-lg hover:bg-surface-container-high text-on-surface/60 hover:text-on-surface transition-colors"
|
||||
title="Edit"
|
||||
>
|
||||
<Pencil size={15} />
|
||||
</button>
|
||||
|
||||
{/* Duplicate */}
|
||||
<button
|
||||
onClick={() => handleDuplicate(meetup.id)}
|
||||
className="p-2 rounded-lg hover:bg-surface-container-high text-on-surface/60 hover:text-on-surface transition-colors"
|
||||
title="Duplicate"
|
||||
>
|
||||
<Copy size={15} />
|
||||
</button>
|
||||
|
||||
{/* Delete */}
|
||||
<button
|
||||
onClick={() => handleDelete(meetup.id)}
|
||||
className="p-2 rounded-lg hover:bg-error-container/30 text-on-surface/60 hover:text-error transition-colors"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 size={15} />
|
||||
</button>
|
||||
|
||||
{/* More menu */}
|
||||
<MoreMenu meetup={meetup} onCopyUrl={() => handleCopyUrl(meetup)} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user