diff --git a/apps/2025/package.json b/apps/2025/package.json index 967bc026d..438e9b896 100644 --- a/apps/2025/package.json +++ b/apps/2025/package.json @@ -25,7 +25,7 @@ "@trpc/server": "^11.10.0", "framer-motion": "^12.34.3", "minimatch": "^10.2.4", - "next": "^16.0.0", + "next": "^16.2.7", "react": "^19.2.4", "react-dom": "^19.2.4", "server-only": "^0.0.1", @@ -40,7 +40,7 @@ "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "eslint": "catalog:", - "eslint-config-next": "^16.0.0", + "eslint-config-next": "^16.2.7", "postcss": "^8.5.6", "prettier": "^3.8.1", "prettier-plugin-tailwindcss": "^0.7.2", diff --git a/apps/2025/src/app/_components/faq/faq.tsx b/apps/2025/src/app/_components/faq/faq.tsx index 0fadcba6b..0be3f718f 100644 --- a/apps/2025/src/app/_components/faq/faq.tsx +++ b/apps/2025/src/app/_components/faq/faq.tsx @@ -144,7 +144,7 @@ const faqData: FaqItem[] = [ id: "11", question: "Is food provided?", answer: - "We provide free meals, snacks, and drinks throughout the event to keep you energized. We also accommodate dietary restrictions—just let us know during registration.", + "We provide free meals, snacks, and drinks throughout the event to keep you energized. We also accommodate dietary restrictions, just let us know during registration.", category: "logistics", }, { @@ -165,7 +165,7 @@ const faqData: FaqItem[] = [ id: "14", question: "Can I use a past project or something I've built before?", answer: - "Nope—projects must be started after the hackathon begins. You're welcome to brainstorm ideas or learn tools ahead of time, but actual work should begin during the event to keep it fair for everyone.", + "Nope, projects must be started after the hackathon begins. You're welcome to brainstorm ideas or learn tools ahead of time, but actual work should begin during the event to keep it fair for everyone.", category: "event-details", }, { diff --git a/apps/blade/package.json b/apps/blade/package.json index bdc485ac6..e1ca72bc9 100644 --- a/apps/blade/package.json +++ b/apps/blade/package.json @@ -50,7 +50,7 @@ "googleapis": "^171.4.0", "lucide-react": "^0.575.0", "minio": "^8.0.6", - "next": "^16.0.0", + "next": "^16.2.7", "next-themes": "^0.4.6", "react": "^19.2.4", "react-dom": "^19.2.4", diff --git a/apps/blade/src/app/_components/admin/banquet-raffle/raffle-draw.tsx b/apps/blade/src/app/_components/admin/banquet-raffle/raffle-draw.tsx index df7ee9e55..1319243db 100644 --- a/apps/blade/src/app/_components/admin/banquet-raffle/raffle-draw.tsx +++ b/apps/blade/src/app/_components/admin/banquet-raffle/raffle-draw.tsx @@ -255,7 +255,7 @@ export default function RaffleDraw({ entries }: { entries: RaffleEntry[] }) { >
- {/* ———————————————————————— STATES ———————————————————————— */} + {/* STATES */} {!isDrawing && !winner ? (
diff --git a/apps/blade/src/app/_components/admin/club/events/create-event.tsx b/apps/blade/src/app/_components/admin/club/events/create-event.tsx index c9936b6cd..b74c2d539 100644 --- a/apps/blade/src/app/_components/admin/club/events/create-event.tsx +++ b/apps/blade/src/app/_components/admin/club/events/create-event.tsx @@ -691,7 +691,7 @@ export function CreateEventButton() { )} /> - {/* Discord Channel ID — shown only for internal events */} + {/* Discord Channel ID, shown only for internal events */} {form.watch("isOperationsCalendar") && (
- {/* End Date — NEW */} + {/* End Date, NEW */} - {/* Discord Channel ID — shown only for internal events */} + {/* Discord Channel ID, shown only for internal events */} {form.watch("isOperationsCalendar") && ( {response.member?.email ?? "N/A"} - + + No response + ); } diff --git a/apps/blade/src/app/_components/admin/forms/responses/PerUserResponsesView.tsx b/apps/blade/src/app/_components/admin/forms/responses/PerUserResponsesView.tsx index 21856fc78..765a22ba2 100644 --- a/apps/blade/src/app/_components/admin/forms/responses/PerUserResponsesView.tsx +++ b/apps/blade/src/app/_components/admin/forms/responses/PerUserResponsesView.tsx @@ -128,7 +128,7 @@ export function PerUserResponsesView({ const formatResponseValue = (value: unknown): string => { if (value === undefined || value === null) { - return "—"; + return "No response"; } if (Array.isArray(value)) { return value.join(", "); diff --git a/apps/blade/src/app/_components/admin/forms/responses/ResponsesTable.tsx b/apps/blade/src/app/_components/admin/forms/responses/ResponsesTable.tsx index 42a9640bb..ac0bcb5a8 100644 --- a/apps/blade/src/app/_components/admin/forms/responses/ResponsesTable.tsx +++ b/apps/blade/src/app/_components/admin/forms/responses/ResponsesTable.tsx @@ -74,7 +74,7 @@ export function ResponsesTable({ question, responses }: ResponsesTableProps) { let displayValue: React.ReactNode; if (answer === undefined || answer === null) { - displayValue = "—"; + displayValue = "No response"; } else if (Array.isArray(answer)) { displayValue = answer.join(", "); } else if (typeof answer === "boolean") { diff --git a/apps/blade/src/app/_components/admin/hackathon/events/update-event.tsx b/apps/blade/src/app/_components/admin/hackathon/events/update-event.tsx index fe21cc084..a856eeb2f 100644 --- a/apps/blade/src/app/_components/admin/hackathon/events/update-event.tsx +++ b/apps/blade/src/app/_components/admin/hackathon/events/update-event.tsx @@ -492,7 +492,7 @@ export function UpdateEventButton({ event }: { event: InsertEvent }) {
- {/* End Date — NEW */} + {/* End Date, NEW */} - {/* Discord Channel ID — shown only for internal events */} + {/* Discord Channel ID, shown only for internal events */} {form.watch("isOperationsCalendar") && ( { toast.error(opts.message); @@ -37,35 +38,13 @@ export default function AcceptButton({ }, }); - const sendEmail = api.email.sendEmail.useMutation({ - onSuccess: () => { - toast.success( - `Acceptance email sent to ${hacker.firstName} ${hacker.lastName}!`, - ); - }, - onError: (opts) => { - toast.error(opts.message); - }, - }); - const handleUpdateStatus = () => { setIsLoading(true); updateStatus.mutate({ id: hacker.id ?? "", status: "accepted", - hackathonName, - }); - - sendEmail.mutate({ - from: "donotreply@knighthacks.org", - to: hacker.email, - subject: `[ACTION REQUIRED] ${hackathonName} Acceptance Information!`, - template_id: HACKATHON_TEMPLATE_IDS.Accepted, - data: { - name: hacker.firstName, - hackathon: hackathonName, - }, + hackathonName: hackathonRouteName, }); }; diff --git a/apps/blade/src/app/_components/admin/hackathon/hackers/blacklist-button.tsx b/apps/blade/src/app/_components/admin/hackathon/hackers/blacklist-button.tsx index 07d3bc062..a34a8fd39 100644 --- a/apps/blade/src/app/_components/admin/hackathon/hackers/blacklist-button.tsx +++ b/apps/blade/src/app/_components/admin/hackathon/hackers/blacklist-button.tsx @@ -2,7 +2,6 @@ import { useState } from "react"; import { Gavel, Loader2 } from "lucide-react"; import type { InsertHacker } from "@forge/db/schemas/knight-hacks"; -import { HACKATHON_TEMPLATE_IDS } from "@forge/email/client"; import { Button } from "@forge/ui/button"; import { Dialog, @@ -19,20 +18,38 @@ import { api } from "~/trpc/react"; export default function BlacklistButton({ hacker, - hackathonName, + hackathonRouteName, }: { hacker: InsertHacker & { status: string }; - hackathonName: string; + hackathonRouteName: string; }) { const [isOpen, setIsOpen] = useState(false); const [isLoading, setIsLoading] = useState(false); const utils = api.useUtils(); + const sendHackathonEmail = api.email.sendHackathonEmail.useMutation({ + onSuccess: () => { + toast.success( + `Blacklist email sent to ${hacker.firstName} ${hacker.lastName}!`, + ); + }, + onError: (opts) => { + toast.error(opts.message); + }, + }); + const updateStatus = api.hackerMutation.updateHackerStatus.useMutation({ onSuccess() { toast.success( `Denied ${hacker.firstName} ${hacker.lastName} successfully!`, ); + sendHackathonEmail.mutate({ + from: "donotreply@knighthacks.org", + hackathonName: hackathonRouteName, + kind: "Blacklist", + recipientName: hacker.firstName, + to: hacker.email, + }); setIsOpen(false); }, onError(opts) { @@ -49,34 +66,12 @@ export default function BlacklistButton({ }, }); - const sendEmail = api.email.sendEmail.useMutation({ - onSuccess: () => { - toast.success( - `Blacklist email sent to ${hacker.firstName} ${hacker.lastName}!`, - ); - }, - onError: (opts) => { - toast.error(opts.message); - }, - }); - const handleUpdateStatus = () => { setIsLoading(true); updateStatus.mutate({ id: hacker.id ?? "", status: "denied", - hackathonName, - }); - - sendEmail.mutate({ - from: "donotreply@knighthacks.org", - to: hacker.email, - subject: `${hackathonName} Status Update`, - template_id: HACKATHON_TEMPLATE_IDS.Blacklist, - data: { - name: hacker.firstName, - hackathon: hackathonName, - }, + hackathonName: hackathonRouteName, }); }; diff --git a/apps/blade/src/app/_components/admin/hackathon/hackers/deny-button.tsx b/apps/blade/src/app/_components/admin/hackathon/hackers/deny-button.tsx index eda81a45a..9f77419df 100644 --- a/apps/blade/src/app/_components/admin/hackathon/hackers/deny-button.tsx +++ b/apps/blade/src/app/_components/admin/hackathon/hackers/deny-button.tsx @@ -2,7 +2,6 @@ import { useState } from "react"; import { Loader2, X } from "lucide-react"; import type { InsertHacker } from "@forge/db/schemas/knight-hacks"; -import { HACKATHON_TEMPLATE_IDS } from "@forge/email/client"; import { Button } from "@forge/ui/button"; import { Dialog, @@ -19,20 +18,38 @@ import { api } from "~/trpc/react"; export default function DenyButton({ hacker, - hackathonName, + hackathonRouteName, }: { hacker: InsertHacker & { status: string }; - hackathonName: string; + hackathonRouteName: string; }) { const [isOpen, setIsOpen] = useState(false); const [isLoading, setIsLoading] = useState(false); const utils = api.useUtils(); + const sendHackathonEmail = api.email.sendHackathonEmail.useMutation({ + onSuccess: () => { + toast.success( + `Denial email sent to ${hacker.firstName} ${hacker.lastName}!`, + ); + }, + onError: (opts) => { + toast.error(opts.message); + }, + }); + const updateStatus = api.hackerMutation.updateHackerStatus.useMutation({ onSuccess() { toast.success( `Denied ${hacker.firstName} ${hacker.lastName} successfully!`, ); + sendHackathonEmail.mutate({ + from: "donotreply@knighthacks.org", + hackathonName: hackathonRouteName, + kind: "Capacity", + recipientName: hacker.firstName, + to: hacker.email, + }); setIsOpen(false); }, onError(opts) { @@ -49,34 +66,12 @@ export default function DenyButton({ }, }); - const sendEmail = api.email.sendEmail.useMutation({ - onSuccess: () => { - toast.success( - `Denial email sent to ${hacker.firstName} ${hacker.lastName}!`, - ); - }, - onError: (opts) => { - toast.error(opts.message); - }, - }); - const handleUpdateStatus = () => { setIsLoading(true); updateStatus.mutate({ id: hacker.id ?? "", status: "denied", - hackathonName, - }); - - sendEmail.mutate({ - from: "donotreply@knighthacks.org", - to: hacker.email, - subject: `${hackathonName} Status Update`, - template_id: HACKATHON_TEMPLATE_IDS.Capacity, - data: { - name: hacker.firstName, - hackathon: hackathonName, - }, + hackathonName: hackathonRouteName, }); }; diff --git a/apps/blade/src/app/_components/admin/hackathon/hackers/hacker-status-toggle.tsx b/apps/blade/src/app/_components/admin/hackathon/hackers/hacker-status-toggle.tsx index a6aae41ad..df1203dd8 100644 --- a/apps/blade/src/app/_components/admin/hackathon/hackers/hacker-status-toggle.tsx +++ b/apps/blade/src/app/_components/admin/hackathon/hackers/hacker-status-toggle.tsx @@ -7,17 +7,20 @@ import WaitlistButton from "./waitlist-button"; export default function HackerStatusToggle({ hacker, - hackathonName, + hackathonRouteName, }: { hacker: InsertHacker & { status: string }; - hackathonName: string; + hackathonRouteName: string; }) { return (
- - - - + + + +
); } diff --git a/apps/blade/src/app/_components/admin/hackathon/hackers/hackers-table.tsx b/apps/blade/src/app/_components/admin/hackathon/hackers/hackers-table.tsx index ffb360f16..d93909f4c 100644 --- a/apps/blade/src/app/_components/admin/hackathon/hackers/hackers-table.tsx +++ b/apps/blade/src/app/_components/admin/hackathon/hackers/hackers-table.tsx @@ -559,7 +559,7 @@ export default function HackerTable({ @@ -574,7 +574,7 @@ export default function HackerTable({ diff --git a/apps/blade/src/app/_components/admin/hackathon/hackers/waitlist-button.tsx b/apps/blade/src/app/_components/admin/hackathon/hackers/waitlist-button.tsx index d2e612cf6..f7f48fd14 100644 --- a/apps/blade/src/app/_components/admin/hackathon/hackers/waitlist-button.tsx +++ b/apps/blade/src/app/_components/admin/hackathon/hackers/waitlist-button.tsx @@ -2,7 +2,6 @@ import { useState } from "react"; import { ClipboardList, Loader2 } from "lucide-react"; import type { InsertHacker } from "@forge/db/schemas/knight-hacks"; -import { HACKATHON_TEMPLATE_IDS } from "@forge/email/client"; import { Button } from "@forge/ui/button"; import { Dialog, @@ -19,20 +18,38 @@ import { api } from "~/trpc/react"; export default function WaitlistButton({ hacker, - hackathonName, + hackathonRouteName, }: { hacker: InsertHacker & { status: string }; - hackathonName: string; + hackathonRouteName: string; }) { const [isOpen, setIsOpen] = useState(false); const [isLoading, setIsLoading] = useState(false); const utils = api.useUtils(); + const sendHackathonEmail = api.email.sendHackathonEmail.useMutation({ + onSuccess: () => { + toast.success( + `Waitlisting email sent to ${hacker.firstName} ${hacker.lastName}!`, + ); + }, + onError: (opts) => { + toast.error(opts.message); + }, + }); + const updateStatus = api.hackerMutation.updateHackerStatus.useMutation({ onSuccess() { toast.success( `Waitlisted ${hacker.firstName} ${hacker.lastName} successfully!`, ); + sendHackathonEmail.mutate({ + from: "donotreply@knighthacks.org", + hackathonName: hackathonRouteName, + kind: "Waitlist", + recipientName: hacker.firstName, + to: hacker.email, + }); setIsOpen(false); }, onError(opts) { @@ -49,34 +66,12 @@ export default function WaitlistButton({ }, }); - const sendEmail = api.email.sendEmail.useMutation({ - onSuccess: () => { - toast.success( - `Waitlisting email sent to ${hacker.firstName} ${hacker.lastName}!`, - ); - }, - onError: (opts) => { - toast.error(opts.message); - }, - }); - const handleUpdateStatus = () => { setIsLoading(true); updateStatus.mutate({ id: hacker.id ?? "", status: "waitlisted", - hackathonName, - }); - - sendEmail.mutate({ - from: "donotreply@knighthacks.org", - to: hacker.email, - subject: `${hackathonName} Waitlist Information`, - template_id: HACKATHON_TEMPLATE_IDS.Waitlist, - data: { - name: hacker.firstName, - hackathon: hackathonName, - }, + hackathonName: hackathonRouteName, }); }; diff --git a/apps/blade/src/app/_components/admin/hackathon/manage/hackathon-manager.tsx b/apps/blade/src/app/_components/admin/hackathon/manage/hackathon-manager.tsx new file mode 100644 index 000000000..0263ce894 --- /dev/null +++ b/apps/blade/src/app/_components/admin/hackathon/manage/hackathon-manager.tsx @@ -0,0 +1,752 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { ExternalLink, Loader2, Pencil, Plus, ShieldCheck } from "lucide-react"; +import { z } from "zod"; + +import type { SelectHackathon } from "@forge/db/schemas/knight-hacks"; +import { HACKATHONS } from "@forge/consts"; +import { + HACKATHON_EMAIL_TEMPLATE_PRESET_KEYS, + HACKATHON_EMAIL_TEMPLATE_PRESET_OPTIONS, +} from "@forge/email/hackathons"; +import { Badge } from "@forge/ui/badge"; +import { Button } from "@forge/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@forge/ui/dialog"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, + useForm, +} from "@forge/ui/form"; +import { Input } from "@forge/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@forge/ui/select"; +import { Switch } from "@forge/ui/switch"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@forge/ui/table"; +import { toast } from "@forge/ui/toast"; +import { + createHackathonApplicationBackgroundKeySchema, + createHackathonEmailTemplateKeySchema, + getHackathonBackgroundIssues, + getHackathonDateWindowIssues, + getHackathonEmailTemplateIssues, + hackathonDisplayNameSchema, + hackathonRouteNameSchema, + hackathonThemeSchema, +} from "@forge/validators/hackathons"; + +import { api } from "~/trpc/react"; + +const BACKGROUND_OPTIONS = HACKATHONS.APPLICATION_BACKGROUND_OPTIONS; +const DEFAULT_BACKGROUND_KEY = BACKGROUND_OPTIONS[0].key; +type ApplicationBackgroundKey = (typeof BACKGROUND_OPTIONS)[number]["key"]; +const EMAIL_TEMPLATE_OPTIONS = HACKATHON_EMAIL_TEMPLATE_PRESET_OPTIONS; +const DEFAULT_EMAIL_TEMPLATE_KEY = EMAIL_TEMPLATE_OPTIONS[0].key; +type EmailTemplateKey = (typeof EMAIL_TEMPLATE_OPTIONS)[number]["key"]; +const hackathonApplicationBackgroundKeySchema = + createHackathonApplicationBackgroundKeySchema( + HACKATHONS.APPLICATION_BACKGROUND_KEYS, + ); +const hackathonEmailTemplateKeySchema = createHackathonEmailTemplateKeySchema( + HACKATHON_EMAIL_TEMPLATE_PRESET_KEYS, +); + +function getSafeBackgroundKey( + backgroundKey?: string | null, +): ApplicationBackgroundKey { + return BACKGROUND_OPTIONS.some( + (background) => background.key === backgroundKey, + ) + ? (backgroundKey as ApplicationBackgroundKey) + : DEFAULT_BACKGROUND_KEY; +} + +function getSafeEmailTemplateKey( + emailTemplateKey?: string | null, +): EmailTemplateKey { + return EMAIL_TEMPLATE_OPTIONS.some( + (template) => template.key === emailTemplateKey, + ) + ? (emailTemplateKey as EmailTemplateKey) + : DEFAULT_EMAIL_TEMPLATE_KEY; +} + +const formSchema = z + .object({ + name: hackathonRouteNameSchema, + displayName: hackathonDisplayNameSchema, + theme: hackathonThemeSchema, + applicationBackgroundEnabled: z.boolean(), + applicationBackgroundKey: hackathonApplicationBackgroundKeySchema, + emailTemplateEnabled: z.boolean(), + emailTemplateKey: hackathonEmailTemplateKeySchema, + applicationOpen: z.string().min(1, "Application open is required."), + applicationDeadline: z.string().min(1, "Application deadline is required."), + confirmationDeadline: z + .string() + .min(1, "Confirmation deadline is required."), + startDate: z.string().min(1, "Start date is required."), + endDate: z.string().min(1, "End date is required."), + }) + .superRefine((values, ctx) => { + const applicationOpen = new Date(values.applicationOpen); + const applicationDeadline = new Date(values.applicationDeadline); + const confirmationDeadline = new Date(values.confirmationDeadline); + const startDate = new Date(values.startDate); + const endDate = new Date(values.endDate); + + for (const issue of getHackathonBackgroundIssues(values)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: issue.message, + path: issue.path, + }); + } + + for (const issue of getHackathonEmailTemplateIssues(values)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: issue.message, + path: issue.path, + }); + } + + for (const issue of getHackathonDateWindowIssues({ + applicationDeadline, + applicationOpen, + confirmationDeadline, + endDate, + startDate, + })) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: issue.message, + path: issue.path, + }); + } + }); + +type HackathonFormValues = z.infer; + +function addDays(date: Date, days: number) { + const next = new Date(date); + next.setDate(next.getDate() + days); + return next; +} + +function toDateTimeLocalValue(value: Date | string) { + const date = new Date(value); + const pad = (number: number) => number.toString().padStart(2, "0"); + + return [ + date.getFullYear(), + "-", + pad(date.getMonth() + 1), + "-", + pad(date.getDate()), + "T", + pad(date.getHours()), + ":", + pad(date.getMinutes()), + ].join(""); +} + +function formatDateTime(value: Date | string) { + return new Intl.DateTimeFormat(undefined, { + dateStyle: "medium", + timeStyle: "short", + }).format(new Date(value)); +} + +function getDefaultValues( + hackathon?: SelectHackathon | null, +): HackathonFormValues { + if (hackathon) { + return { + name: hackathon.name, + displayName: hackathon.displayName, + theme: hackathon.theme, + applicationBackgroundEnabled: hackathon.applicationBackgroundEnabled, + applicationBackgroundKey: getSafeBackgroundKey( + hackathon.applicationBackgroundKey, + ), + emailTemplateEnabled: hackathon.emailTemplateEnabled, + emailTemplateKey: getSafeEmailTemplateKey(hackathon.emailTemplateKey), + applicationOpen: toDateTimeLocalValue(hackathon.applicationOpen), + applicationDeadline: toDateTimeLocalValue(hackathon.applicationDeadline), + confirmationDeadline: toDateTimeLocalValue( + hackathon.confirmationDeadline, + ), + startDate: toDateTimeLocalValue(hackathon.startDate), + endDate: toDateTimeLocalValue(hackathon.endDate), + }; + } + + const now = new Date(); + now.setSeconds(0, 0); + const applicationOpen = now; + const applicationDeadline = addDays(now, 30); + const confirmationDeadline = addDays(now, 45); + const startDate = addDays(now, 60); + const endDate = addDays(now, 62); + + return { + name: "", + displayName: "", + theme: "", + applicationBackgroundEnabled: false, + applicationBackgroundKey: DEFAULT_BACKGROUND_KEY, + emailTemplateEnabled: false, + emailTemplateKey: DEFAULT_EMAIL_TEMPLATE_KEY, + applicationOpen: toDateTimeLocalValue(applicationOpen), + applicationDeadline: toDateTimeLocalValue(applicationDeadline), + confirmationDeadline: toDateTimeLocalValue(confirmationDeadline), + startDate: toDateTimeLocalValue(startDate), + endDate: toDateTimeLocalValue(endDate), + }; +} + +function toMutationPayload(values: HackathonFormValues) { + return { + name: values.name, + displayName: values.displayName, + theme: values.theme, + applicationBackgroundEnabled: values.applicationBackgroundEnabled, + applicationBackgroundKey: values.applicationBackgroundEnabled + ? (values.applicationBackgroundKey as + | ApplicationBackgroundKey + | undefined) + : null, + emailTemplateEnabled: values.emailTemplateEnabled, + emailTemplateKey: values.emailTemplateEnabled + ? (values.emailTemplateKey as EmailTemplateKey | undefined) + : null, + applicationOpen: new Date(values.applicationOpen), + applicationDeadline: new Date(values.applicationDeadline), + confirmationDeadline: new Date(values.confirmationDeadline), + startDate: new Date(values.startDate), + endDate: new Date(values.endDate), + }; +} + +export function HackathonManager() { + const utils = api.useUtils(); + const [dialogOpen, setDialogOpen] = useState(false); + const [editingHackathon, setEditingHackathon] = + useState(null); + const { + data: hackathons = [], + error: hackathonsError, + isLoading, + refetch: refetchHackathons, + } = api.hackathon.getManagedHackathons.useQuery(); + + const form = useForm({ + schema: formSchema, + defaultValues: getDefaultValues(), + }); + + const selectedBackgroundEnabled = form.watch("applicationBackgroundEnabled"); + const selectedEmailTemplateEnabled = form.watch("emailTemplateEnabled"); + + const closeDialog = () => { + setDialogOpen(false); + setEditingHackathon(null); + form.reset(getDefaultValues()); + }; + + const openCreateDialog = () => { + setEditingHackathon(null); + form.reset(getDefaultValues()); + setDialogOpen(true); + }; + + const openEditDialog = (hackathon: SelectHackathon) => { + setEditingHackathon(hackathon); + form.reset(getDefaultValues(hackathon)); + setDialogOpen(true); + }; + + const createHackathon = api.hackathon.createHackathon.useMutation({ + onSuccess() { + toast.success("Hackathon created."); + closeDialog(); + }, + onError(error) { + toast.error(error.message); + }, + async onSettled() { + await utils.hackathon.invalidate(); + }, + }); + + const updateHackathon = api.hackathon.updateHackathon.useMutation({ + onSuccess() { + toast.success("Hackathon updated."); + closeDialog(); + }, + onError(error) { + toast.error(error.message); + }, + async onSettled() { + await utils.hackathon.invalidate(); + }, + }); + + const isSaving = createHackathon.isPending || updateHackathon.isPending; + + return ( +
+
+
+
+ + Officer-only setup +
+

Hackathons

+

+ Create application routes, control deadlines, and choose the + application and email presets for each hackathon. +

+
+ +
+ +
+ + + + Hackathon + Application + Event Dates + Background + Email + Actions + + + + {isLoading ? ( + + + Loading hackathons... + + + ) : hackathonsError ? ( + + +
+
+
+ Failed to load hackathons. +
+
+ {hackathonsError.message} +
+
+ +
+
+
+ ) : hackathons.length === 0 ? ( + + + No hackathons yet. + + + ) : ( + hackathons.map((hackathon) => ( + + +
+
{hackathon.displayName}
+
+ /hacker/application/{hackathon.name} +
+
+
+ +
{formatDateTime(hackathon.applicationOpen)}
+
+ Closes {formatDateTime(hackathon.applicationDeadline)} +
+
+ +
{formatDateTime(hackathon.startDate)}
+
Ends {formatDateTime(hackathon.endDate)}
+
+ + {hackathon.applicationBackgroundEnabled && + hackathon.applicationBackgroundKey ? ( + + {hackathon.applicationBackgroundKey} + + ) : ( + Default + )} + + + {hackathon.emailTemplateEnabled && + hackathon.emailTemplateKey ? ( + + {hackathon.emailTemplateKey} + + ) : ( + Stock + )} + + +
+ + +
+
+
+ )) + )} +
+
+
+ + { + if (open) { + setDialogOpen(true); + return; + } + + closeDialog(); + }} + > + + + + {editingHackathon ? "Edit Hackathon" : "Create Hackathon"} + + + Dates are saved from your local timezone. Route names become the + public application URL. + + + +
+ { + const payload = toMutationPayload(values); + + if (editingHackathon) { + updateHackathon.mutate({ + id: editingHackathon.id, + ...payload, + }); + return; + } + + createHackathon.mutate(payload); + })} + > +
+ ( + + Route Name + + + + + Used in /hacker/application/[route-name]. + + + + )} + /> + + ( + + Display Name + + + + + + )} + /> + + ( + + Theme + + + + + + )} + /> +
+ +
+ ( + + Applications Open + + + + + + )} + /> + ( + + Application Deadline + + + + + + )} + /> + ( + + Confirmation Deadline + + + + + + )} + /> + ( + + Hackathon Starts + + + + + + )} + /> + ( + + Hackathon Ends + + + + + + )} + /> +
+ +
+ ( + +
+ Application Background Override + + Leave off to use the stock purple application + background. + +
+ + + +
+ )} + /> + + ( + + Background Preset + + + + )} + /> +
+ +
+ ( + +
+ Email Template Override + + Leave off to use the current Knight Hacks VIII email + templates. + +
+ + + +
+ )} + /> + + ( + + Email Template Preset + + + + )} + /> +
+ + + + + +
+ +
+
+
+ ); +} diff --git a/apps/blade/src/app/_components/dashboard/hackathon-dashboard/hackathon-data.tsx b/apps/blade/src/app/_components/dashboard/hackathon-dashboard/hackathon-data.tsx index 0b3c9f1a6..39c56a0c9 100644 --- a/apps/blade/src/app/_components/dashboard/hackathon-dashboard/hackathon-data.tsx +++ b/apps/blade/src/app/_components/dashboard/hackathon-dashboard/hackathon-data.tsx @@ -154,7 +154,7 @@ export function HackathonData({ {/* QR Code and Apple Wallet */}
- + {hacker && }
{/* Hacker Guide Link */} diff --git a/apps/blade/src/app/_components/dashboard/hacker-dashboard/confirm-button.tsx b/apps/blade/src/app/_components/dashboard/hacker-dashboard/confirm-button.tsx index 97ddf4071..194b1ac3d 100644 --- a/apps/blade/src/app/_components/dashboard/hacker-dashboard/confirm-button.tsx +++ b/apps/blade/src/app/_components/dashboard/hacker-dashboard/confirm-button.tsx @@ -71,7 +71,7 @@ export default function ConfirmWithTOS({

By confirming, you agree to follow the{" "} { - toast.success("You're Confirmed!"); + const { data: numConfirmed } = api.hackathon.getNumConfirmed.useQuery( + { + hackathonId: currentHackathon?.id ?? "", }, - onError: (opts) => { - toast.error(opts.message); + { + enabled: Boolean(currentHackathon?.id), + retry: false, }, - }); + ); const utils = api.useUtils(); @@ -73,19 +69,6 @@ export function HackerData({ confirmHacker.mutate({ id: hacker?.id ?? "", }); - - if (!hacker || !currentHackathon?.displayName) return; - - sendEmail.mutate({ - from: "donotreply@knighthacks.org", - to: hacker.email, - subject: `See you at ${currentHackathon.displayName}!`, - template_id: HACKATHON_TEMPLATE_IDS.Confirmation, - data: { - name: hacker.firstName, - hackathon: currentHackathon.displayName, - }, - }); }; const handleWithdraw = () => { @@ -110,6 +93,7 @@ export function HackerData({ setHackerStatus("Confirmed"); setHackerStatusColor(getStatusColor("confirmed")); setIsConfirmOpen(true); + toast.success("You're Confirmed!"); await utils.hackerQuery.getHacker.invalidate(); }, onError() { diff --git a/apps/blade/src/app/_components/dashboard/hacker-dashboard/hacker-qr-button.tsx b/apps/blade/src/app/_components/dashboard/hacker-dashboard/hacker-qr-button.tsx index 560113a3c..10c425c89 100644 --- a/apps/blade/src/app/_components/dashboard/hacker-dashboard/hacker-qr-button.tsx +++ b/apps/blade/src/app/_components/dashboard/hacker-dashboard/hacker-qr-button.tsx @@ -1,5 +1,6 @@ "use client"; +import { useState } from "react"; import Image from "next/image"; import { Loader2, QrCode } from "lucide-react"; @@ -25,7 +26,15 @@ import { useMediaQuery } from "@forge/ui/use-media-query"; import { api } from "~/trpc/react"; export function HackerQRCodePopup() { - const { data: userQR, isLoading, isError } = api.qr.getQRCode.useQuery(); + const [isOpen, setIsOpen] = useState(false); + const { + data: userQR, + isLoading, + isError, + } = api.qr.getQRCode.useQuery(undefined, { + enabled: isOpen, + retry: false, + }); const isDesktop = useMediaQuery("(min-width: 768px)"); const qrInner = isError ? ( @@ -63,7 +72,7 @@ export function HackerQRCodePopup() { if (isDesktop) { return ( -

+ {qrTrigger} @@ -81,7 +90,7 @@ export function HackerQRCodePopup() { return (
- + {qrTrigger} diff --git a/apps/blade/src/app/_components/dashboard/hacker-dashboard/past-hackathons.tsx b/apps/blade/src/app/_components/dashboard/hacker-dashboard/past-hackathons.tsx index c228ecdea..463f3de06 100644 --- a/apps/blade/src/app/_components/dashboard/hacker-dashboard/past-hackathons.tsx +++ b/apps/blade/src/app/_components/dashboard/hacker-dashboard/past-hackathons.tsx @@ -13,6 +13,9 @@ import { time } from "@forge/utils"; import type { api } from "~/trpc/server"; +const triggerClassName = + "relative flex h-14 w-full cursor-pointer items-center justify-center gap-x-2 border border-[#1F2937] bg-transparent transition-all duration-200 ease-in-out hover:bg-[#E5E7EB] dark:hover:bg-[#1F2937]"; + export function PastHackathonButton({ hackathons, }: { @@ -24,11 +27,9 @@ export function PastHackathonButton({ return (
- -
- -
View Past Hackathons
-
+ + + View Past Hackathons
@@ -47,11 +48,9 @@ export function PastHackathonButton({ return (
- -
- -
View Past Hackathons
-
+ + + View Past Hackathons
diff --git a/apps/blade/src/app/_components/dashboard/hacker/hackbackgrounds/bloomknights.ts b/apps/blade/src/app/_components/dashboard/hacker/hackbackgrounds/bloomknights.ts new file mode 100644 index 000000000..851a3835a --- /dev/null +++ b/apps/blade/src/app/_components/dashboard/hacker/hackbackgrounds/bloomknights.ts @@ -0,0 +1,546 @@ +import type { ApplicationVisualConfig, BackgroundSize } from "./types"; + +const BLOOMKNIGHTS_SCENE_SIZE = { + height: 2250, + width: 12000, +} satisfies BackgroundSize; + +const BLOOMKNIGHTS_APPLICATION_WEBP = + "https://assets.knighthacks.org/bloomknightsApplication.webp"; +const BLOOMKNIGHTS_APPLICATION_TABLET_WEBP = + "https://assets.knighthacks.org/bloomknights-application-6400.webp"; + +const BLOOMKNIGHTS_BIRD_SVG = + "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 96 36'%3E%3Cpath d='M4 26c17-24 31-24 44 0 13-24 27-24 44 0' fill='none' stroke='%23eff8ff' stroke-width='7' stroke-linecap='round'/%3E%3Cpath d='M4 26c17-24 31-24 44 0 13-24 27-24 44 0' fill='none' stroke='%232c5b62' stroke-width='3' stroke-linecap='round' opacity='.45'/%3E%3C/svg%3E"; + +export const bloomknightsApplicationStyles = ` +@keyframes khBloomLeafFieldA { + 0%, 100% { transform: translate3d(-0.35rem, 2vh, 0) rotate(-24deg) scale(1); } + 42% { transform: translate3d(1.45rem, -8vh, 0) rotate(86deg) scale(1.18); } + 72% { transform: translate3d(-1.1rem, 9vh, 0) rotate(154deg) scale(0.96); } +} + +@keyframes khBloomLeafFieldB { + 0%, 100% { transform: translate3d(0.4rem, 3vh, 0) rotate(22deg) scale(1.05); } + 38% { transform: translate3d(-1.6rem, -9vh, 0) rotate(-88deg) scale(0.94); } + 76% { transform: translate3d(1.2rem, 10vh, 0) rotate(-166deg) scale(1.2); } +} + +@keyframes khBloomBirdGlideA { + 0% { transform: translate3d(-12vw, 0, 0) scale(0.42); } + 50% { transform: translate3d(22vw, -2vh, 0) scale(0.46); } + 100% { transform: translate3d(52vw, 1vh, 0) scale(0.42); } +} + +@keyframes khBloomBirdGlideB { + 0% { transform: translate3d(18vw, 0, 0) scaleX(-1) scale(0.48); } + 52% { transform: translate3d(-12vw, 2vh, 0) scaleX(-1) scale(0.44); } + 100% { transform: translate3d(-42vw, -1vh, 0) scaleX(-1) scale(0.48); } +} + +@keyframes khBloomBirdGlideC { + 0% { transform: translate3d(-22vw, 1vh, 0) scale(0.34); } + 46% { transform: translate3d(16vw, -1.5vh, 0) scale(0.38); } + 100% { transform: translate3d(46vw, 0, 0) scale(0.34); } +} + +@keyframes khBloomBirdGlideD { + 0% { transform: translate3d(30vw, -1vh, 0) scaleX(-1) scale(0.32); } + 54% { transform: translate3d(-8vw, 1.5vh, 0) scaleX(-1) scale(0.36); } + 100% { transform: translate3d(-38vw, 0, 0) scaleX(-1) scale(0.32); } +} + +@keyframes khBloomGodrayDrift { + 0%, 100% { transform: translate3d(-1.2%, 0.4%, 0) rotate(-2deg) scale(1); } + 46% { transform: translate3d(1.1%, -0.8%, 0) rotate(1.5deg) scale(1.035); } +} + +@keyframes khBloomSunMistDrift { + 0%, 100% { transform: translate3d(0.8%, 0.6%, 0) scale(1); } + 52% { transform: translate3d(-1%, -0.9%, 0) scale(1.045); } +} + +form[data-application-visual="bloomknights"], +.kh-application-shell[data-application-visual="bloomknights"] { + background-color: #0f2f32; + background-image: + linear-gradient( + to bottom, + rgba(4, 13, 17, 0.26) 0%, + rgba(5, 18, 22, 0.14) 44%, + rgba(5, 20, 22, 0.56) 78%, + rgba(3, 13, 15, 0.78) 100% + ), + linear-gradient(90deg, rgba(5, 18, 22, 0.36), rgba(8, 32, 42, 0.18)), + image-set( + url("${BLOOMKNIGHTS_APPLICATION_TABLET_WEBP}") type("image/webp"), + url("${BLOOMKNIGHTS_APPLICATION_WEBP}") type("image/webp") + ); + background-position: center center; + background-repeat: no-repeat; + background-size: cover; +} + +.kh-application-shell[data-application-visual="bloomknights"]::before { + content: ""; + position: absolute; + inset: 0; + z-index: 5; + pointer-events: none; + background: + linear-gradient( + to bottom, + rgba(3, 11, 14, 0.18) 0%, + rgba(5, 18, 22, 0.08) 42%, + rgba(5, 20, 22, 0.42) 76%, + rgba(3, 13, 15, 0.72) 100% + ), + linear-gradient( + 90deg, + rgba(5, 18, 22, 0.28) 0%, + rgba(8, 32, 42, 0.14) 54%, + rgba(8, 23, 31, 0.08) 100% + ); +} + +.kh-application-shell[data-application-visual="bloomknights"] .kh-readable-text { + text-shadow: + 0 1px 8px rgba(14, 54, 35, 0.34), + 0 1px 1px rgba(14, 54, 35, 0.32); +} + +.kh-application-shell[data-application-visual="bloomknights"] .kh-step-title { + text-shadow: + 0 3px 18px rgba(10, 54, 34, 0.28), + 0 1px 1px rgba(10, 54, 34, 0.35); +} + +.kh-application-shell[data-application-visual="bloomknights"] .kh-nav-button { + box-shadow: 0 10px 26px rgba(10, 44, 30, 0.28); +} + +.kh-application-shell[data-application-visual="bloomknights"] .kh-nav-button:hover:not(:disabled) { + box-shadow: 0 14px 34px rgba(10, 44, 30, 0.34); +} + +.kh-application-shell[data-application-visual="bloomknights"] .kh-resume-info-trigger { + border-color: rgba(239, 248, 255, 0.62); + background: rgba(239, 248, 255, 0.18); + color: rgba(255, 255, 255, 0.92); + box-shadow: 0 8px 20px rgba(14, 54, 35, 0.18); +} + +.kh-application-shell[data-application-visual="bloomknights"] .kh-resume-info-trigger:hover, +.kh-application-shell[data-application-visual="bloomknights"] .kh-resume-info-trigger:focus-visible, +.kh-application-shell[data-application-visual="bloomknights"] .kh-resume-info-trigger[data-state="open"] { + border-color: rgba(255, 255, 255, 0.86); + background: rgba(255, 255, 255, 0.28); + color: white; + box-shadow: 0 11px 28px rgba(10, 44, 30, 0.28); +} + +.kh-resume-info-popover[data-application-visual="bloomknights"] { + border-color: rgba(220, 242, 226, 0.34); + background: rgba(18, 59, 43, 0.95); + color: rgba(245, 255, 249, 0.94); + box-shadow: 0 18px 54px rgba(10, 44, 30, 0.34); +} + +.kh-bloom-birds { + pointer-events: none; + width: clamp(2.4rem, 5vw, 4.25rem); + filter: drop-shadow(0 4px 7px rgba(19, 74, 78, 0.14)); + will-change: transform; +} + +.kh-bloom-godrays { + backface-visibility: hidden; + pointer-events: none; + transform: translateZ(0); +} + +.kh-bloom-godrays::before, +.kh-bloom-godrays::after { + backface-visibility: hidden; + content: ""; + position: absolute; + pointer-events: none; + will-change: transform; +} + +.kh-bloom-godrays::before { + left: 31%; + top: -5%; + width: 46%; + height: 73%; + background: + linear-gradient(104deg, transparent 4%, rgba(255, 248, 204, 0.3) 17%, transparent 28%), + linear-gradient(111deg, transparent 20%, rgba(255, 243, 181, 0.23) 38%, transparent 54%), + linear-gradient(96deg, transparent 45%, rgba(255, 255, 231, 0.2) 60%, transparent 74%), + radial-gradient(ellipse at 54% 8%, rgba(255, 252, 219, 0.34) 0, rgba(255, 244, 192, 0.18) 34%, transparent 72%); + filter: blur(8px); + -webkit-mask-image: radial-gradient(ellipse at 50% 18%, black 0 50%, transparent 84%); + mask-image: radial-gradient(ellipse at 50% 18%, black 0 50%, transparent 84%); + opacity: 0.72; + animation: khBloomGodrayDrift 22s ease-in-out infinite; +} + +.kh-bloom-godrays::after { + left: 36%; + top: 19%; + width: 38%; + height: 54%; + background: + radial-gradient(ellipse at 50% 8%, rgba(255, 250, 216, 0.26) 0, rgba(255, 241, 184, 0.12) 28%, transparent 68%), + radial-gradient(ellipse at 48% 70%, rgba(178, 227, 198, 0.16) 0, rgba(178, 227, 198, 0.08) 34%, transparent 74%); + filter: blur(13px); + -webkit-mask-image: radial-gradient(ellipse at center, black 0 46%, transparent 84%); + mask-image: radial-gradient(ellipse at center, black 0 46%, transparent 84%); + opacity: 0.6; + animation: khBloomSunMistDrift 26s ease-in-out infinite reverse; +} + +.kh-bloom-leaf-field { + backface-visibility: hidden; + z-index: 3; + pointer-events: none; + overflow: hidden; + contain: paint; + clip-path: inset(48% 0 -8% 0); + transform: translateZ(0); +} + +.kh-bloom-leaf-field::before, +.kh-bloom-leaf-field::after { + backface-visibility: hidden; + content: ""; + position: absolute; + width: clamp(1.1rem, 1.45vw, 2.2rem); + aspect-ratio: 42 / 24; + border-radius: 100% 0 100% 0; + background: + linear-gradient(135deg, rgba(244, 238, 140, 0.78), rgba(113, 157, 74, 0.48)), + linear-gradient(145deg, transparent 44%, rgba(70, 105, 48, 0.5) 47%, transparent 52%); + filter: drop-shadow(0 4px 7px rgba(38, 82, 43, 0.14)); + transform-origin: center; + transform: translateZ(0); + will-change: transform; +} + +.kh-bloom-leaves-a::before { + left: 8%; + top: 64%; + animation: khBloomLeafFieldA 25s ease-in-out infinite; +} + +.kh-bloom-leaves-a::after { + left: 22%; + top: 76%; + width: clamp(0.95rem, 1.2vw, 1.85rem); + animation: khBloomLeafFieldB 30s ease-in-out infinite; + animation-delay: -7s; +} + +.kh-bloom-leaves-b::before { + left: 38%; + top: 68%; + width: clamp(1rem, 1.22vw, 1.9rem); + animation: khBloomLeafFieldB 28s ease-in-out infinite; + animation-delay: -10s; +} + +.kh-bloom-leaves-b::after { + left: 51%; + top: 83%; + width: clamp(0.9rem, 1.05vw, 1.7rem); + animation: khBloomLeafFieldA 32s ease-in-out infinite; + animation-delay: -16s; +} + +.kh-bloom-leaves-c::before { + left: 64%; + top: 62%; + width: clamp(1.05rem, 1.35vw, 2.05rem); + animation: khBloomLeafFieldA 29s ease-in-out infinite; + animation-delay: -13s; +} + +.kh-bloom-leaves-c::after { + left: 78%; + top: 74%; + width: clamp(0.92rem, 1.12vw, 1.75rem); + animation: khBloomLeafFieldB 34s ease-in-out infinite; + animation-delay: -21s; +} + +.kh-bloom-leaves-d::before { + left: 88%; + top: 66%; + width: clamp(0.98rem, 1.18vw, 1.85rem); + animation: khBloomLeafFieldB 31s ease-in-out infinite; + animation-delay: -18s; +} + +.kh-bloom-leaves-d::after { + left: 93%; + top: 86%; + width: clamp(0.82rem, 0.98vw, 1.55rem); + animation: khBloomLeafFieldA 36s ease-in-out infinite; + animation-delay: -24s; +} + +.kh-bloom-leaves-e::before { + left: 13%; + top: 87%; + width: clamp(0.9rem, 1.08vw, 1.7rem); + animation: khBloomLeafFieldB 33s ease-in-out infinite; + animation-delay: -20s; +} + +.kh-bloom-leaves-e::after { + left: 31%; + top: 59%; + width: clamp(1.02rem, 1.28vw, 1.95rem); + animation: khBloomLeafFieldA 27s ease-in-out infinite; + animation-delay: -12s; +} + +.kh-bloom-leaves-f::before { + left: 45%; + top: 90%; + width: clamp(0.86rem, 1vw, 1.6rem); + animation: khBloomLeafFieldA 35s ease-in-out infinite; + animation-delay: -26s; +} + +.kh-bloom-leaves-f::after { + left: 58%; + top: 57%; + width: clamp(1.08rem, 1.38vw, 2.1rem); + animation: khBloomLeafFieldB 29s ease-in-out infinite; + animation-delay: -15s; +} + +.kh-bloom-leaves-g::before { + left: 70%; + top: 88%; + width: clamp(0.88rem, 1.04vw, 1.65rem); + animation: khBloomLeafFieldB 37s ease-in-out infinite; + animation-delay: -29s; +} + +.kh-bloom-leaves-g::after { + left: 84%; + top: 58%; + width: clamp(1rem, 1.26vw, 1.95rem); + animation: khBloomLeafFieldA 28s ease-in-out infinite; + animation-delay: -19s; +} + +.kh-bloom-leaves-h::before { + left: 5%; + top: 54%; + width: clamp(0.94rem, 1.14vw, 1.78rem); + animation: khBloomLeafFieldA 31s ease-in-out infinite; + animation-delay: -23s; +} + +.kh-bloom-leaves-h::after { + left: 96%; + top: 55%; + width: clamp(0.9rem, 1.08vw, 1.68rem); + animation: khBloomLeafFieldB 39s ease-in-out infinite; + animation-delay: -31s; +} + +.kh-bloom-birds-far { + left: 9%; + top: 9%; + animation: khBloomBirdGlideA 40s ease-in-out infinite; +} + +.kh-bloom-birds-near { + right: 14%; + top: 16%; + animation: khBloomBirdGlideB 48s ease-in-out infinite; + animation-delay: -18s; +} + +.kh-bloom-birds-high { + left: 28%; + top: 6%; + width: clamp(1.9rem, 3.8vw, 3.3rem); + animation: khBloomBirdGlideC 54s ease-in-out infinite; + animation-delay: -28s; +} + +.kh-bloom-birds-mid { + right: 30%; + top: 22%; + width: clamp(2rem, 4.2vw, 3.6rem); + animation: khBloomBirdGlideD 58s ease-in-out infinite; + animation-delay: -36s; +} + +@media (prefers-reduced-motion: reduce) { + .kh-bloom-godrays::before, + .kh-bloom-godrays::after, + .kh-bloom-leaf-field::before, + .kh-bloom-leaf-field::after, + .kh-bloom-birds { + animation: none; + } +} +`; + +export const bloomknightsApplicationBackground = { + key: "bloomknights", + label: "BloomKnights mountain meadow", + ambientLayers: [ + { + id: "bloomknights-godrays", + className: "kh-bloom-godrays", + space: "viewport", + style: { + contain: "layout style", + overflow: "visible", + }, + zIndex: 2, + }, + { + id: "bloomknights-leaves-a", + className: "kh-bloom-leaf-field kh-bloom-leaves-a", + space: "scene", + zIndex: 3, + }, + { + id: "bloomknights-leaves-b", + className: "kh-bloom-leaf-field kh-bloom-leaves-b", + space: "scene", + zIndex: 3, + }, + { + id: "bloomknights-leaves-c", + className: "kh-bloom-leaf-field kh-bloom-leaves-c", + space: "scene", + zIndex: 3, + }, + { + id: "bloomknights-leaves-d", + className: "kh-bloom-leaf-field kh-bloom-leaves-d", + space: "scene", + zIndex: 3, + }, + { + id: "bloomknights-leaves-e", + className: "kh-bloom-leaf-field kh-bloom-leaves-e", + space: "scene", + zIndex: 3, + }, + { + id: "bloomknights-leaves-f", + className: "kh-bloom-leaf-field kh-bloom-leaves-f", + space: "scene", + zIndex: 3, + }, + { + id: "bloomknights-leaves-g", + className: "kh-bloom-leaf-field kh-bloom-leaves-g", + space: "scene", + zIndex: 3, + }, + { + id: "bloomknights-leaves-h", + className: "kh-bloom-leaf-field kh-bloom-leaves-h", + space: "scene", + zIndex: 3, + }, + ], + baseLayerId: "bloomknights-meadow", + layers: [ + { + id: "bloomknights-meadow", + alt: "Watercolor mountain meadow for BloomKnights", + kind: "image", + nativeSize: BLOOMKNIGHTS_SCENE_SIZE, + sources: [ + { + media: "(max-height: 1440px)", + mimeType: "image/webp", + src: BLOOMKNIGHTS_APPLICATION_TABLET_WEBP, + }, + { + mimeType: "image/webp", + src: BLOOMKNIGHTS_APPLICATION_WEBP, + }, + ], + src: BLOOMKNIGHTS_APPLICATION_WEBP, + space: "scene", + zIndex: 0, + }, + { + id: "bloomknights-birds-far", + kind: "image", + mediaClassName: "h-auto w-full", + nativeSize: { + height: 36, + width: 96, + }, + opacity: 0.72, + src: BLOOMKNIGHTS_BIRD_SVG, + space: "viewport", + className: "kh-bloom-birds kh-bloom-birds-far", + zIndex: 2, + }, + { + id: "bloomknights-birds-near", + kind: "image", + mediaClassName: "h-auto w-full", + nativeSize: { + height: 36, + width: 96, + }, + opacity: 0.62, + src: BLOOMKNIGHTS_BIRD_SVG, + space: "viewport", + className: "kh-bloom-birds kh-bloom-birds-near", + zIndex: 2, + }, + { + id: "bloomknights-birds-high", + kind: "image", + mediaClassName: "h-auto w-full", + nativeSize: { + height: 36, + width: 96, + }, + opacity: 0.58, + src: BLOOMKNIGHTS_BIRD_SVG, + space: "viewport", + className: "kh-bloom-birds kh-bloom-birds-high", + zIndex: 2, + }, + { + id: "bloomknights-birds-mid", + kind: "image", + mediaClassName: "h-auto w-full", + nativeSize: { + height: 36, + width: 96, + }, + opacity: 0.54, + src: BLOOMKNIGHTS_BIRD_SVG, + space: "viewport", + className: "kh-bloom-birds kh-bloom-birds-mid", + zIndex: 2, + }, + ], + mode: "dynamic", + overlayClassName: + "bg-[linear-gradient(90deg,rgba(5,18,22,0.6)_0%,rgba(8,32,42,0.38)_48%,rgba(8,23,31,0.2)_100%)]", + questionTransitionMs: 0, + showStockEffects: false, + stepTransitionMs: 220, + styles: bloomknightsApplicationStyles, + transitionMs: 220, +} satisfies ApplicationVisualConfig; diff --git a/apps/blade/src/app/_components/dashboard/hacker/hackbackgrounds/index.ts b/apps/blade/src/app/_components/dashboard/hacker/hackbackgrounds/index.ts new file mode 100644 index 000000000..16efaab72 --- /dev/null +++ b/apps/blade/src/app/_components/dashboard/hacker/hackbackgrounds/index.ts @@ -0,0 +1,65 @@ +import { HACKATHONS } from "@forge/consts"; + +import type { ApplicationVisualConfig } from "./types"; +import { bloomknightsApplicationBackground } from "./bloomknights"; +import { khixApplicationBackground } from "./khix"; + +export const DEFAULT_APPLICATION_VISUAL = { + key: "default", + label: "Stock purple", + mode: "dynamic", + showStockEffects: true, +} satisfies ApplicationVisualConfig; + +const HACKER_APPLICATION_BACKGROUND_REGISTRY = { + bloomknights: bloomknightsApplicationBackground, + khix: khixApplicationBackground, +} satisfies Record< + HACKATHONS.ApplicationBackgroundKey, + ApplicationVisualConfig +>; + +export type HackerApplicationBackgroundKey = + HACKATHONS.ApplicationBackgroundKey; + +export const HACKER_APPLICATION_BACKGROUNDS: Record< + HackerApplicationBackgroundKey, + ApplicationVisualConfig +> = HACKER_APPLICATION_BACKGROUND_REGISTRY; + +export const HACKER_APPLICATION_BACKGROUND_OPTIONS = + HACKATHONS.APPLICATION_BACKGROUND_OPTIONS; + +function isHackerApplicationBackgroundKey( + backgroundKey: string, +): backgroundKey is HackerApplicationBackgroundKey { + return HACKATHONS.APPLICATION_BACKGROUND_KEYS.includes( + backgroundKey as HackerApplicationBackgroundKey, + ); +} + +export function getHackerApplicationBackgroundKey( + backgroundKey?: string | null, +): HackerApplicationBackgroundKey | null { + if (!backgroundKey) return null; + if (isHackerApplicationBackgroundKey(backgroundKey)) return backgroundKey; + + if (backgroundKey === "knight-hacks-ix") return "khix"; + + return null; +} + +export function getHackerApplicationBackground( + backgroundKey?: string | null, +): ApplicationVisualConfig { + const applicationBackgroundKey = + getHackerApplicationBackgroundKey(backgroundKey); + + if (!applicationBackgroundKey) { + return DEFAULT_APPLICATION_VISUAL; + } + + return HACKER_APPLICATION_BACKGROUNDS[applicationBackgroundKey]; +} + +export type { ApplicationVisualConfig, ApplicationVisualLayer } from "./types"; diff --git a/apps/blade/src/app/_components/dashboard/hacker/hackbackgrounds/khix.ts b/apps/blade/src/app/_components/dashboard/hacker/hackbackgrounds/khix.ts new file mode 100644 index 000000000..751a700fe --- /dev/null +++ b/apps/blade/src/app/_components/dashboard/hacker/hackbackgrounds/khix.ts @@ -0,0 +1,456 @@ +import type { ApplicationVisualConfig, BackgroundSize } from "./types"; + +const KHIX_SCENE_SIZE = { + height: 2250, + width: 12000, +} satisfies BackgroundSize; + +const KHIX_FLAT_WEBP = "https://assets.knighthacks.org/khix-flat.webp"; +const KHIX_FOREGROUND_WEBP = + "https://assets.knighthacks.org/khix-foreground.webp"; +const KHIX_LENNY_ANIM_WEBP = + "https://assets.knighthacks.org/khix-lenny-anim.webp"; +const KHIX_LENNY_IDLE_WEBP = + "https://assets.knighthacks.org/khix-lenny-idle.webp"; + +export const khixApplicationStyles = ` +@keyframes khixForestMistDrift { + 0%, 100% { transform: translate3d(-3.8%, 1.4%, 0) scale(1.04); } + 34% { transform: translate3d(2.5%, -2.2%, 0) scale(1.1); } + 68% { transform: translate3d(5.4%, 0.9%, 0) scale(1.07); } +} + +@keyframes khixCalmCanopyBreeze { + 0%, 100% { transform: translate3d(-0.35%, 0, 0) skewX(-0.5deg); } + 50% { transform: translate3d(0.5%, -0.35%, 0) skewX(0.8deg); } +} + +@keyframes khixCalmLeafDrift { + 0% { transform: translate3d(-0.5%, 0.6%, 0) rotate(-1deg); } + 45% { transform: translate3d(0.8%, -0.9%, 0) rotate(4deg); } + 100% { transform: translate3d(1.2%, 0.3%, 0) rotate(7deg); } +} + +@keyframes khixMagicVeil { + 0%, 100% { transform: translate3d(-0.2%, 0, 0) scale(0.98); } + 50% { transform: translate3d(0.35%, -0.45%, 0) scale(1.015); } +} + +@keyframes khixArcaneSmokeFlow { + 0%, 100% { transform: translate3d(-0.25%, 0.35%, 0) scale(0.99); } + 50% { transform: translate3d(0.35%, -0.55%, 0) scale(1.02); } +} + +@keyframes khixSpookyWispThread { + 0%, 100% { transform: translate3d(-0.35%, 0.55%, 0) scale(0.98); } + 38% { transform: translate3d(0.5%, -0.75%, 0) scale(1.025); } + 72% { transform: translate3d(0.8%, 0.1%, 0) scale(1.005); } +} + +@keyframes khixSpookyShadowCrawl { + 0%, 100% { transform: translate3d(0.55%, 0.25%, 0) scaleX(0.98); } + 50% { transform: translate3d(-0.45%, -0.35%, 0) scaleX(1.02); } +} + +.kh-application-shell[data-application-visual="khix"] .kh-step-content :is(input, textarea) { + border-color: rgba(255, 255, 255, 0.42); +} + +.kh-application-shell[data-application-visual="khix"] .kh-step-content :is(input, textarea):focus-visible { + border-color: rgba(255, 255, 255, 0.78); +} + +.kh-application-shell[data-application-visual="khix"] .kh-resume-info-trigger { + border-color: rgba(226, 255, 151, 0.48); + background: rgba(226, 255, 151, 0.12); + color: rgba(245, 255, 196, 0.9); + box-shadow: + 0 8px 24px rgba(0, 0, 0, 0.26), + 0 0 18px rgba(216, 255, 134, 0.18); +} + +.kh-application-shell[data-application-visual="khix"] .kh-resume-info-trigger:hover, +.kh-application-shell[data-application-visual="khix"] .kh-resume-info-trigger:focus-visible, +.kh-application-shell[data-application-visual="khix"] .kh-resume-info-trigger[data-state="open"] { + border-color: rgba(245, 255, 183, 0.78); + background: rgba(229, 255, 147, 0.2); + color: white; + box-shadow: + 0 10px 28px rgba(0, 0, 0, 0.32), + 0 0 26px rgba(218, 255, 133, 0.34); +} + +.kh-resume-info-popover[data-application-visual="khix"] { + border-color: rgba(227, 255, 151, 0.24); + background: rgba(12, 23, 16, 0.96); + color: rgba(247, 255, 214, 0.92); + box-shadow: + 0 18px 62px rgba(0, 0, 0, 0.45), + 0 0 32px rgba(208, 255, 122, 0.18); +} + +.khix-forest-ambient { + backface-visibility: hidden; + pointer-events: none; + overflow: hidden; + contain: paint; + transform: translateZ(0); +} + +.khix-forest-ambient::before, +.khix-forest-ambient::after { + backface-visibility: hidden; + content: ""; + position: absolute; + inset: 0; + pointer-events: none; + transform: translateZ(0); + will-change: transform; +} + +.khix-normal-forest-mist::before { + background: + radial-gradient(ellipse at 8% 48%, rgba(221, 242, 176, 0.18) 0, rgba(221, 242, 176, 0.09) 14%, rgba(221, 242, 176, 0.03) 28%, transparent 48%), + radial-gradient(ellipse at 19% 58%, rgba(132, 187, 119, 0.16) 0, rgba(132, 187, 119, 0.08) 16%, rgba(132, 187, 119, 0.03) 32%, transparent 52%), + radial-gradient(ellipse at 31% 44%, rgba(118, 182, 168, 0.12) 0, rgba(118, 182, 168, 0.06) 16%, transparent 46%), + radial-gradient(ellipse at 52% 45%, rgba(65, 155, 135, 0.1) 0, rgba(65, 155, 135, 0.05) 18%, transparent 48%); + filter: none; + opacity: 0.42; + animation: khixForestMistDrift 20s ease-in-out infinite; +} + +.khix-normal-forest-mist::after { + background: + radial-gradient(ellipse at 7% 74%, rgba(205, 232, 147, 0.2) 0, rgba(205, 232, 147, 0.1) 12%, rgba(205, 232, 147, 0.04) 24%, transparent 42%), + radial-gradient(ellipse at 18% 70%, rgba(135, 190, 124, 0.18) 0, rgba(135, 190, 124, 0.09) 14%, rgba(135, 190, 124, 0.03) 28%, transparent 46%), + radial-gradient(ellipse at 38% 76%, rgba(229, 218, 152, 0.14) 0, rgba(229, 218, 152, 0.07) 14%, transparent 42%), + radial-gradient(ellipse at 58% 74%, rgba(94, 133, 93, 0.12) 0, rgba(94, 133, 93, 0.06) 18%, transparent 48%); + filter: none; + opacity: 0.5; + animation: khixForestMistDrift 17s ease-in-out infinite reverse; +} + +.khix-calm-canopy-breeze::before { + background: + radial-gradient(ellipse at 8% 11%, rgba(255, 244, 155, 0.12) 0, rgba(255, 244, 155, 0.06) 8%, transparent 24%), + radial-gradient(ellipse at 17% 9%, rgba(140, 215, 105, 0.12) 0, rgba(140, 215, 105, 0.06) 10%, transparent 28%), + radial-gradient(ellipse at 26% 18%, rgba(255, 236, 145, 0.08) 0, rgba(255, 236, 145, 0.04) 12%, transparent 30%); + opacity: 0.38; + animation: khixCalmCanopyBreeze 13s ease-in-out infinite; +} + +.khix-calm-canopy-breeze::after { + background: + radial-gradient(ellipse at 13% 27%, rgba(255, 237, 145, 0.12) 0, rgba(255, 237, 145, 0.05) 10%, transparent 30%), + radial-gradient(ellipse at 25% 32%, rgba(214, 255, 162, 0.11) 0, rgba(214, 255, 162, 0.05) 10%, transparent 30%); + opacity: 0.32; + animation: khixCalmCanopyBreeze 17s ease-in-out infinite reverse; +} + +.khix-calm-leaf-drift::before, +.khix-calm-leaf-drift::after { + background: + radial-gradient(ellipse at 7% 41%, rgba(231, 245, 136, 0.56) 0, rgba(231, 245, 136, 0.56) 0.1rem, transparent 0.42rem), + radial-gradient(ellipse at 14% 52%, rgba(156, 215, 107, 0.48) 0, rgba(156, 215, 107, 0.48) 0.1rem, transparent 0.4rem), + radial-gradient(ellipse at 23% 39%, rgba(238, 215, 116, 0.44) 0, rgba(238, 215, 116, 0.44) 0.09rem, transparent 0.36rem), + radial-gradient(ellipse at 31% 58%, rgba(165, 223, 112, 0.42) 0, rgba(165, 223, 112, 0.42) 0.1rem, transparent 0.38rem); + filter: drop-shadow(0 0 6px rgba(218, 242, 130, 0.24)); + opacity: 0.5; + animation: khixCalmLeafDrift 15s ease-in-out infinite; +} + +.khix-calm-leaf-drift::after { + opacity: 0.64; + animation-duration: 19s; + animation-delay: -8s; +} + +.khix-normal-forest-fireflies::before, +.khix-normal-forest-fireflies::after { + background: + radial-gradient(circle at 9% 58%, rgba(255, 255, 215, 1) 0, rgba(255, 255, 178, 1) 0.16rem, rgba(229, 255, 132, 0.68) 0.45rem, transparent 1rem), + radial-gradient(circle at 18% 72%, rgba(255, 246, 159, 1) 0, rgba(255, 236, 123, 1) 0.14rem, rgba(255, 216, 89, 0.58) 0.4rem, transparent 0.92rem), + radial-gradient(circle at 31% 42%, rgba(237, 255, 189, 1) 0, rgba(221, 255, 145, 1) 0.14rem, rgba(178, 255, 110, 0.56) 0.39rem, transparent 0.9rem), + radial-gradient(circle at 48% 66%, rgba(255, 255, 210, 1) 0, rgba(255, 248, 164, 1) 0.16rem, rgba(224, 255, 132, 0.62) 0.44rem, transparent 0.98rem), + radial-gradient(circle at 67% 51%, rgba(255, 247, 164, 1) 0, rgba(255, 237, 121, 1) 0.15rem, rgba(255, 215, 88, 0.58) 0.42rem, transparent 0.94rem); + filter: drop-shadow(0 0 8px rgba(255, 252, 176, 0.9)) drop-shadow(0 0 22px rgba(219, 255, 128, 0.58)); + opacity: 1; + will-change: auto; +} + +.khix-normal-forest-fireflies::after { + background: + radial-gradient(circle at 13% 36%, rgba(240, 255, 198, 1) 0, rgba(223, 255, 151, 1) 0.13rem, rgba(181, 255, 112, 0.52) 0.36rem, transparent 0.84rem), + radial-gradient(circle at 39% 83%, rgba(255, 242, 151, 1) 0, rgba(255, 228, 108, 1) 0.14rem, rgba(255, 207, 83, 0.54) 0.38rem, transparent 0.88rem), + radial-gradient(circle at 56% 35%, rgba(255, 255, 207, 1) 0, rgba(255, 250, 162, 1) 0.14rem, rgba(222, 255, 130, 0.56) 0.39rem, transparent 0.9rem), + radial-gradient(circle at 75% 76%, rgba(238, 255, 189, 1) 0, rgba(221, 255, 145, 1) 0.13rem, rgba(178, 255, 110, 0.52) 0.36rem, transparent 0.84rem), + radial-gradient(circle at 90% 43%, rgba(255, 244, 153, 1) 0, rgba(255, 231, 111, 1) 0.14rem, rgba(255, 211, 85, 0.54) 0.38rem, transparent 0.88rem); + filter: drop-shadow(0 0 7px rgba(255, 248, 157, 0.82)) drop-shadow(0 0 19px rgba(211, 255, 122, 0.5)); + opacity: 1; +} + +.khix-magic-glowing-gems::before { + background: + radial-gradient(ellipse at 55% 77%, rgba(178, 255, 232, 0.86) 0, rgba(178, 255, 232, 0.86) 0.14rem, rgba(74, 255, 226, 0.42) 0.32rem, transparent 1.1rem), + radial-gradient(ellipse at 64% 68%, rgba(222, 167, 255, 0.8) 0, rgba(222, 167, 255, 0.8) 0.16rem, rgba(142, 92, 255, 0.38) 0.4rem, transparent 1.2rem), + radial-gradient(ellipse at 73% 84%, rgba(100, 249, 221, 0.82) 0, rgba(100, 249, 221, 0.82) 0.14rem, rgba(64, 205, 255, 0.34) 0.34rem, transparent 1.1rem), + radial-gradient(ellipse at 83% 62%, rgba(181, 140, 255, 0.78) 0, rgba(181, 140, 255, 0.78) 0.15rem, rgba(100, 73, 255, 0.34) 0.36rem, transparent 1.14rem), + radial-gradient(ellipse at 94% 78%, rgba(111, 246, 255, 0.78) 0, rgba(111, 246, 255, 0.78) 0.14rem, rgba(54, 210, 230, 0.34) 0.34rem, transparent 1.1rem); + filter: drop-shadow(0 0 8px rgba(99, 255, 224, 0.3)); + opacity: 0.68; + will-change: auto; +} + +.khix-magic-glowing-gems::after { + background: + radial-gradient(ellipse at 58% 74%, rgba(226, 255, 251, 0.42) 0, rgba(226, 255, 251, 0.2) 7%, transparent 20%), + radial-gradient(ellipse at 70% 61%, rgba(235, 203, 255, 0.38) 0, rgba(235, 203, 255, 0.18) 7%, transparent 22%), + radial-gradient(ellipse at 83% 75%, rgba(175, 255, 244, 0.36) 0, rgba(175, 255, 244, 0.17) 7%, transparent 22%); + filter: none; + opacity: 0.5; + will-change: auto; +} + +.khix-magic-spore-glints::before { + background: + radial-gradient(circle at 57% 69%, rgba(142, 255, 236, 0.82) 0, rgba(142, 255, 236, 0.82) 0.08rem, rgba(92, 232, 255, 0.32) 0.3rem, transparent 0.74rem), + radial-gradient(circle at 64% 50%, rgba(218, 164, 255, 0.72) 0, rgba(218, 164, 255, 0.72) 0.08rem, rgba(137, 83, 255, 0.28) 0.28rem, transparent 0.68rem), + radial-gradient(circle at 72% 73%, rgba(115, 255, 231, 0.78) 0, rgba(115, 255, 231, 0.78) 0.07rem, rgba(78, 217, 255, 0.28) 0.26rem, transparent 0.62rem), + radial-gradient(circle at 82% 41%, rgba(196, 132, 255, 0.68) 0, rgba(196, 132, 255, 0.68) 0.08rem, rgba(135, 87, 255, 0.26) 0.28rem, transparent 0.68rem), + radial-gradient(circle at 91% 58%, rgba(235, 187, 255, 0.66) 0, rgba(235, 187, 255, 0.66) 0.07rem, rgba(160, 91, 255, 0.22) 0.26rem, transparent 0.66rem); + filter: drop-shadow(0 0 7px rgba(126, 246, 255, 0.26)); + opacity: 0.62; + will-change: auto; +} + +.khix-magic-spore-glints::after { + background: + radial-gradient(ellipse at 58% 25%, rgba(112, 255, 241, 0.18) 0, rgba(112, 255, 241, 0.08) 8%, transparent 26%), + radial-gradient(circle at 67% 64%, rgba(255, 226, 168, 0.62) 0, rgba(255, 226, 168, 0.62) 0.06rem, transparent 0.28rem), + radial-gradient(ellipse at 78% 42%, rgba(184, 112, 255, 0.16) 0, rgba(184, 112, 255, 0.07) 8%, transparent 28%), + radial-gradient(circle at 88% 70%, rgba(110, 245, 255, 0.6) 0, rgba(110, 245, 255, 0.6) 0.06rem, transparent 0.28rem); + filter: none; + opacity: 0.5; + will-change: auto; +} + +.khix-arcane-smoke-pool::before { + background: + radial-gradient(ellipse at 58% 64%, rgba(85, 244, 231, 0.28) 0, rgba(85, 244, 231, 0.13) 9%, rgba(85, 244, 231, 0.04) 19%, transparent 34%), + radial-gradient(ellipse at 73% 54%, rgba(171, 101, 255, 0.24) 0, rgba(171, 101, 255, 0.11) 10%, rgba(171, 101, 255, 0.03) 20%, transparent 35%), + radial-gradient(ellipse at 91% 62%, rgba(91, 225, 255, 0.2) 0, rgba(91, 225, 255, 0.1) 10%, rgba(91, 225, 255, 0.03) 22%, transparent 38%); + filter: none; + opacity: 0.7; + animation: khixArcaneSmokeFlow 12s ease-in-out infinite; +} + +.khix-arcane-smoke-pool::after { + background: + radial-gradient(ellipse at 56% 78%, rgba(118, 255, 235, 0.24) 0, rgba(118, 255, 235, 0.11) 10%, rgba(118, 255, 235, 0.03) 20%, transparent 36%), + radial-gradient(ellipse at 70% 75%, rgba(216, 143, 255, 0.22) 0, rgba(216, 143, 255, 0.1) 10%, rgba(216, 143, 255, 0.03) 20%, transparent 36%), + radial-gradient(ellipse at 88% 77%, rgba(95, 226, 255, 0.2) 0, rgba(95, 226, 255, 0.09) 10%, rgba(95, 226, 255, 0.03) 20%, transparent 36%); + filter: none; + opacity: 0.62; + animation: khixArcaneSmokeFlow 15s ease-in-out infinite reverse; +} + +.khix-spooky-magic-wisps::before { + background: + radial-gradient(ellipse at 58% 60%, rgba(112, 255, 239, 0.22) 0, rgba(112, 255, 239, 0.1) 10%, rgba(112, 255, 239, 0.03) 20%, transparent 36%), + radial-gradient(ellipse at 70% 43%, rgba(195, 135, 255, 0.23) 0, rgba(195, 135, 255, 0.1) 10%, rgba(195, 135, 255, 0.03) 22%, transparent 38%), + radial-gradient(ellipse at 84% 58%, rgba(93, 239, 214, 0.18) 0, rgba(93, 239, 214, 0.08) 10%, rgba(93, 239, 214, 0.03) 20%, transparent 36%); + filter: none; + opacity: 0.62; + animation: khixMagicVeil 11s ease-in-out infinite; +} + +.khix-spooky-magic-wisps::after { + background: + radial-gradient(ellipse at 75% 30%, rgba(115, 255, 244, 0.12) 0, rgba(115, 255, 244, 0.06) 10%, transparent 30%), + radial-gradient(ellipse at 91% 62%, rgba(162, 88, 255, 0.14) 0, rgba(162, 88, 255, 0.07) 12%, transparent 32%); + filter: none; + opacity: 0.4; + animation: khixSpookyWispThread 12s ease-in-out infinite reverse; +} + +.khix-spooky-shadow-crawl::before { + background: + radial-gradient(ellipse at 68% 56%, rgba(12, 4, 34, 0.22) 0, rgba(12, 4, 34, 0.1) 10%, rgba(12, 4, 34, 0.03) 22%, transparent 40%), + radial-gradient(ellipse at 82% 46%, rgba(29, 7, 57, 0.2) 0, rgba(29, 7, 57, 0.09) 10%, rgba(29, 7, 57, 0.03) 22%, transparent 40%), + radial-gradient(ellipse at 95% 73%, rgba(5, 20, 37, 0.2) 0, rgba(5, 20, 37, 0.09) 10%, rgba(5, 20, 37, 0.03) 22%, transparent 40%); + filter: none; + opacity: 0.3; + animation: khixSpookyShadowCrawl 14s ease-in-out infinite; +} + +.khix-spooky-shadow-crawl::after { + background: + radial-gradient(ellipse at 71% 76%, rgba(18, 5, 42, 0.16) 0, rgba(18, 5, 42, 0.08) 10%, rgba(18, 5, 42, 0.02) 22%, transparent 40%), + radial-gradient(ellipse at 92% 78%, rgba(9, 30, 48, 0.16) 0, rgba(9, 30, 48, 0.08) 10%, rgba(9, 30, 48, 0.02) 22%, transparent 40%); + filter: none; + opacity: 0.28; + animation: khixSpookyShadowCrawl 18s ease-in-out infinite reverse; +} + +@media (prefers-reduced-motion: reduce) { + .khix-normal-forest-mist::before, + .khix-normal-forest-mist::after, + .khix-calm-canopy-breeze::before, + .khix-calm-canopy-breeze::after, + .khix-calm-leaf-drift::before, + .khix-calm-leaf-drift::after, + .khix-normal-forest-fireflies::before, + .khix-normal-forest-fireflies::after, + .khix-magic-glowing-gems::before, + .khix-magic-glowing-gems::after, + .khix-magic-spore-glints::before, + .khix-magic-spore-glints::after, + .khix-arcane-smoke-pool::before, + .khix-arcane-smoke-pool::after, + .khix-spooky-magic-wisps::before, + .khix-spooky-magic-wisps::after, + .khix-spooky-shadow-crawl::before, + .khix-spooky-shadow-crawl::after { + animation: none; + } +} +`; + +export const khixApplicationBackground = { + key: "khix", + label: "KHIX forest walk", + ambientLayers: [ + { + id: "khix-normal-forest-mist", + className: "khix-forest-ambient khix-normal-forest-mist", + space: "scene", + style: { + contain: "layout style", + overflow: "visible", + }, + zIndex: 1, + }, + { + id: "khix-calm-canopy-breeze", + className: "khix-forest-ambient khix-calm-canopy-breeze", + space: "scene", + zIndex: 1, + }, + { + id: "khix-calm-leaf-drift", + className: "khix-forest-ambient khix-calm-leaf-drift", + space: "scene", + zIndex: 1, + }, + { + id: "khix-normal-forest-fireflies", + className: "khix-forest-ambient khix-normal-forest-fireflies", + space: "scene", + zIndex: 4, + }, + { + id: "khix-magic-glowing-gems", + className: "khix-forest-ambient khix-magic-glowing-gems", + space: "scene", + zIndex: 1, + }, + { + id: "khix-magic-spore-glints", + className: "khix-forest-ambient khix-magic-spore-glints", + space: "scene", + zIndex: 1, + }, + { + id: "khix-arcane-smoke-pool", + className: "khix-forest-ambient khix-arcane-smoke-pool", + space: "scene", + style: { + contain: "layout style", + overflow: "visible", + }, + zIndex: 1, + }, + { + id: "khix-spooky-magic-wisps", + className: "khix-forest-ambient khix-spooky-magic-wisps", + space: "scene", + style: { + contain: "layout style", + overflow: "visible", + }, + zIndex: 1, + }, + { + id: "khix-spooky-shadow-crawl", + className: "khix-forest-ambient khix-spooky-shadow-crawl", + space: "scene", + style: { + contain: "layout style", + overflow: "visible", + }, + zIndex: 1, + }, + ], + baseLayerId: "khix-flat", + layers: [ + { + id: "khix-flat", + kind: "image", + nativeSize: KHIX_SCENE_SIZE, + sources: [ + { + mimeType: "image/webp", + src: KHIX_FLAT_WEBP, + }, + ], + src: KHIX_FLAT_WEBP, + space: "scene", + zIndex: 0, + }, + { + id: "khix-lenny", + animatedSrc: KHIX_LENNY_ANIM_WEBP, + className: + "khix-lenny bottom-[4svh] left-1/2 w-[clamp(17rem,58vw,24rem)] -translate-x-1/2 sm:bottom-[3svh] sm:w-[clamp(20rem,46vw,29rem)] sm:translate-y-[1%] md:bottom-[-3svh] md:w-[clamp(22rem,34vw,31rem)] md:translate-y-[8%] lg:bottom-[8svh] lg:w-[min(28vw,21rem)] lg:translate-y-0 [@media_(orientation:landscape)_and_(max-height:560px)]:bottom-[-10svh] [@media_(orientation:landscape)_and_(max-height:560px)]:left-[58%] [@media_(orientation:landscape)_and_(max-height:560px)]:w-[clamp(15rem,27vw,20rem)] [@media_(orientation:landscape)_and_(max-height:560px)]:translate-y-[10%]", + idleSrc: KHIX_LENNY_IDLE_WEBP, + kind: "image", + mediaClassName: "h-auto w-full select-none", + mediaStyle: { + filter: "saturate(1.08) drop-shadow(0 18px 28px rgba(0,0,0,0.38))", + }, + motion: { + facesStepDirection: true, + turnDurationMs: 260, + }, + nativeSize: { + height: 960, + width: 740, + }, + src: KHIX_LENNY_IDLE_WEBP, + space: "viewport", + zIndex: 2, + }, + { + id: "khix-foreground", + kind: "image", + nativeSize: KHIX_SCENE_SIZE, + sources: [ + { + mimeType: "image/webp", + src: KHIX_FOREGROUND_WEBP, + }, + ], + src: KHIX_FOREGROUND_WEBP, + space: "scene", + zIndex: 3, + }, + ], + mode: "dynamic", + overlayClassName: + "bg-[linear-gradient(90deg,rgba(8,4,14,0.76)_0%,rgba(8,4,14,0.54)_45%,rgba(8,4,14,0.3)_100%)]", + questionTransitionMs: 900, + showStockEffects: false, + stepTransitionMs: 1500, + styles: khixApplicationStyles, + transitionMs: 1500, +} satisfies ApplicationVisualConfig; diff --git a/apps/blade/src/app/_components/dashboard/hacker/hackbackgrounds/types.ts b/apps/blade/src/app/_components/dashboard/hacker/hackbackgrounds/types.ts new file mode 100644 index 000000000..919a6e1b6 --- /dev/null +++ b/apps/blade/src/app/_components/dashboard/hacker/hackbackgrounds/types.ts @@ -0,0 +1,68 @@ +import type { CSSProperties } from "react"; + +export type ApplicationVisualMode = "static" | "dynamic"; +export type ApplicationVisualLayerKind = "image" | "video"; +export type ApplicationVisualLayerSpace = "scene" | "viewport"; + +export interface BackgroundSize { + height: number; + width: number; +} + +export interface ApplicationVisualLayerSource { + media?: string; + mimeType?: string; + src: string; +} + +export interface ApplicationVisualLayerMotion { + facesStepDirection?: boolean; + turnDurationMs?: number; +} + +export interface ApplicationVisualLayer { + id: string; + kind: ApplicationVisualLayerKind; + src: string; + alt?: string; + animatedSrc?: string; + className?: string; + idleSrc?: string; + mediaClassName?: string; + mediaStyle?: CSSProperties; + mimeType?: string; + motion?: ApplicationVisualLayerMotion; + nativeSize?: BackgroundSize; + opacity?: number; + parallax?: number; + playbackRate?: number; + preload?: "auto" | "metadata" | "none"; + sources?: readonly ApplicationVisualLayerSource[]; + space?: ApplicationVisualLayerSpace; + style?: CSSProperties; + zIndex?: number; +} + +export interface ApplicationVisualAmbientLayer { + id: string; + className: string; + parallax?: number; + space?: ApplicationVisualLayerSpace; + style?: CSSProperties; + zIndex?: number; +} + +export interface ApplicationVisualConfig { + key: string; + label: string; + ambientLayers?: readonly ApplicationVisualAmbientLayer[]; + baseLayerId?: string; + layers?: readonly ApplicationVisualLayer[]; + mode: ApplicationVisualMode; + overlayClassName?: string; + questionTransitionMs?: number; + showStockEffects?: boolean; + stepTransitionMs?: number; + styles?: string; + transitionMs?: number; +} diff --git a/apps/blade/src/app/_components/dashboard/hacker/hacker-application-background.tsx b/apps/blade/src/app/_components/dashboard/hacker/hacker-application-background.tsx new file mode 100644 index 000000000..bff93ea27 --- /dev/null +++ b/apps/blade/src/app/_components/dashboard/hacker/hacker-application-background.tsx @@ -0,0 +1,483 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; + +import { cn } from "@forge/ui"; + +import type { + ApplicationVisualAmbientLayer, + ApplicationVisualLayer, + ApplicationVisualMode, + BackgroundSize, +} from "./hackbackgrounds/types"; +import { getHackerApplicationBackground } from "./hackbackgrounds"; + +type StepDirection = "forward" | "back"; + +interface BackgroundFrame { + endX: number; + height: number; + startX: number; + width: number; +} + +interface LayerState { + failedLayerIds: Set; + layerSizes: Record; + visualKey: string; +} + +const EMPTY_VISUAL_LAYERS: readonly ApplicationVisualLayer[] = []; +const EMPTY_AMBIENT_LAYERS: readonly ApplicationVisualAmbientLayer[] = []; + +function isValidBackgroundSize( + size: BackgroundSize | null | undefined, +): size is BackgroundSize { + return ( + !!size && + Number.isFinite(size.width) && + Number.isFinite(size.height) && + size.width > 0 && + size.height > 0 + ); +} + +function getCoverBackgroundFrame({ + image, + viewport, +}: { + image: BackgroundSize; + viewport: BackgroundSize; +}): BackgroundFrame { + const coverScale = Math.max( + viewport.width / image.width, + viewport.height / image.height, + ); + const width = image.width * coverScale; + const height = image.height * coverScale; + const endX = Math.min(0, viewport.width - width); + + return { + endX, + height, + startX: 0, + width, + }; +} + +function getInitialLayerSizes(layers: readonly ApplicationVisualLayer[]) { + const sizes: Record = {}; + + for (const layer of layers) { + if (layer.nativeSize) { + sizes[layer.id] = layer.nativeSize; + } + } + + return sizes; +} + +function getFreshLayerState( + visualKey: string, + layers: readonly ApplicationVisualLayer[], +): LayerState { + return { + failedLayerIds: new Set(), + layerSizes: getInitialLayerSizes(layers), + visualKey, + }; +} + +function clampProgress(progress: number) { + if (!Number.isFinite(progress)) return 0; + return Math.min(Math.max(progress, 0), 1); +} + +function getFrameTranslateX({ + frame, + mode, + progress, +}: { + frame: BackgroundFrame; + mode: ApplicationVisualMode; + progress: number; +}) { + if (mode === "static") { + return frame.endX / 2; + } + + return frame.startX + (frame.endX - frame.startX) * progress; +} + +export function HackerApplicationBackground({ + backgroundKey, + isTransitioning = false, + progress, + transitionDirection = "forward", +}: { + backgroundKey?: string | null; + isTransitioning?: boolean; + progress: number; + transitionDirection?: StepDirection; +}) { + const viewportRef = useRef(null); + const visualConfig = getHackerApplicationBackground(backgroundKey); + const layers = visualConfig.layers ?? EMPTY_VISUAL_LAYERS; + const ambientLayers = visualConfig.ambientLayers ?? EMPTY_AMBIENT_LAYERS; + const primaryLayer = + layers.find((layer) => layer.id === visualConfig.baseLayerId) ?? + layers.find((layer) => (layer.space ?? "scene") === "scene") ?? + layers[0]; + const [layerState, setLayerState] = useState(() => + getFreshLayerState(visualConfig.key, layers), + ); + const [viewportSize, setViewportSize] = useState(null); + const activeLayerState = + layerState.visualKey === visualConfig.key + ? layerState + : getFreshLayerState(visualConfig.key, layers); + const { failedLayerIds, layerSizes } = activeLayerState; + + useEffect(() => { + const viewport = viewportRef.current; + if (!viewport) return; + + const updateViewportSize = () => { + const rect = viewport.getBoundingClientRect(); + const width = rect.width || window.innerWidth; + const height = rect.height || window.innerHeight; + + setViewportSize({ + height, + width, + }); + }; + + updateViewportSize(); + + const observer = new ResizeObserver(updateViewportSize); + observer.observe(viewport); + window.addEventListener("resize", updateViewportSize); + window.visualViewport?.addEventListener("resize", updateViewportSize); + + return () => { + observer.disconnect(); + window.removeEventListener("resize", updateViewportSize); + window.visualViewport?.removeEventListener("resize", updateViewportSize); + }; + }, [visualConfig.key]); + + const primaryLayerFailed = + !!primaryLayer && failedLayerIds.has(primaryLayer.id); + const hasCustomVisual = layers.length > 0 && !!primaryLayer; + const canRenderCustomVisual = hasCustomVisual && !primaryLayerFailed; + const primaryLayerSize = primaryLayer + ? (layerSizes[primaryLayer.id] ?? primaryLayer.nativeSize) + : null; + const frame = + isValidBackgroundSize(primaryLayerSize) && + isValidBackgroundSize(viewportSize) + ? getCoverBackgroundFrame({ + image: primaryLayerSize, + viewport: viewportSize, + }) + : null; + const safeProgress = clampProgress(progress); + const translateX = frame + ? getFrameTranslateX({ + frame, + mode: visualConfig.mode, + progress: safeProgress, + }) + : 0; + const transition = `${visualConfig.transitionMs ?? 620}ms cubic-bezier(0.22, 1, 0.36, 1)`; + const showStockEffects = + !canRenderCustomVisual || visualConfig.showStockEffects === true; + + const setLayerSize = (layerId: string, size: BackgroundSize) => { + setLayerState((current) => { + const baseState = + current.visualKey === visualConfig.key + ? current + : getFreshLayerState(visualConfig.key, layers); + + return { + ...baseState, + layerSizes: { + ...baseState.layerSizes, + [layerId]: size, + }, + }; + }); + }; + + const markLayerFailed = (layerId: string) => { + setLayerState((current) => { + const baseState = + current.visualKey === visualConfig.key + ? current + : getFreshLayerState(visualConfig.key, layers); + + if (baseState.failedLayerIds.has(layerId)) return current; + + const failedLayerIds = new Set(baseState.failedLayerIds); + failedLayerIds.add(layerId); + return { + ...baseState, + failedLayerIds, + }; + }); + }; + + const getLayerMediaStyle = (layer: ApplicationVisualLayer) => { + const shouldFaceBackward = + isTransitioning && + transitionDirection === "back" && + layer.motion?.facesStepDirection === true; + const turnDurationMs = layer.motion?.turnDurationMs ?? 220; + const transform = [ + layer.mediaStyle?.transform, + shouldFaceBackward ? "scaleX(-1)" : "scaleX(1)", + ] + .filter(Boolean) + .join(" "); + const transition = [ + layer.mediaStyle?.transition, + `transform ${turnDurationMs}ms cubic-bezier(0.22, 1, 0.36, 1)`, + ] + .filter(Boolean) + .join(", "); + + return { + ...layer.mediaStyle, + ...(layer.motion?.facesStepDirection + ? { + transform, + transition, + willChange: "transform", + } + : {}), + }; + }; + + const renderLayerMedia = (layer: ApplicationVisualLayer) => { + const isPrimaryLayer = layer.id === primaryLayer?.id; + const layerSrc = + isTransitioning && layer.animatedSrc + ? layer.animatedSrc + : (layer.idleSrc ?? layer.src); + const layerMediaStyle = getLayerMediaStyle(layer); + const layerSources = + layerSrc === layer.src && layer.sources + ? layer.sources + : [{ mimeType: layer.mimeType, src: layerSrc }]; + + if (layer.kind === "video") { + return ( + + ); + } + + const imageElement = ( + // eslint-disable-next-line @next/next/no-img-element -- Supports arbitrary R2 image URLs while reading natural dimensions for pan math. + {layer.alt { + markLayerFailed(layer.id); + }} + onLoad={(event) => { + if ( + event.currentTarget.naturalWidth <= 0 || + event.currentTarget.naturalHeight <= 0 + ) { + return; + } + + setLayerSize(layer.id, { + height: event.currentTarget.naturalHeight, + width: event.currentTarget.naturalWidth, + }); + }} + /> + ); + + if (!layer.sources?.length || layerSrc !== layer.src) { + return imageElement; + } + + return ( + + {layer.sources.map((source) => ( + + ))} + {imageElement} + + ); + }; + + const renderSceneLayer = (layer: ApplicationVisualLayer) => { + const parallax = layer.parallax ?? 1; + + return ( +
+ {renderLayerMedia(layer)} +
+ ); + }; + + const renderViewportLayer = (layer: ApplicationVisualLayer) => ( +
+ {renderLayerMedia(layer)} +
+ ); + + const renderSceneAmbientLayer = (layer: ApplicationVisualAmbientLayer) => { + const parallax = layer.parallax ?? 1; + + return ( +
+ ); + }; + + const renderViewportAmbientLayer = (layer: ApplicationVisualAmbientLayer) => ( +
+ ); + + return ( + <> + {canRenderCustomVisual && ( +