Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions src/app/wrapped/client.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
"use client";

import { WrappedExperience } from "./components/wrapped-experience";

export function WrappedPageClient() {
return <WrappedExperience mode="user" />;
}
87 changes: 87 additions & 0 deletions src/app/wrapped/components/count-up.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<span className={className} style={style}>
{formatValue(current, format)}
</span>
);
}

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 (
<span className={className} style={style}>
<span style={{ display: "block" }}>{String(hours)} godz</span>
<span style={{ display: "block" }}>{String(mins)} min</span>
</span>
);
}
103 changes: 103 additions & 0 deletions src/app/wrapped/components/empty-state.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div
data-screen-label="Pusty stan"
style={{
position: "absolute",
inset: 0,
zIndex: 7,
background: "#15171c",
color: "#fff",
padding: "104px 34px 40px",
display: "flex",
flexDirection: "column",
justifyContent: "center",
textAlign: "center",
}}
>
<div
style={{
animation: "wr-rise .6s .05s both",
fontFamily: "var(--fm)",
fontSize: "13px",
letterSpacing: ".2em",
textTransform: "uppercase",
color: "#c6ff3a",
marginBottom: "16px",
}}
>
Wrapped {yearLabel}
</div>
<h1
style={{
animation: "wr-rise .6s .15s both",
fontFamily: "var(--fd)",
fontSize: "clamp(40px,12vw,58px)",
lineHeight: 0.96,
letterSpacing: ".01em",
margin: 0,
textTransform: "uppercase",
}}
>
Brak
<br />
aktywności
</h1>
<p
style={{
animation: "wr-rise .6s .3s both",
fontSize: "17px",
lineHeight: 1.5,
color: "rgba(255,255,255,.7)",
margin: "20px auto 0",
maxWidth: "28ch",
}}
>
{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 : (
<>
{" "}
<b style={{ color: "#fff" }}>
Bez aktywności w danym semestrze nie mamy z czego go złożyć.
</b>
</>
)}
</p>
<div style={{ marginTop: "30px", alignSelf: "center" }}>
<Link
href="/quizzes"
style={{
display: "inline-flex",
alignItems: "center",
height: "46px",
padding: "0 22px",
borderRadius: "12px",
background: "#c6ff3a",
color: "#15171c",
fontFamily: "var(--fm)",
fontSize: "15px",
fontWeight: 700,
textDecoration: "none",
}}
>
Przejdź do quizów →
</Link>
</div>
</div>
);
}
128 changes: 128 additions & 0 deletions src/app/wrapped/components/wrapped-chrome.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
zIndex: 6,
padding: "14px 18px 0",
color: "var(--slide-fg, #fff)",
pointerEvents: "none",
}}
>
<WrappedProgress
style={progressStyle}
idx={idx}
count={count}
onSeek={onSeek}
/>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginTop: "13px",
}}
>
<span style={{ display: "flex", alignItems: "center", gap: "9px" }}>
<button
type="button"
onClick={onLogoClick}
aria-label="Testownik"
title="Testownik"
style={{
pointerEvents: "auto",
background: "none",
border: "none",
padding: 0,
margin: 0,
cursor: "pointer",
display: "flex",
alignItems: "center",
}}
>
<Image
// Use the light-on-dark / dark-on-light logo variant per slide.
src={dark ? LogoFullDark : LogoFull}
alt="Testownik"
className="block h-[17px] w-auto"
/>
</button>
<span
style={{
fontFamily: "var(--fm)",
fontSize: "10px",
letterSpacing: ".14em",
textTransform: "uppercase",
fontWeight: 700,
padding: "3px 8px",
borderRadius: "99px",
background: "var(--hl)",
color: "var(--slide-bg, #15171c)",
whiteSpace: "nowrap",
}}
>
Wrapped {yearLabel}
</span>
</span>
{showSkip ? (
<button
type="button"
onClick={onSkip}
style={{
pointerEvents: "auto",
background: "none",
border: "none",
color: "currentColor",
fontFamily: "var(--fm)",
fontSize: "11px",
letterSpacing: ".12em",
textTransform: "uppercase",
opacity: 0.65,
cursor: "pointer",
padding: "4px",
}}
>
Pomiń
</button>
) : null}
</div>
</div>
);
}
51 changes: 51 additions & 0 deletions src/app/wrapped/components/wrapped-decorations.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div
aria-hidden
style={{
position: "absolute",
inset: 0,
pointerEvents: "none",
zIndex: 0,
overflow: "hidden",
}}
>
{items.map((style) => (
<div
key={`${kind}-${String(idx)}-${decorationKey(style)}`}
style={style}
/>
))}
</div>
);
}
Loading
Loading