Add streak counter to flashcards
All checks were successful
Automated Container Build / build-and-push (push) Successful in 1m17s
All checks were successful
Automated Container Build / build-and-push (push) Successful in 1m17s
This commit is contained in:
parent
a6af3a49ad
commit
32bce673ac
6 changed files with 452 additions and 6 deletions
|
|
@ -170,6 +170,65 @@
|
||||||
.animate-slide-up { animation: slideUp .32s var(--ease-spring); }
|
.animate-slide-up { animation: slideUp .32s var(--ease-spring); }
|
||||||
.animate-subtle-pulse { animation: subtlePulse 1.7s ease-in-out infinite; }
|
.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. */
|
/* Arcade routes take over the complete application shell, including navigation. */
|
||||||
body:has(.arcade-route) {
|
body:has(.arcade-route) {
|
||||||
--theme-bg-base: #070b18;
|
--theme-bg-base: #070b18;
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
import { getShareLink } from "@/services/shareService";
|
import { getShareLink, getShareLinkMeta } from "@/services/shareService";
|
||||||
import { notFound, redirect } from "next/navigation";
|
import { notFound, redirect } from "next/navigation";
|
||||||
import { ThemeToggle } from "@/components/ui/ThemeToggle";
|
import { ThemeToggle } from "@/components/ui/ThemeToggle";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { SharedViewer } from "./SharedViewer";
|
import { SharedViewer } from "./SharedViewer";
|
||||||
import { SharedGroupViewer } from "./SharedGroupViewer";
|
import { SharedGroupViewer } from "./SharedGroupViewer";
|
||||||
|
import { buildShareMetadata } from "@/lib/shareMetadata";
|
||||||
|
import type { Metadata } from "next";
|
||||||
import type { SharedGroupData, SharedStudyItem } from "@/types/study";
|
import type { SharedGroupData, SharedStudyItem } from "@/types/study";
|
||||||
|
|
||||||
type SharedContentType = "flashcards" | "quizzes";
|
type SharedContentType = "flashcards" | "quizzes";
|
||||||
|
|
@ -16,6 +18,20 @@ interface SharedPageProps {
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function generateMetadata(
|
||||||
|
props: SharedPageProps & { searchParams: Promise<{ itemId?: string }> },
|
||||||
|
): Promise<Metadata> {
|
||||||
|
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 }> }) {
|
export default async function SharedPage(props: SharedPageProps & { searchParams: Promise<{ itemId?: string }> }) {
|
||||||
const { classSlug, type, token } = await props.params;
|
const { classSlug, type, token } = await props.params;
|
||||||
const { itemId } = await props.searchParams;
|
const { itemId } = await props.searchParams;
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,18 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useCallback, useRef } from "react";
|
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
||||||
import ReactMarkdown from "react-markdown";
|
import ReactMarkdown from "react-markdown";
|
||||||
import remarkGfm from "remark-gfm";
|
import remarkGfm from "remark-gfm";
|
||||||
import { recordStudyActivity } from "@/lib/activityClient";
|
import { recordStudyActivity } from "@/lib/activityClient";
|
||||||
|
import {
|
||||||
|
STREAK_FLAME_THRESHOLD,
|
||||||
|
StreakBurst,
|
||||||
|
StreakCounter,
|
||||||
|
isStreakMilestone,
|
||||||
|
streakBurstDuration,
|
||||||
|
streakTier,
|
||||||
|
type StreakTier,
|
||||||
|
} from "./StreakFlame";
|
||||||
|
|
||||||
interface Card {
|
interface Card {
|
||||||
id: string;
|
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 missedCount = Object.values(results).filter((r) => r === "missed").length;
|
||||||
const totalGraded = correctCount + missedCount;
|
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
|
// Auto-hide toast
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (toastMessage) {
|
if (toastMessage) {
|
||||||
|
|
@ -344,19 +395,20 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
|
||||||
<div className="flex flex-col items-center">
|
<div className="flex flex-col items-center">
|
||||||
{/* Running tally */}
|
{/* Running tally */}
|
||||||
<div className="mb-5 w-full max-w-4xl rounded-xl border border-border-light bg-bg-surface/70 px-4 py-3">
|
<div className="mb-5 w-full max-w-4xl rounded-xl border border-border-light bg-bg-surface/70 px-4 py-3">
|
||||||
<div className="flex items-center justify-between text-sm">
|
<div className="flex flex-wrap items-center justify-between gap-x-3 gap-y-1.5 text-sm">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5">
|
||||||
<span className="text-success font-medium">{correctCount} ✓</span>
|
<span className="text-success font-medium">{correctCount} ✓</span>
|
||||||
<span className="text-error font-medium">{missedCount} ✗</span>
|
<span className="text-error font-medium">{missedCount} ✗</span>
|
||||||
|
{streak >= 2 && <StreakCounter streak={streak} />}
|
||||||
</div>
|
</div>
|
||||||
<span className="text-text-muted">
|
<span className="text-text-muted">
|
||||||
{currentIndex + 1} of {order.length}
|
{currentIndex + 1} of {order.length}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{/* Progress bar */}
|
{/* Progress bar — turns to flame once the streak is hot */}
|
||||||
<div className="w-full bg-bg-surface rounded-full h-2 mt-2 border border-border-light/50 overflow-hidden">
|
<div className="w-full bg-bg-surface rounded-full h-2 mt-2 border border-border-light/50 overflow-hidden">
|
||||||
<div
|
<div
|
||||||
className="h-full bg-primary transition-all duration-300"
|
className={`h-full transition-all duration-300 ${isOnFire ? "streak-bar-flame" : "bg-primary"}`}
|
||||||
style={{ width: `${((currentIndex + 1) / order.length) * 100}%` }}
|
style={{ width: `${((currentIndex + 1) / order.length) * 100}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -433,6 +485,9 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Streak milestone burst */}
|
||||||
|
{burst && <StreakBurst key={burst.id} streak={burst.streak} tier={burst.tier} />}
|
||||||
|
|
||||||
{/* Toast Notification */}
|
{/* Toast Notification */}
|
||||||
{toastMessage && (
|
{toastMessage && (
|
||||||
<div className="absolute bottom-6 left-1/2 -translate-x-1/2 z-30 animate-fade-in pointer-events-none">
|
<div className="absolute bottom-6 left-1/2 -translate-x-1/2 z-30 animate-fade-in pointer-events-none">
|
||||||
|
|
|
||||||
114
src/components/flashcards/StreakFlame.tsx
Normal file
114
src/components/flashcards/StreakFlame.tsx
Normal file
|
|
@ -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<StreakTier, string> = {
|
||||||
|
1: "On fire",
|
||||||
|
2: "Blazing",
|
||||||
|
3: "Unstoppable",
|
||||||
|
};
|
||||||
|
|
||||||
|
const TIER_FLAME_SIZE: Record<StreakTier, string> = {
|
||||||
|
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<StreakTier, string> = {
|
||||||
|
1: "text-lg",
|
||||||
|
2: "text-2xl",
|
||||||
|
3: "text-4xl md:text-5xl",
|
||||||
|
};
|
||||||
|
|
||||||
|
const TIER_DURATION: Record<StreakTier, number> = {
|
||||||
|
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 (
|
||||||
|
<svg className={className} viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id={id} x1="0" y1="1" x2="0" y2="0">
|
||||||
|
<stop offset="0%" stopColor="#ea580c" />
|
||||||
|
<stop offset="55%" stopColor="#fb923c" />
|
||||||
|
<stop offset="100%" stopColor="#fbbf24" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<path
|
||||||
|
fill={`url(#${id})`}
|
||||||
|
fillRule="evenodd"
|
||||||
|
clipRule="evenodd"
|
||||||
|
d="M12.963 2.286a.75.75 0 0 0-1.071-.136 9.742 9.742 0 0 0-3.539 6.177A7.547 7.547 0 0 1 6.648 6.61a.75.75 0 0 0-1.152-.082A9 9 0 1 0 15.68 4.534a7.46 7.46 0 0 1-2.717-2.248ZM15.75 14.25a3.75 3.75 0 1 1-7.313-1.172c.628.465 1.35.81 2.133 1a5.99 5.99 0 0 1 1.925-3.547 3.75 3.75 0 0 1 3.255 3.719Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 (
|
||||||
|
<div
|
||||||
|
className="streak-scrim pointer-events-none absolute inset-0 z-40 flex items-center justify-center rounded-[2rem]"
|
||||||
|
style={{ "--streak-pop-duration": `${TIER_DURATION[tier]}ms` } as CSSProperties}
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
<div className="streak-pop flex flex-col items-center">
|
||||||
|
<FlameIcon className={`streak-pop-flame drop-shadow-[0_6px_24px_rgba(234,88,12,0.55)] ${TIER_FLAME_SIZE[tier]}`} />
|
||||||
|
<span className={`streak-pop-heading mt-1 font-black uppercase tracking-wide ${TIER_HEADING_SIZE[tier]}`}>
|
||||||
|
{TIER_COPY[tier]}
|
||||||
|
</span>
|
||||||
|
<span className="mt-1 text-sm font-semibold text-text-heading">{streak} in a row</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 (
|
||||||
|
<span
|
||||||
|
className={`streak-chip inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-bold whitespace-nowrap ${
|
||||||
|
onFire ? "streak-chip-flame" : "bg-bg-surface-alt text-text-secondary"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{onFire && <FlameIcon className="h-3.5 w-3.5 shrink-0" />}
|
||||||
|
{streak} in a row
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
159
src/lib/shareMetadata.ts
Normal file
159
src/lib/shareMetadata.ts
Normal file
|
|
@ -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}`);
|
||||||
|
}
|
||||||
|
|
@ -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) {
|
export async function getShareLinkForContent(targetType: "DECK" | "QUIZ" | "GROUP", contentId: string) {
|
||||||
if (targetType === "DECK") {
|
if (targetType === "DECK") {
|
||||||
return prisma.shareLink.findUnique({ where: { deckId: contentId } });
|
return prisma.shareLink.findUnique({ where: { deckId: contentId } });
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue