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..b6d6aa0 --- /dev/null +++ b/app/admin/talks/page.tsx @@ -0,0 +1,719 @@ +"use client" +import Link from "next/link" +import styled from "styled-components" +import { useState, useEffect, useCallback } from "react" +import { supabaseClient } from "../../../lib/supabaseClient" +import { useRequireAdminAuth } from "../../hooks/useRequireAdminAuth" +import { PotionBackground } from "../../components/PotionBackground" +import { TalkThumbnail } from "../../components/TalkThumbnail" + +// Types // + +type TalkStatus = + | "pending" + | "under_review" + | "approved" + | "rejected" + | "scheduled" + | "completed" + | "cancelled" + +interface TalkSubmission { + id: number + talk_title: string + talk_hook: string | null + 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 + phone_number: string | null + handle: string | null + profile_photo: 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 { loading, isAdmin } = useRequireAdminAuth() + 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 () => { + let query = supabaseClient + .from("talk_submissions") + .select( + ` + id, + talk_title, + talk_hook, + talk_synopsis, + slides_type, + slides_url, + slides_file_path, + status, + admin_notes, + created_at, + updated_at, + profiles ( + full_name, + email, + phone_number, + handle, + profile_photo + ) + ` + ) + .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(() => { + 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 getFileNameFromPath = (filePath: string) => { + const pathSegments = filePath.split("/") + 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 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 `/admin/thumbnails?${params.toString()}` + } + + 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", + 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} + + handleStatusChange(talk.id, e.target.value as TalkStatus)} + disabled={updatingId === talk.id} + aria-label={`Change status for ${talk.talk_title}`} + > + {TALK_STATUSES.map((status) => ( + + ))} + + + + + + + + + + + + {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)}`} + + + )} + + )} + + + + + 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)} + + + + ))} + + )} + + + + ) +} + +// 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); + -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; + 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 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%; + min-width: 0; + + @media (max-width: 900px) { + max-width: none; + justify-self: stretch; + } +` + +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; + gap: 0.75rem; + background-color: rgba(255, 255, 255, 0.035); + border-radius: 0.75rem; + padding: 0.875rem; +` + +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` + display: grid; + 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` + 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 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 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/talk-thumbnail-gen/page.tsx b/app/admin/thumbnails/page.tsx similarity index 86% rename from app/talk-thumbnail-gen/page.tsx rename to app/admin/thumbnails/page.tsx index 05f5940..351d0c5 100644 --- a/app/talk-thumbnail-gen/page.tsx +++ b/app/admin/thumbnails/page.tsx @@ -1,12 +1,14 @@ "use client" import styled from "styled-components" import { useState, useRef, useCallback, useEffect } from "react" -import { supabaseClient } from "../../lib/supabaseClient" -import { TalkThumbnail } from "../components/TalkThumbnail" -import { TextInput } from "../components/TextInput" -import { Button } from "../components/Button" -import { PotionBackground } from "../components/PotionBackground" -import { PageContainer } from "../components/PageContainer" +import { useSearchParams } from "next/navigation" +import { supabaseClient } from "../../../lib/supabaseClient" +import { useRequireAdminAuth } from "../../hooks/useRequireAdminAuth" +import { TalkThumbnail } from "../../components/TalkThumbnail" +import { TextInput } from "../../components/TextInput" +import { Button } from "../../components/Button" +import { PotionBackground } from "../../components/PotionBackground" +import { PageContainer } from "../../components/PageContainer" // // Constants @@ -19,15 +21,19 @@ const THUMBNAIL_HEIGHT = 720 // Components // -export default function TalkThumbnailGen() { +export default function AdminTalkThumbnailGen() { + const { loading, isAdmin } = useRequireAdminAuth() + 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 +44,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] @@ -165,6 +196,21 @@ export default function TalkThumbnailGen() { [hook] ) + if (loading) { + return ( + <> + + + + + Verifying admin access... + + + ) + } + + if (!isAdmin) return null + return ( <> @@ -241,7 +287,11 @@ export default function TalkThumbnailGen() { Photo loaded - {photoSource === "handle" ? ` from @${handle}` : " from upload"} + {photoSource === "handle" + ? ` from @${handle}` + : photoSource === "upload" + ? " from upload" + : " from URL"}