Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions src/app/api/admin/events/order/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
}
21 changes: 20 additions & 1 deletion src/app/api/admin/events/route.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -31,6 +32,18 @@ function textLimitError(fields: Partial<Record<keyof typeof TEXT_LIMITS, string
return null;
}

/** The sort_order that places an event after every other event in its category. */
async function nextSortOrder(supabase: SupabaseClient, category: string): Promise<number> {
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 {
Expand All @@ -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) {
Expand Down Expand Up @@ -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,
};
Expand Down Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions src/app/api/events/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
104 changes: 101 additions & 3 deletions src/components/innovision/admin/EventsManager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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);
Expand Down Expand Up @@ -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<string, string> = { '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 =
Expand All @@ -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;
Expand Down Expand Up @@ -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 (
<article
key={event.id}
style={{
Expand Down Expand Up @@ -755,6 +810,21 @@ export default function EventsManager({
>
{event.title}
</h3>
{position >= 0 && (
<span
title="Position in its category on the site"
style={{
flex: 'none',
marginLeft: '10px',
fontSize: '12px',
fontWeight: 800,
letterSpacing: '.08em',
color: 'oklch(0.8 0.12 85)',
}}
>
#{position + 1}
</span>
)}
</div>

{/* Description */}
Expand Down Expand Up @@ -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) => (
<button
key={arrow.dir}
type="button"
onClick={() => moveEvent(event, arrow.dir)}
disabled={!arrow.enabled}
aria-label={arrow.label}
title={arrow.label}
style={{
padding: '9px 12px',
background: 'rgba(236,232,223,0.06)',
border: '1px solid rgba(236,232,223,0.22)',
clipPath: 'polygon(6px 0, 100% 0, 100% calc(100% - 6px), calc(100% - 6px) 100%, 0 100%, 0 6px)',
color: '#ECE8DF',
cursor: arrow.enabled ? 'pointer' : 'not-allowed',
opacity: arrow.enabled ? 1 : 0.35,
}}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<polyline points={arrow.points} />
</svg>
</button>
))}

<button
type="button"
onClick={() => openEditModal(event)}
Expand Down Expand Up @@ -871,7 +968,8 @@ export default function EventsManager({
</div>
</div>
</article>
))}
);
})}
</div>
)}

Expand Down
7 changes: 4 additions & 3 deletions src/lib/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<EventItem, 'id' | 'title' | 'description' | 'poster_url' | 'brochure_url' | 'category' | 'format' | 'duration' | 'venue' | 'created_at' | 'updated_at'>;
export type PublicEvent = Pick<EventItem, 'id' | 'title' | 'description' | 'poster_url' | 'brochure_url' | 'category' | 'format' | 'duration' | 'venue' | 'sort_order' | 'created_at' | 'updated_at'>;

/**
* 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.
*/
Expand All @@ -16,6 +16,7 @@ export async function fetchPublicEvents(): Promise<PublicEvent[] | null> {
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) {
Expand Down
1 change: 1 addition & 0 deletions src/lib/supabase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
26 changes: 26 additions & 0 deletions supabase/manual/add_event_sort_order.sql
Original file line number Diff line number Diff line change
@@ -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);
4 changes: 4 additions & 0 deletions supabase/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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:).
Expand All @@ -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)
Expand Down
Loading