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
10 changes: 1 addition & 9 deletions src/app/wrapped/components/empty-state.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 : (
<>
{" "}
<b style={{ color: "#fff" }}>
Bez aktywności w danym semestrze nie mamy z czego go złożyć.
</b>
</>
)}
: "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."}
</p>
<div style={{ marginTop: "30px", alignSelf: "center" }}>
<Link
Expand Down
112 changes: 98 additions & 14 deletions src/app/wrapped/components/wrapped-experience.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
"use client";

import { useQuery } from "@tanstack/react-query";
import { LogInIcon } from "lucide-react";
import Image from "next/image";
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 Logo from "@/assets/logo.svg";
import { Button, ButtonLink } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import { PermissionAction } from "@/lib/auth/permissions";
import { getWrappedService } from "@/services";
import { ACCOUNT_TYPE } from "@/types/user";

import { wrappedFontVariables } from "../fonts";
import type { WrappedSettings } from "../wrapped.config";
Expand Down Expand Up @@ -57,23 +62,97 @@ function WrappedCard({ children }: { children: React.ReactNode }) {
function WrappedLoading() {
return (
<WrappedCard>
<span
<Spinner className="size-9 text-[#c6ff3a]" />
</WrappedCard>
);
}
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 (
<WrappedCard>
<div
aria-hidden
style={{
width: "34px",
height: "34px",
borderRadius: "50%",
border: "3px solid rgba(255,255,255,.2)",
borderTopColor: "#c6ff3a",
animation: "wr-spin .8s linear infinite",
width: "64px",
height: "64px",
borderRadius: "var(--radius-xl)",
background: "#c6ff3a",
color: "#15171c",
display: "grid",
placeItems: "center",
boxShadow: "0 18px 55px -24px #c6ff3a",
}}
/>
>
{/* eslint-disable-next-line @typescript-eslint/no-unsafe-assignment */}
<Image src={Logo} alt="Logo" className="h-9" />
</div>
<div style={{ display: "grid", gap: "10px", maxWidth: "34ch" }}>
<p
style={{
margin: 0,
color: "#c6ff3a",
fontFamily: "var(--font-sans)",
fontSize: "12px",
fontWeight: 700,
letterSpacing: ".18em",
textTransform: "uppercase",
}}
>
Testownik Wrapped
</p>
<h1
style={{
margin: 0,
fontFamily: "var(--font-display)",
fontSize: "clamp(34px, 9vw, 56px)",
lineHeight: 0.98,
textTransform: "uppercase",
whiteSpace: "pre-wrap",
}}
>
{title}
</h1>
<p
style={{
margin: 0,
color: "rgba(255,255,255,.72)",
fontSize: "17px",
lineHeight: 1.5,
}}
>
{description}
</p>
</div>
<ButtonLink
href={`/login?redirect=${encodeURIComponent(redirectPath)}`}
size="lg"
className="mt-2 bg-[#c6ff3a] text-[#15171c] hover:bg-[#d7ff66]"
>
<LogInIcon />
{isGuest ? "Przejdź do logowania" : "Zaloguj się"}
</ButtonLink>
</WrappedCard>
);
}

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] =
Expand Down Expand Up @@ -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 <LoginPrompt />;
if (!canViewWrapped) {
return (
<WrappedLogin
isGuest={user?.account_type === ACCOUNT_TYPE.GUEST}
isGlobal={mode === "global"}
/>
);
}

if (isPending) {
Expand Down
5 changes: 5 additions & 0 deletions src/app/wrapped/components/wrapped-story.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion src/app/wrapped/slides/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -853,7 +853,7 @@ function HardestSlide({ data }: SlideProps) {
aria-hidden
style={{
position: "absolute",
bottom: "10%",
bottom: "-2%",
left: "-3%",
transform: "rotate(6deg)",
pointerEvents: "none",
Expand Down
80 changes: 80 additions & 0 deletions src/app/wrapped/use-immersive-theme-color.ts
Original file line number Diff line number Diff line change
@@ -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]);
}
32 changes: 24 additions & 8 deletions src/app/wrapped/wrapped.css
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,15 @@
width: 100%;
}

/* Applied to <body> 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. */
Expand All @@ -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;
Expand All @@ -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;
}
Expand Down
1 change: 1 addition & 0 deletions src/hooks/use-auto-guest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const GUEST_EXCLUDED_ROUTES = [
"/oauth",
"/login-otp",
"/privacy-policy",
"/wrapped",
];

export function useAutoGuest(user: JWTPayload | null) {
Expand Down
5 changes: 5 additions & 0 deletions src/lib/auth/permissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -64,6 +68,7 @@ export const PERMISSIONS_BY_ROLE: Record<
PermissionAction.QUIZ_CONTINUITY,
PermissionAction.AI_FEATURES,
PermissionAction.VIEW_QUIZ_STATS,
PermissionAction.VIEW_WRAPPED,
],
};

Expand Down
Loading