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-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;
|
||||
|
|
|
|||
|
|
@ -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<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 }> }) {
|
||||
const { classSlug, type, token } = await props.params;
|
||||
const { itemId } = await props.searchParams;
|
||||
|
|
|
|||
|
|
@ -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
|
|||
<div className="flex flex-col items-center">
|
||||
{/* 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="flex items-center justify-between text-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-x-3 gap-y-1.5 text-sm">
|
||||
<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-error font-medium">{missedCount} ✗</span>
|
||||
{streak >= 2 && <StreakCounter streak={streak} />}
|
||||
</div>
|
||||
<span className="text-text-muted">
|
||||
{currentIndex + 1} of {order.length}
|
||||
</span>
|
||||
</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="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}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -433,6 +485,9 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Streak milestone burst */}
|
||||
{burst && <StreakBurst key={burst.id} streak={burst.streak} tier={burst.tier} />}
|
||||
|
||||
{/* Toast Notification */}
|
||||
{toastMessage && (
|
||||
<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) {
|
||||
if (targetType === "DECK") {
|
||||
return prisma.shareLink.findUnique({ where: { deckId: contentId } });
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue