From c7e603b997248f0e801422d3c5f4643ad79d8fca Mon Sep 17 00:00:00 2001 From: claudinethelobster Date: Sun, 22 Feb 2026 23:38:21 -0800 Subject: [PATCH 1/8] feat(admin): add admin roles and talks review dashboard --- README.md | 13 + app/admin/talks/page.tsx | 563 ++++++++++++++++++ app/components/Header.tsx | 14 + lib/adminCheck.ts | 17 + .../20260222000000_add_admin_role.sql | 40 ++ 5 files changed, 647 insertions(+) create mode 100644 app/admin/talks/page.tsx create mode 100644 lib/adminCheck.ts create mode 100644 supabase/migrations/20260222000000_add_admin_role.sql diff --git a/README.md b/README.md index 8daada3..600dddd 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,19 @@ For OAuth providers (GitHub, Google), see the [Local OAuth Setup Guide](./docs/l - **Docker daemon errors (`Cannot connect to the Docker daemon`)** Make sure Docker Desktop is installed and running before you call `supabase start`. +## Admin Access + +Certain features (e.g. reviewing talk submissions) are restricted to admin users. +Admin status is stored as `is_admin` on the `profiles` table. To grant admin access, +set `is_admin = true` for the relevant profile row in Supabase (via Studio or SQL). + +Admin-only pages: + +- `/admin/talks` — View and manage all talk submissions (filter by status, change status) + +Access is enforced at both the database level (RLS policies) and the frontend (redirect on +non-admin access). + ## Contributing We welcome contributions from the community! If you have suggestions or improvements, feel free to open an issue or submit a pull request. diff --git a/app/admin/talks/page.tsx b/app/admin/talks/page.tsx new file mode 100644 index 0000000..f434ca2 --- /dev/null +++ b/app/admin/talks/page.tsx @@ -0,0 +1,563 @@ +"use client" +import styled from "styled-components" +import { useState, useEffect, useCallback } from "react" +import { useRouter } from "next/navigation" +import { supabaseClient } from "../../../lib/supabaseClient" +import { checkIsAdmin } from "../../../lib/adminCheck" +import { PotionBackground } from "../../components/PotionBackground" +import { Button } from "../../components/Button" + +// Types // + +type TalkStatus = + | "pending" + | "under_review" + | "approved" + | "rejected" + | "scheduled" + | "completed" + | "cancelled" + +interface TalkSubmission { + id: number + talk_title: string + talk_synopsis: string + slides_type: "url" | "upload" + slides_url: string | null + slides_file_path: string | null + status: TalkStatus + admin_notes: string | null + created_at: string + updated_at: string + profiles: { + full_name: string + email: string + handle: string | null + } +} + +// Constants // + +const TALK_STATUSES: TalkStatus[] = [ + "pending", + "under_review", + "approved", + "rejected", + "scheduled", + "completed", + "cancelled" +] + +const STATUS_COLORS: Record = { + pending: "#f59e0b", + under_review: "#3b82f6", + approved: "#10b981", + rejected: "#ef4444", + scheduled: "#8b5cf6", + completed: "#6b7280", + cancelled: "#9ca3af" +} + +// Components // + +export default function AdminTalks() { + const router = useRouter() + const [loading, setLoading] = useState(true) + const [isAdmin, setIsAdmin] = useState(false) + const [talks, setTalks] = useState([]) + const [filterStatus, setFilterStatus] = useState("all") + const [updatingId, setUpdatingId] = useState(null) + const [error, setError] = useState(null) + + const fetchTalks = useCallback(async () => { + let query = supabaseClient + .from("talk_submissions") + .select( + ` + id, + talk_title, + talk_synopsis, + slides_type, + slides_url, + slides_file_path, + status, + admin_notes, + created_at, + updated_at, + profiles ( + full_name, + email, + handle + ) + ` + ) + .order("created_at", { ascending: false }) + + if (filterStatus !== "all") { + query = query.eq("status", filterStatus) + } + + const { data, error: fetchError } = await query + + if (fetchError) { + setError(fetchError.message) + return + } + + setTalks((data as unknown as TalkSubmission[]) || []) + }, [filterStatus]) + + useEffect(() => { + const init = async () => { + const { + data: { user } + } = await supabaseClient.auth.getUser() + + if (!user) { + router.push("/login?redirect=%2Fadmin%2Ftalks") + return + } + + const admin = await checkIsAdmin() + if (!admin) { + router.push("/") + return + } + + setIsAdmin(true) + setLoading(false) + } + + init() + }, [router]) + + useEffect(() => { + if (isAdmin) { + fetchTalks() + } + }, [isAdmin, fetchTalks]) + + const handleStatusChange = async (talkId: number, newStatus: TalkStatus) => { + setUpdatingId(talkId) + setError(null) + + try { + const { + data: { user } + } = await supabaseClient.auth.getUser() + + const { error: updateError } = await supabaseClient + .from("talk_submissions") + .update({ + status: newStatus, + reviewed_by: user?.id, + reviewed_at: new Date().toISOString() + }) + .eq("id", talkId) + + if (updateError) throw updateError + + await fetchTalks() + } catch (err: any) { + setError(err.message || "Failed to update status") + } finally { + setUpdatingId(null) + } + } + + const formatDate = (dateString: string) => { + return new Date(dateString).toLocaleDateString("en-US", { + year: "numeric", + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit" + }) + } + + if (loading) { + return ( + <> + + + + + Verifying admin access... + + + ) + } + + if (!isAdmin) return null + + return ( + <> + + + + + + + Talk Submissions + + {talks.length} submission{talks.length !== 1 ? "s" : ""} + {filterStatus !== "all" ? ` (${filterStatus})` : ""} + + + + {error && {error}} + + + Filter by status: + + setFilterStatus("all")}> + All + + {TALK_STATUSES.map((status) => ( + setFilterStatus(status)} + > + {status.replace("_", " ")} + + ))} + + + + {talks.length === 0 ? ( + No submissions found. + ) : ( + + {talks.map((talk) => ( + + + {talk.talk_title} + + {talk.status.replace("_", " ")} + + + + + + Submitter + + {talk.profiles.full_name} + {talk.profiles.handle && ( + + @{talk.profiles.handle} + + )} + + + + Email + {talk.profiles.email} + + + Submitted + {formatDate(talk.created_at)} + + + Updated + {formatDate(talk.updated_at)} + + + + {talk.talk_synopsis} + + {(talk.slides_url || talk.slides_file_path) && ( + + Slides: {talk.slides_type === "url" ? "URL" : "Uploaded file"} + {talk.slides_url && ( + <> + {" — "} + + {talk.slides_url} + + + )} + {talk.slides_file_path && <> — {talk.slides_file_path}} + + )} + + + Change status: + handleStatusChange(talk.id, e.target.value as TalkStatus)} + disabled={updatingId === talk.id} + > + {TALK_STATUSES.map((s) => ( + + ))} + + {updatingId === talk.id && Saving...} + + + ))} + + )} + + + + ) +} + +// Styled Components // + +const BackgroundContainer = styled.section` + background-color: #0a0a0a; + position: fixed; + height: 100vh; + width: 100vw; + top: 0; + left: 0; + z-index: -1; +` + +const Container = styled.main` + min-height: 100vh; + display: flex; + justify-content: center; + padding: 2rem 1rem; +` + +const ContentWrapper = styled.div` + width: 100%; + max-width: 960px; + display: flex; + flex-direction: column; + gap: 1.5rem; +` + +const PageHeader = styled.div` + text-align: center; +` + +const Title = styled.h1` + font-size: 2rem; + font-weight: 700; + color: white; + margin: 0; +` + +const Subtitle = styled.p` + color: rgba(255, 255, 255, 0.6); + margin: 0.5rem 0 0 0; +` + +const FilterBar = styled.div` + display: flex; + flex-direction: column; + gap: 0.75rem; +` + +const FilterLabel = styled.span` + color: rgba(255, 255, 255, 0.7); + font-size: 0.875rem; + font-weight: 600; +` + +const FilterButtons = styled.div` + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +` + +const FilterButton = styled.button<{ $active: boolean; $color?: string }>` + padding: 0.375rem 0.75rem; + border-radius: 1rem; + font-size: 0.8125rem; + font-family: inherit; + cursor: pointer; + transition: all 0.2s ease; + text-transform: capitalize; + border: 1px solid + ${(props) => (props.$active ? props.$color || "white" : "rgba(255, 255, 255, 0.2)")}; + background-color: ${(props) => + props.$active ? (props.$color || "white") + "22" : "transparent"}; + color: ${(props) => (props.$active ? props.$color || "white" : "rgba(255, 255, 255, 0.6)")}; + + &:hover { + border-color: ${(props) => props.$color || "white"}; + color: ${(props) => props.$color || "white"}; + } +` + +const TalkList = styled.div` + display: flex; + flex-direction: column; + gap: 1rem; +` + +const TalkCard = styled.div` + background-color: rgba(21, 21, 28, 0.75); + backdrop-filter: blur(10px); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 0.75rem; + padding: 1.5rem; + display: flex; + flex-direction: column; + gap: 1rem; +` + +const CardHeader = styled.div` + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 1rem; +` + +const TalkTitle = styled.h2` + font-size: 1.125rem; + font-weight: 600; + color: white; + margin: 0; + flex: 1; +` + +const StatusBadge = styled.span<{ $color: string }>` + padding: 0.25rem 0.625rem; + border-radius: 1rem; + font-size: 0.75rem; + font-weight: 600; + text-transform: capitalize; + white-space: nowrap; + color: ${(props) => props.$color}; + background-color: ${(props) => props.$color}22; + border: 1px solid ${(props) => props.$color}44; +` + +const MetaRow = styled.div` + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 0.75rem; +` + +const MetaItem = styled.div` + display: flex; + flex-direction: column; + gap: 0.125rem; +` + +const MetaLabel = styled.span` + font-size: 0.6875rem; + font-weight: 600; + color: rgba(255, 255, 255, 0.5); + text-transform: uppercase; + letter-spacing: 0.05em; +` + +const MetaValue = styled.span` + font-size: 0.875rem; + color: rgba(255, 255, 255, 0.9); + display: flex; + align-items: center; + gap: 0.5rem; +` + +const HandleLink = styled.a` + color: rgba(156, 163, 255, 0.9); + text-decoration: none; + font-size: 0.8125rem; + + &:hover { + text-decoration: underline; + } +` + +const Synopsis = styled.p` + color: rgba(255, 255, 255, 0.7); + font-size: 0.875rem; + line-height: 1.6; + margin: 0; + white-space: pre-wrap; +` + +const SlidesInfo = styled.div` + font-size: 0.8125rem; + color: rgba(255, 255, 255, 0.5); +` + +const SlidesLink = styled.a` + color: rgba(156, 163, 255, 0.9); + text-decoration: none; + word-break: break-all; + + &:hover { + text-decoration: underline; + } +` + +const CardActions = styled.div` + display: flex; + align-items: center; + gap: 0.75rem; + padding-top: 0.75rem; + border-top: 1px solid rgba(255, 255, 255, 0.1); +` + +const ActionLabel = styled.span` + font-size: 0.8125rem; + color: rgba(255, 255, 255, 0.6); + white-space: nowrap; +` + +const StatusSelect = styled.select` + padding: 0.375rem 0.75rem; + border-radius: 0.375rem; + background-color: rgba(255, 255, 255, 0.1); + border: 1px solid rgba(255, 255, 255, 0.2); + color: white; + font-size: 0.8125rem; + font-family: inherit; + cursor: pointer; + text-transform: capitalize; + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + option { + background-color: #1a1a2e; + color: white; + } +` + +const UpdatingText = styled.span` + font-size: 0.75rem; + color: rgba(255, 255, 255, 0.5); + font-style: italic; +` + +const ErrorMessage = styled.div` + color: #ff6b6b; + background-color: rgba(255, 107, 107, 0.1); + padding: 0.75rem; + border-radius: 0.5rem; + font-size: 0.875rem; + text-align: center; +` + +const EmptyState = styled.div` + text-align: center; + color: rgba(255, 255, 255, 0.5); + padding: 3rem; + font-size: 1rem; +` + +const LoadingText = styled.div` + color: white; + font-size: 1.25rem; + text-align: center; + margin-top: 4rem; +` diff --git a/app/components/Header.tsx b/app/components/Header.tsx index 3d7ef7e..067a76b 100644 --- a/app/components/Header.tsx +++ b/app/components/Header.tsx @@ -7,6 +7,7 @@ import { GiveATalkCTA } from "./GiveATalkCTA" import { Button } from "./Button" import { supabaseClient } from "../../lib/supabaseClient" import { getProfileFromCache } from "../../lib/profileCache" +import { checkIsAdmin } from "../../lib/adminCheck" // Components // @@ -18,6 +19,7 @@ export const Header = () => { const [userHandle, setUserHandle] = useState(null) const [profilePhoto, setProfilePhoto] = useState(null) const [userLoading, setUserLoading] = useState(true) + const [isAdmin, setIsAdmin] = useState(false) useEffect(() => { // Check initial session and load handle and photo from cache @@ -31,6 +33,7 @@ export const Header = () => { const { handle, profilePhoto } = getProfileFromCache(user) setUserHandle(handle) setProfilePhoto(profilePhoto) + checkIsAdmin().then(setIsAdmin) } setUserLoading(false) @@ -51,6 +54,7 @@ export const Header = () => { } else { setUserHandle(null) setProfilePhoto(null) + setIsAdmin(false) } setUserLoading(false) @@ -253,6 +257,16 @@ export const Header = () => { )} + {isAdmin && ( + <> + + + + Admin: Talks + + + + )} {user && ( diff --git a/lib/adminCheck.ts b/lib/adminCheck.ts new file mode 100644 index 0000000..5dd9adb --- /dev/null +++ b/lib/adminCheck.ts @@ -0,0 +1,17 @@ +import { supabaseClient } from "./supabaseClient" + +export async function checkIsAdmin(): Promise { + const { + data: { user } + } = await supabaseClient.auth.getUser() + + if (!user) return false + + const { data: profile } = await supabaseClient + .from("profiles") + .select("is_admin") + .eq("user_id", user.id) + .single() + + return profile?.is_admin === true +} diff --git a/supabase/migrations/20260222000000_add_admin_role.sql b/supabase/migrations/20260222000000_add_admin_role.sql new file mode 100644 index 0000000..bcee64d --- /dev/null +++ b/supabase/migrations/20260222000000_add_admin_role.sql @@ -0,0 +1,40 @@ +-- Add is_admin column to profiles table +ALTER TABLE profiles +ADD COLUMN is_admin BOOLEAN NOT NULL DEFAULT FALSE; + +-- Index for quick admin lookups +CREATE INDEX idx_profiles_is_admin ON profiles(is_admin) +WHERE is_admin = TRUE; + +-- Allow admins to read ALL talk submissions (not just their own) +CREATE POLICY "Admins can view all talk submissions" + ON talk_submissions + FOR SELECT + TO authenticated + USING ( + EXISTS ( + SELECT 1 FROM profiles + WHERE profiles.user_id = auth.uid() + AND profiles.is_admin = TRUE + ) + ); + +-- Allow admins to update any talk submission (status, admin_notes, reviewed_by, reviewed_at) +CREATE POLICY "Admins can update any talk submission" + ON talk_submissions + FOR UPDATE + TO authenticated + USING ( + EXISTS ( + SELECT 1 FROM profiles + WHERE profiles.user_id = auth.uid() + AND profiles.is_admin = TRUE + ) + ) + WITH CHECK ( + EXISTS ( + SELECT 1 FROM profiles + WHERE profiles.user_id = auth.uid() + AND profiles.is_admin = TRUE + ) + ); From 2ac6b7469e8e026cd847d32930c30ceea61530c5 Mon Sep 17 00:00:00 2001 From: Sam Holmes Date: Sat, 14 Mar 2026 09:34:06 -0700 Subject: [PATCH 2/8] feat(admin): add downloadable uploaded slides in talks dashboard Enable admin users to download uploaded slide files directly from talk submissions, with loading state and error handling for failed storage downloads. --- app/admin/talks/page.tsx | 70 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 69 insertions(+), 1 deletion(-) diff --git a/app/admin/talks/page.tsx b/app/admin/talks/page.tsx index f434ca2..447bcbc 100644 --- a/app/admin/talks/page.tsx +++ b/app/admin/talks/page.tsx @@ -67,6 +67,7 @@ export default function AdminTalks() { const [talks, setTalks] = useState([]) const [filterStatus, setFilterStatus] = useState("all") const [updatingId, setUpdatingId] = useState(null) + const [downloadingId, setDownloadingId] = useState(null) const [error, setError] = useState(null) const fetchTalks = useCallback(async () => { @@ -165,6 +166,37 @@ export default function AdminTalks() { } } + const getFileNameFromPath = (filePath: string) => { + const pathSegments = filePath.split("/") + return pathSegments[pathSegments.length - 1] || filePath + } + + const handleSlidesDownload = async (talkId: number, filePath: string) => { + setDownloadingId(talkId) + setError(null) + + try { + const { data: fileData, error: downloadError } = await supabaseClient.storage + .from("talk-slides") + .download(filePath) + + if (downloadError) throw downloadError + + const fileUrl = window.URL.createObjectURL(fileData) + const linkElement = document.createElement("a") + linkElement.href = fileUrl + linkElement.download = getFileNameFromPath(filePath) + document.body.appendChild(linkElement) + linkElement.click() + document.body.removeChild(linkElement) + window.URL.revokeObjectURL(fileUrl) + } catch (err: any) { + setError(err.message || "Failed to download slides") + } finally { + setDownloadingId(null) + } + } + const formatDate = (dateString: string) => { return new Date(dateString).toLocaleDateString("en-US", { year: "numeric", @@ -282,7 +314,22 @@ export default function AdminTalks() { )} - {talk.slides_file_path && <> — {talk.slides_file_path}} + {talk.slides_file_path && ( + <> + {" — "} + + handleSlidesDownload(talk.id, talk.slides_file_path as string) + } + disabled={downloadingId === talk.id} + > + {downloadingId === talk.id + ? "Downloading..." + : `Download ${getFileNameFromPath(talk.slides_file_path)}`} + + + )} )} @@ -497,6 +544,27 @@ const SlidesLink = styled.a` } ` +const SlidesDownloadButton = styled.button` + background: none; + border: none; + padding: 0; + color: rgba(156, 163, 255, 0.9); + font-size: 0.8125rem; + font-family: inherit; + text-decoration: underline; + cursor: pointer; + + &:disabled { + opacity: 0.6; + cursor: not-allowed; + text-decoration: none; + } + + &:hover:not(:disabled) { + color: rgba(156, 163, 255, 1); + } +` + const CardActions = styled.div` display: flex; align-items: center; From 3b18bddf4435ef89eb7cf8735722e9ee585baf2c Mon Sep 17 00:00:00 2001 From: Sam Holmes Date: Sat, 14 Mar 2026 09:42:38 -0700 Subject: [PATCH 3/8] feat(admin): enhance talks cards with thumbnail-based two-column layout Display each submission with a TalkThumbnail and reorganize details into a responsive two-column section with subtle grouped backgrounds for metadata and description content. --- app/admin/talks/page.tsx | 120 ++++++++++++++++++++++++++------------- 1 file changed, 82 insertions(+), 38 deletions(-) diff --git a/app/admin/talks/page.tsx b/app/admin/talks/page.tsx index 447bcbc..6aa71a8 100644 --- a/app/admin/talks/page.tsx +++ b/app/admin/talks/page.tsx @@ -5,7 +5,7 @@ import { useRouter } from "next/navigation" import { supabaseClient } from "../../../lib/supabaseClient" import { checkIsAdmin } from "../../../lib/adminCheck" import { PotionBackground } from "../../components/PotionBackground" -import { Button } from "../../components/Button" +import { TalkThumbnail } from "../../components/TalkThumbnail" // Types // @@ -21,6 +21,7 @@ type TalkStatus = interface TalkSubmission { id: number talk_title: string + talk_hook: string | null talk_synopsis: string slides_type: "url" | "upload" slides_url: string | null @@ -33,6 +34,7 @@ interface TalkSubmission { full_name: string email: string handle: string | null + profile_photo: string | null } } @@ -77,6 +79,7 @@ export default function AdminTalks() { ` id, talk_title, + talk_hook, talk_synopsis, slides_type, slides_url, @@ -88,7 +91,8 @@ export default function AdminTalks() { profiles ( full_name, email, - handle + handle, + profile_photo ) ` ) @@ -296,42 +300,53 @@ export default function AdminTalks() { {formatDate(talk.updated_at)} - - {talk.talk_synopsis} - - {(talk.slides_url || talk.slides_file_path) && ( - - Slides: {talk.slides_type === "url" ? "URL" : "Uploaded file"} - {talk.slides_url && ( - <> - {" — "} - - {talk.slides_url} - - - )} - {talk.slides_file_path && ( - <> - {" — "} - - handleSlidesDownload(talk.id, talk.slides_file_path as string) - } - disabled={downloadingId === talk.id} - > - {downloadingId === talk.id - ? "Downloading..." - : `Download ${getFileNameFromPath(talk.slides_file_path)}`} - - + + + + + + {talk.talk_synopsis} + + {(talk.slides_url || talk.slides_file_path) && ( + + Slides: {talk.slides_type === "url" ? "URL" : "Uploaded file"} + {talk.slides_url && ( + <> + {" — "} + + {talk.slides_url} + + + )} + {talk.slides_file_path && ( + <> + {" — "} + + handleSlidesDownload(talk.id, talk.slides_file_path as string) + } + disabled={downloadingId === talk.id} + > + {downloadingId === talk.id + ? "Downloading..." + : `Download ${getFileNameFromPath(talk.slides_file_path)}`} + + + )} + )} - - )} + + Change status: @@ -447,7 +462,8 @@ const TalkList = styled.div` const TalkCard = styled.div` background-color: rgba(21, 21, 28, 0.75); - backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(20px); + backdrop-filter: blur(20px); border: 1px solid rgba(255, 255, 255, 0.1); border-radius: 0.75rem; padding: 1.5rem; @@ -471,6 +487,31 @@ const TalkTitle = styled.h2` flex: 1; ` +const DetailsGrid = styled.div` + display: grid; + grid-template-columns: minmax(280px, 420px) minmax(0, 1fr); + gap: 1rem; + align-items: start; + + @media (max-width: 900px) { + grid-template-columns: 1fr; + } +` + +const ThumbnailColumn = styled.div` + max-width: 420px; + width: 100%; +` + +const DetailsColumn = styled.div` + display: flex; + flex-direction: column; + gap: 0.75rem; + background-color: rgba(255, 255, 255, 0.035); + border-radius: 0.75rem; + padding: 0.875rem; +` + const StatusBadge = styled.span<{ $color: string }>` padding: 0.25rem 0.625rem; border-radius: 1rem; @@ -487,6 +528,9 @@ const MetaRow = styled.div` display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 0.75rem; + background-color: rgba(255, 255, 255, 0.03); + border-radius: 0.75rem; + padding: 0.75rem; ` const MetaItem = styled.div` From cf7588fbf2c03e17721f6804231bf177c4a3fee1 Mon Sep 17 00:00:00 2001 From: Sam Holmes Date: Sat, 14 Mar 2026 09:56:28 -0700 Subject: [PATCH 4/8] feat(admin): turn status badge into styled inline status selector Replace the separate status dropdown with a badge-style header select that preserves the existing look while supporting inline updates, adaptive sizing, and custom chevron styling. --- app/admin/talks/page.tsx | 145 ++++++++++++++++++++++----------------- 1 file changed, 81 insertions(+), 64 deletions(-) diff --git a/app/admin/talks/page.tsx b/app/admin/talks/page.tsx index 6aa71a8..815d092 100644 --- a/app/admin/talks/page.tsx +++ b/app/admin/talks/page.tsx @@ -175,6 +175,11 @@ export default function AdminTalks() { return pathSegments[pathSegments.length - 1] || filePath } + const getStatusLabel = (status: TalkStatus) => status.replace("_", " ") + + const getStatusWidthCh = (status: TalkStatus) => + Math.max(Math.ceil(getStatusLabel(status).length * 1.05) + 3, 10) + const handleSlidesDownload = async (talkId: number, filePath: string) => { setDownloadingId(talkId) setError(null) @@ -270,9 +275,23 @@ export default function AdminTalks() { {talk.talk_title} - - {talk.status.replace("_", " ")} - + + handleStatusChange(talk.id, e.target.value as TalkStatus)} + disabled={updatingId === talk.id} + aria-label={`Change status for ${talk.talk_title}`} + > + {TALK_STATUSES.map((status) => ( + + ))} + + @@ -347,22 +366,6 @@ export default function AdminTalks() { )} - - - Change status: - handleStatusChange(talk.id, e.target.value as TalkStatus)} - disabled={updatingId === talk.id} - > - {TALK_STATUSES.map((s) => ( - - ))} - - {updatingId === talk.id && Saving...} - ))} @@ -512,16 +515,72 @@ const DetailsColumn = styled.div` padding: 0.875rem; ` -const StatusBadge = styled.span<{ $color: string }>` - padding: 0.25rem 0.625rem; - border-radius: 1rem; +const StatusPillWrap = styled.div` + position: relative; + display: inline-flex; + align-items: center; +` + +const StatusPillSelect = styled.select<{ $color: string; $widthCh: number }>` + width: ${(props) => `${props.$widthCh}ch`}; + padding: 0.28rem 1.65rem 0.28rem 0.72rem; + border-radius: 999px; font-size: 0.75rem; font-weight: 600; text-transform: capitalize; white-space: nowrap; + text-align: center; + text-align-last: center; + font-family: inherit; color: ${(props) => props.$color}; background-color: ${(props) => props.$color}22; border: 1px solid ${(props) => props.$color}44; + cursor: pointer; + appearance: none !important; + -webkit-appearance: none !important; + -moz-appearance: none !important; + background-image: none; + line-height: 1.15; + transition: + background-color 0.2s ease, + border-color 0.2s ease, + box-shadow 0.2s ease; + + &:hover:not(:disabled) { + background-color: ${(props) => props.$color}2c; + border-color: ${(props) => props.$color}66; + } + + &:focus-visible { + outline: none; + box-shadow: 0 0 0 2px ${(props) => props.$color}55; + } + + &:disabled { + opacity: 0.7; + cursor: not-allowed; + } + + &::-ms-expand { + display: none; + } + + option { + background-color: #1a1a2e; + color: white; + } +` + +const StatusChevron = styled.span<{ $color: string }>` + position: absolute; + right: 0.62rem; + top: 50%; + transform: translateY(-58%) rotate(45deg); + width: 0.5rem; + height: 0.5rem; + border-right: 2px solid ${(props) => props.$color}; + border-bottom: 2px solid ${(props) => props.$color}; + pointer-events: none; ` const MetaRow = styled.div` @@ -609,48 +668,6 @@ const SlidesDownloadButton = styled.button` } ` -const CardActions = styled.div` - display: flex; - align-items: center; - gap: 0.75rem; - padding-top: 0.75rem; - border-top: 1px solid rgba(255, 255, 255, 0.1); -` - -const ActionLabel = styled.span` - font-size: 0.8125rem; - color: rgba(255, 255, 255, 0.6); - white-space: nowrap; -` - -const StatusSelect = styled.select` - padding: 0.375rem 0.75rem; - border-radius: 0.375rem; - background-color: rgba(255, 255, 255, 0.1); - border: 1px solid rgba(255, 255, 255, 0.2); - color: white; - font-size: 0.8125rem; - font-family: inherit; - cursor: pointer; - text-transform: capitalize; - - &:disabled { - opacity: 0.5; - cursor: not-allowed; - } - - option { - background-color: #1a1a2e; - color: white; - } -` - -const UpdatingText = styled.span` - font-size: 0.75rem; - color: rgba(255, 255, 255, 0.5); - font-style: italic; -` - const ErrorMessage = styled.div` color: #ff6b6b; background-color: rgba(255, 107, 107, 0.1); From 821b0a096c667e378484591d1dee0f9a0683bf82 Mon Sep 17 00:00:00 2001 From: Sam Holmes Date: Sat, 14 Mar 2026 10:13:54 -0700 Subject: [PATCH 5/8] feat(admin): refine talks card layout and responsive metadata details Reorder talk cards to prioritize thumbnail and description content, add phone number to admin fields, and improve responsive behavior for stacked thumbnail and metadata grids on smaller screens. --- app/admin/talks/page.tsx | 74 +++++++++++++++++++++++++--------------- 1 file changed, 47 insertions(+), 27 deletions(-) diff --git a/app/admin/talks/page.tsx b/app/admin/talks/page.tsx index 815d092..689f16f 100644 --- a/app/admin/talks/page.tsx +++ b/app/admin/talks/page.tsx @@ -33,6 +33,7 @@ interface TalkSubmission { profiles: { full_name: string email: string + phone_number: string | null handle: string | null profile_photo: string | null } @@ -91,6 +92,7 @@ export default function AdminTalks() { profiles ( full_name, email, + phone_number, handle, profile_photo ) @@ -294,38 +296,13 @@ export default function AdminTalks() { - - - Submitter - - {talk.profiles.full_name} - {talk.profiles.handle && ( - - @{talk.profiles.handle} - - )} - - - - Email - {talk.profiles.email} - - - Submitted - {formatDate(talk.created_at)} - - - Updated - {formatDate(talk.updated_at)} - - @@ -366,6 +343,35 @@ export default function AdminTalks() { )} + + + Submitter + + {talk.profiles.full_name} + {talk.profiles.handle && ( + + @{talk.profiles.handle} + + )} + + + + Email + {talk.profiles.email} + + + Phone + {talk.profiles.phone_number || "Not provided"} + + + Submitted + {formatDate(talk.created_at)} + + + Updated + {formatDate(talk.updated_at)} + + ))} @@ -504,6 +510,12 @@ const DetailsGrid = styled.div` const ThumbnailColumn = styled.div` max-width: 420px; width: 100%; + min-width: 0; + + @media (max-width: 900px) { + max-width: none; + justify-self: stretch; + } ` const DetailsColumn = styled.div` @@ -585,11 +597,19 @@ const StatusChevron = styled.span<{ $color: string }>` const MetaRow = styled.div` display: grid; - grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(0, 1fr)); gap: 0.75rem; background-color: rgba(255, 255, 255, 0.03); border-radius: 0.75rem; padding: 0.75rem; + + @media (max-width: 900px) { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + @media (max-width: 560px) { + grid-template-columns: 1fr; + } ` const MetaItem = styled.div` From 9589fe6c3f423d2c5b86151ba9797c5bf3db18a2 Mon Sep 17 00:00:00 2001 From: Sam Holmes Date: Sat, 14 Mar 2026 11:03:08 -0700 Subject: [PATCH 6/8] feat(admin): link talk thumbnails to prefilled generator Let admins click any submission thumbnail to jump into the thumbnail generator with hook, speaker, and photo context already loaded so download actions are faster and consistent. --- app/admin/talks/page.tsx | 46 ++++++++++++++++++++++++++++----- app/talk-thumbnail-gen/page.tsx | 39 +++++++++++++++++++++++++--- 2 files changed, 76 insertions(+), 9 deletions(-) diff --git a/app/admin/talks/page.tsx b/app/admin/talks/page.tsx index 689f16f..05063f9 100644 --- a/app/admin/talks/page.tsx +++ b/app/admin/talks/page.tsx @@ -1,4 +1,5 @@ "use client" +import Link from "next/link" import styled from "styled-components" import { useState, useEffect, useCallback } from "react" import { useRouter } from "next/navigation" @@ -182,6 +183,19 @@ export default function AdminTalks() { const getStatusWidthCh = (status: TalkStatus) => Math.max(Math.ceil(getStatusLabel(status).length * 1.05) + 3, 10) + const buildThumbnailGeneratorUrl = (talk: TalkSubmission) => { + const params = new URLSearchParams() + params.set("hook", talk.talk_hook || talk.talk_title) + params.set("speakerName", talk.profiles.full_name) + if (talk.profiles.handle) { + params.set("handle", talk.profiles.handle) + } + if (talk.profiles.profile_photo) { + params.set("profilePhotoUrl", talk.profiles.profile_photo) + } + return `/talk-thumbnail-gen?${params.toString()}` + } + const handleSlidesDownload = async (talkId: number, filePath: string) => { setDownloadingId(talkId) setError(null) @@ -298,12 +312,17 @@ export default function AdminTalks() { - + + + {talk.talk_synopsis} @@ -518,6 +537,21 @@ const ThumbnailColumn = styled.div` } ` +const ThumbnailLink = styled(Link)` + display: block; + border-radius: 0.5rem; + text-decoration: none; + + &:hover { + opacity: 0.95; + } + + &:focus-visible { + outline: 2px solid rgba(156, 163, 255, 0.9); + outline-offset: 2px; + } +` + const DetailsColumn = styled.div` display: flex; flex-direction: column; diff --git a/app/talk-thumbnail-gen/page.tsx b/app/talk-thumbnail-gen/page.tsx index 05f5940..860e1a7 100644 --- a/app/talk-thumbnail-gen/page.tsx +++ b/app/talk-thumbnail-gen/page.tsx @@ -1,6 +1,7 @@ "use client" import styled from "styled-components" import { useState, useRef, useCallback, useEffect } from "react" +import { useSearchParams } from "next/navigation" import { supabaseClient } from "../../lib/supabaseClient" import { TalkThumbnail } from "../components/TalkThumbnail" import { TextInput } from "../components/TextInput" @@ -20,14 +21,17 @@ const THUMBNAIL_HEIGHT = 720 // export default function TalkThumbnailGen() { + const searchParams = useSearchParams() const [hook, setHook] = useState("") + const [speakerName, setSpeakerName] = useState("") const [handle, setHandle] = useState("") const [photoUrl, setPhotoUrl] = useState(null) - const [photoSource, setPhotoSource] = useState<"none" | "upload" | "handle">("none") + const [photoSource, setPhotoSource] = useState<"none" | "upload" | "handle" | "url">("none") const [handleLoading, setHandleLoading] = useState(false) const [handleError, setHandleError] = useState(null) const fileInputRef = useRef(null) const svgContainerRef = useRef(null) + const didApplyQueryPrefill = useRef(false) // Revoke upload blob URL on unmount to prevent memory leaks useEffect(() => { @@ -38,6 +42,31 @@ export default function TalkThumbnailGen() { } }, [photoUrl, photoSource]) + useEffect(() => { + if (didApplyQueryPrefill.current) return + + const hookFromUrl = searchParams.get("hook") + const speakerNameFromUrl = searchParams.get("speakerName") + const handleFromUrl = searchParams.get("handle") + const profilePhotoUrlFromUrl = searchParams.get("profilePhotoUrl") + + if (hookFromUrl) { + setHook(hookFromUrl) + } + if (speakerNameFromUrl) { + setSpeakerName(speakerNameFromUrl) + } + if (handleFromUrl) { + setHandle(handleFromUrl) + } + if (profilePhotoUrlFromUrl) { + setPhotoUrl(profilePhotoUrlFromUrl) + setPhotoSource("url") + } + + didApplyQueryPrefill.current = true + }, [searchParams]) + const handleFileUpload = useCallback( (e: React.ChangeEvent) => { const file = e.target.files?.[0] @@ -241,7 +270,11 @@ export default function TalkThumbnailGen() { Photo loaded - {photoSource === "handle" ? ` from @${handle}` : " from upload"} + {photoSource === "handle" + ? ` from @${handle}` + : photoSource === "upload" + ? " from upload" + : " from URL"}