diff --git a/src/app/wrapped/client.tsx b/src/app/wrapped/client.tsx new file mode 100644 index 00000000..e7f5de49 --- /dev/null +++ b/src/app/wrapped/client.tsx @@ -0,0 +1,7 @@ +"use client"; + +import { WrappedExperience } from "./components/wrapped-experience"; + +export function WrappedPageClient() { + return ; +} diff --git a/src/app/wrapped/components/count-up.tsx b/src/app/wrapped/components/count-up.tsx new file mode 100644 index 00000000..6a57d1cc --- /dev/null +++ b/src/app/wrapped/components/count-up.tsx @@ -0,0 +1,87 @@ +"use client"; + +import type { CSSProperties } from "react"; +import { useEffect, useState } from "react"; + +import { formatValue } from "../format"; +import type { WrappedFormat } from "../format"; +import { usePrefersReducedMotion } from "../use-prefers-reduced-motion"; +import { ENTER_MS } from "../wrapped.config"; + +function easeOutCubic(t: number): number { + const clamped = Math.max(0, Math.min(1, t)); + return 1 - (1 - clamped) ** 3; +} + +/** + * Animates a number from 0 → `value` over `ENTER_MS` on mount. Because each + * slide is keyed by index, this remounts (and replays) whenever its slide + * becomes active. Honours `prefers-reduced-motion` by snapping to the final value. + */ +function useCountUp(value: number): number { + const reduced = usePrefersReducedMotion(); + const [current, setCurrent] = useState(() => (reduced ? value : 0)); + + useEffect(() => { + if (reduced) { + setCurrent(value); + return; + } + let raf = 0; + const start = performance.now(); + const tick = (t: number) => { + const eased = easeOutCubic((t - start) / ENTER_MS); + setCurrent(value * eased); + if (eased < 1) { + raf = requestAnimationFrame(tick); + } + }; + raf = requestAnimationFrame(tick); + return () => { + cancelAnimationFrame(raf); + }; + }, [value, reduced]); + + return current; +} + +interface CountUpProps { + value: number; + format?: WrappedFormat; + className?: string; + style?: CSSProperties; +} + +export function CountUp({ + value, + format = "int", + className, + style, +}: CountUpProps) { + const current = useCountUp(value); + return ( + + {formatValue(current, format)} + + ); +} + +interface TimeCountUpProps { + minutes: number; + className?: string; + style?: CSSProperties; +} + +/** Animated study-time, split across two lines: hours then minutes. */ +export function TimeCountUp({ minutes, className, style }: TimeCountUpProps) { + const current = useCountUp(minutes); + const total = Math.round(current); + const hours = Math.floor(total / 60); + const mins = total % 60; + return ( + + {String(hours)} godz + {String(mins)} min + + ); +} diff --git a/src/app/wrapped/components/empty-state.tsx b/src/app/wrapped/components/empty-state.tsx new file mode 100644 index 00000000..42ec57d1 --- /dev/null +++ b/src/app/wrapped/components/empty-state.tsx @@ -0,0 +1,103 @@ +import Link from "next/link"; + +import type { WrappedSeason } from "@/types/wrapped"; + +/** Shown when the user hasn't generated enough activity for a Wrapped yet. */ +export function WrappedEmptyState({ + isGlobal = false, + season, +}: { + isGlobal?: boolean; + season: WrappedSeason | null; +}) { + const yearLabel = season?.year_label ?? "semestru"; + + return ( +
+
+ Wrapped {yearLabel} +
+

+ Brak +
+ aktywności +

+

+ {isGlobal + ? "W tym semestrze nie ma jeszcze aktywności, z której można złożyć globalne Wrapped." + : "Dla tego semestru nie mamy danych do Twojego Wrapped, więc nie da się go już wygenerować za ten okres. Korzystaj z Testownika w następnym semestrze, a wtedy przygotujemy podsumowanie."} + {isGlobal ? null : ( + <> + {" "} + + Bez aktywności w danym semestrze nie mamy z czego go złożyć. + + + )} +

+
+ + Przejdź do quizów → + +
+
+ ); +} diff --git a/src/app/wrapped/components/wrapped-chrome.tsx b/src/app/wrapped/components/wrapped-chrome.tsx new file mode 100644 index 00000000..ead4f0ca --- /dev/null +++ b/src/app/wrapped/components/wrapped-chrome.tsx @@ -0,0 +1,128 @@ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +import Image from "next/image"; + +import LogoFullDark from "@/assets/logo-full-dark.svg"; +import LogoFull from "@/assets/logo-full.svg"; + +import type { ProgressStyle } from "../wrapped.config"; +import { WrappedProgress } from "./wrapped-progress"; + +interface WrappedChromeProps { + progressStyle: ProgressStyle; + idx: number; + count: number; + yearLabel: string; + /** True when the current slide background is dark (drives logo colour). */ + dark: boolean; + onSeek: (index: number) => void; + onSkip: () => void; + /** Logo tap — wired to the theme easter egg. */ + onLogoClick?: () => void; +} + +/** Top chrome: progress indicator, brand logo + pill, and skip button. */ +export function WrappedChrome({ + progressStyle, + idx, + count, + yearLabel, + dark, + onSeek, + onSkip, + onLogoClick, +}: WrappedChromeProps) { + const showSkip = idx < count - 1; + + return ( +
+ +
+ + + + Wrapped {yearLabel} + + + {showSkip ? ( + + ) : null} +
+
+ ); +} diff --git a/src/app/wrapped/components/wrapped-decorations.tsx b/src/app/wrapped/components/wrapped-decorations.tsx new file mode 100644 index 00000000..8bf847b2 --- /dev/null +++ b/src/app/wrapped/components/wrapped-decorations.tsx @@ -0,0 +1,51 @@ +import type { CSSProperties } from "react"; + +import { buildDecorations } from "../decorations"; +import type { DecorationName } from "../wrapped.config"; + +interface WrappedDecorationsProps { + kind: DecorationName; + /** Current slide foreground colour (decorations key off it). */ + fg: string; + danger: string; + idx: number; +} + +function decorationKey(style: CSSProperties): string { + return Object.entries(style) + .map(([property, value]) => `${property}:${String(value)}`) + .join(";"); +} + +/** Absolutely-positioned decorative layer behind the slide content. */ +export function WrappedDecorations({ + kind, + fg, + danger, + idx, +}: WrappedDecorationsProps) { + if (kind === "none") { + return null; + } + const items = buildDecorations(kind, fg, idx, danger); + + return ( +
+ {items.map((style) => ( +
+ ))} +
+ ); +} diff --git a/src/app/wrapped/components/wrapped-experience.tsx b/src/app/wrapped/components/wrapped-experience.tsx new file mode 100644 index 00000000..0358894d --- /dev/null +++ b/src/app/wrapped/components/wrapped-experience.tsx @@ -0,0 +1,159 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { useContext, useRef, useState } from "react"; +import { toast } from "sonner"; + +import { AppContext } from "@/app-context"; +import { LoginPrompt } from "@/components/login-prompt"; +import { Button } from "@/components/ui/button"; +import { getWrappedService } from "@/services"; + +import { wrappedFontVariables } from "../fonts"; +import type { WrappedSettings } from "../wrapped.config"; +import { + LOGO_EASTER_EGG_CLICKS, + LOGO_EASTER_EGG_WINDOW_MS, + WRAPPED_DEFAULTS, + pickRandomSettings, +} from "../wrapped.config"; +import "../wrapped.css"; +import { WrappedStory } from "./wrapped-story"; + +function WrappedFrame({ children }: { children: React.ReactNode }) { + return ( +
{children}
+ ); +} + +function WrappedCard({ children }: { children: React.ReactNode }) { + return ( + +
+
+ {children} +
+
+
+ ); +} + +function WrappedLoading() { + return ( + + + + ); +} + +export function WrappedExperience({ mode }: { mode: "user" | "global" }) { + const { isAuthenticated } = useContext(AppContext); + + // Theme settings: defaults < easter-egg (temporary React state). + const [easterSettings, setEasterSettings] = + useState | null>(null); + const settings: WrappedSettings = { + ...WRAPPED_DEFAULTS, + ...easterSettings, + }; + + // Logo easter egg: 5 quick clicks → random theme (not persisted). + const clicks = useRef({ count: 0, last: 0 }); + const onLogoClick = () => { + const now = Date.now(); + const info = clicks.current; + if (now - info.last > LOGO_EASTER_EGG_WINDOW_MS) { + info.count = 0; + } + info.count += 1; + info.last = now; + if (info.count >= LOGO_EASTER_EGG_CLICKS) { + info.count = 0; + const random = pickRandomSettings(); + setEasterSettings(random); + toast.success( + `Nowy motyw: ${random.palette} · ${random.decoration} · ${random.progress} ✨`, + ); + } + }; + + const { data, isPending, error, refetch } = useQuery({ + queryKey: ["wrapped", mode], + queryFn: async () => + mode === "global" + ? getWrappedService().getGlobalWrapped() + : getWrappedService().getWrapped(), + enabled: isAuthenticated, + staleTime: 5 * 60 * 1000, + }); + + if (!isAuthenticated) { + return ; + } + + if (isPending) { + return ; + } + + if (error !== null) { + const isUnavailable = + error instanceof Error && + (error.message.includes("Wrapped is not available") || + error.message.includes("(404)")); + + if (isUnavailable) { + return ( + +

+ Testownik Wrapped jest obecnie niedostępny. +

+
+ ); + } + + return ( +
+

Nie udało się wczytać Wrapped.

+ +
+ ); + } + + return ( + + + + ); +} diff --git a/src/app/wrapped/components/wrapped-progress.tsx b/src/app/wrapped/components/wrapped-progress.tsx new file mode 100644 index 00000000..974a4c74 --- /dev/null +++ b/src/app/wrapped/components/wrapped-progress.tsx @@ -0,0 +1,156 @@ +import type { CSSProperties } from "react"; + +import type { ProgressStyle } from "../wrapped.config"; + +interface WrappedProgressProps { + style: ProgressStyle; + idx: number; + count: number; + onSeek: (index: number) => void; +} + +/** + * Story progress indicator. The active segment's fill is driven by the live + * `--wr-progress` CSS variable (written each frame by the player), so it + * animates without re-rendering. + */ +export function WrappedProgress({ + style, + idx, + count, + onSeek, +}: WrappedProgressProps) { + if (style === "none") { + return null; + } + + if (style === "numbers") { + const label = `${String(idx + 1).padStart(2, "0")} / ${String(count).padStart(2, "0")}`; + return ( +
+ + {label} + +
+
+
+
+ ); + } + + const isDots = style === "dots"; + const segments = Array.from({ length: count }, (_, index) => index); + + const fillTransform = (index: number): string => { + const axis = isDots ? "scale" : "scaleX"; + if (index < idx) { + return `${axis}(1)`; + } + if (index === idx) { + return `${axis}(var(--wr-progress, 0))`; + } + return `${axis}(0)`; + }; + + const rowStyle: CSSProperties = isDots + ? { display: "flex", gap: "6px", alignItems: "center" } + : { display: "flex", gap: "5px", alignItems: "center" }; + + return ( +
+ {segments.map((index) => ( + + ))} +
+ ); +} diff --git a/src/app/wrapped/components/wrapped-story.tsx b/src/app/wrapped/components/wrapped-story.tsx new file mode 100644 index 00000000..f140fe1c --- /dev/null +++ b/src/app/wrapped/components/wrapped-story.tsx @@ -0,0 +1,246 @@ +"use client"; + +import type { CSSProperties } from "react"; +import { useRef } from "react"; + +import type { WrappedData, WrappedStoryData } from "@/types/wrapped"; +import { isWrappedStoryData } from "@/types/wrapped"; + +import { shareWrapped } from "../share-image"; +import type { ShareTheme } from "../share-image"; +import { SLIDE_REGISTRY } from "../slides"; +import { isDark, slideColors } from "../theme"; +import { useWrappedPlayer } from "../use-wrapped-player"; +import type { WrappedSettings } from "../wrapped.config"; +import { DEFAULT_DURATION, PALETTES, resolveSlides } from "../wrapped.config"; +import { WrappedEmptyState } from "./empty-state"; +import { WrappedChrome } from "./wrapped-chrome"; +import { WrappedDecorations } from "./wrapped-decorations"; + +type StageStyle = CSSProperties & Record<`--${string}`, string>; + +function cssVariable( + style: CSSStyleDeclaration, + name: string, + fallback: string, +): string { + return style.getPropertyValue(name).trim() || fallback; +} + +/** Wrap the stage in the centred phone-sized shell. */ +function Shell({ children }: { children: React.ReactNode }) { + return ( +
+
{children}
+
+ ); +} + +function StoryPlayer({ + data, + settings, + onLogoClick, +}: { + data: WrappedStoryData; + settings: WrappedSettings; + onLogoClick?: () => void; +}) { + const slides = resolveSlides(data); + const count = slides.length; + const palette = PALETTES[settings.palette]; + + const getDuration = (target: number) => + slides[target]?.duration ?? DEFAULT_DURATION; + + const player = useWrappedPlayer({ count, getDuration }); + const sharing = useRef(false); + + const index = Math.min(player.idx, count - 1); + const colors = slideColors(palette, index); + const slideId = slides[index].id; + const Slide = SLIDE_REGISTRY[slideId]; + const isEdgeSlide = index === 0 || index === count - 1; + + const onShare = () => { + if (sharing.current) { + return; + } + sharing.current = true; + + const computed = + player.stageRef.current === null + ? null + : getComputedStyle(player.stageRef.current); + const shareTheme: ShareTheme = + computed === null + ? { + bg: colors.bg, + fg: colors.fg, + accent: colors.accent, + danger: colors.danger, + decoration: settings.decoration, + slideIndex: index, + } + : { + bg: cssVariable(computed, "--slide-bg", colors.bg), + fg: cssVariable(computed, "--slide-fg", colors.fg), + accent: cssVariable(computed, "--hl", colors.accent), + danger: cssVariable(computed, "--red", colors.danger), + decoration: settings.decoration, + slideIndex: index, + }; + + void shareWrapped(data, shareTheme).finally(() => { + sharing.current = false; + }); + }; + + const onRestart = () => { + player.goto(0); + }; + + const stageStyle: StageStyle = { + flex: 1, + position: "relative", + overflow: "hidden", + borderRadius: "18px", + background: colors.bg, + color: colors.fg, + boxShadow: "0 30px 80px -20px rgba(0,0,0,.5)", + "--slide-bg": colors.bg, + "--slide-fg": colors.fg, + "--hl": colors.accent, + "--pen": colors.accent, + "--red": colors.danger, + "--paper": "#ffffff", + "--fd": "var(--font-display)", + "--fm": "var(--font-sans)", + }; + + return ( + +
+ + + {/* Readability vignette: tints the edges so text stays legible over + decorations without muddying the centre. */} +
+ + { + player.goto(count - 1); + }} + onLogoClick={onLogoClick} + /> + + {/* Tap zones: left = back (32%), right = forward (68%). */} +
+
+ + {/* Slide canvas — keyed by index so entrance animations replay. */} +
+ +
+
+ + ); +} + +export function WrappedStory({ + data, + settings, + onLogoClick, +}: { + data: WrappedData; + settings: WrappedSettings; + onLogoClick?: () => void; +}) { + if (!isWrappedStoryData(data)) { + return ( + +
+ +
+
+ ); + } + + return ( + + ); +} diff --git a/src/app/wrapped/decorations.ts b/src/app/wrapped/decorations.ts new file mode 100644 index 00000000..5802f14c --- /dev/null +++ b/src/app/wrapped/decorations.ts @@ -0,0 +1,292 @@ +import type { CSSProperties } from "react"; + +import { createRng, shuffle, withAlpha } from "./theme"; +import type { DecorationName } from "./wrapped.config"; + +/** A custom-property-friendly style object. */ +type DecoStyle = CSSProperties & Record<`--${string}`, string | number>; + +const STAR = + "polygon(50% 0,60% 40%,100% 50%,60% 60%,50% 100%,40% 60%,0 50%,40% 40%)"; + +const CONFETTI_COLORS = [ + "#ff5147", + "#c6ff3a", + "#19d3da", + "#ffb020", + "#ff3d8b", + "#7b2dff", +]; + +// Small CSS-unit helpers — keep numeric interpolation out of template literals. +const toPx = (v: number): string => `${String(v)}px`; +const toPct = (v: number): string => `${String(v)}%`; +const toDeg = (v: number): string => `rotate(${String(v)}deg)`; + +/** Wrap an SVG markup string into a `url(data:…)` value. */ +function svgUri(svg: string): string { + return `url("data:image/svg+xml,${encodeURIComponent(svg)}")`; +} + +/** + * Build the decorative layer for a slide. Deterministic per `index` (seeded), so + * the layout is stable across re-renders but differs from slide to slide. + * + * Ported from the original Spotify-Wrapped prototype. Returns plain style + * objects to be spread onto absolutely-positioned `
`s. + */ +export function buildDecorations( + kind: DecorationName, + fg: string, + index: number, + danger: string, +): DecoStyle[] { + const out: DecoStyle[] = []; + const slideIndex = Math.trunc(index); + const seed = (slideIndex * 1297 + 71) >>> 0; + + switch (kind) { + case "sparks": { + const r = createRng(seed + 7); + for (let index_ = 0; index_ < 16; index_++) { + const sz = 9 + r() * 18; + out.push({ + position: "absolute", + left: toPct(r() * 92), + top: toPct(r() * 92), + width: toPx(sz), + height: toPx(sz), + background: fg, + clipPath: STAR, + opacity: Number((0.14 + r() * 0.26).toFixed(2)), + transform: toDeg(Math.trunc(r() * 90)), + }); + } + break; + } + case "confetti": { + const r = createRng(seed + 13); + for (let index_ = 0; index_ < 24; index_++) { + out.push({ + position: "absolute", + left: toPct(r() * 94), + top: toPct(r() * 94), + width: "7px", + height: "14px", + background: CONFETTI_COLORS[Math.trunc(r() * CONFETTI_COLORS.length)], + opacity: 0.6, + borderRadius: "1px", + transform: toDeg(Math.trunc(r() * 180)), + }); + } + break; + } + case "blobs": { + const r = createRng(seed + 21); + for (let index_ = 0; index_ < 6; index_++) { + const sz = 150 + r() * 200; + out.push({ + position: "absolute", + left: toPct(r() * 80 - 12), + top: toPct(r() * 80 - 12), + width: toPx(sz), + height: toPx(sz), + background: fg, + opacity: 0.08, + borderRadius: "42% 58% 63% 37% / 41% 44% 56% 59%", + filter: "blur(10px)", + }); + } + break; + } + case "summer": { + // Real summer illustrations (OpenMoji, CC BY-SA 4.0), shuffled per slide. + const base = + "https://cdn.jsdelivr.net/gh/hfg-gmuend/openmoji@15.0.0/color/svg/"; + const r = createRng(seed + 33); + const pool = shuffle( + [ + "2600", + "1F334", + "1F349", + "1F379", + "1F576", + "1F366", + "1F33A", + "1F34D", + "1F3D6", + "1F41A", + ], + r, + ); + // Sit at the frame edges — visible, but the content vignette keeps them + // from competing with the central text. + const slots: { c: CSSProperties; big?: boolean; spin?: boolean }[] = [ + { c: { top: "6%", right: "2%" }, big: true, spin: true }, + { + c: { bottom: "1%", right: "2%", transformOrigin: "bottom center" }, + big: true, + }, + { c: { bottom: "3%", left: "2%" } }, + { c: { top: "8%", left: "1%" } }, + { c: { bottom: "18%", right: "3%" } }, + { c: { bottom: "14%", left: "3%" } }, + ]; + for (const [index_, slot] of slots.entries()) { + const code = pool[index_ % pool.length]; + const sz = (slot.big === true ? 100 : 52) + Math.trunc(r() * 14); + const anim = + slot.spin === true + ? `wr-spin ${String(70 + Math.trunc(r() * 22))}s linear infinite` + : `${r() < 0.5 ? "wr-float " : "wr-sway "}${(4.6 + r() * 2).toFixed(1)}s ease-in-out ${(r() * 0.9).toFixed(1)}s infinite`; + out.push({ + position: "absolute", + backgroundImage: `url('${base}${code}.svg')`, + backgroundRepeat: "no-repeat", + backgroundPosition: "center", + backgroundSize: "contain", + width: toPx(sz), + height: toPx(sz), + "--r": `${String(Math.trunc(r() * 16 - 8))}deg`, + filter: "drop-shadow(0 6px 14px rgba(0,0,0,.18))", + pointerEvents: "none", + animation: anim, + ...slot.c, + }); + } + for (let index_ = 0; index_ < 3; index_++) { + const sz = 16 + r() * 10; + out.push({ + position: "absolute", + left: `${(14 + r() * 64).toFixed(1)}%`, + top: `${(15 + r() * 30).toFixed(1)}%`, + width: toPx(sz), + height: toPx(sz), + backgroundImage: `url('${base}2728.svg')`, + backgroundRepeat: "no-repeat", + backgroundPosition: "center", + backgroundSize: "contain", + opacity: 0.8, + animation: `wr-twinkle ${(2.4 + r()).toFixed(1)}s ease-in-out ${r().toFixed(1)}s infinite`, + }); + } + break; + } + case "wrapped": { + // Liquid gradient blobs + rainbow ribbon + pixel squiggle. + const r = createRng(seed + 51); + const cols = [ + ["#7b2dff", "#19d3da"], + ["#ff5147", "#ffb020"], + ["#c6ff3a", "#19d3da"], + ["#ff3d8b", "#7b2dff"], + ["#ffb020", "#ff3d8b"], + ]; + const blobPos: CSSProperties[] = [ + { top: "-8%", left: "-8%" }, + { bottom: "-10%", right: "-8%" }, + { top: "34%", right: "-12%" }, + ]; + for (const [index_, p] of blobPos.entries()) { + const c = cols[(slideIndex + index_) % cols.length]; + const sz = 220 + Math.trunc(r() * 130); + out.push({ + position: "absolute", + width: toPx(sz), + height: toPx(sz), + borderRadius: "50%", + background: `radial-gradient(circle at 35% 35%, ${c[0]}, ${c[1]} 68%, transparent 72%)`, + filter: "blur(16px)", + opacity: 0.5, + animation: `wr-float ${(9 + r() * 6).toFixed(1)}s ease-in-out ${r().toFixed(1)}s infinite`, + ...p, + }); + } + const ribbon = + ""; + out.push({ + position: "absolute", + top: `${(-2 + r() * 30).toFixed(0)}%`, + right: `${(-8 + r() * 14).toFixed(0)}%`, + width: "72%", + height: "72%", + backgroundImage: svgUri(ribbon), + backgroundRepeat: "no-repeat", + backgroundPosition: "center", + backgroundSize: "contain", + opacity: 0.92, + transform: toDeg((slideIndex * 47) % 360), + }); + const pixels = + ""; + out.push({ + position: "absolute", + left: `${(1 + r() * 8).toFixed(0)}%`, + bottom: `${(2 + r() * 9).toFixed(0)}%`, + width: "62%", + height: "72px", + backgroundImage: svgUri(pixels), + backgroundRepeat: "no-repeat", + backgroundPosition: "left center", + backgroundSize: "contain", + opacity: 0.85, + }); + break; + } + case "lines": { + // Warped B&W concentric rings + scribble + red/black dot cluster. + const r = createRng(seed + 67); + const ink = fg; + const redc = danger || "#ff2d2d"; + const corners: CSSProperties[] = [ + { top: "-10%", left: "-9%" }, + { top: "-10%", right: "-9%" }, + { bottom: "-10%", left: "-9%" }, + { bottom: "-10%", right: "-9%" }, + ]; + out.push({ + position: "absolute", + width: "64%", + height: "42%", + background: `repeating-radial-gradient(circle at center, ${withAlpha(ink, 0.5)} 0 11px, transparent 11px 22px)`, + transform: `scaleX(1.35) rotate(${String(((slideIndex * 23) % 44) - 22)}deg)`, + opacity: 0.6, + ...corners[slideIndex % corners.length], + }); + const scrib = ``; + out.push({ + position: "absolute", + top: `${(8 + r() * 16).toFixed(0)}%`, + left: `${(8 + r() * 22).toFixed(0)}%`, + width: "60%", + height: "36%", + backgroundImage: svgUri(scrib), + backgroundRepeat: "no-repeat", + backgroundPosition: "center", + backgroundSize: "contain", + opacity: 0.7, + transform: toDeg(((slideIndex * 37) % 34) - 17), + }); + for (let index_ = 0; index_ < 11; index_++) { + const sz = 9 + r() * 17; + const isRed = r() < 0.45; + out.push({ + position: "absolute", + left: `${(50 + r() * 48).toFixed(1)}%`, + bottom: `${(2 + r() * 32).toFixed(1)}%`, + width: toPx(sz), + height: toPx(sz), + borderRadius: "50%", + background: isRed ? redc : withAlpha(ink, 0.8), + }); + } + break; + } + case "none": + default: { + break; + } + } + + return out; +} diff --git a/src/app/wrapped/derived.ts b/src/app/wrapped/derived.ts new file mode 100644 index 00000000..92687abb --- /dev/null +++ b/src/app/wrapped/derived.ts @@ -0,0 +1,62 @@ +import type { WrappedPersona } from "@/types/wrapped"; + +export function formatStudyDuration(seconds: number): string { + const minutes = Math.round(seconds / 60); + return `${String(Math.floor(minutes / 60))} godz ${String(minutes % 60).padStart(2, "0")} min`; +} + +export function peakHourLabel(hour: number): string { + return `${String(hour).padStart(2, "0")}:00`; +} + +export function personaForHour( + hour: number, + { isGlobal = false }: { isGlobal?: boolean } = {}, +): WrappedPersona { + if (hour >= 0 && hour <= 4) { + return { + name: "Nocny Marek", + description: isGlobal + ? "Najwięcej odpowiedzi padało głęboko w nocy." + : "Najwięcej odpowiedzi udzielasz głęboko w nocy.", + }; + } + if (hour >= 5 && hour <= 8) { + return { + name: "Ranny Ptaszek", + description: isGlobal + ? "Najwięcej odpowiedzi padało tuż po przebudzeniu." + : "Najwięcej odpowiedzi udzielasz tuż po przebudzeniu.", + }; + } + if (hour >= 9 && hour <= 11) { + return { + name: "Poranny Umysł", + description: isGlobal + ? "Testownik najlepiej rozkręcał się przed południem." + : "Najlepiej rozkręcasz się przed południem.", + }; + } + if (hour >= 12 && hour <= 16) { + return { + name: "Popołudniowy Maratończyk", + description: isGlobal + ? "Najwięcej odpowiedzi padało po południu." + : "Najwięcej odpowiedzi udzielasz po południu.", + }; + } + if (hour >= 17 && hour <= 20) { + return { + name: "Wieczorny Strateg", + description: isGlobal + ? "Najwięcej odpowiedzi padało wieczorem." + : "Najwięcej odpowiedzi udzielasz wieczorem.", + }; + } + return { + name: "Nocny Marek", + description: isGlobal + ? "Najwięcej odpowiedzi padało tuż przed północą." + : "Najwięcej odpowiedzi udzielasz tuż przed północą.", + }; +} diff --git a/src/app/wrapped/fonts.ts b/src/app/wrapped/fonts.ts new file mode 100644 index 00000000..f071b338 --- /dev/null +++ b/src/app/wrapped/fonts.ts @@ -0,0 +1,20 @@ +import { Anton, Space_Grotesk } from "next/font/google"; + +/** Big, bold display face for the headline numbers (the "Wrapped" look). */ +export const wrappedDisplay = Anton({ + weight: "400", + subsets: ["latin", "latin-ext"], + display: "swap", + variable: "--font-wrapped-display", +}); + +/** Body / label face. */ +export const wrappedSans = Space_Grotesk({ + weight: ["400", "500", "600", "700"], + subsets: ["latin", "latin-ext"], + display: "swap", + variable: "--font-wrapped-sans", +}); + +/** Class string to apply on the Wrapped root so the CSS variables resolve. */ +export const wrappedFontVariables = `${wrappedDisplay.variable} ${wrappedSans.variable}`; diff --git a/src/app/wrapped/format.ts b/src/app/wrapped/format.ts new file mode 100644 index 00000000..cbc142e8 --- /dev/null +++ b/src/app/wrapped/format.ts @@ -0,0 +1,16 @@ +/** Number formats used by the count-up animation and static labels. */ +export type WrappedFormat = "int" | "pct" | "time"; + +/** Format a value for display. `time` interprets the value as **minutes**. */ +export function formatValue(value: number, format: WrappedFormat): string { + if (format === "time") { + const minutes = Math.round(value); + const hours = Math.floor(minutes / 60); + return `${String(hours)} godz ${String(minutes % 60)} min`; + } + if (format === "pct") { + return `${String(Math.round(value))}%`; + } + // pl-PL groups with a narrow/no-break space; normalise to a plain space. + return Math.round(value).toLocaleString("pl-PL").replaceAll(/\s/g, " "); +} diff --git a/src/app/wrapped/global/page.tsx b/src/app/wrapped/global/page.tsx new file mode 100644 index 00000000..a86cdbdf --- /dev/null +++ b/src/app/wrapped/global/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from "next"; + +import { WrappedExperience } from "../components/wrapped-experience"; + +export const metadata: Metadata = { + title: "Wrapped — cały Testownik", + description: "Podsumowanie semestru całego Testownika w liczbach.", +}; + +export default function GlobalWrappedPage() { + return ; +} diff --git a/src/app/wrapped/page.tsx b/src/app/wrapped/page.tsx new file mode 100644 index 00000000..d3e955b3 --- /dev/null +++ b/src/app/wrapped/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from "next"; + +import { WrappedPageClient } from "./client"; + +export const metadata: Metadata = { + title: "Wrapped", + description: "Twój semestr w Testowniku — podsumowanie w liczbach.", +}; + +export default function WrappedPage() { + return ; +} diff --git a/src/app/wrapped/share-image.ts b/src/app/wrapped/share-image.ts new file mode 100644 index 00000000..7997e0f9 --- /dev/null +++ b/src/app/wrapped/share-image.ts @@ -0,0 +1,500 @@ +import { toast } from "sonner"; + +import { env } from "@/env"; +import type { WrappedStoryData } from "@/types/wrapped"; + +import { personaForHour } from "./derived"; +import { wrappedDisplay, wrappedSans } from "./fonts"; +import { formatValue } from "./format"; +import { createRng, isDark, withAlpha } from "./theme"; +import type { DecorationName } from "./wrapped.config"; + +const WIDTH = 1080; +const HEIGHT = 1920; +const PAD = 96; + +/** Colours the share card is drawn with (matches the current theme). */ +export interface ShareTheme { + bg: string; + fg: string; + accent: string; + danger: string; + decoration: DecorationName; + slideIndex: number; +} + +interface Tile { + value: string; + label: string; + highlight: boolean; +} + +function summaryTiles(data: WrappedStoryData): Tile[] { + const hours = Math.floor(data.study_time.total_minutes / 60); + const persona = personaForHour(data.rhythm.peak_hour, { + isGlobal: data.is_global === true, + }); + if (data.is_global === true) { + return [ + { + value: `${String(hours)} godz`, + label: "czasu nauki", + highlight: false, + }, + { + value: formatValue(data.volume.total_answers, "int"), + label: "pytań", + highlight: true, + }, + { + value: `${String(data.accuracy.percent)}%`, + label: "skuteczność", + highlight: false, + }, + { + value: formatValue(data.volume.sessions, "int"), + label: "sesji", + highlight: true, + }, + { + value: persona.name, + label: "rytm semestru", + highlight: false, + }, + { + value: data.top_quizzes[0]?.name ?? "brak danych", + label: "najdłuższy quiz", + highlight: true, + }, + ]; + } + + return [ + { value: `${String(hours)} godz`, label: "czasu nauki", highlight: false }, + { + value: formatValue(data.volume.total_answers, "int"), + label: "pytań", + highlight: true, + }, + { + value: `${String(data.accuracy.percent)}%`, + label: "skuteczność", + highlight: false, + }, + { + value: formatValue(data.volume.sessions, "int"), + label: "sesji", + highlight: true, + }, + { + value: `top ${String(data.rank.top_percent)}%`, + label: "najwytrwalszych", + highlight: false, + }, + { value: persona.name, label: "Twój typ", highlight: true }, + ]; +} + +function wrappedPath(data: WrappedStoryData): "/wrapped" | "/wrapped/global" { + return data.is_global === true ? "/wrapped/global" : "/wrapped"; +} + +function fitText( + context: CanvasRenderingContext2D, + text: string, + x: number, + y: number, + maxWidth: number, + baseSize: number, + family: string, +): void { + let size = baseSize; + context.font = `${String(size)}px ${family}`; + while (context.measureText(text).width > maxWidth && size > 18) { + size -= 2; + context.font = `${String(size)}px ${family}`; + } + context.fillText(text, x, y); +} + +function roundRect( + context: CanvasRenderingContext2D, + x: number, + y: number, + w: number, + h: number, + r: number, +): void { + context.beginPath(); + context.moveTo(x + r, y); + context.arcTo(x + w, y, x + w, y + h, r); + context.arcTo(x + w, y + h, x, y + h, r); + context.arcTo(x, y + h, x, y, r); + context.arcTo(x, y, x + w, y, r); + context.closePath(); +} + +function drawStar( + context: CanvasRenderingContext2D, + x: number, + y: number, + size: number, + color: string, + alpha: number, +): void { + context.save(); + context.translate(x, y); + context.globalAlpha = alpha; + context.fillStyle = color; + context.beginPath(); + for (let index = 0; index < 8; index++) { + const radius = index % 2 === 0 ? size : size * 0.34; + const angle = -Math.PI / 2 + (index * Math.PI) / 4; + const px = Math.cos(angle) * radius; + const py = Math.sin(angle) * radius; + if (index === 0) { + context.moveTo(px, py); + } else { + context.lineTo(px, py); + } + } + context.closePath(); + context.fill(); + context.restore(); +} + +function drawShareDecorations( + context: CanvasRenderingContext2D, + theme: ShareTheme, +): void { + if (theme.decoration === "none") { + return; + } + + const rng = createRng(theme.slideIndex * 1297 + 191); + const colors = [ + theme.accent, + theme.fg, + theme.danger, + "#19d3da", + "#ffb020", + "#7b2dff", + ]; + + context.save(); + + switch (theme.decoration) { + case "sparks": { + for (let index = 0; index < 24; index++) { + drawStar( + context, + rng() * WIDTH, + rng() * HEIGHT, + 10 + rng() * 22, + theme.accent, + 0.08 + rng() * 0.16, + ); + } + break; + } + case "confetti": { + for (let index = 0; index < 42; index++) { + context.save(); + context.translate(rng() * WIDTH, rng() * HEIGHT); + context.rotate(rng() * Math.PI); + context.globalAlpha = 0.28 + rng() * 0.22; + context.fillStyle = + colors[Math.floor(rng() * colors.length)] ?? theme.accent; + roundRect(context, -7, -16, 14, 32, 3); + context.fill(); + context.restore(); + } + break; + } + case "lines": { + context.strokeStyle = withAlpha(theme.fg, 0.18); + context.lineWidth = 12; + for (let index = 0; index < 9; index++) { + context.beginPath(); + context.ellipse( + rng() < 0.5 ? 80 : WIDTH - 80, + rng() < 0.5 ? 120 : HEIGHT - 180, + 120 + index * 38, + 72 + index * 24, + rng() * Math.PI, + 0, + Math.PI * 2, + ); + context.stroke(); + } + for (let index = 0; index < 16; index++) { + context.fillStyle = + rng() < 0.42 ? theme.danger : withAlpha(theme.fg, 0.34); + context.beginPath(); + context.arc( + WIDTH * (0.62 + rng() * 0.34), + HEIGHT * (0.72 + rng() * 0.18), + 8 + rng() * 16, + 0, + Math.PI * 2, + ); + context.fill(); + } + break; + } + case "summer": { + const sun = context.createRadialGradient( + WIDTH - 150, + HEIGHT - 210, + 20, + WIDTH - 150, + HEIGHT - 210, + 160, + ); + sun.addColorStop(0, withAlpha(theme.accent, 0.5)); + sun.addColorStop(1, withAlpha(theme.accent, 0)); + context.fillStyle = sun; + context.beginPath(); + context.arc(WIDTH - 150, HEIGHT - 210, 160, 0, Math.PI * 2); + context.fill(); + + context.strokeStyle = withAlpha(theme.fg, 0.22); + context.lineWidth = 10; + for (let index = 0; index < 3; index++) { + context.beginPath(); + context.moveTo(80, HEIGHT - 260 + index * 34); + context.bezierCurveTo( + 230, + HEIGHT - 310 + index * 34, + 330, + HEIGHT - 210 + index * 34, + 490, + HEIGHT - 260 + index * 34, + ); + context.stroke(); + } + break; + } + case "wrapped": + case "blobs": { + const blobColors = + theme.decoration === "wrapped" + ? [theme.danger, theme.accent, "#19d3da", "#7b2dff"] + : [theme.accent, theme.fg, theme.danger]; + for (let index = 0; index < 5; index++) { + const x = rng() * WIDTH; + const y = rng() * HEIGHT; + const radius = 180 + rng() * 220; + const color = blobColors[index % blobColors.length] ?? theme.accent; + const gradient = context.createRadialGradient(x, y, 20, x, y, radius); + gradient.addColorStop(0, withAlpha(color, 0.18)); + gradient.addColorStop(1, withAlpha(color, 0)); + context.fillStyle = gradient; + context.beginPath(); + context.arc(x, y, radius, 0, Math.PI * 2); + context.fill(); + } + break; + } + } + + context.restore(); +} + +/** Render the shareable summary card to a PNG blob, in the given theme. */ +export async function generateShareImage( + data: WrappedStoryData, + theme: ShareTheme, +): Promise { + if (typeof document === "undefined") { + return null; + } + const canvas = document.createElement("canvas"); + canvas.width = WIDTH; + canvas.height = HEIGHT; + const context = canvas.getContext("2d"); + if (context === null) { + return null; + } + + const display = wrappedDisplay.style.fontFamily; + const sans = wrappedSans.style.fontFamily; + try { + await document.fonts.ready; + await Promise.all([ + document.fonts.load(`96px ${display}`), + document.fonts.load(`600 36px ${sans}`), + ]); + } catch { + // proceed with whatever is available + } + + const { bg, fg, accent } = theme; + const muted = withAlpha(fg, 0.55); + const onAccent = isDark(accent) ? "#ffffff" : "#15171c"; + + // Background. + context.fillStyle = bg; + context.fillRect(0, 0, WIDTH, HEIGHT); + drawShareDecorations(context, theme); + + // Header. + context.textBaseline = "alphabetic"; + context.fillStyle = accent; + context.font = `700 34px ${sans}`; + context.fillText(`TESTOWNIK · WRAPPED ${data.season.year_label}`, PAD, 180); + + // Title. + context.fillStyle = fg; + context.font = `150px ${display}`; + context.fillText("TAKI BYŁ", PAD, 360); + context.fillText( + data.is_global === true ? "TESTOWNIK" : "MÓJ SEMESTR", + PAD, + 500, + ); + + // Stats grid (2 cols × 3 rows). + const gridTop = 660; + const gap = 26; + const tileW = (WIDTH - PAD * 2 - gap) / 2; + const tileH = 300; + const tiles = summaryTiles(data); + for (const [index, tile] of tiles.entries()) { + const col = index % 2; + const row = Math.floor(index / 2); + const x = PAD + col * (tileW + gap); + const y = gridTop + row * (tileH + gap); + + context.strokeStyle = withAlpha(fg, 0.18); + context.lineWidth = 2; + roundRect(context, x, y, tileW, tileH, 28); + context.stroke(); + + context.fillStyle = tile.highlight ? accent : fg; + fitText(context, tile.value, x + 36, y + 150, tileW - 72, 96, display); + + context.fillStyle = muted; + context.font = `500 32px ${sans}`; + context.fillText(tile.label.toUpperCase(), x + 38, y + 210); + } + + // Footer link pill. + const host = env.NEXT_PUBLIC_SITE_URL.replace(/^https?:\/\//, "").replace( + /\/$/, + "", + ); + const link = `${host}${wrappedPath(data)}`; + context.font = `700 38px ${sans}`; + const pillW = context.measureText(link).width + 96; + const pillH = 92; + const pillX = (WIDTH - pillW) / 2; + const pillY = HEIGHT - PAD - pillH; + context.fillStyle = accent; + roundRect(context, pillX, pillY, pillW, pillH, pillH / 2); + context.fill(); + context.fillStyle = onAccent; + context.textAlign = "center"; + context.fillText(link, WIDTH / 2, pillY + 60); + context.textAlign = "left"; + + return new Promise((resolve) => { + canvas.toBlob((blob) => { + resolve(blob); + }, "image/png"); + }); +} + +function shareText(data: WrappedStoryData): string { + const hours = Math.floor(data.study_time.total_minutes / 60); + if (data.is_global === true) { + return ( + `Testownik Wrapped: ${String(hours)} godz nauki, ` + + `${formatValue(data.volume.total_answers, "int")} pytań, ` + + `${String(data.accuracy.percent)}% skuteczności w całym semestrze.` + ); + } + + return ( + `Mój Testownik Wrapped: ${String(hours)} godz nauki, ` + + `${formatValue(data.volume.total_answers, "int")} pytań, ` + + `${String(data.accuracy.percent)}% skuteczności. Top ${String( + data.rank.top_percent, + )}%!` + ); +} + +/** + * Share the Wrapped summary. The link is included in the text (not as a + * separate `url` field) so targets receive the same caption/link payload in + * both native and fallback flows. + */ +export async function shareWrapped( + data: WrappedStoryData, + theme: ShareTheme, +): Promise { + const url = `${env.NEXT_PUBLIC_SITE_URL.replace(/\/$/, "")}${wrappedPath(data)}`; + const text = shareText(data); + const textWithLink = `${text} ${url}`; + const blob = await generateShareImage(data, theme); + + const nav = navigator as unknown as { + share?: (data?: ShareData) => Promise; + canShare?: (data?: ShareData) => boolean; + }; + + if (blob !== null) { + const file = new File([blob], "testownik-wrapped.png", { + type: "image/png", + }); + const shareData: ShareData = { + files: [file], + text: textWithLink, + title: "Testownik Wrapped", + }; + if (nav.canShare?.(shareData) === true && typeof nav.share === "function") { + try { + await nav.share(shareData); + return; + } catch { + // Native share may reject after the target handled/cancelled the file. + // Do not fall through, or we risk creating a duplicate image/link. + return; + } + } + downloadBlob(blob, "testownik-wrapped.png"); + void copyLink(textWithLink); + toast.success("Zapisano obrazek — link do Wrapped skopiowany!"); + return; + } + + if (typeof nav.share === "function") { + try { + await nav.share({ text: textWithLink, title: "Testownik Wrapped" }); + return; + } catch { + // fall through + } + } + void copyLink(textWithLink); + toast.success("Link do Wrapped skopiowany!"); +} + +function downloadBlob(blob: Blob, filename: string): void { + const href = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = href; + anchor.download = filename; + document.body.append(anchor); + anchor.click(); + anchor.remove(); + URL.revokeObjectURL(href); +} + +async function copyLink(value: string): Promise { + try { + await navigator.clipboard.writeText(value); + } catch { + // clipboard unavailable — best effort + } +} diff --git a/src/app/wrapped/slides/index.tsx b/src/app/wrapped/slides/index.tsx new file mode 100644 index 00000000..b6e7360a --- /dev/null +++ b/src/app/wrapped/slides/index.tsx @@ -0,0 +1,1507 @@ +"use client"; + +/* This module is a slide registry: it intentionally co-locates the slide + components with the `SLIDE_REGISTRY` lookup that the config references. + Fast-refresh's "only export components" rule doesn't apply to this pattern. */ +/* eslint-disable react-refresh/only-export-components */ +import formbricks from "@formbricks/js"; +import type { CSSProperties, ComponentType } from "react"; +import { SiGithub } from "react-icons/si"; + +import { MarkdownRenderer } from "@/components/markdown-renderer"; +import type { WrappedStoryData } from "@/types/wrapped"; + +import { CountUp, TimeCountUp } from "../components/count-up"; +import { formatStudyDuration, peakHourLabel, personaForHour } from "../derived"; +import { formatValue } from "../format"; +import { FORMBRICKS_SURVEY_ACTION, GITHUB_URL } from "../wrapped.config"; +import type { SlideId } from "../wrapped.config"; + +export interface SlideProps { + data: WrappedStoryData; + onShare: () => void; + onRestart: () => void; +} + +const FD = "var(--fd)"; +const FM = "var(--fm)"; +const HL = "var(--hl)"; +const muted = (pct: number) => + `color-mix(in srgb, currentColor ${String(pct)}%, transparent)`; + +const HOUR_BUCKETS = Array.from({ length: 24 }, (_, hour) => hour); + +const int = (n: number) => formatValue(n, "int"); +const isGlobalStory = (data: WrappedStoryData) => data.is_global === true; +const volumeHeroFontSize = (value: number) => { + const length = int(value).length; + + if (length >= 11) { + return "clamp(42px, 13vw, 66px)"; + } + if (length >= 10) { + return "clamp(46px, 14.5vw, 72px)"; + } + if (length >= 9) { + return "clamp(54px, 17vw, 86px)"; + } + if (length >= 8) { + return "clamp(62px, 21vw, 104px)"; + } + return "clamp(72px,26vw,124px)"; +}; + +interface SummaryItem { + val: string; + label: string; + highlight: boolean; + compact?: boolean; +} + +function summaryValueStyle(item: SummaryItem): CSSProperties { + const length = item.val.length; + const compact = item.compact === true || length > 16; + const fontSize = + length >= 34 + ? "13px" + : length >= 26 + ? "14px" + : length >= 20 + ? "15px" + : compact + ? "17px" + : "26px"; + + return { + fontFamily: FD, + fontSize, + lineHeight: compact ? 1.08 : 1, + color: item.highlight ? HL : "currentColor", + overflow: "hidden", + display: "-webkit-box", + WebkitLineClamp: length >= 34 ? 4 : length >= 24 ? 3 : compact ? 2 : 1, + WebkitBoxOrient: "vertical", + overflowWrap: "anywhere", + wordBreak: "break-word", + }; +} + +/** Bordered stat chip used on a few slides. */ +function StatBox({ value, label }: { value: string; label: string }) { + return ( +
+
+ {value} +
+
+ {label} +
+
+ ); +} + +/* ------------------------------------------------------------------ intro */ +function IntroSlide({ data }: SlideProps) { + const global = isGlobalStory(data); + + return ( +
+
+ {data.season.label} +
+

+ {global ? "Cały" : "Twój"} +
+ {global ? "Testownik" : "semestr"} +
+ + {global ? "w semestrze" : "w liczbach"} + +

+

+ {data.season.date_range} +

+
+ {" "} + dotknij, aby zacząć → +
+
+ ); +} + +/* ------------------------------------------------------------------- time */ +function TimeSlide({ data }: SlideProps) { + const global = isGlobalStory(data); + return ( +
+
+ {global ? "Czas całej platformy" : "Czas w Testowniku"} +
+

+ {global + ? "W tym semestrze spędzono nad nauką" + : "W tym semestrze spędziłeś nad nauką"} +

+
+ +
+
+ + ≈ {data.study_time.total_minutes / 20} + + + rundek w +
+ lige +
+
+
+ ); +} + +/* ----------------------------------------------------------------- volume */ +function VolumeSlide({ data }: SlideProps) { + const global = isGlobalStory(data); + + return ( +
+
+ {global ? "Odpowiedzi na Testowniku" : "Liczba odpowiedzi"} +
+

+ {global + ? "W całym Testowniku kliknięto odpowiedzi" + : "Kliknięto odpowiedzi"} +

+
+ +
+
+ razy. +
+
+ + +
+
+ ); +} + +/* --------------------------------------------------------------- accuracy */ +function AccuracySlide({ data }: SlideProps) { + const global = isGlobalStory(data); + const correct = Math.round(data.accuracy.percent); + return ( +
+
+ {global ? "Jak odpowiadaliście?" : "Twoja odpowiedzi"} +
+
+ +
+
+ {Array.from({ length: 100 }, (_, index) => ( + + ))} +
+
+ + + {int(data.accuracy.correct)} dobrze + + + + {int(data.accuracy.wrong)} źle + +
+

+ Za pierwszym podejściem{" "} + {global ? "trafiano w " : "trafiałeś w "} + + {data.accuracy.first_attempt_percent}% + {" "} + pytań. +

+
+ ); +} + +/* ---------------------------------------------------------------- rhythm */ +function RhythmSlide({ data }: SlideProps) { + const global = isGlobalStory(data); + const persona = personaForHour(data.rhythm.peak_hour, { + isGlobal: global, + }); + + return ( +
+
+ Godziny aktywności +
+

+ Najwięcej +
+ {global ? "odpowiadano o " : "odpowiadasz o "} + + {peakHourLabel(data.rhythm.peak_hour)} + +

+
+ {HOUR_BUCKETS.map((hour) => { + const total = data.rhythm.hours[hour] ?? 0; + const correct = data.rhythm.correct_hours[hour] ?? 0; + return ( +
+ {/* All answers (behind). */} +
+ {/* Correct answers (front). */} +
+
+ ); + })} +
+
+ 00 + 06 + 12 + 18 + 23 +
+
+ + + wszystkie + + + + poprawne + +
+
+
+ {global ? "Typ semestru" : "Twój typ"} +
+
+ {persona.name} +
+
+ {persona.description} +
+
+
+ ); +} + +/* ------------------------------------------------------------------- top */ +function TopSlide({ data }: SlideProps) { + const global = isGlobalStory(data); + const max = data.top_quizzes[0]?.value || 1; + return ( +
+
+ {global ? "Najdłużej rozwiązywane" : "Najwięcej czasu"} +
+

+ {global ? "Top quizy Testownika" : "Twoje top quizy"} +

+
+ {data.top_quizzes.map((q, index) => ( +
+
+ + {q.rank} + + + {q.name} + + + {formatStudyDuration(q.value)} + +
+
+
+
+
+ ))} +
+
+ ); +} + +/* --------------------------------------------------------------- hardest */ +function HardestSlide({ data }: SlideProps) { + const global = isGlobalStory(data); + const q = data.hardest_question; + if (q == null) { + return null; + } + const hasImage = q.image != null && q.image !== ""; + return ( +
+ {/* Faint floating "trauma" labels behind the content. */} + + + PTSD + + + + + flashbacki z wietnamu + + + +
+
+ {global ? "Koszmar semestru" : "Twój koszmar"} +
+

+ {global + ? "Pytanie, które najczęściej dawało popalić" + : "Pytanie, które dało Ci popalić"} +

+
+
+ Pytanie #{q.question_number} · quiz {q.quiz_name} +
+ {/* Markdown so code / formatting renders; clamped so a long + question (with code blocks) can't blow out the card. */} +
+ + {q.text} + +
+
+ {hasImage ? ( + // eslint-disable-next-line @next/next/no-img-element + + ) : null} +
+
+ + {q.wrong_count}× + + + źle + +
+
+ + {q.correct_count}× + + + dobrze + +
+
+
+
+
+ ); +} + +/* --------------------------------------------------------------- creator */ +function CreatorSlide({ data }: SlideProps) { + const c = data.creator_impact; + if (c == null) { + return null; + } + return ( +
+
+ Twoje quizy u innych +
+

+ Twoje quizy uczą innych +

+
+ +
+
+ osoby +
+
+ + +
+
+ ); +} + +/* ------------------------------------------------------------------ rank */ +function RankSlide({ data }: SlideProps) { + return ( +
+
+ Na tle innych +
+

+ Jesteś w +

+
+ + top + + + + +
+

+ najwytrwalszych studentów na Testowniku w tym semestrze. +

+
+
+
+
+
+
+ reszta + Ty +
+
+
+ ); +} + +/* ----------------------------------------------------------------- outro */ +function OutroSlide({ data, onShare, onRestart }: SlideProps) { + const global = isGlobalStory(data); + const persona = personaForHour(data.rhythm.peak_hour, { + isGlobal: global, + }); + const topQuizName = data.top_quizzes[0]?.name ?? "brak danych"; + const summary: SummaryItem[] = global + ? [ + { + val: `${String(Math.floor(data.study_time.total_minutes / 60))} godz`, + label: "czasu nauki", + highlight: false, + }, + { + val: int(data.volume.total_answers), + label: "pytań", + highlight: true, + }, + { + val: `${String(data.accuracy.percent)}%`, + label: "skuteczność", + highlight: false, + }, + { val: int(data.volume.sessions), label: "sesji", highlight: true }, + { + val: persona.name, + label: "rytm semestru", + highlight: false, + compact: true, + }, + { + val: topQuizName, + label: "najdłuższy quiz", + highlight: true, + compact: true, + }, + ] + : [ + { + val: `${String(Math.floor(data.study_time.total_minutes / 60))} godz`, + label: "czasu nauki", + highlight: false, + }, + { + val: int(data.volume.total_answers), + label: "pytań", + highlight: true, + }, + { + val: `${String(data.accuracy.percent)}%`, + label: "skuteczność", + highlight: false, + }, + { val: int(data.volume.sessions), label: "sesji", highlight: true }, + { + val: `top ${String(data.rank.top_percent)}%`, + label: "najwytrwalszych", + highlight: false, + }, + { + val: persona.name, + label: "Twój typ", + highlight: true, + compact: true, + }, + ]; + + return ( +
+
+ Testownik + · Wrapped {data.season.year_label} +
+

+ {global ? "Taki był Testownik" : "Taki był Twój semestr"} +

+
+ {summary.map((s) => ( +
+
{s.val}
+
+ {s.label} +
+
+ ))} +
+
+ + +
+
+ + Wbij gwiazdkę + + +
+
+ ); +} + +/** Slide id → component. Add an entry here when you add a slide to the config. */ +export const SLIDE_REGISTRY: Record> = { + intro: IntroSlide, + time: TimeSlide, + volume: VolumeSlide, + accuracy: AccuracySlide, + rhythm: RhythmSlide, + top: TopSlide, + hardest: HardestSlide, + creator: CreatorSlide, + rank: RankSlide, + outro: OutroSlide, +}; diff --git a/src/app/wrapped/theme.ts b/src/app/wrapped/theme.ts new file mode 100644 index 00000000..1dc4706a --- /dev/null +++ b/src/app/wrapped/theme.ts @@ -0,0 +1,71 @@ +import type { Palette } from "./wrapped.config"; + +/** Per-slide resolved colours. */ +export interface SlideColors { + bg: string; + fg: string; + accent: string; + danger: string; +} + +function parseHex(hex: string): number | null { + if (!hex.startsWith("#")) { + return null; + } + const h = hex.replace("#", ""); + // Expand shorthand (#abc → #aabbcc) by doubling each hex digit. + const full = h.length === 3 ? h.replaceAll(/(.)/g, "$1$1") : h; + return Number.parseInt(full, 16); +} + +/** Translate a hex colour to an `rgba()` string at the given alpha. */ +export function withAlpha(hex: string, alpha: number): string { + const n = parseHex(hex); + if (n === null) { + return hex; + } + const r = (n >> 16) & 255; + const g = (n >> 8) & 255; + const b = n & 255; + return `rgba(${String(r)},${String(g)},${String(b)},${String(alpha)})`; +} + +/** Perceptual-luminance check used to pick a readable accent. */ +export function isDark(hex: string): boolean { + const n = parseHex(hex); + if (n === null) { + return false; + } + const r = (n >> 16) & 255; + const g = (n >> 8) & 255; + const b = n & 255; + return (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255 < 0.45; +} + +/** Deterministic LCG — same seed always yields the same decoration layout. */ +export function createRng(seed: number): () => number { + let s = seed >>> 0; + return () => { + s = (s * 1_664_525 + 1_013_904_223) >>> 0; + return s / 4_294_967_296; + }; +} + +/** Resolve background / foreground / accent / danger for a slide index. */ +export function slideColors(palette: Palette, index: number): SlideColors { + const n = palette.pop.length; + const bg = palette.pop[index % n]; + const fg = palette.fg[index % n]; + const accent = isDark(bg) ? palette.hlDark : fg; + return { bg, fg, accent, danger: palette.danger }; +} + +/** Seeded array shuffle (Fisher–Yates). */ +export function shuffle(array: readonly T[], rng: () => number): T[] { + const a = [...array]; + for (let index = a.length - 1; index > 0; index--) { + const target = Math.trunc(rng() * (index + 1)); + [a[index], a[target]] = [a[target], a[index]]; + } + return a; +} diff --git a/src/app/wrapped/use-prefers-reduced-motion.ts b/src/app/wrapped/use-prefers-reduced-motion.ts new file mode 100644 index 00000000..cb2d4a35 --- /dev/null +++ b/src/app/wrapped/use-prefers-reduced-motion.ts @@ -0,0 +1,26 @@ +"use client"; + +import { useSyncExternalStore } from "react"; + +const REDUCED_MOTION_QUERY = "(prefers-reduced-motion: reduce)"; + +function subscribe(onStoreChange: () => void): () => void { + const mediaQuery = window.matchMedia(REDUCED_MOTION_QUERY); + mediaQuery.addEventListener("change", onStoreChange); + return () => { + mediaQuery.removeEventListener("change", onStoreChange); + }; +} + +function getSnapshot(): boolean { + return window.matchMedia(REDUCED_MOTION_QUERY).matches; +} + +function getServerSnapshot(): boolean { + return false; +} + +/** Reactively tracks the user's `prefers-reduced-motion` setting. */ +export function usePrefersReducedMotion(): boolean { + return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); +} diff --git a/src/app/wrapped/use-wrapped-player.ts b/src/app/wrapped/use-wrapped-player.ts new file mode 100644 index 00000000..9a79633e --- /dev/null +++ b/src/app/wrapped/use-wrapped-player.ts @@ -0,0 +1,215 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; + +/** Pointer held shorter than this counts as a tap (navigate), not a pause. */ +const TAP_MS = 240; + +function nowMs(): number { + return typeof performance === "undefined" ? Date.now() : performance.now(); +} + +function clampIndex(index: number, count: number): number { + const max = Math.max(0, count - 1); + return Math.max(0, Math.min(max, index)); +} + +interface UseWrappedPlayerOptions { + count: number; + getDuration: (index: number) => number; +} + +interface HoldHandlers { + onPointerDown: () => void; + onPointerUp: () => void; + onPointerLeave: () => void; + onPointerCancel: () => void; +} + +export interface WrappedPlayer { + idx: number; + count: number; + /** Whether the user has interacted yet (autoplay waits for the first tap). */ + started: boolean; + /** Attach to the stage element — receives the live `--wr-progress` var. */ + stageRef: React.RefObject; + next: () => void; + prev: () => void; + goto: (index: number) => void; + /** Tap/hold handlers for the left (back) zone. */ + leftHold: HoldHandlers; + /** Tap/hold handlers for the right (forward) zone. */ + rightHold: HoldHandlers; +} + +/** + * Story-player state machine: drives auto-advance, the progress fill, hold-to-pause, + * tap-to-navigate and keyboard control. Autoplay only kicks in after the first user + * interaction, so the intro slide waits for a tap. Progress is written imperatively + * to a CSS variable so per-frame updates never re-render the slides. + * + * (React Compiler memoizes the callbacks below — no manual `useCallback` needed.) + */ +export function useWrappedPlayer({ + count, + getDuration, +}: UseWrappedPlayerOptions): WrappedPlayer { + const [index, setIndex] = useState(0); + const [started, setStarted] = useState(false); + const stageRef = useRef(null); + const visibleIndex = clampIndex(index, count); + + // Mutable timing state, kept out of React to avoid per-frame renders. + const timing = useRef({ + slideStart: 0, + paused: false, + pauseAt: 0, + advancing: false, + holdStart: 0, + started: false, + }); + + // Latest values for the rAF loop to read without re-subscribing. + const live = useRef({ idx: visibleIndex, count, getDuration }); + live.current = { idx: visibleIndex, count, getDuration }; + + const markStarted = () => { + if (!timing.current.started) { + timing.current.started = true; + setStarted(true); + } + }; + + const goto = (target: number) => { + markStarted(); + setIndex((current) => { + const clamped = clampIndex(target, live.current.count); + return clamped === current ? current : clamped; + }); + }; + + const next = () => { + goto(live.current.idx + 1); + }; + + const previous = () => { + goto(live.current.idx - 1); + }; + + // Latest nav handlers, so the lifetime effects below can run once (empty deps) + // without going stale. + const handlers = useRef({ next, previous }); + handlers.current = { next, previous }; + + // Reset timing whenever the slide changes. + useEffect(() => { + const t = timing.current; + t.slideStart = nowMs(); + t.pauseAt = 0; + t.paused = false; + t.advancing = false; + const stage = stageRef.current; + if (stage !== null) { + stage.style.setProperty("--wr-progress", "0"); + stage.style.setProperty("--wr-index", String(visibleIndex)); + } + }, [visibleIndex]); + + // Single rAF loop for the lifetime of the player. + useEffect(() => { + let raf = 0; + const setProgress = (pf: number) => { + stageRef.current?.style.setProperty("--wr-progress", String(pf)); + }; + const tick = () => { + const t = timing.current; + const { idx: current, count: n, getDuration: dur } = live.current; + if (!t.paused) { + const isLast = current >= n - 1; + const canAdvance = t.started && !isLast; + if (canAdvance && !t.advancing) { + const pf = Math.min(1, (nowMs() - t.slideStart) / dur(current)); + setProgress(pf); + if (pf >= 1) { + t.advancing = true; + handlers.current.next(); + } + } else { + // Not started yet → empty; last/started → full. + setProgress(t.started ? 1 : 0); + } + } + raf = requestAnimationFrame(tick); + }; + raf = requestAnimationFrame(tick); + return () => { + cancelAnimationFrame(raf); + }; + }, []); + + // Keyboard control. + useEffect(() => { + const onKey = (event: KeyboardEvent) => { + if ( + event.key === "ArrowRight" || + event.key === " " || + event.key === "Spacebar" + ) { + event.preventDefault(); + handlers.current.next(); + } else if (event.key === "ArrowLeft") { + event.preventDefault(); + handlers.current.previous(); + } + }; + window.addEventListener("keydown", onKey); + return () => { + window.removeEventListener("keydown", onKey); + }; + }, []); + + const pause = () => { + const t = timing.current; + if (!t.paused) { + t.paused = true; + t.pauseAt = nowMs(); + } + }; + + const resume = () => { + const t = timing.current; + if (t.paused) { + const pausedAt = t.pauseAt === 0 ? nowMs() : t.pauseAt; + t.slideStart += nowMs() - pausedAt; + t.paused = false; + } + }; + + const makeHold = (onTap: () => void): HoldHandlers => ({ + onPointerDown: () => { + timing.current.holdStart = nowMs(); + pause(); + }, + onPointerUp: () => { + const held = nowMs() - timing.current.holdStart; + resume(); + if (held < TAP_MS) { + onTap(); + } + }, + onPointerLeave: resume, + onPointerCancel: resume, + }); + + return { + idx: visibleIndex, + count, + started, + stageRef, + next, + prev: previous, + goto, + leftHold: makeHold(previous), + rightHold: makeHold(next), + }; +} diff --git a/src/app/wrapped/wrapped.config.ts b/src/app/wrapped/wrapped.config.ts new file mode 100644 index 00000000..780567f8 --- /dev/null +++ b/src/app/wrapped/wrapped.config.ts @@ -0,0 +1,295 @@ +/** + * Single source of truth for the Testownik Wrapped story. + * + * Everything you'd want to retune is hardcoded here: + * - which slides appear and in what order → `SLIDES` + * - per-slide auto-advance timing → `SLIDES[].duration` / `DEFAULT_DURATION` + * - colour palettes (themes) → `PALETTES` + * - the default look → `WRAPPED_DEFAULTS` + * - the logo easter-egg → `LOGO_EASTER_EGG_CLICKS` + * + * To add a slide: write a component, register it in `slides/index.tsx`, then add + * its id to `SLIDES`. To reorder: move the entry. To hide one: remove it (or give + * it an `enabled` predicate). No player/orchestration changes required. + * + * Whether the story is shown at all (vs. the empty state) and whether the + * "creator impact" slide appears are derived from the API response, not config. + */ +import type { WrappedData } from "@/types/wrapped"; + +export type SlideId = + | "intro" + | "time" + | "volume" + | "accuracy" + | "rhythm" + | "top" + | "hardest" + | "creator" + | "rank" + | "outro"; + +export const PALETTE_NAMES = [ + "wrapped", + "summer", + "neon", + "indigo", + "pastel", +] as const; +export type PaletteName = (typeof PALETTE_NAMES)[number]; + +export const DECORATION_NAMES = [ + "none", + "summer", + "wrapped", + "lines", + "confetti", + "sparks", + "blobs", +] as const; +export type DecorationName = (typeof DECORATION_NAMES)[number]; + +export const PROGRESS_NAMES = ["dots", "bars", "numbers", "none"] as const; +export type ProgressStyle = (typeof PROGRESS_NAMES)[number]; + +export interface Palette { + accent: string; + /** Accent used on dark backgrounds (where `fg` would clash). */ + hlDark: string; + danger: string; + /** Per-slide background, cycled by slide index. */ + pop: string[]; + /** Per-slide foreground, cycled by slide index (parallel to `pop`). */ + fg: string[]; +} + +export const PALETTES: Record = { + wrapped: { + accent: "#ff3d8b", + hlDark: "#c6ff3a", + danger: "#ff5147", + pop: [ + "#15171c", + "#ff5147", + "#c6ff3a", + "#19d3da", + "#ffb020", + "#ff3d8b", + "#c6ff3a", + "#ff5147", + "#19d3da", + "#15171c", + ], + fg: [ + "#ffffff", + "#ffffff", + "#15171c", + "#15171c", + "#15171c", + "#ffffff", + "#15171c", + "#ffffff", + "#15171c", + "#ffffff", + ], + }, + summer: { + accent: "#ff4d6d", + hlDark: "#ffd23f", + danger: "#ff5d3c", + pop: [ + "#0a3d4d", + "#ff7a4d", + "#ffd23f", + "#16c0b0", + "#ff5d8f", + "#ffd23f", + "#16c0b0", + "#ff7a4d", + "#3aa0ff", + "#0a3d4d", + ], + fg: [ + "#ffe9c7", + "#15171c", + "#15171c", + "#06231f", + "#15171c", + "#15171c", + "#06231f", + "#15171c", + "#07263f", + "#ffe9c7", + ], + }, + neon: { + accent: "#ff2d8b", + hlDark: "#c6ff3a", + danger: "#ff2d8b", + pop: [ + "#0b0014", + "#c6ff3a", + "#ff2d8b", + "#7b2dff", + "#19d3da", + "#c6ff3a", + "#ff2d8b", + "#7b2dff", + "#19d3da", + "#0b0014", + ], + fg: [ + "#c6ff3a", + "#0b0014", + "#ffffff", + "#ffffff", + "#0b0014", + "#0b0014", + "#ffffff", + "#ffffff", + "#0b0014", + "#c6ff3a", + ], + }, + indigo: { + accent: "#2f6fd6", + hlDark: "#9db8e8", + danger: "#e23a4a", + pop: [ + "#192941", + "#5278B9", + "#37548C", + "#e8eef7", + "#5278B9", + "#37548C", + "#192941", + "#5278B9", + "#37548C", + "#192941", + ], + fg: [ + "#e8eef7", + "#ffffff", + "#ffffff", + "#192941", + "#ffffff", + "#ffffff", + "#e8eef7", + "#ffffff", + "#ffffff", + "#e8eef7", + ], + }, + pastel: { + accent: "#ff7aa0", + hlDark: "#ffb3c1", + danger: "#ff7aa0", + pop: [ + "#2b2440", + "#ffb3c1", + "#a0e8d8", + "#ffe0a3", + "#b8c6ff", + "#ffb3c1", + "#a0e8d8", + "#ffe0a3", + "#b8c6ff", + "#2b2440", + ], + fg: [ + "#ffe0a3", + "#2b2440", + "#1f3b34", + "#3d2f10", + "#1d2742", + "#2b2440", + "#1f3b34", + "#3d2f10", + "#1d2742", + "#ffe0a3", + ], + }, +}; + +export interface SlideConfig { + id: SlideId; + /** Auto-advance duration in ms. Falls back to `DEFAULT_DURATION`. */ + duration?: number; + /** Optional gate (derived from API data). When false the slide is skipped. */ + enabled?: (data: WrappedData) => boolean; +} + +/** Time (ms) a slide stays on screen before auto-advancing. */ +export const DEFAULT_DURATION = 8200; + +/** + * Ordered story. Reorder / add / remove freely. + * The `creator` slide only appears when the API returns creator-impact data. + */ +export const SLIDES: SlideConfig[] = [ + { id: "intro", duration: 5200 }, + { id: "time" }, + { id: "volume" }, + { id: "accuracy" }, + { id: "rhythm" }, + { id: "top", duration: 10_500 }, + { + id: "hardest", + duration: 9800, + enabled: (data) => data.hardest_question != null, + }, + { + id: "creator", + enabled: (data) => data.is_global !== true && data.creator_impact != null, + }, + { id: "rank", enabled: (data) => data.is_global !== true }, + { id: "outro" }, +]; + +/** Duration (ms) of the entrance / count-up animation on each slide. */ +export const ENTER_MS = 1100; + +/** Outro call-to-action targets. */ +export const GITHUB_URL = "https://github.com/Solvro/web-testownik"; +/** Formbricks action fired when the user opts into the survey. */ +export const FORMBRICKS_SURVEY_ACTION = "wrapped_survey"; + +export interface WrappedSettings { + palette: PaletteName; + decoration: DecorationName; + progress: ProgressStyle; +} + +export const WRAPPED_DEFAULTS: WrappedSettings = { + palette: "wrapped", + decoration: "summer", + progress: "bars", +}; + +// --- Logo easter egg ------------------------------------------------------- + +/** Clicks on the logo (in a row) that trigger a random theme. */ +export const LOGO_EASTER_EGG_CLICKS = 5; +/** Max gap (ms) between clicks for them to count as "in a row". */ +export const LOGO_EASTER_EGG_WINDOW_MS = 600; + +function randomFrom(items: readonly T[]): T { + return items[Math.floor(Math.random() * items.length)]; +} + +/** + * Pick a random, visible theme combination for the easter egg. It lives in + * React state only — deliberately not persisted, so it resets on reload. + */ +export function pickRandomSettings(): WrappedSettings { + return { + palette: randomFrom(PALETTE_NAMES), + decoration: randomFrom(DECORATION_NAMES.filter((d) => d !== "none")), + progress: randomFrom(PROGRESS_NAMES.filter((p) => p !== "none")), + }; +} + +/** The visible, ordered slides for the given API data. */ +export function resolveSlides(data: WrappedData): SlideConfig[] { + return SLIDES.filter((slide) => slide.enabled?.(data) ?? true); +} diff --git a/src/app/wrapped/wrapped.css b/src/app/wrapped/wrapped.css new file mode 100644 index 00000000..4824fcb4 --- /dev/null +++ b/src/app/wrapped/wrapped.css @@ -0,0 +1,196 @@ +/* Testownik Wrapped — animation keyframes + scoped base styles. + Keyframe names are prefixed `wr-` to avoid colliding with Tailwind utilities. */ + +@keyframes wr-rise { + from { + opacity: 0; + transform: translateY(18px); + } + to { + opacity: 1; + transform: translateY(0); + } +} +@keyframes wr-pop { + 0% { + opacity: 0; + transform: scale(0.78); + } + 60% { + transform: scale(1.05); + } + 100% { + opacity: 1; + transform: scale(1); + } +} +@keyframes wr-grow { + from { + transform: scaleY(0); + } + to { + transform: scaleY(1); + } +} +@keyframes wr-wipe { + from { + clip-path: inset(0 100% 0 0); + } + to { + clip-path: inset(0 0 0 0); + } +} +@keyframes wr-bub { + from { + transform: scale(0); + } + to { + transform: scale(1); + } +} +@keyframes wr-blink { + 0%, + 48% { + opacity: 1; + } + 49%, + 100% { + opacity: 0; + } +} +@keyframes wr-spin { + to { + transform: rotate(360deg); + } +} +@keyframes wr-twinkle { + 0%, + 100% { + opacity: 0.25; + transform: scale(0.6); + } + 50% { + opacity: 1; + transform: scale(1); + } +} +@keyframes wr-float { + 0%, + 100% { + transform: translateY(0) rotate(var(--r, 0deg)); + } + 50% { + transform: translateY(-12px) rotate(var(--r, 0deg)); + } +} +@keyframes wr-sway { + 0%, + 100% { + transform: rotate(-4deg); + } + 50% { + transform: rotate(4deg); + } +} +@keyframes wr-drift { + from { + transform: translateY(-30px) rotate(0deg); + } + to { + transform: translateY(40px) rotate(180deg); + } +} + +/* Base styles, scoped to the Wrapped root. */ +.wrapped-root { + --font-display: var(--font-wrapped-display), "Anton", system-ui, sans-serif; + --font-sans: var(--font-wrapped-sans), "Space Grotesk", system-ui, sans-serif; + font-family: var(--font-sans); + /* Story taps shouldn't zoom, select text, or pop the callout menu. */ + touch-action: manipulation; + user-select: none; + -webkit-user-select: none; + -webkit-touch-callout: none; +} + +.wrapped-root * { + box-sizing: border-box; +} + +/* Centred phone-sized shell (desktop). */ +.wrapped-shell-wrap { + display: flex; + justify-content: center; + align-items: center; + width: 100%; +} +.wrapped-shell { + position: relative; + width: min(468px, 100%); + height: min(900px, calc(100dvh - 8rem)); + display: flex; + flex-direction: column; +} + +/* Smooth palette transitions between slides. */ +.wrapped-stage { + transition: + background-color 0.45s ease, + color 0.45s ease; +} + +.wrapped-empty-stage { + width: 100%; +} + +/* Mobile: middle story slides use the immersive fixed overlay. Intro/outro use + `.is-edge` to pull into a card, and the no-activity state uses its own card + class so it does not trap the rest of the app behind it. */ +@media (max-width: 640px) { + .wrapped-shell { + width: 100%; + height: auto; + } + .wrapped-stage { + position: fixed !important; + top: 0 !important; + left: 0 !important; + right: 0 !important; + bottom: 0 !important; + z-index: 50 !important; + margin: 0 !important; + border-radius: 0 !important; + box-shadow: none !important; + transition: + top 0.42s cubic-bezier(0.4, 0, 0.2, 1), + left 0.42s cubic-bezier(0.4, 0, 0.2, 1), + right 0.42s cubic-bezier(0.4, 0, 0.2, 1), + bottom 0.42s cubic-bezier(0.4, 0, 0.2, 1), + border-radius 0.42s cubic-bezier(0.4, 0, 0.2, 1), + background-color 0.45s ease, + color 0.45s ease; + } + .wrapped-stage.is-edge { + top: 4.75rem !important; + left: 0.75rem !important; + z-index: 10 !important; + right: 0.75rem !important; + bottom: 0.75rem !important; + border-radius: 22px !important; + box-shadow: 0 20px 60px -20px rgba(0, 0, 0, 0.5) !important; + } + .wrapped-empty-stage { + width: calc(100% - 1.5rem); + min-height: min(620px, calc(100dvh - 7rem)) !important; + margin: 0.75rem auto 1rem; + border-radius: 22px !important; + } +} + +/* Respect reduced-motion: kill entrance + decorative animation, show final state. */ +@media (prefers-reduced-motion: reduce) { + .wrapped-root [style*="animation"], + .wrapped-root [data-wr-anim] { + animation: none !important; + } +} diff --git a/src/assets/logo-full-dark.svg b/src/assets/logo-full-dark.svg new file mode 100644 index 00000000..e7e510f5 --- /dev/null +++ b/src/assets/logo-full-dark.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/logo-full.svg b/src/assets/logo-full.svg new file mode 100644 index 00000000..5aca0e38 --- /dev/null +++ b/src/assets/logo-full.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/src/components/navbar/navbar-client.tsx b/src/components/navbar/navbar-client.tsx index 71a889e2..a844e46d 100644 --- a/src/components/navbar/navbar-client.tsx +++ b/src/components/navbar/navbar-client.tsx @@ -7,6 +7,7 @@ import { AppContext } from "@/app-context"; import { AppLogo } from "@/components/app-logo"; import { AuthButtons } from "@/components/navbar/auth-buttons"; import { LogoutButton } from "@/components/navbar/logout-button"; +import { cn } from "@/lib/utils"; import { MobileMenu } from "./mobile-menu"; import { MobileMenuButton } from "./mobile-menu-button"; @@ -21,7 +22,12 @@ export function NavbarClient() { }; return ( -
+