From a890e0513c8620b2a5fac0cbdc8351022e18d93a Mon Sep 17 00:00:00 2001 From: Krish Agarwal Date: Wed, 14 Oct 2026 02:16:34 +0530 Subject: [PATCH] event resize --- src/app/api/admin/events/order/route.ts | 72 ++++++++++++ src/app/api/admin/events/route.ts | 21 +++- src/app/api/events/route.ts | 1 + .../innovision/admin/EventsManager.tsx | 104 +++++++++++++++++- src/lib/events.ts | 7 +- src/lib/supabase.ts | 1 + supabase/manual/add_event_sort_order.sql | 26 +++++ supabase/schema.sql | 4 + 8 files changed, 229 insertions(+), 7 deletions(-) create mode 100644 src/app/api/admin/events/order/route.ts create mode 100644 supabase/manual/add_event_sort_order.sql diff --git a/src/app/api/admin/events/order/route.ts b/src/app/api/admin/events/order/route.ts new file mode 100644 index 0000000..d14d06c --- /dev/null +++ b/src/app/api/admin/events/order/route.ts @@ -0,0 +1,72 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { revalidatePath } from 'next/cache'; +import { verifyEventStaff } from '@/lib/auth-server'; +import { isUuid, readJsonObject } from '@/lib/security'; +import { ALLOWED_EVENT_CATEGORIES, type AllowedEventCategory } from '../route'; + +export const runtime = 'nodejs'; + +/** + * POST { category, ids }: set the order of every event in one category (Authorized IT Team & Admin). + * `ids` must list each event of that category exactly once, top first; it becomes sort_order 1, 2, 3... + */ +export async function POST(req: NextRequest) { + try { + const auth = await verifyEventStaff(req); + if ('error' in auth) { + return NextResponse.json({ error: auth.error }, { status: auth.status }); + } + + const parsed = await readJsonObject(req); + if (!parsed.ok) { + return NextResponse.json({ error: parsed.error }, { status: parsed.status }); + } + const { category: rawCategory, ids } = parsed.value; + const category = typeof rawCategory === 'string' ? rawCategory.trim().toLowerCase() : ''; + if (!ALLOWED_EVENT_CATEGORIES.includes(category as AllowedEventCategory)) { + return NextResponse.json({ error: 'Invalid event category.' }, { status: 400 }); + } + if (!Array.isArray(ids) || ids.length === 0 || ids.length > 500 || !ids.every(isUuid)) { + return NextResponse.json({ error: 'Invalid event order.' }, { status: 400 }); + } + + const { supabase, user } = auth; + const { data: current, error: fetchErr } = await supabase + .from('events') + .select('id') + .eq('category', category); + if (fetchErr) { + console.error('/api/admin/events/order database error:', fetchErr); + return NextResponse.json({ error: 'Database operation failed.' }, { status: 500 }); + } + + // Only a complete order of the category as it is now: a stale list (an event added, moved or deleted since the + // page loaded) would otherwise leave events with clashing positions. + const known = new Set((current || []).map((ev) => ev.id as string)); + if (new Set(ids).size !== ids.length || ids.length !== known.size || !ids.every((id) => known.has(id))) { + return NextResponse.json( + { error: 'The events in this category changed. Refresh and try again.' }, + { status: 409 } + ); + } + + const results = await Promise.all( + ids.map((id, i) => + supabase.from('events').update({ sort_order: i + 1, updated_by: user.id }).eq('id', id) + ) + ); + const updateErr = results.find((r) => r.error)?.error; + if (updateErr) { + console.error('/api/admin/events/order database error:', updateErr); + return NextResponse.json({ error: 'Database operation failed.' }, { status: 500 }); + } + + revalidatePath('/events'); + revalidatePath('/events/[world]', 'page'); + + return NextResponse.json({ success: true, message: 'Event order saved.' }); + } catch (err: unknown) { + console.error('/api/admin/events/order error:', err); + return NextResponse.json({ error: 'Failed to save event order' }, { status: 500 }); + } +} diff --git a/src/app/api/admin/events/route.ts b/src/app/api/admin/events/route.ts index 5a8371e..db061bf 100644 --- a/src/app/api/admin/events/route.ts +++ b/src/app/api/admin/events/route.ts @@ -1,4 +1,5 @@ import { NextRequest, NextResponse } from 'next/server'; +import type { SupabaseClient } from '@supabase/supabase-js'; import { verifyEventStaff, isValidGoogleDriveUrl, isWebPImage } from '@/lib/auth-server'; import { getImageKit } from '@/lib/imagekit'; import { formFile, formText, isUuid, jsonText, readFormLimited, readJsonObject } from '@/lib/security'; @@ -31,6 +32,18 @@ function textLimitError(fields: Partial { + const { data } = await supabase + .from('events') + .select('sort_order') + .eq('category', category) + .order('sort_order', { ascending: false }) + .limit(1) + .maybeSingle(); + return (data?.sort_order ?? 0) + 1; +} + // GET all events (Staff access) export async function GET(req: NextRequest) { try { @@ -43,6 +56,7 @@ export async function GET(req: NextRequest) { const { data: events, error } = await supabase .from('events') .select('*') + .order('sort_order', { ascending: true }) .order('created_at', { ascending: false }); if (error) { @@ -196,6 +210,7 @@ export async function POST(req: NextRequest) { poster_url: posterUrl, brochure_url: brochureUrl || null, category, + sort_order: await nextSortOrder(supabase, category), created_by: user.id, updated_by: user.id, }; @@ -343,7 +358,11 @@ export async function PATCH(req: NextRequest) { { status: 400 } ); } - updates.category = normCat; + if (normCat !== existing.category) { + updates.category = normCat; + // A moved event goes to the end of its new category. + updates.sort_order = await nextSortOrder(supabase, normCat); + } } if (brochureUrl !== undefined) { updates.brochure_url = brochureUrl.trim() !== '' ? brochureUrl.trim() : null; diff --git a/src/app/api/events/route.ts b/src/app/api/events/route.ts index d132865..426a718 100644 --- a/src/app/api/events/route.ts +++ b/src/app/api/events/route.ts @@ -22,6 +22,7 @@ export async function GET() { .from('events') // Public columns only: no staff user ids or storage file ids. .select(PUBLIC_EVENT_COLUMNS) + .order('sort_order', { ascending: true }) .order('created_at', { ascending: false }) .limit(500); diff --git a/src/components/innovision/admin/EventsManager.tsx b/src/components/innovision/admin/EventsManager.tsx index 5316a8c..666fb43 100644 --- a/src/components/innovision/admin/EventsManager.tsx +++ b/src/components/innovision/admin/EventsManager.tsx @@ -37,6 +37,12 @@ const categoryRank = (cat?: string | null) => { return k < 0 ? EVENT_CATEGORIES.length : k; }; +/** Site order: by category, then by the position staff set, newest first among equal positions. */ +const byDisplayOrder = (a: EventItem, b: EventItem) => + categoryRank(a.category) - categoryRank(b.category) || + (a.sort_order ?? 0) - (b.sort_order ?? 0) || + (b.created_at || '').localeCompare(a.created_at || ''); + export type EventCategoryKey = typeof EVENT_CATEGORIES[number]['key']; export default function EventsManager({ @@ -49,6 +55,7 @@ export default function EventsManager({ const [loading, setLoading] = useState(false); const [searchQuery, setSearchQuery] = useState(''); const [categoryFilter, setCategoryFilter] = useState('all'); + const [reordering, setReordering] = useState(false); // Modals const [isUploadModalOpen, setIsUploadModalOpen] = useState(false); @@ -357,6 +364,49 @@ export default function EventsManager({ }); }; + // Each category's events in site order, unfiltered: positions and arrow moves ignore the search box. + const orderedEvents = [...events].sort(byDisplayOrder); + const categoryOrder = (cat?: string | null) => + orderedEvents.filter((ev) => (ev.category || '').toLowerCase() === (cat || '').toLowerCase()); + + // Move an event one place up (-1) or down (1) within its category, then save the category's whole order. + const moveEvent = async (event: EventItem, dir: -1 | 1) => { + if (reordering || !event.id) return; + const list = categoryOrder(event.category); + const from = list.findIndex((ev) => ev.id === event.id); + const to = from + dir; + if (from < 0 || to < 0 || to >= list.length) return; + + const reordered = [...list]; + [reordered[from], reordered[to]] = [reordered[to], reordered[from]]; + const ids = reordered.map((ev) => ev.id as string); + const position = new Map(ids.map((id, i) => [id, i + 1])); + + const previous = events; + setEvents((prev) => prev.map((ev) => (ev.id && position.has(ev.id) ? { ...ev, sort_order: position.get(ev.id) } : ev))); + setReordering(true); + try { + const token = await getAuthToken(); + const headers: Record = { 'Content-Type': 'application/json' }; + if (token) headers['Authorization'] = `Bearer ${token}`; + + const res = await fetch('/api/admin/events/order', { + method: 'POST', + headers, + credentials: 'include', + body: JSON.stringify({ category: event.category, ids }), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(httpErrorMessage(res) || data.error || 'Failed to save event order'); + } catch (err: unknown) { + setEvents(previous); + const msg = err instanceof Error ? err.message : 'Failed to save event order'; + showToast(msg, 'error'); + } finally { + setReordering(false); + } + }; + // Filtered Events const filteredEvents = events.filter((ev) => { const matchesSearch = @@ -369,7 +419,7 @@ export default function EventsManager({ ev.category?.toLowerCase() === categoryFilter.toLowerCase(); return matchesSearch && matchesCat; - }).sort((a, b) => categoryRank(a.category) - categoryRank(b.category)); + }).sort(byDisplayOrder); // Category counts for top stat cards const countFlagship = events.filter((e) => (e.category || '').toLowerCase() === 'flagship events').length; @@ -645,7 +695,12 @@ export default function EventsManager({ gap: '22px', }} > - {filteredEvents.map((event) => ( + {filteredEvents.map((event) => { + const siblings = categoryOrder(event.category); + const position = siblings.findIndex((ev) => ev.id === event.id); + const canMoveUp = !reordering && position > 0; + const canMoveDown = !reordering && position >= 0 && position < siblings.length - 1; + return (
{event.title} + {position >= 0 && ( + + #{position + 1} + + )} {/* Description */} @@ -821,6 +891,33 @@ export default function EventsManager({ borderTop: '1px solid rgba(236,232,223,0.1)', }} > + {([ + { dir: -1, enabled: canMoveUp, label: 'Move up', points: '18 15 12 9 6 15' }, + { dir: 1, enabled: canMoveDown, label: 'Move down', points: '6 9 12 15 18 9' }, + ] as const).map((arrow) => ( + + ))} +
- ))} + ); + })} )} diff --git a/src/lib/events.ts b/src/lib/events.ts index 83a7091..a48cec4 100644 --- a/src/lib/events.ts +++ b/src/lib/events.ts @@ -2,12 +2,12 @@ import { WORLDS, type World } from '@/components/innovision/data'; import { getSupabaseAdmin, type EventItem } from '@/lib/supabase'; /** Columns anyone may read: no staff user ids or storage file ids. Shared by /api/events and the /events pages. */ -export const PUBLIC_EVENT_COLUMNS = 'id, title, description, poster_url, brochure_url, category, format, duration, venue, created_at, updated_at'; +export const PUBLIC_EVENT_COLUMNS = 'id, title, description, poster_url, brochure_url, category, format, duration, venue, sort_order, created_at, updated_at'; -export type PublicEvent = Pick; +export type PublicEvent = Pick; /** - * Published events, newest first, for the server-rendered /events pages. Read-only. Never throws: if the database is + * Published events in the order staff set (newest first within a position), for the server-rendered /events pages. Read-only. Never throws: if the database is * unreachable or not configured (e.g. a build without Supabase env), it returns null and the pages show their * announcement state until the next revalidation succeeds. */ @@ -16,6 +16,7 @@ export async function fetchPublicEvents(): Promise { const { data, error } = await getSupabaseAdmin() .from('events') .select(PUBLIC_EVENT_COLUMNS) + .order('sort_order', { ascending: true }) .order('created_at', { ascending: false }) .limit(500); if (error) { diff --git a/src/lib/supabase.ts b/src/lib/supabase.ts index 9f51b05..886d45a 100644 --- a/src/lib/supabase.ts +++ b/src/lib/supabase.ts @@ -55,6 +55,7 @@ export interface EventItem { format?: string; duration?: string; venue?: string; + sort_order?: number; // Position within its category; lower shows first created_by?: string | null; updated_by?: string | null; created_at?: string; diff --git a/supabase/manual/add_event_sort_order.sql b/supabase/manual/add_event_sort_order.sql new file mode 100644 index 0000000..cc1bfc4 --- /dev/null +++ b/supabase/manual/add_event_sort_order.sql @@ -0,0 +1,26 @@ +-- ============================================================================== +-- MIGRATION (run once in the Supabase SQL editor): let staff choose the order of events. +-- +-- Why: events were always listed newest first. Admins and the IT team can now move events up and down within their +-- category from the admin Events tab, which stores each event's position in public.events.sort_order. +-- This adds the column and numbers the existing events 1, 2, 3... per category, newest first, so the site looks the +-- same until someone reorders. Nothing else changes: no other table, policy, trigger or column is touched. +-- +-- Run this BEFORE deploying the code that orders by sort_order, or the events queries fail. +-- Matches supabase/schema.sql (except the one-time numbering). Kept out of supabase/migrations/ on purpose so the +-- Supabase CLI never applies it automatically. Safe to re-run: the numbering only happens while no event has been +-- given a position yet. +-- ============================================================================== + +ALTER TABLE public.events ADD COLUMN IF NOT EXISTS sort_order INTEGER NOT NULL DEFAULT 0; + +CREATE INDEX IF NOT EXISTS idx_events_category_sort ON public.events(category, sort_order); + +UPDATE public.events e +SET sort_order = r.rn +FROM ( + SELECT id, row_number() OVER (PARTITION BY category ORDER BY created_at DESC) AS rn + FROM public.events +) r +WHERE e.id = r.id + AND NOT EXISTS (SELECT 1 FROM public.events WHERE sort_order <> 0); diff --git a/supabase/schema.sql b/supabase/schema.sql index 8c2d1e3..e16dbd8 100644 --- a/supabase/schema.sql +++ b/supabase/schema.sql @@ -476,6 +476,7 @@ CREATE TABLE IF NOT EXISTS public.events ( format TEXT DEFAULT 'Solo / Team', duration TEXT DEFAULT 'TBA', venue TEXT DEFAULT 'NIT Rourkela', + sort_order INTEGER NOT NULL DEFAULT 0, -- Position within its category, set by staff in the admin Events tab created_by UUID REFERENCES public.profiles(id), updated_by UUID REFERENCES public.profiles(id), created_at TIMESTAMPTZ DEFAULT timezone('utc'::text, now()) NOT NULL, @@ -492,6 +493,7 @@ BEGIN ALTER TABLE public.events ALTER COLUMN format DROP NOT NULL; ALTER TABLE public.events ALTER COLUMN duration DROP NOT NULL; ALTER TABLE public.events ALTER COLUMN venue DROP NOT NULL; + ALTER TABLE public.events ADD COLUMN IF NOT EXISTS sort_order INTEGER NOT NULL DEFAULT 0; ALTER TABLE public.events DROP CONSTRAINT IF EXISTS events_category_check; ALTER TABLE public.events ADD CONSTRAINT events_category_check CHECK (category IN ('flagship events', 'standout events', 'main events', 'dts events', 'fun events')); -- Brochure links are rendered as public hrefs: only Google Drive/Docs URLs (never javascript:/data:). @@ -504,6 +506,8 @@ BEGIN END IF; END $$; +CREATE INDEX IF NOT EXISTS idx_events_category_sort ON public.events(category, sort_order); + ALTER TABLE public.events ENABLE ROW LEVEL SECURITY; -- Anyone can view events (public and authenticated)