diff --git a/src/app/wrapped/components/empty-state.tsx b/src/app/wrapped/components/empty-state.tsx index 42ec57d1..df5e9381 100644 --- a/src/app/wrapped/components/empty-state.tsx +++ b/src/app/wrapped/components/empty-state.tsx @@ -68,15 +68,7 @@ export function WrappedEmptyState({ > {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ć. - - - )} + : "Nie rozwiązywałeś żadnych quizów w tym semestrze, więc nie ma z czego złożyć Wrapped. Wróć tu po sesji w następnym."}

- + + ); +} +function WrappedLogin({ + isGuest, + isGlobal, +}: { + isGuest: boolean; + isGlobal: boolean; +}) { + const redirectPath = `/wrapped${isGlobal ? "/global" : ""}`; + const title = isGuest + ? "Wrapped nie jest dostępne dla gości" + : "Zaloguj się\n do Wrapped"; + const description = isGuest + ? "Ta funkcja jest dostępna tylko dla zalogowanych kont. Zaloguj się żeby kontynuować." + : isGlobal + ? "Zaloguj się, żeby zobaczyć semestr całego Testownika w liczbach." + : "Zaloguj się, żeby zobaczyć swoje semestralne podsumowanie w Testowniku."; + + return ( + +
+ > + {/* eslint-disable-next-line @typescript-eslint/no-unsafe-assignment */} + Logo +
+
+

+ Testownik Wrapped +

+

+ {title} +

+

+ {description} +

+
+ + + {isGuest ? "Przejdź do logowania" : "Zaloguj się"} +
); } export function WrappedExperience({ mode }: { mode: "user" | "global" }) { - const { isAuthenticated } = useContext(AppContext); + const { checkPermission, user } = useContext(AppContext); + const canViewWrapped = checkPermission(PermissionAction.VIEW_WRAPPED); // Theme settings: defaults < easter-egg (temporary React state). const [easterSettings, setEasterSettings] = @@ -109,12 +188,17 @@ export function WrappedExperience({ mode }: { mode: "user" | "global" }) { mode === "global" ? getWrappedService().getGlobalWrapped() : getWrappedService().getWrapped(), - enabled: isAuthenticated, + enabled: canViewWrapped, staleTime: 5 * 60 * 1000, }); - if (!isAuthenticated) { - return ; + if (!canViewWrapped) { + return ( + + ); } if (isPending) { diff --git a/src/app/wrapped/components/wrapped-story.tsx b/src/app/wrapped/components/wrapped-story.tsx index f140fe1c..834b33a6 100644 --- a/src/app/wrapped/components/wrapped-story.tsx +++ b/src/app/wrapped/components/wrapped-story.tsx @@ -10,6 +10,7 @@ import { shareWrapped } from "../share-image"; import type { ShareTheme } from "../share-image"; import { SLIDE_REGISTRY } from "../slides"; import { isDark, slideColors } from "../theme"; +import { useImmersiveThemeColor } from "../use-immersive-theme-color"; import { useWrappedPlayer } from "../use-wrapped-player"; import type { WrappedSettings } from "../wrapped.config"; import { DEFAULT_DURATION, PALETTES, resolveSlides } from "../wrapped.config"; @@ -61,6 +62,10 @@ function StoryPlayer({ const Slide = SLIDE_REGISTRY[slideId]; const isEdgeSlide = index === 0 || index === count - 1; + // Edge slides render as a card over the app background, so the browser + // bars should keep their default tint there. + useImmersiveThemeColor(isEdgeSlide ? null : colors.bg); + const onShare = () => { if (sharing.current) { return; diff --git a/src/app/wrapped/slides/index.tsx b/src/app/wrapped/slides/index.tsx index 1c09a980..a083d27f 100644 --- a/src/app/wrapped/slides/index.tsx +++ b/src/app/wrapped/slides/index.tsx @@ -853,7 +853,7 @@ function HardestSlide({ data }: SlideProps) { aria-hidden style={{ position: "absolute", - bottom: "10%", + bottom: "-2%", left: "-3%", transform: "rotate(6deg)", pointerEvents: "none", diff --git a/src/app/wrapped/use-immersive-theme-color.ts b/src/app/wrapped/use-immersive-theme-color.ts new file mode 100644 index 00000000..cc251f4d --- /dev/null +++ b/src/app/wrapped/use-immersive-theme-color.ts @@ -0,0 +1,80 @@ +"use client"; + +import { useEffect, useRef } from "react"; + +/** Keep in step with the fullscreen overlay breakpoint in wrapped.css. */ +const IMMERSIVE_QUERY = "(max-width: 640px)"; + +/** Let the card expand before Safari starts sampling the body tint. */ +const EDGE_EXPAND_MS = 150; + +const IOS_SAFARI_EXCLUDE = /(CriOS|FxiOS|EdgiOS|OPiOS|DuckDuckGo)/; + +function isIosSafari(): boolean { + const { maxTouchPoints, userAgent } = navigator; + const isIos = + /iP(hone|ad|od)/.test(userAgent) || + (userAgent.includes("Macintosh") && maxTouchPoints > 1); + + return ( + isIos && + userAgent.includes("Safari") && + userAgent.includes("Version/") && + !IOS_SAFARI_EXCLUDE.test(userAgent) + ); +} + +/** + * Safari caches tint samples from fixed overlays; the body background updates live. + * During immersive slides, mirror the active colour there so browser chrome follows. + */ +export function useImmersiveThemeColor(color: string | null): void { + // Delay only the first tint after entering immersive mode. + const applied = useRef(false); + + useEffect(() => { + if (color === null || !isIosSafari()) { + applied.current = false; + return; + } + const media = window.matchMedia(IMMERSIVE_QUERY); + const root = document.documentElement; + const { body } = document; + + const previous = { + root: root.style.backgroundColor, + body: body.style.backgroundColor, + }; + + const restore = () => { + root.style.backgroundColor = previous.root; + body.style.backgroundColor = previous.body; + body.classList.remove("wrapped-immersive-body"); + }; + + const sync = () => { + if (media.matches) { + applied.current = true; + body.classList.add("wrapped-immersive-body"); + root.style.backgroundColor = color; + body.style.backgroundColor = color; + } else { + applied.current = false; + restore(); + } + }; + + const timeout = applied.current ? null : setTimeout(sync, EDGE_EXPAND_MS); + if (timeout === null) { + sync(); + } + media.addEventListener("change", sync); + return () => { + if (timeout !== null) { + clearTimeout(timeout); + } + media.removeEventListener("change", sync); + restore(); + }; + }, [color]); +} diff --git a/src/app/wrapped/wrapped.css b/src/app/wrapped/wrapped.css index 965178ef..e2c7e5a9 100644 --- a/src/app/wrapped/wrapped.css +++ b/src/app/wrapped/wrapped.css @@ -144,6 +144,15 @@ width: 100%; } +/* Applied to while the fullscreen story is active so the Safari + toolbar tint (sampled from the body colour) fades in step with the stage. + The layout's gradient background-image would paint over the synced + background-color, so it is suppressed here. */ +body.wrapped-immersive-body { + background-image: none !important; + transition: background-color 0.45s ease; +} + /* 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. */ @@ -154,10 +163,10 @@ } .wrapped-stage { position: fixed !important; - top: 0 !important; - left: 0 !important; - right: 0 !important; - bottom: 0 !important; + top: 0; + left: 0; + right: 0; + bottom: 0; z-index: 50 !important; margin: 0 !important; border-radius: 0 !important; @@ -171,12 +180,19 @@ background-color 0.45s ease, color 0.45s ease; } + @supports (-webkit-touch-callout: none) { + .wrapped-stage:not(.is-edge) { + /* iOS Safari tint workaround: leave tiny body-sampled strips. */ + top: 6px; + bottom: 5px; + } + } .wrapped-stage.is-edge { - top: 4.75rem !important; - left: 0.75rem !important; + top: 4.75rem; + left: 0.75rem; z-index: 10 !important; - right: 0.75rem !important; - bottom: 0.75rem !important; + right: 0.75rem; + bottom: 0.75rem; border-radius: 22px !important; box-shadow: 0 20px 60px -20px rgba(0, 0, 0, 0.5) !important; } diff --git a/src/hooks/use-auto-guest.ts b/src/hooks/use-auto-guest.ts index ea40d96f..684ca65c 100644 --- a/src/hooks/use-auto-guest.ts +++ b/src/hooks/use-auto-guest.ts @@ -12,6 +12,7 @@ const GUEST_EXCLUDED_ROUTES = [ "/oauth", "/login-otp", "/privacy-policy", + "/wrapped", ]; export function useAutoGuest(user: JWTPayload | null) { diff --git a/src/lib/auth/permissions.ts b/src/lib/auth/permissions.ts index a65f1db8..951e792c 100644 --- a/src/lib/auth/permissions.ts +++ b/src/lib/auth/permissions.ts @@ -22,6 +22,8 @@ export enum PermissionAction { AI_FEATURES = "ai_features", // Viewing quiz statistics VIEW_QUIZ_STATS = "view_quiz_stats", + // Viewing Testownik Wrapped summaries + VIEW_WRAPPED = "view_wrapped", } export const PERMISSIONS_BY_ROLE: Record< @@ -42,6 +44,7 @@ export const PERMISSIONS_BY_ROLE: Record< PermissionAction.QUIZ_CONTINUITY, PermissionAction.AI_FEATURES, PermissionAction.VIEW_QUIZ_STATS, + PermissionAction.VIEW_WRAPPED, ], [ACCOUNT_TYPE.STUDENT]: [ PermissionAction.BROWSE_PUBLIC_QUIZZES, @@ -54,6 +57,7 @@ export const PERMISSIONS_BY_ROLE: Record< PermissionAction.QUIZ_CONTINUITY, PermissionAction.AI_FEATURES, PermissionAction.VIEW_QUIZ_STATS, + PermissionAction.VIEW_WRAPPED, ], [ACCOUNT_TYPE.LECTURER]: [ PermissionAction.VIEW_SHARED_QUIZZES, @@ -64,6 +68,7 @@ export const PERMISSIONS_BY_ROLE: Record< PermissionAction.QUIZ_CONTINUITY, PermissionAction.AI_FEATURES, PermissionAction.VIEW_QUIZ_STATS, + PermissionAction.VIEW_WRAPPED, ], };