From 32bce673ace2b93b68b365d8eb6a29d67f3d9a60 Mon Sep 17 00:00:00 2001 From: Elijah Date: Mon, 27 Jul 2026 14:05:35 -0700 Subject: [PATCH] Add streak counter to flashcards --- src/app/globals.css | 59 +++++++ .../[classSlug]/[type]/[token]/page.tsx | 18 +- src/components/flashcards/FlashcardViewer.tsx | 65 ++++++- src/components/flashcards/StreakFlame.tsx | 114 +++++++++++++ src/lib/shareMetadata.ts | 159 ++++++++++++++++++ src/services/shareService.ts | 43 +++++ 6 files changed, 452 insertions(+), 6 deletions(-) create mode 100644 src/components/flashcards/StreakFlame.tsx create mode 100644 src/lib/shareMetadata.ts diff --git a/src/app/globals.css b/src/app/globals.css index 75eeb37..6d04040 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -170,6 +170,65 @@ .animate-slide-up { animation: slideUp .32s var(--ease-spring); } .animate-subtle-pulse { animation: subtlePulse 1.7s ease-in-out infinite; } +/* Flashcard streaks. Flame colours are deliberately theme-independent: the same + yellow-to-orange ramp reads correctly on both the light and dark surfaces. */ +:root { + --flame-hot: #fbbf24; + --flame-mid: #fb923c; + --flame-core: #ea580c; + --flame-ink: #451a03; +} + +@keyframes streakBarFlow { from { background-position: 0% 50%; } to { background-position: -200% 50%; } } +@keyframes streakChipIn { from { opacity: 0; transform: translateY(-4px) scale(.9); } to { opacity: 1; transform: translateY(0) scale(1); } } +@keyframes streakFlicker { + 0%, 100% { transform: scale(1) rotate(0); } + 25% { transform: scale(1.07) rotate(-2.5deg); } + 60% { transform: scale(.96) rotate(2deg); } +} +@keyframes streakPop { + 0% { opacity: 0; transform: translateY(26px) scale(.55); } + 28% { opacity: 1; transform: translateY(-2px) scale(1.1); } + 44% { transform: translateY(0) scale(1); } + 74% { opacity: 1; transform: translateY(-5px) scale(1); } + 100% { opacity: 0; transform: translateY(-38px) scale(.9); } +} + +.streak-bar-flame { + background-image: linear-gradient(100deg, var(--flame-hot), var(--flame-mid) 35%, var(--flame-core) 55%, var(--flame-mid) 75%, var(--flame-hot)); + background-size: 200% 100%; + animation: streakBarFlow 2.6s linear infinite; + box-shadow: 0 0 12px color-mix(in srgb, var(--flame-core) 55%, transparent); +} + +.streak-chip { animation: streakChipIn .28s var(--ease-spring); } +.streak-chip-flame { + color: var(--flame-ink); + background-image: linear-gradient(100deg, var(--flame-hot), var(--flame-core)); + box-shadow: 0 2px 12px color-mix(in srgb, var(--flame-core) 40%, transparent); +} + +/* Softens the card behind a burst so the milestone stays readable. */ +@keyframes streakScrim { 0% { opacity: 0; } 14% { opacity: 1; } 76% { opacity: 1; } 100% { opacity: 0; } } +.streak-scrim { + background: radial-gradient( + circle at 50% 46%, + color-mix(in srgb, var(--color-bg-surface) 94%, transparent) 0%, + color-mix(in srgb, var(--color-bg-surface) 78%, transparent) 42%, + transparent 76% + ); + animation: streakScrim var(--streak-pop-duration, 1.8s) ease-out forwards; +} + +.streak-pop { animation: streakPop var(--streak-pop-duration, 1.8s) var(--ease-spring) forwards; } +.streak-pop-flame { animation: streakFlicker .9s ease-in-out infinite; transform-origin: 50% 85%; } +.streak-pop-heading { + background-image: linear-gradient(180deg, var(--flame-hot), var(--flame-core)); + background-clip: text; + -webkit-background-clip: text; + color: transparent; +} + /* Arcade routes take over the complete application shell, including navigation. */ body:has(.arcade-route) { --theme-bg-base: #070b18; diff --git a/src/app/shared/[classSlug]/[type]/[token]/page.tsx b/src/app/shared/[classSlug]/[type]/[token]/page.tsx index 501f0bd..c3ec969 100644 --- a/src/app/shared/[classSlug]/[type]/[token]/page.tsx +++ b/src/app/shared/[classSlug]/[type]/[token]/page.tsx @@ -1,9 +1,11 @@ -import { getShareLink } from "@/services/shareService"; +import { getShareLink, getShareLinkMeta } from "@/services/shareService"; import { notFound, redirect } from "next/navigation"; import { ThemeToggle } from "@/components/ui/ThemeToggle"; import Link from "next/link"; import { SharedViewer } from "./SharedViewer"; import { SharedGroupViewer } from "./SharedGroupViewer"; +import { buildShareMetadata } from "@/lib/shareMetadata"; +import type { Metadata } from "next"; import type { SharedGroupData, SharedStudyItem } from "@/types/study"; type SharedContentType = "flashcards" | "quizzes"; @@ -16,6 +18,20 @@ interface SharedPageProps { }>; } +export async function generateMetadata( + props: SharedPageProps & { searchParams: Promise<{ itemId?: string }> }, +): Promise { + const { classSlug, type, token } = await props.params; + const { itemId } = await props.searchParams; + + if (type !== "flashcards" && type !== "quizzes" && type !== "groups") { + return buildShareMetadata(null); + } + + const link = await getShareLinkMeta(token); + return buildShareMetadata(link, { classSlug, itemId }); +} + export default async function SharedPage(props: SharedPageProps & { searchParams: Promise<{ itemId?: string }> }) { const { classSlug, type, token } = await props.params; const { itemId } = await props.searchParams; diff --git a/src/components/flashcards/FlashcardViewer.tsx b/src/components/flashcards/FlashcardViewer.tsx index ecb6f15..05686cf 100644 --- a/src/components/flashcards/FlashcardViewer.tsx +++ b/src/components/flashcards/FlashcardViewer.tsx @@ -1,9 +1,18 @@ "use client"; -import { useState, useEffect, useCallback, useRef } from "react"; +import { useState, useEffect, useCallback, useMemo, useRef } from "react"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import { recordStudyActivity } from "@/lib/activityClient"; +import { + STREAK_FLAME_THRESHOLD, + StreakBurst, + StreakCounter, + isStreakMilestone, + streakBurstDuration, + streakTier, + type StreakTier, +} from "./StreakFlame"; interface Card { id: string; @@ -66,6 +75,48 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre const missedCount = Object.values(results).filter((r) => r === "missed").length; const totalGraded = correctCount + missedCount; + // Current run of consecutive correct cards, derived by walking back from the + // current position. Deriving it (rather than storing it) keeps resumed + // sessions and the "previous card" button correct without extra persistence. + const streak = useMemo(() => { + let run = 0; + for (let i = currentIndex - 1; i >= 0; i--) { + if (results[order[i]] !== "correct") break; + run++; + } + return run; + }, [order, currentIndex, results]); + + const isOnFire = streak >= STREAK_FLAME_THRESHOLD; + + // Milestone celebration. The first settled streak is only a baseline, so + // resuming a session mid-streak never fires a burst on arrival. + const [burst, setBurst] = useState<{ streak: number; tier: StreakTier; id: number } | null>(null); + const prevStreakRef = useRef(0); + const streakBaselineRef = useRef(false); + + useEffect(() => { + if (!isLoaded) return; + + const previous = prevStreakRef.current; + prevStreakRef.current = streak; + + if (!streakBaselineRef.current) { + streakBaselineRef.current = true; + return; + } + + if (streak > previous && isStreakMilestone(streak)) { + setBurst({ streak, tier: streakTier(streak), id: Date.now() }); + } + }, [streak, isLoaded]); + + useEffect(() => { + if (!burst) return; + const timer = setTimeout(() => setBurst(null), streakBurstDuration(burst.tier)); + return () => clearTimeout(timer); + }, [burst]); + // Auto-hide toast useEffect(() => { if (toastMessage) { @@ -344,19 +395,20 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
{/* Running tally */}
-
-
+
+
{correctCount} ✓ {missedCount} ✗ + {streak >= 2 && }
{currentIndex + 1} of {order.length}
- {/* Progress bar */} + {/* Progress bar — turns to flame once the streak is hot */}
@@ -433,6 +485,9 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
)} + {/* Streak milestone burst */} + {burst && } + {/* Toast Notification */} {toastMessage && (
diff --git a/src/components/flashcards/StreakFlame.tsx b/src/components/flashcards/StreakFlame.tsx new file mode 100644 index 0000000..bdd5338 --- /dev/null +++ b/src/components/flashcards/StreakFlame.tsx @@ -0,0 +1,114 @@ +"use client"; + +import { useId, type CSSProperties } from "react"; + +/** + * Streak visuals for the flashcard viewer. A streak is a run of consecutive + * "correct" grades; milestones fire a flame burst that clears itself. + */ + +export type StreakTier = 1 | 2 | 3; + +/** Milestones: 5, then every 10 (10, 20, 30, ...). */ +export function isStreakMilestone(streak: number) { + return streak === 5 || (streak >= 10 && streak % 10 === 0); +} + +export function streakTier(streak: number): StreakTier { + if (streak >= 20) return 3; + if (streak >= 10) return 2; + return 1; +} + +/** A streak this long turns the progress bar (and counter) to flame. */ +export const STREAK_FLAME_THRESHOLD = 5; + +const TIER_COPY: Record = { + 1: "On fire", + 2: "Blazing", + 3: "Unstoppable", +}; + +const TIER_FLAME_SIZE: Record = { + 1: "h-16 w-16", + 2: "h-24 w-24", + 3: "h-36 w-36 md:h-44 md:w-44", +}; + +const TIER_HEADING_SIZE: Record = { + 1: "text-lg", + 2: "text-2xl", + 3: "text-4xl md:text-5xl", +}; + +const TIER_DURATION: Record = { + 1: 1600, + 2: 1900, + 3: 2400, +}; + +export function streakBurstDuration(tier: StreakTier) { + return TIER_DURATION[tier]; +} + +export function FlameIcon({ className }: { className?: string }) { + // useId() output contains punctuation that is unsafe inside a url(#...) + // reference, so strip it down to an id the gradient fill can resolve. + const id = `flame-gradient-${useId().replace(/[^a-zA-Z0-9]/g, "")}`; + return ( + + ); +} + +/** + * The celebratory flame overlay. Rendered inside the card's relative wrapper and + * never interactive, so taps still reach the card underneath. + */ +export function StreakBurst({ streak, tier }: { streak: number; tier: StreakTier }) { + return ( +
+
+ + + {TIER_COPY[tier]} + + {streak} in a row +
+
+ ); +} + +/** The persistent "x in a row" counter shown next to the running tally. */ +export function StreakCounter({ streak }: { streak: number }) { + const onFire = streak >= STREAK_FLAME_THRESHOLD; + + return ( + + {onFire && } + {streak} in a row + + ); +} diff --git a/src/lib/shareMetadata.ts b/src/lib/shareMetadata.ts new file mode 100644 index 0000000..8613ebf --- /dev/null +++ b/src/lib/shareMetadata.ts @@ -0,0 +1,159 @@ +import type { Metadata } from "next"; + +/** + * Builds the link-preview metadata (Discord, Slack, iMessage, ...) for a public + * share URL, so every shared deck, quiz and group describes itself rather than + * falling back to the generic site description. + */ + +const SITE_NAME = "Study Desk"; +const SITE_DESCRIPTION = "A focused workspace for flashcards and practice quizzes"; + +interface MetaClass { + slug: string; + name: string; +} + +interface MetaItem { + id: string; + name: string; + description: string | null; + count: number; +} + +export interface ShareMetaLink { + deck: { + name: string; + description: string | null; + class: MetaClass; + _count: { cards: number }; + } | null; + quizSet: { + name: string; + description: string | null; + class: MetaClass; + _count: { questions: number }; + } | null; + group: { + name: string; + type: string; + class: MetaClass; + decks: { id: string; name: string; description: string | null; _count: { cards: number } }[]; + quizSets: { id: string; name: string; description: string | null; _count: { questions: number } }[]; + } | null; +} + +function count(n: number, singular: string, plural: string) { + return `${n} ${n === 1 ? singular : plural}`; +} + +/** Collapses Markdown-ish whitespace and keeps previews to a sane length. */ +function tidy(text: string, maxLength = 200) { + const flat = text.replace(/\s+/g, " ").trim(); + if (flat.length <= maxLength) return flat; + return `${flat.slice(0, maxLength - 1).trimEnd()}…`; +} + +function toMetadata(title: string, description: string): Metadata { + return { + title, + description, + openGraph: { + title, + description, + siteName: SITE_NAME, + type: "website", + }, + twitter: { + card: "summary", + title, + description, + }, + }; +} + +function itemMetadata( + item: MetaItem, + className: string, + kind: "flashcards" | "quiz", + groupName?: string, +) { + const title = `${item.name} · ${className}`; + const context = groupName ? `${className} ${groupName}` : className; + const fallback = + kind === "flashcards" + ? `${context} flashcard deck · ${count(item.count, "card", "cards")}` + : `${context} practice quiz · ${count(item.count, "question", "questions")}`; + + const described = item.description?.trim(); + return toMetadata(title, described ? tidy(described) : fallback); +} + +/** + * `itemId` mirrors the `?itemId=` deep link into a shared group, which shows a + * single deck or quiz instead of the group index. + */ +export function buildShareMetadata( + link: ShareMetaLink | null, + options: { classSlug?: string; itemId?: string } = {}, +): Metadata { + const { classSlug, itemId } = options; + + if (!link) return toMetadata(SITE_NAME, SITE_DESCRIPTION); + + // Never describe content whose URL does not agree with the stored class, so + // previews stay silent for the same requests the page itself rejects. + const target = link.deck ?? link.quizSet ?? link.group; + if (!target || (classSlug && target.class.slug !== classSlug)) { + return toMetadata(SITE_NAME, SITE_DESCRIPTION); + } + + if (link.deck) { + return itemMetadata( + { id: "", name: link.deck.name, description: link.deck.description, count: link.deck._count.cards }, + link.deck.class.name, + "flashcards", + ); + } + + if (link.quizSet) { + return itemMetadata( + { id: "", name: link.quizSet.name, description: link.quizSet.description, count: link.quizSet._count.questions }, + link.quizSet.class.name, + "quiz", + ); + } + + const group = link.group!; + const className = group.class.name; + const isDeckGroup = group.type === "DECK"; + + if (itemId) { + const deck = isDeckGroup ? group.decks.find((d) => d.id === itemId) : undefined; + const quizSet = isDeckGroup ? undefined : group.quizSets.find((q) => q.id === itemId); + + if (deck) { + return itemMetadata( + { id: deck.id, name: deck.name, description: deck.description, count: deck._count.cards }, + className, + "flashcards", + group.name, + ); + } + + if (quizSet) { + return itemMetadata( + { id: quizSet.id, name: quizSet.name, description: quizSet.description, count: quizSet._count.questions }, + className, + "quiz", + group.name, + ); + } + } + + const members = isDeckGroup + ? count(group.decks.length, "flashcard deck", "flashcard decks") + : count(group.quizSets.length, "quiz", "quizzes"); + + return toMetadata(`${group.name} · ${className}`, `${className} ${group.name} — ${members}`); +} diff --git a/src/services/shareService.ts b/src/services/shareService.ts index 8f6e4e1..71058eb 100644 --- a/src/services/shareService.ts +++ b/src/services/shareService.ts @@ -49,6 +49,49 @@ export async function getShareLink(token: string) { }); } +/** + * Names, descriptions and counts only — enough to build social/link-preview + * metadata without loading every card, question and option. + */ +export async function getShareLinkMeta(token: string) { + return prisma.shareLink.findUnique({ + where: { id: token }, + select: { + deck: { + select: { + name: true, + description: true, + class: { select: { slug: true, name: true } }, + _count: { select: { cards: true } }, + }, + }, + quizSet: { + select: { + name: true, + description: true, + class: { select: { slug: true, name: true } }, + _count: { select: { questions: true } }, + }, + }, + group: { + select: { + name: true, + type: true, + class: { select: { slug: true, name: true } }, + decks: { + select: { id: true, name: true, description: true, _count: { select: { cards: true } } }, + orderBy: { sortOrder: "asc" }, + }, + quizSets: { + select: { id: true, name: true, description: true, _count: { select: { questions: true } } }, + orderBy: { sortOrder: "asc" }, + }, + }, + }, + }, + }); +} + export async function getShareLinkForContent(targetType: "DECK" | "QUIZ" | "GROUP", contentId: string) { if (targetType === "DECK") { return prisma.shareLink.findUnique({ where: { deckId: contentId } });