Skip to content

Commit 296400d

Browse files
authored
Merge pull request #41 from Krish-2118/main
events reorder fix
2 parents cd79350 + 7d082f0 commit 296400d

8 files changed

Lines changed: 229 additions & 7 deletions

File tree

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { NextRequest, NextResponse } from 'next/server';
2+
import { revalidatePath } from 'next/cache';
3+
import { verifyEventStaff } from '@/lib/auth-server';
4+
import { isUuid, readJsonObject } from '@/lib/security';
5+
import { ALLOWED_EVENT_CATEGORIES, type AllowedEventCategory } from '../route';
6+
7+
export const runtime = 'nodejs';
8+
9+
/**
10+
* POST { category, ids }: set the order of every event in one category (Authorized IT Team & Admin).
11+
* `ids` must list each event of that category exactly once, top first; it becomes sort_order 1, 2, 3...
12+
*/
13+
export async function POST(req: NextRequest) {
14+
try {
15+
const auth = await verifyEventStaff(req);
16+
if ('error' in auth) {
17+
return NextResponse.json({ error: auth.error }, { status: auth.status });
18+
}
19+
20+
const parsed = await readJsonObject(req);
21+
if (!parsed.ok) {
22+
return NextResponse.json({ error: parsed.error }, { status: parsed.status });
23+
}
24+
const { category: rawCategory, ids } = parsed.value;
25+
const category = typeof rawCategory === 'string' ? rawCategory.trim().toLowerCase() : '';
26+
if (!ALLOWED_EVENT_CATEGORIES.includes(category as AllowedEventCategory)) {
27+
return NextResponse.json({ error: 'Invalid event category.' }, { status: 400 });
28+
}
29+
if (!Array.isArray(ids) || ids.length === 0 || ids.length > 500 || !ids.every(isUuid)) {
30+
return NextResponse.json({ error: 'Invalid event order.' }, { status: 400 });
31+
}
32+
33+
const { supabase, user } = auth;
34+
const { data: current, error: fetchErr } = await supabase
35+
.from('events')
36+
.select('id')
37+
.eq('category', category);
38+
if (fetchErr) {
39+
console.error('/api/admin/events/order database error:', fetchErr);
40+
return NextResponse.json({ error: 'Database operation failed.' }, { status: 500 });
41+
}
42+
43+
// Only a complete order of the category as it is now: a stale list (an event added, moved or deleted since the
44+
// page loaded) would otherwise leave events with clashing positions.
45+
const known = new Set((current || []).map((ev) => ev.id as string));
46+
if (new Set(ids).size !== ids.length || ids.length !== known.size || !ids.every((id) => known.has(id))) {
47+
return NextResponse.json(
48+
{ error: 'The events in this category changed. Refresh and try again.' },
49+
{ status: 409 }
50+
);
51+
}
52+
53+
const results = await Promise.all(
54+
ids.map((id, i) =>
55+
supabase.from('events').update({ sort_order: i + 1, updated_by: user.id }).eq('id', id)
56+
)
57+
);
58+
const updateErr = results.find((r) => r.error)?.error;
59+
if (updateErr) {
60+
console.error('/api/admin/events/order database error:', updateErr);
61+
return NextResponse.json({ error: 'Database operation failed.' }, { status: 500 });
62+
}
63+
64+
revalidatePath('/events');
65+
revalidatePath('/events/[world]', 'page');
66+
67+
return NextResponse.json({ success: true, message: 'Event order saved.' });
68+
} catch (err: unknown) {
69+
console.error('/api/admin/events/order error:', err);
70+
return NextResponse.json({ error: 'Failed to save event order' }, { status: 500 });
71+
}
72+
}

‎src/app/api/admin/events/route.ts‎

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { NextRequest, NextResponse } from 'next/server';
2+
import type { SupabaseClient } from '@supabase/supabase-js';
23
import { verifyEventStaff, isValidGoogleDriveUrl, isWebPImage } from '@/lib/auth-server';
34
import { getImageKit } from '@/lib/imagekit';
45
import { formFile, formText, isUuid, jsonText, readFormLimited, readJsonObject } from '@/lib/security';
@@ -31,6 +32,18 @@ function textLimitError(fields: Partial<Record<keyof typeof TEXT_LIMITS, string
3132
return null;
3233
}
3334

