From dfc6b7617a85104a2beb2381da31f8010ac090bf Mon Sep 17 00:00:00 2001 From: Antoni Czaplicki Date: Thu, 2 Jul 2026 00:58:06 +0200 Subject: [PATCH] feat(wrapped): improve responsive design and sharing logic --- src/app/wrapped/global/page.tsx | 6 + src/app/wrapped/page.tsx | 6 + src/app/wrapped/share-image.ts | 246 +++++++++++++++++++++++++------ src/app/wrapped/slides/index.tsx | 66 ++++----- src/app/wrapped/wrapped.css | 1 + 5 files changed, 240 insertions(+), 85 deletions(-) diff --git a/src/app/wrapped/global/page.tsx b/src/app/wrapped/global/page.tsx index a86cdbdf..6f512ca2 100644 --- a/src/app/wrapped/global/page.tsx +++ b/src/app/wrapped/global/page.tsx @@ -5,6 +5,12 @@ import { WrappedExperience } from "../components/wrapped-experience"; export const metadata: Metadata = { title: "Wrapped — cały Testownik", description: "Podsumowanie semestru całego Testownika w liczbach.", + openGraph: { + title: "Testownik Wrapped — cały Testownik", + description: "Podsumowanie semestru całego Testownika w liczbach.", + type: "website", + locale: "pl_PL", + }, }; export default function GlobalWrappedPage() { diff --git a/src/app/wrapped/page.tsx b/src/app/wrapped/page.tsx index d3e955b3..2a66ec06 100644 --- a/src/app/wrapped/page.tsx +++ b/src/app/wrapped/page.tsx @@ -5,6 +5,12 @@ import { WrappedPageClient } from "./client"; export const metadata: Metadata = { title: "Wrapped", description: "Twój semestr w Testowniku — podsumowanie w liczbach.", + openGraph: { + title: "Testownik Wrapped", + description: "Twój semestr w Testowniku — podsumowanie w liczbach.", + type: "website", + locale: "pl_PL", + }, }; export default function WrappedPage() { diff --git a/src/app/wrapped/share-image.ts b/src/app/wrapped/share-image.ts index 7997e0f9..a0ee0573 100644 --- a/src/app/wrapped/share-image.ts +++ b/src/app/wrapped/share-image.ts @@ -12,6 +12,20 @@ import type { DecorationName } from "./wrapped.config"; const WIDTH = 1080; const HEIGHT = 1920; const PAD = 96; +const OPENMOJI_BASE = + "https://cdn.jsdelivr.net/gh/hfg-gmuend/openmoji@15.0.0/color/svg/"; +const SUMMER_SHARE_STICKERS = [ + { code: "2600", x: WIDTH - 122, y: 382, size: 152, rotation: 0.08 }, + { code: "1F3D6", x: 106, y: 600, size: 90, rotation: -0.22 }, + { code: "1F366", x: 94, y: 1642, size: 88, rotation: -0.18 }, + { code: "1F33A", x: 128, y: HEIGHT - 112, size: 70, rotation: -0.34 }, + { code: "1F334", x: WIDTH - 108, y: HEIGHT - 124, size: 150, rotation: 0.04 }, +] as const; +const SUMMER_SHARE_SPARKLES = [ + { x: 388, y: 612, size: 38, rotation: -0.14 }, + { x: 558, y: 544, size: 32, rotation: 0.08 }, + { x: 828, y: 620, size: 30, rotation: 0.16 }, +] as const; /** Colours the share card is drawn with (matches the current theme). */ export interface ShareTheme { @@ -163,13 +177,187 @@ function drawStar( context.restore(); } -function drawShareDecorations( +async function loadCanvasImage( + source: string, +): Promise { + return new Promise((resolve) => { + const image = new Image(); + const timeout = window.setTimeout(() => { + resolve(null); + }, 2500); + + image.crossOrigin = "anonymous"; + image.addEventListener("load", () => { + window.clearTimeout(timeout); + resolve(image); + }); + image.addEventListener("error", () => { + window.clearTimeout(timeout); + resolve(null); + }); + image.src = source; + }); +} + +function drawImageSticker( + context: CanvasRenderingContext2D, + image: HTMLImageElement, + x: number, + y: number, + size: number, + rotation: number, +): void { + context.save(); + context.translate(x, y); + context.rotate(rotation); + context.shadowColor = "rgba(0,0,0,.16)"; + context.shadowBlur = 16; + context.shadowOffsetY = 8; + context.drawImage(image, -size / 2, -size / 2, size, size); + context.restore(); +} + +function drawSummerFallback( context: CanvasRenderingContext2D, theme: ShareTheme, ): void { + const outline = "#0b0d12"; + const yellow = isDark(theme.bg) ? theme.accent : "#ffe14d"; + + context.save(); + context.lineCap = "round"; + context.lineJoin = "round"; + context.strokeStyle = outline; + context.lineWidth = 7; + + // Sun, low left. + context.save(); + context.translate(170, HEIGHT - 210); + for (let index = 0; index < 12; index++) { + const angle = (index * Math.PI * 2) / 12; + context.beginPath(); + context.moveTo(Math.cos(angle) * 58, Math.sin(angle) * 58); + context.lineTo(Math.cos(angle) * 86, Math.sin(angle) * 86); + context.stroke(); + } + context.fillStyle = yellow; + context.beginPath(); + context.arc(0, 0, 54, 0, Math.PI * 2); + context.fill(); + context.stroke(); + context.restore(); + + // Flower, upper left. + context.save(); + context.translate(160, 650); + context.fillStyle = "#ff8fb0"; + for (let index = 0; index < 5; index++) { + const angle = (index * Math.PI * 2) / 5; + context.save(); + context.rotate(angle); + context.beginPath(); + context.ellipse(0, -34, 18, 30, 0, 0, Math.PI * 2); + context.fill(); + context.stroke(); + context.restore(); + } + context.fillStyle = "#ffe14d"; + context.beginPath(); + context.arc(0, 0, 20, 0, Math.PI * 2); + context.fill(); + context.stroke(); + context.restore(); + + // Drink, low right. + context.save(); + context.translate(WIDTH - 170, HEIGHT - 315); + context.rotate(-0.12); + context.fillStyle = withAlpha("#ffd23f", 0.84); + context.beginPath(); + context.moveTo(-44, -48); + context.lineTo(44, -48); + context.lineTo(24, 58); + context.lineTo(-24, 58); + context.closePath(); + context.fill(); + context.stroke(); + context.beginPath(); + context.moveTo(0, 58); + context.lineTo(0, 106); + context.moveTo(-42, 106); + context.lineTo(42, 106); + context.stroke(); + context.restore(); + + context.restore(); +} + +async function drawSummerShareDecorations( + context: CanvasRenderingContext2D, + theme: ShareTheme, +): Promise { + const images = await Promise.all( + SUMMER_SHARE_STICKERS.map(async (sticker) => { + return loadCanvasImage(`${OPENMOJI_BASE}${sticker.code}.svg`); + }), + ); + + context.save(); + let drewAny = false; + for (const [index, image] of images.entries()) { + if (image === null) { + continue; + } + const sticker = SUMMER_SHARE_STICKERS[index]; + drawImageSticker( + context, + image, + sticker.x, + sticker.y, + sticker.size, + sticker.rotation, + ); + drewAny = true; + } + + const sparkleImages = await Promise.all( + SUMMER_SHARE_SPARKLES.map(async () => { + return loadCanvasImage(`${OPENMOJI_BASE}2728.svg`); + }), + ); + for (const [index, image] of sparkleImages.entries()) { + if (image === null) { + continue; + } + const sparkle = SUMMER_SHARE_SPARKLES[index]; + drawImageSticker( + context, + image, + sparkle.x, + sparkle.y, + sparkle.size, + sparkle.rotation, + ); + drewAny = true; + } + context.restore(); + + if (!drewAny) { + drawSummerFallback(context, theme); + } +} + +async function drawShareDecorations( + context: CanvasRenderingContext2D, + theme: ShareTheme, +): Promise { if (theme.decoration === "none") { return; } + if (theme.decoration === "summer") { + await drawSummerShareDecorations(context, theme); + return; + } const rng = createRng(theme.slideIndex * 1297 + 191); const colors = [ @@ -242,39 +430,6 @@ function drawShareDecorations( } 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 = @@ -336,7 +491,7 @@ export async function generateShareImage( // Background. context.fillStyle = bg; context.fillRect(0, 0, WIDTH, HEIGHT); - drawShareDecorations(context, theme); + await drawShareDecorations(context, theme); // Header. context.textBaseline = "alphabetic"; @@ -399,9 +554,13 @@ export async function generateShareImage( context.textAlign = "left"; return new Promise((resolve) => { - canvas.toBlob((blob) => { - resolve(blob); - }, "image/png"); + try { + canvas.toBlob((blob) => { + resolve(blob); + }, "image/png"); + } catch { + resolve(null); + } }); } @@ -409,18 +568,15 @@ 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.` + `Testownik Wrapped: ${String(hours)} godz nauki i ` + + `${formatValue(data.volume.total_answers, "int")} pytań.` ); } 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, - )}%!` + `${formatValue(data.volume.total_answers, "int")} pytań.` + + `Top ${String(data.rank.top_percent)}%!` ); } diff --git a/src/app/wrapped/slides/index.tsx b/src/app/wrapped/slides/index.tsx index b6e7360a..1c09a980 100644 --- a/src/app/wrapped/slides/index.tsx +++ b/src/app/wrapped/slides/index.tsx @@ -63,19 +63,19 @@ function summaryValueStyle(item: SummaryItem): CSSProperties { const compact = item.compact === true || length > 16; const fontSize = length >= 34 - ? "13px" + ? "clamp(11px, 1.8dvh, 13px)" : length >= 26 - ? "14px" + ? "clamp(12px, 1.95dvh, 14px)" : length >= 20 - ? "15px" + ? "clamp(13px, 2.1dvh, 15px)" : compact - ? "17px" - : "26px"; + ? "clamp(13px, 2.25dvh, 17px)" + : "clamp(20px, 3.2dvh, 26px)"; return { fontFamily: FD, fontSize, - lineHeight: compact ? 1.08 : 1, + lineHeight: compact ? 1.04 : 0.98, color: item.highlight ? HL : "currentColor", overflow: "hidden", display: "-webkit-box", @@ -271,7 +271,7 @@ function TimeSlide({ data }: SlideProps) { }} > - ≈ {data.study_time.total_minutes / 20} + ≈ {Math.round(data.study_time.total_minutes / 20)} -
- Testownik - · Wrapped {data.season.year_label} -

@@ -1373,15 +1356,18 @@ function OutroSlide({ data, onShare, onRestart }: SlideProps) { {summary.map((s) => (
{s.val}
{s.label} @@ -1394,7 +1380,7 @@ function OutroSlide({ data, onShare, onRestart }: SlideProps) { animation: "wr-rise .5s .4s both", display: "flex", gap: "10px", - marginTop: "24px", + marginTop: "clamp(12px, 2.3dvh, 24px)", pointerEvents: "auto", }} > @@ -1403,13 +1389,13 @@ function OutroSlide({ data, onShare, onRestart }: SlideProps) { onClick={onShare} style={{ flex: 1, - height: "46px", + height: "clamp(38px, 5.4dvh, 46px)", border: "none", borderRadius: "12px", background: HL, color: "var(--slide-bg, #15171c)", fontFamily: FM, - fontSize: "15px", + fontSize: "clamp(13px, 1.9dvh, 15px)", fontWeight: 700, cursor: "pointer", }} @@ -1421,13 +1407,13 @@ function OutroSlide({ data, onShare, onRestart }: SlideProps) { onClick={onRestart} style={{ flex: 1, - height: "46px", + height: "clamp(38px, 5.4dvh, 46px)", borderRadius: "12px", background: "transparent", border: "1.5px solid currentColor", color: "currentColor", fontFamily: FM, - fontSize: "15px", + fontSize: "clamp(13px, 1.9dvh, 15px)", fontWeight: 600, cursor: "pointer", }} @@ -1440,7 +1426,7 @@ function OutroSlide({ data, onShare, onRestart }: SlideProps) { animation: "wr-rise .5s .5s both", display: "flex", gap: "10px", - marginTop: "10px", + marginTop: "clamp(6px, 1.2dvh, 10px)", pointerEvents: "auto", }} > @@ -1450,7 +1436,7 @@ function OutroSlide({ data, onShare, onRestart }: SlideProps) { rel="noreferrer" style={{ flex: 1, - height: "42px", + height: "clamp(34px, 4.8dvh, 42px)", display: "flex", alignItems: "center", justifyContent: "center", @@ -1460,7 +1446,7 @@ function OutroSlide({ data, onShare, onRestart }: SlideProps) { border: `1.5px solid ${muted(35)}`, color: "currentColor", fontFamily: FM, - fontSize: "13px", + fontSize: "clamp(11px, 1.65dvh, 13px)", fontWeight: 600, textDecoration: "none", }} @@ -1474,13 +1460,13 @@ function OutroSlide({ data, onShare, onRestart }: SlideProps) { }} style={{ flex: 1, - height: "42px", + height: "clamp(34px, 4.8dvh, 42px)", borderRadius: "12px", background: "transparent", border: `1.5px solid ${muted(35)}`, color: "currentColor", fontFamily: FM, - fontSize: "13px", + fontSize: "clamp(11px, 1.65dvh, 13px)", fontWeight: 600, cursor: "pointer", }} diff --git a/src/app/wrapped/wrapped.css b/src/app/wrapped/wrapped.css index 4824fcb4..965178ef 100644 --- a/src/app/wrapped/wrapped.css +++ b/src/app/wrapped/wrapped.css @@ -134,6 +134,7 @@ /* Smooth palette transitions between slides. */ .wrapped-stage { + overscroll-behavior: none; transition: background-color 0.45s ease, color 0.45s ease;