Add streak counter to flashcards
All checks were successful
Automated Container Build / build-and-push (push) Successful in 1m17s

This commit is contained in:
Elijah 2026-07-27 14:05:35 -07:00
parent a6af3a49ad
commit 32bce673ac
6 changed files with 452 additions and 6 deletions

159
src/lib/shareMetadata.ts Normal file
View 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}`);
}