35+
/** The sort_order that places an event after every other event in its category. */
36+
async function nextSortOrder(supabase: SupabaseClient, category: string): Promise<number> {
37+
const { data } = await supabase
38+
.from('events')
39+
.select('sort_order')
40+
.eq('category', category)
41+
.order('sort_order', { ascending: false })
42+
.limit(1)
43+
.maybeSingle();
44+
return (data?.sort_order ?? 0) + 1;
45+
}
46+
3447
// GET all events (Staff access)
3548
export async function GET(req: NextRequest) {
3649
try {
@@ -43,6 +56,7 @@ export async function GET(req: NextRequest) {
4356
const { data: events, error } = await supabase
4457
.from('events')
4558
.select('*')
59+
.order('sort_order', { ascending: true })
4660
.order('created_at', { ascending: false });
4761

4862
if (error) {
@@ -196,6 +210,7 @@ export async function POST(req: NextRequest) {
196210
poster_url: posterUrl,
197211
brochure_url: brochureUrl || null,
198212
category,
213+
sort_order: await nextSortOrder(supabase, category),
199214
created_by: user.id,
200215
updated_by: user.id,
201216
};
@@ -343,7 +358,11 @@ export async function PATCH(req: NextRequest) {
343358
{ status: 400 }
344359
);
345360
}
346-
updates.category = normCat;
361+
if (normCat !== existing.category) {
362+
updates.category = normCat;
363+
// A moved event goes to the end of its new category.
364+
updates.sort_order = await nextSortOrder(supabase, normCat);
365+
}
347366
}
348367
if (brochureUrl !== undefined) {
349368
updates.brochure_url = brochureUrl.trim() !== '' ? brochureUrl.trim() : null;

‎src/app/api/events/route.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export async function GET() {
2222
.from('events')
2323
// Public columns only: no staff user ids or storage file ids.
2424
.select(PUBLIC_EVENT_COLUMNS)
25+
.order('sort_order', { ascending: true })
2526
.order('created_at', { ascending: false })
2627
.limit(500);
2728

‎src/components/innovision/admin/EventsManager.tsx‎

Lines changed: 101 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,12 @@ const categoryRank = (cat?: string | null) => {
3737
return k < 0 ? EVENT_CATEGORIES.length : k;
3838
};
3939

40+
/** Site order: by category, then by the position staff set, newest first among equal positions. */
41+
const byDisplayOrder = (a: EventItem, b: EventItem) =>
42+
categoryRank(a.category) - categoryRank(b.category) ||
43+
(a.sort_order ?? 0) - (b.sort_order ?? 0) ||
44+
(b.created_at || '').localeCompare(a.created_at || '');
45+
4046
export type EventCategoryKey = typeof EVENT_CATEGORIES[number]['key'];
4147

4248
export default function EventsManager({
@@ -49,6 +55,7 @@ export default function EventsManager({
4955
const [loading, setLoading] = useState(false);
5056
const [searchQuery, setSearchQuery] = useState('');
5157
const [categoryFilter, setCategoryFilter] = useState('all');
58+
const [reordering, setReordering] = useState(false);
5259

5360
// Modals
5461
const [isUploadModalOpen, setIsUploadModalOpen] = useState(false);
@@ -357,6 +364,49 @@ export default function EventsManager({
357364
});
358365
};
359366

367+
// Each category's events in site order, unfiltered: positions and arrow moves ignore the search box.
368+
const orderedEvents = [...events].sort(byDisplayOrder);
369+
const categoryOrder = (cat?: string | null) =>
370+
orderedEvents.filter((ev) => (ev.category || '').toLowerCase() === (cat || '').toLowerCase());
371+
372+
// Move an event one place up (-1) or down (1) within its category, then save the category's whole order.
373+
const moveEvent = async (event: EventItem, dir: -1 | 1) => {
374+
if (reordering || !event.id) return;
375+
const list = categoryOrder(event.category);
376+
const from = list.findIndex((ev) => ev.id === event.id);
377+
const to = from + dir;
378+
if (from < 0 || to < 0 || to >= list.length) return;
379+
380+
const reordered = [...list];
381+
[reordered[from], reordered[to]] = [reordered[to], reordered[from]];
382+
const ids = reordered.map((ev) => ev.id as string);
383+
const position = new Map(ids.map((id, i) => [id, i + 1]));
384+
385+
const previous = events;
386+
setEvents((prev) => prev.map((ev) => (ev.id && position.has(ev.id) ? { ...ev, sort_order: position.get(ev.id) } : ev)));
387+
setReordering(true);
388+
try {
389+
const token = await getAuthToken();
390+
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
391+
if (token) headers['Authorization'] = `Bearer ${token}`;
392+
393+
const res = await fetch('/api/admin/events/order', {
394+
method: 'POST',
395+
headers,
396+
credentials: 'include',
397+
body: JSON.stringify({ category: event.category, ids }),
398+
});
399+
const data = await res.json().catch(() => ({}));
400+
if (!res.ok) throw new Error(httpErrorMessage(res) || data.error || 'Failed to save event order');
401+
} catch (err: unknown) {
402+
setEvents(previous);
403+
const msg = err instanceof Error ? err.message : 'Failed to save event order';
404+
showToast(msg, 'error');
405+
} finally {
406+
setReordering(false);
407+
}
408+
};
409+
360410
// Filtered Events
361411
const filteredEvents = events.filter((ev) => {
362412
const matchesSearch =
@@ -369,7 +419,7 @@ export default function EventsManager({
369419
ev.category?.toLowerCase() === categoryFilter.toLowerCase();
370420

371421
return matchesSearch && matchesCat;
372-
}).sort((a, b) => categoryRank(a.category) - categoryRank(b.category));
422+
}).sort(byDisplayOrder);
373423

374424
// Category counts for top stat cards
375425
const countFlagship = events.filter((e) => (e.category || '').toLowerCase() === 'flagship events').length;
@@ -645,7 +695,12 @@ export default function EventsManager({
645695
gap: '22px',
646696
}}
647697
>
648-
{filteredEvents.map((event) => (
698+
{filteredEvents.map((event) => {
699+
const siblings = categoryOrder(event.category);
700+
const position = siblings.findIndex((ev) => ev.id === event.id);
701+
const canMoveUp = !reordering && position > 0;
702+
const canMoveDown = !reordering && position >= 0 && position < siblings.length - 1;
703+
return (
649704
<article
650705
key={event.id}
651706
style={{
@@ -755,6 +810,21 @@ export default function EventsManager({
755810
>
756811
{event.title}
757812
</h3>
813+
{position >= 0 && (
814+
<span
815+
title="Position in its category on the site"
816+
style={{
817+
flex: 'none',
818+
marginLeft: '10px',
819+
fontSize: '12px',
820+
fontWeight: 800,
821+
letterSpacing: '.08em',
822+
color: 'oklch(0.8 0.12 85)',
823+
}}
824+
>
825+
#{position + 1}
826+
</span>
827+
)}
758828
</div>
759829

760830
{/* Description */}
@@ -821,6 +891,33 @@ export default function EventsManager({
821891
borderTop: '1px solid rgba(236,232,223,0.1)',
822892
}}
823893
>
894+
{([
895+
{ dir: -1, enabled: canMoveUp, label: 'Move up', points: '18 15 12 9 6 15' },
896+
{ dir: 1, enabled: canMoveDown, label: 'Move down', points: '6 9 12 15 18 9' },
897+
] as const).map((arrow) => (
898+
<button
899+
key={arrow.dir}
900+
type="button"
901+
onClick={() => moveEvent(event, arrow.dir)}
902+
disabled={!arrow.enabled}
903+
aria-label={arrow.label}
904+
title={arrow.label}
905+
style={{
906+
padding: '9px 12px',
907+
background: 'rgba(236,232,223,0.06)',
908+
border: '1px solid rgba(236,232,223,0.22)',
909+
clipPath: 'polygon(6px 0, 100% 0, 100% calc(100% - 6px), calc(100% - 6px) 100%, 0 100%, 0 6px)',
910+
color: '#ECE8DF',
911+
cursor: arrow.enabled ? 'pointer' : 'not-allowed',
912+
opacity: arrow.enabled ? 1 : 0.35,
913+
}}
914+
>
915+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
916+
<polyline points={arrow.points} />
917+
</svg>
918+
</button>
919+
))}
920+
824921
<button
825922
type="button"
826923
onClick={() => openEditModal(event)}
@@ -871,7 +968,8 @@ export default function EventsManager({
871968
</div>
872969
</div>
873970
</article>
874-
))}
971+
);
972+
})}
875973
</div>
876974
)}
877975

‎src/lib/events.ts‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,12 @@ import { WORLDS, type World } from '@/components/innovision/data';
22
import { getSupabaseAdmin, type EventItem } from '@/lib/supabase';
33

44
/** Columns anyone may read: no staff user ids or storage file ids. Shared by /api/events and the /events pages. */
5-
export const PUBLIC_EVENT_COLUMNS = 'id, title, description, poster_url, brochure_url, category, format, duration, venue, created_at, updated_at';
5+
export const PUBLIC_EVENT_COLUMNS = 'id, title, description, poster_url, brochure_url, category, format, duration, venue, sort_order, created_at, updated_at';
66

7-
export type PublicEvent = Pick<EventItem, 'id' | 'title' | 'description' | 'poster_url' | 'brochure_url' | 'category' | 'format' | 'duration' | 'venue' | 'created_at' | 'updated_at'>;
7+
export type PublicEvent = Pick<EventItem, 'id' | 'title' | 'description' | 'poster_url' | 'brochure_url' | 'category' | 'format' | 'duration' | 'venue' | 'sort_order' | 'created_at' | 'updated_at'>;
88

99
/**
10-
* Published events, newest first, for the server-rendered /events pages. Read-only. Never throws: if the database is
10+
* 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
1111
* unreachable or not configured (e.g. a build without Supabase env), it returns null and the pages show their
1212
* announcement state until the next revalidation succeeds.
1313
*/
@@ -16,6 +16,7 @@ export async function fetchPublicEvents(): Promise<PublicEvent[] | null> {
1616
const { data, error } = await getSupabaseAdmin()
1717
.from('events')
1818
.select(PUBLIC_EVENT_COLUMNS)
19+
.order('sort_order', { ascending: true })
1920
.order('created_at', { ascending: false })
2021
.limit(500);
2122
if (error) {

‎src/lib/supabase.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ export interface EventItem {
5555
format?: string;
5656
duration?: string;
5757
venue?: string;
58+
sort_order?: number; // Position within its category; lower shows first
5859
created_by?: string | null;
5960
updated_by?: string | null;
6061
created_at?: string;
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
-- ==============================================================================
2+
-- MIGRATION (run once in the Supabase SQL editor): let staff choose the order of events.
3+
--
4+
-- Why: events were always listed newest first. Admins and the IT team can now move events up and down within their
5+
-- category from the admin Events tab, which stores each event's position in public.events.sort_order.
6+
-- This adds the column and numbers the existing events 1, 2, 3... per category, newest first, so the site looks the
7+
-- same until someone reorders. Nothing else changes: no other table, policy, trigger or column is touched.
8+
--
9+
-- Run this BEFORE deploying the code that orders by sort_order, or the events queries fail.
10+
-- Matches supabase/schema.sql (except the one-time numbering). Kept out of supabase/migrations/ on purpose so the
11+
-- Supabase CLI never applies it automatically. Safe to re-run: the numbering only happens while no event has been
12+
-- given a position yet.
13+
-- ==============================================================================
14+
15+
ALTER TABLE public.events ADD COLUMN IF NOT EXISTS sort_order INTEGER NOT NULL DEFAULT 0;
16+
17+
CREATE INDEX IF NOT EXISTS idx_events_category_sort ON public.events(category, sort_order);
18+
19+
UPDATE public.events e
20+
SET sort_order = r.rn
21+
FROM (
22+
SELECT id, row_number() OVER (PARTITION BY category ORDER BY created_at DESC) AS rn
23+
FROM public.events
24+
) r
25+
WHERE e.id = r.id
26+
AND NOT EXISTS (SELECT 1 FROM public.events WHERE sort_order <> 0);

‎supabase/schema.sql‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -476,6 +476,7 @@ CREATE TABLE IF NOT EXISTS public.events (
476476
format TEXT DEFAULT 'Solo / Team',
477477
duration TEXT DEFAULT 'TBA',
478478
venue TEXT DEFAULT 'NIT Rourkela',
479+
sort_order INTEGER NOT NULL DEFAULT 0, -- Position within its category, set by staff in the admin Events tab
479480
created_by UUID REFERENCES public.profiles(id),
480481
updated_by UUID REFERENCES public.profiles(id),
481482
created_at TIMESTAMPTZ DEFAULT timezone('utc'::text, now()) NOT NULL,
@@ -492,6 +493,7 @@ BEGIN
492493
ALTER TABLE public.events ALTER COLUMN format DROP NOT NULL;
493494
ALTER TABLE public.events ALTER COLUMN duration DROP NOT NULL;
494495
ALTER TABLE public.events ALTER COLUMN venue DROP NOT NULL;
496+
ALTER TABLE public.events ADD COLUMN IF NOT EXISTS sort_order INTEGER NOT NULL DEFAULT 0;
495497
ALTER TABLE public.events DROP CONSTRAINT IF EXISTS events_category_check;
496498
ALTER TABLE public.events ADD CONSTRAINT events_category_check CHECK (category IN ('flagship events', 'standout events', 'main events', 'dts events', 'fun events'));
497499
-- Brochure links are rendered as public hrefs: only Google Drive/Docs URLs (never javascript:/data:).
@@ -504,6 +506,8 @@ BEGIN
504506
END IF;
505507
END $$;
506508

509+
CREATE INDEX IF NOT EXISTS idx_events_category_sort ON public.events(category, sort_order);
510+
507511
ALTER TABLE public.events ENABLE ROW LEVEL SECURITY;
508512

509513
-- Anyone can view events (public and authenticated)

0 commit comments

Comments
 (0)