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..d16512b0c --- /dev/null +++ b/apps/blade/src/app/_components/admin/hackathon/manage/hackathon-manager.tsx @@ -0,0 +1,647 @@ +"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 { 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, + getHackathonBackgroundIssues, + getHackathonDateWindowIssues, + hackathonDisplayNameSchema, + hackathonRouteNameSchema, + hackathonThemeSchema, +} from "@forge/validators"; + +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 hackathonApplicationBackgroundKeySchema = + createHackathonApplicationBackgroundKeySchema( + HACKATHONS.APPLICATION_BACKGROUND_KEYS, + ); + +function getSafeBackgroundKey( + backgroundKey?: string | null, +): ApplicationBackgroundKey { + return BACKGROUND_OPTIONS.some( + (background) => background.key === backgroundKey, + ) + ? (backgroundKey as ApplicationBackgroundKey) + : DEFAULT_BACKGROUND_KEY; +} + +const formSchema = z + .object({ + name: hackathonRouteNameSchema, + displayName: hackathonDisplayNameSchema, + theme: hackathonThemeSchema, + applicationBackgroundEnabled: z.boolean(), + applicationBackgroundKey: hackathonApplicationBackgroundKeySchema, + 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 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, + ), + 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, + 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, + 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 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 background preset for each hackathon. +

+
+ +
+ +
+ + + + Hackathon + Application + Event Dates + Background + 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 + )} + + +
+ + +
+
+
+ )) + )} +
+
+
+ + { + 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 + + + + )} + /> +
+ + + + + +
+ +
+
+
+ ); +} 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{" "} ; + +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..62a9b3a46 --- /dev/null +++ b/apps/blade/src/app/_components/dashboard/hacker/hackbackgrounds/types.ts @@ -0,0 +1,67 @@ +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 { + 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..293695a98 --- /dev/null +++ b/apps/blade/src/app/_components/dashboard/hacker/hacker-application-background.tsx @@ -0,0 +1,475 @@ +"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 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 && ( +