Compare commits
10
Commits
2597c6f2ec
...
6a6143ceb2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a6143ceb2 | ||
|
|
9f67da37a6 | ||
|
|
cce8a4c05f | ||
|
|
b391e1c575 | ||
|
|
3ce08428c8 | ||
|
|
bf8db28a7c | ||
|
|
cc9ea45c61 | ||
|
|
7a9d362b15 | ||
|
|
c001eeab45 | ||
|
|
2b0f966f90 |
@@ -9,6 +9,7 @@ import {
|
|||||||
} from "@/lib/transcriptions";
|
} from "@/lib/transcriptions";
|
||||||
import { buildOgMeta, ogForTranscription, extractQuoteFromHl } from "@/lib/og";
|
import { buildOgMeta, ogForTranscription, extractQuoteFromHl } from "@/lib/og";
|
||||||
import { PageHeader } from "@/components/layout/page-header";
|
import { PageHeader } from "@/components/layout/page-header";
|
||||||
|
import { QuizButton } from "@/components/quiz/quiz-button";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { buttonVariants } from "@/components/ui/button";
|
import { buttonVariants } from "@/components/ui/button";
|
||||||
@@ -95,6 +96,9 @@ export default async function TranscriptionPage({
|
|||||||
/>
|
/>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
sticky
|
sticky
|
||||||
|
// With audio, the quiz button sits beside the play control below
|
||||||
|
// instead; without it, the header shows it as on any other page.
|
||||||
|
quiz={!t.audioUrl}
|
||||||
offline={{
|
offline={{
|
||||||
type: "transcript",
|
type: "transcript",
|
||||||
title: t.name,
|
title: t.name,
|
||||||
@@ -168,7 +172,8 @@ export default async function TranscriptionPage({
|
|||||||
|
|
||||||
{/* Play Audio button */}
|
{/* Play Audio button */}
|
||||||
{t.audioUrl && (
|
{t.audioUrl && (
|
||||||
<div className="w-full md:w-[25%] flex justify-end">
|
<div className="w-full md:w-auto flex flex-wrap items-center justify-end gap-2">
|
||||||
|
<QuizButton path={`/transcriptions/${t.slug}`} className="py-2" />
|
||||||
<PlayAudioButton
|
<PlayAudioButton
|
||||||
audioUrl={t.audioUrl}
|
audioUrl={t.audioUrl}
|
||||||
title={t.name}
|
title={t.name}
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import { toast } from "sonner";
|
|||||||
import { channelAuthorizer } from "@/lib/realtime";
|
import { channelAuthorizer } from "@/lib/realtime";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useVisualViewport } from "@/lib/use-visual-viewport";
|
import { useVisualViewport } from "@/lib/use-visual-viewport";
|
||||||
|
import { ConfirmModal } from "@/components/ui/confirm-modal";
|
||||||
import { useNotifications } from "@/components/notifications/notifications-provider";
|
import { useNotifications } from "@/components/notifications/notifications-provider";
|
||||||
import {
|
import {
|
||||||
chat,
|
chat,
|
||||||
@@ -71,6 +72,9 @@ export default function ChatThreadPage() {
|
|||||||
const [replyTo, setReplyTo] = useState<ChatMessage | null>(null);
|
const [replyTo, setReplyTo] = useState<ChatMessage | null>(null);
|
||||||
const [menu, setMenu] = useState<{ message: ChatMessage; point: MenuPoint } | null>(null);
|
const [menu, setMenu] = useState<{ message: ChatMessage; point: MenuPoint } | null>(null);
|
||||||
const [forwarding, setForwarding] = useState<ChatMessage | null>(null);
|
const [forwarding, setForwarding] = useState<ChatMessage | null>(null);
|
||||||
|
// Non-null asks before deleting for everyone. Deleting only for yourself is
|
||||||
|
// reversible by the others still having it, so it goes straight through.
|
||||||
|
const [confirmDelete, setConfirmDelete] = useState<ChatMessage | null>(null);
|
||||||
const [starredIds, setStarredIds] = useState<Set<string>>(new Set());
|
const [starredIds, setStarredIds] = useState<Set<string>>(new Set());
|
||||||
// user id → when they last typed. Kept as timestamps rather than a boolean
|
// user id → when they last typed. Kept as timestamps rather than a boolean
|
||||||
// so the indicator expires on its own: there is no "stopped typing" event,
|
// so the indicator expires on its own: there is no "stopped typing" event,
|
||||||
@@ -295,12 +299,6 @@ export default function ChatThreadPage() {
|
|||||||
|
|
||||||
const deleteMessage = useCallback(
|
const deleteMessage = useCallback(
|
||||||
async (m: ChatMessage, scope: "me" | "everyone") => {
|
async (m: ChatMessage, scope: "me" | "everyone") => {
|
||||||
if (
|
|
||||||
scope === "everyone" &&
|
|
||||||
!window.confirm("Delete this message for everyone?")
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
await chat.deleteMessage(id, m.id, scope);
|
await chat.deleteMessage(id, m.id, scope);
|
||||||
if (scope === "me") {
|
if (scope === "me") {
|
||||||
@@ -695,7 +693,8 @@ export default function ChatThreadPage() {
|
|||||||
// Only the sender may erase a message for everyone.
|
// Only the sender may erase a message for everyone.
|
||||||
onDeleteForEveryone:
|
onDeleteForEveryone:
|
||||||
menu.message.sender_id === user.id
|
menu.message.sender_id === user.id
|
||||||
? () => void deleteMessage(menu.message, "everyone")
|
? // Ask first — the modal calls deleteMessage on confirm.
|
||||||
|
() => setConfirmDelete(menu.message)
|
||||||
: undefined,
|
: undefined,
|
||||||
// Reporting your own message is meaningless.
|
// Reporting your own message is meaningless.
|
||||||
onReport:
|
onReport:
|
||||||
@@ -714,6 +713,17 @@ export default function ChatThreadPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<ConfirmModal
|
||||||
|
open={confirmDelete !== null}
|
||||||
|
onClose={() => setConfirmDelete(null)}
|
||||||
|
title="Delete for everyone?"
|
||||||
|
message="This removes the message from the conversation for all participants. It can't be undone."
|
||||||
|
confirmLabel="Delete"
|
||||||
|
onConfirm={async () => {
|
||||||
|
if (confirmDelete) await deleteMessage(confirmDelete, "everyone");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
{forwarding && (
|
{forwarding && (
|
||||||
<ForwardDialog
|
<ForwardDialog
|
||||||
message={forwarding}
|
message={forwarding}
|
||||||
|
|||||||
+84
-15
@@ -16,14 +16,19 @@
|
|||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { MessageCircle, Users } from "lucide-react";
|
import { MessageCircle, Search, Users, X } from "lucide-react";
|
||||||
import { useAuth } from "@/components/auth/auth-provider";
|
import { useAuth } from "@/components/auth/auth-provider";
|
||||||
import { useAuthGate } from "@/components/auth/auth-gate";
|
import { useAuthGate } from "@/components/auth/auth-gate";
|
||||||
import { useNotifications } from "@/components/notifications/notifications-provider";
|
import { useNotifications } from "@/components/notifications/notifications-provider";
|
||||||
import { KautilyaIcon } from "@/components/selection-toolbar/shared";
|
import { KautilyaIcon } from "@/components/selection-toolbar/shared";
|
||||||
import { ChatListItem } from "@/components/chat/chat-list-item";
|
import { ChatListItem } from "@/components/chat/chat-list-item";
|
||||||
import { NewGroupDialog } from "@/components/chat/new-group-dialog";
|
import { NewGroupDialog } from "@/components/chat/new-group-dialog";
|
||||||
import { chat, type InboxItem, type InboxTab } from "@/lib/chat";
|
import {
|
||||||
|
chat,
|
||||||
|
conversationTitle,
|
||||||
|
type InboxItem,
|
||||||
|
type InboxTab,
|
||||||
|
} from "@/lib/chat";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
/** How long a row stays highlighted after a message lands. */
|
/** How long a row stays highlighted after a message lands. */
|
||||||
@@ -36,6 +41,15 @@ export default function ChatsPage() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const [tab, setTab] = useState<InboxTab>("chats");
|
const [tab, setTab] = useState<InboxTab>("chats");
|
||||||
|
const [searching, setSearching] = useState(false);
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
|
||||||
|
// Closing clears the term too: leaving a filter applied behind a collapsed
|
||||||
|
// field is how a chat goes missing and looks like a bug.
|
||||||
|
const closeSearch = useCallback(() => {
|
||||||
|
setSearching(false);
|
||||||
|
setQuery("");
|
||||||
|
}, []);
|
||||||
const [items, setItems] = useState<InboxItem[]>([]);
|
const [items, setItems] = useState<InboxItem[]>([]);
|
||||||
const [state, setState] = useState<"loading" | "ready" | "error">("loading");
|
const [state, setState] = useState<"loading" | "ready" | "error">("loading");
|
||||||
const [highlighted, setHighlighted] = useState<Set<string>>(new Set());
|
const [highlighted, setHighlighted] = useState<Set<string>>(new Set());
|
||||||
@@ -120,22 +134,69 @@ export default function ChatsPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Filtered here rather than on the server: the inbox is already loaded in
|
||||||
|
// full, so this is instant and keeps working offline. Matches the thread's
|
||||||
|
// name and, for a DM, the peer's handle — the two things you actually
|
||||||
|
// remember someone by.
|
||||||
|
const term = query.trim().toLowerCase();
|
||||||
|
const visible = term
|
||||||
|
? items.filter((item) => {
|
||||||
|
const title = conversationTitle(item).toLowerCase();
|
||||||
|
const handle = item.peer?.handle?.toLowerCase() ?? "";
|
||||||
|
return title.includes(term) || handle.includes(term);
|
||||||
|
})
|
||||||
|
: items;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="flex-1 max-w-2xl w-full mx-auto px-4 sm:px-6 py-6 space-y-6">
|
<main className="flex-1 max-w-2xl w-full mx-auto px-4 sm:px-6 py-6 space-y-6">
|
||||||
<div className="flex items-center justify-between gap-3">
|
{searching ? (
|
||||||
<h1 className="font-[family-name:var(--font-title)] text-3xl font-semibold text-foreground">
|
// The search field REPLACES the header rather than sitting under it:
|
||||||
Chats
|
// the title and New group are not useful while filtering, and stacking
|
||||||
</h1>
|
// a second row would push the list down every time it opens.
|
||||||
{tab === "chats" && (
|
<div className="flex items-center gap-2 rounded-lg border border-border bg-muted/40 px-3 py-2">
|
||||||
|
<Search className="size-4 shrink-0 text-muted-foreground" />
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
placeholder="Search chats"
|
||||||
|
className="flex-1 bg-transparent text-sm outline-none pointer-coarse:text-base placeholder:text-muted-foreground"
|
||||||
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setCreatingGroup(true)}
|
onClick={closeSearch}
|
||||||
className="inline-flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-sm font-medium text-foreground hover:bg-muted/60"
|
aria-label="Close search"
|
||||||
|
className="shrink-0 rounded-md p-1 text-muted-foreground hover:text-foreground"
|
||||||
>
|
>
|
||||||
<Users className="size-4" /> New group
|
<X className="size-4" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
</div>
|
||||||
</div>
|
) : (
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<h1 className="font-[family-name:var(--font-title)] text-3xl font-semibold text-foreground">
|
||||||
|
Chats
|
||||||
|
</h1>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSearching(true)}
|
||||||
|
aria-label="Search chats"
|
||||||
|
className="inline-flex size-9 items-center justify-center rounded-lg border border-border text-foreground hover:bg-muted/60"
|
||||||
|
>
|
||||||
|
<Search className="size-4" />
|
||||||
|
</button>
|
||||||
|
{tab === "chats" && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCreatingGroup(true)}
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-sm font-medium text-foreground hover:bg-muted/60"
|
||||||
|
>
|
||||||
|
<Users className="size-4" /> New group
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex gap-1 rounded-lg bg-muted p-1">
|
<div className="flex gap-1 rounded-lg bg-muted p-1">
|
||||||
<TabButton active={tab === "chats"} onClick={() => setTab("chats")}>
|
<TabButton active={tab === "chats"} onClick={() => setTab("chats")}>
|
||||||
@@ -155,8 +216,16 @@ export default function ChatsPage() {
|
|||||||
</p>
|
</p>
|
||||||
) : state === "loading" ? (
|
) : state === "loading" ? (
|
||||||
<p className="text-sm text-muted-foreground">Loading…</p>
|
<p className="text-sm text-muted-foreground">Loading…</p>
|
||||||
) : items.length === 0 ? (
|
) : visible.length === 0 ? (
|
||||||
tab === "kautilya" ? (
|
// A search that matches nothing is NOT an empty inbox: saying "no
|
||||||
|
// chats yet" there reads as data loss rather than a narrow filter.
|
||||||
|
term ? (
|
||||||
|
<Empty
|
||||||
|
icon={<Search className="size-8" />}
|
||||||
|
title="No matches"
|
||||||
|
text={`Nothing in ${tab === "kautilya" ? "Kautilya" : "your chats"} matches “${query.trim()}”.`}
|
||||||
|
/>
|
||||||
|
) : tab === "kautilya" ? (
|
||||||
<Empty
|
<Empty
|
||||||
icon={<KautilyaIcon className="size-8" />}
|
icon={<KautilyaIcon className="size-8" />}
|
||||||
title="Kautilya is coming soon"
|
title="Kautilya is coming soon"
|
||||||
@@ -171,7 +240,7 @@ export default function ChatsPage() {
|
|||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
<ul className="divide-y divide-border rounded-2xl border border-border overflow-hidden">
|
<ul className="divide-y divide-border rounded-2xl border border-border overflow-hidden">
|
||||||
{items.map((item) => (
|
{visible.map((item) => (
|
||||||
<ChatListItem
|
<ChatListItem
|
||||||
key={item.id}
|
key={item.id}
|
||||||
item={item}
|
item={item}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
type AppNotification,
|
type AppNotification,
|
||||||
type UserSummary,
|
type UserSummary,
|
||||||
} from "@/lib/social";
|
} from "@/lib/social";
|
||||||
|
import { RichText } from "@/lib/rich-text";
|
||||||
import { UserAvatar } from "@/components/ui/user-avatar";
|
import { UserAvatar } from "@/components/ui/user-avatar";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { timeAgo } from "@/lib/time";
|
import { timeAgo } from "@/lib/time";
|
||||||
@@ -275,10 +276,16 @@ function NotificationRow({
|
|||||||
<UserAvatar src={isAdmin ? null : n.actor_avatar_url} name={display} />
|
<UserAvatar src={isAdmin ? null : n.actor_avatar_url} name={display} />
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
{isAdmin ? (
|
{isAdmin ? (
|
||||||
<p className="text-sm text-foreground leading-snug whitespace-pre-line">
|
// A div, not a <p>: the formatted body nests elements, and RichText
|
||||||
|
// handles line breaks itself (so no whitespace-pre-line either).
|
||||||
|
<div className="text-sm text-foreground leading-snug">
|
||||||
<span className="font-semibold">{display}</span>
|
<span className="font-semibold">{display}</span>
|
||||||
{n.body ? <span className="block mt-0.5">{n.body}</span> : null}
|
{n.body ? (
|
||||||
</p>
|
<span className="block mt-0.5">
|
||||||
|
<RichText text={n.body} />
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-foreground leading-snug">
|
<p className="text-sm text-foreground leading-snug">
|
||||||
<span className="font-semibold">{display}</span> {message}
|
<span className="font-semibold">{display}</span> {message}
|
||||||
|
|||||||
+23
-6
@@ -6,6 +6,7 @@ import { useAuth } from "@/components/auth/auth-provider";
|
|||||||
import { SadhnaStreakCTA } from "@/components/sadhna/streak-cta";
|
import { SadhnaStreakCTA } from "@/components/sadhna/streak-cta";
|
||||||
import { VaniOfTheDayCTA } from "@/components/vani/vani-of-the-day-cta";
|
import { VaniOfTheDayCTA } from "@/components/vani/vani-of-the-day-cta";
|
||||||
import { HomeFeed } from "@/components/feed/home-feed";
|
import { HomeFeed } from "@/components/feed/home-feed";
|
||||||
|
import { QuizzesCTA } from "@/components/quiz/quizzes-cta";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* / — the Home (Feed) gate.
|
* / — the Home (Feed) gate.
|
||||||
@@ -33,9 +34,17 @@ export default function HomePage() {
|
|||||||
// mobile this max-width is wider than the viewport, so it's full-bleed
|
// mobile this max-width is wider than the viewport, so it's full-bleed
|
||||||
// as before.
|
// as before.
|
||||||
<main className="flex-1 max-w-lg w-full mx-auto px-4 sm:px-6 py-6">
|
<main className="flex-1 max-w-lg w-full mx-auto px-4 sm:px-6 py-6">
|
||||||
<div className="mb-6 flex flex-col gap-3">
|
<div className="mb-6">
|
||||||
<SadhnaStreakCTA />
|
<div className="flex h-24 gap-3">
|
||||||
<VaniOfTheDayCTA />
|
{/* A compact sādhana tile beside a wider Vāṇī of the Day: the
|
||||||
|
streak is a daily habit you already know how to reach, so it
|
||||||
|
needs only a recognisable mark; the vāṇī changes daily and
|
||||||
|
earns the room to say what it is. */}
|
||||||
|
<SadhnaStreakCTA compact />
|
||||||
|
<VaniOfTheDayCTA className="h-full min-w-0 max-w-none flex-1" />
|
||||||
|
</div>
|
||||||
|
{/* Only while something is left to take. */}
|
||||||
|
<QuizzesCTA pendingOnly className="mt-3" />
|
||||||
</div>
|
</div>
|
||||||
<HomeFeed />
|
<HomeFeed />
|
||||||
</main>
|
</main>
|
||||||
@@ -88,9 +97,17 @@ export default function HomePage() {
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Daily sādhana streak + Vāṇī of the day — entry points for visitors. */}
|
{/* Daily sādhana streak + Vāṇī of the day — entry points for visitors. */}
|
||||||
<div className="mt-6 w-full max-w-md flex flex-col gap-3">
|
<div className="mt-6 w-full max-w-md">
|
||||||
<SadhnaStreakCTA />
|
<div className="flex h-24 gap-3">
|
||||||
<VaniOfTheDayCTA />
|
{/* A compact sādhana tile beside a wider Vāṇī of the Day: the
|
||||||
|
streak is a daily habit you already know how to reach, so it
|
||||||
|
needs only a recognisable mark; the vāṇī changes daily and
|
||||||
|
earns the room to say what it is. */}
|
||||||
|
<SadhnaStreakCTA compact />
|
||||||
|
<VaniOfTheDayCTA className="h-full min-w-0 max-w-none flex-1" />
|
||||||
|
</div>
|
||||||
|
{/* Only while something is left to take. */}
|
||||||
|
<QuizzesCTA pendingOnly className="mt-3" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -0,0 +1,306 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Taking a quiz: an introduction, then one question at a time, then your
|
||||||
|
* result.
|
||||||
|
*
|
||||||
|
* Each question is fetched from the BFF as you reach it, and the BFF starts
|
||||||
|
* its clock at that moment — so response times are measured server-side and
|
||||||
|
* reloading the page doesn't restart them. Correct answers are only revealed
|
||||||
|
* on the results screen, never while you're still answering.
|
||||||
|
*
|
||||||
|
* Tapping an option only selects it; the answer is sent, and becomes final,
|
||||||
|
* when you press Submit. Multi questions ("select all that apply") take
|
||||||
|
* several selections.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useParams } from "next/navigation";
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
ArrowRight,
|
||||||
|
Check,
|
||||||
|
ClipboardCheck,
|
||||||
|
Loader2,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { useAuth } from "@/components/auth/auth-provider";
|
||||||
|
import { useAuthGate } from "@/components/auth/auth-gate";
|
||||||
|
import { RichText } from "@/lib/rich-text";
|
||||||
|
import {
|
||||||
|
quiz as quizApi,
|
||||||
|
quizActionLabel,
|
||||||
|
QuizError,
|
||||||
|
type QuizCard,
|
||||||
|
type QuizPlay,
|
||||||
|
type QuizResult,
|
||||||
|
} from "@/lib/quiz";
|
||||||
|
import { QuizResultView } from "@/components/quiz/quiz-result-view";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
type Stage =
|
||||||
|
| { kind: "loading" }
|
||||||
|
| { kind: "intro"; card: QuizCard }
|
||||||
|
| { kind: "playing"; card: QuizCard; play: QuizPlay }
|
||||||
|
| { kind: "result"; card: QuizCard; result: QuizResult }
|
||||||
|
| { kind: "missing" };
|
||||||
|
|
||||||
|
export default function QuizPage() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const { user } = useAuth();
|
||||||
|
const { requireAuth } = useAuthGate();
|
||||||
|
const [stage, setStage] = useState<Stage>({ kind: "loading" });
|
||||||
|
const [selected, setSelected] = useState<string[]>([]);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
// Every new question starts with nothing selected.
|
||||||
|
const goTo = (card: QuizCard, play: QuizPlay) => {
|
||||||
|
setSelected([]);
|
||||||
|
setStage({ kind: "playing", card, play });
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggle = (play: QuizPlay, optionId: string) => {
|
||||||
|
if (submitting) return;
|
||||||
|
setSelected((cur) =>
|
||||||
|
play.question?.multi
|
||||||
|
? cur.includes(optionId)
|
||||||
|
? cur.filter((x) => x !== optionId)
|
||||||
|
: [...cur, optionId]
|
||||||
|
: [optionId],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const showResult = useCallback(
|
||||||
|
async (card: QuizCard) => {
|
||||||
|
const result = await quizApi.result(id);
|
||||||
|
setStage({ kind: "result", card, result });
|
||||||
|
},
|
||||||
|
[id],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
quizApi
|
||||||
|
.get(id)
|
||||||
|
.then(async (card) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
if (card.my.state === "completed") await showResult(card);
|
||||||
|
else setStage({ kind: "intro", card });
|
||||||
|
})
|
||||||
|
.catch(() => !cancelled && setStage({ kind: "missing" }));
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
// user?.id: progress appears once you sign in.
|
||||||
|
}, [id, user?.id, showResult]);
|
||||||
|
|
||||||
|
const begin = async (card: QuizCard) => {
|
||||||
|
if (!(await requireAuth())) return;
|
||||||
|
try {
|
||||||
|
const play = await quizApi.start(id);
|
||||||
|
if (play.done) await showResult(card);
|
||||||
|
else goTo(card, play);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(
|
||||||
|
err instanceof QuizError ? err.message : "Could not start the quiz",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const submit = async (card: QuizCard, play: QuizPlay) => {
|
||||||
|
if (!play.question || submitting || selected.length === 0) return;
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
const next = await quizApi.answer(id, play.question.id, selected);
|
||||||
|
if (next.done) await showResult(card);
|
||||||
|
else goTo(card, next);
|
||||||
|
} catch (err) {
|
||||||
|
// Most likely the question was already answered in another tab: pick up
|
||||||
|
// wherever the attempt actually is rather than leaving this one stuck.
|
||||||
|
toast.error(
|
||||||
|
err instanceof QuizError ? err.message : "Could not save that answer",
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
const resumed = await quizApi.start(id);
|
||||||
|
if (resumed.done) await showResult(card);
|
||||||
|
else goTo(card, resumed);
|
||||||
|
} catch {
|
||||||
|
/* the toast already said something went wrong */
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="mx-auto w-full max-w-2xl flex-1 px-4 py-6 sm:px-6 sm:py-10">
|
||||||
|
<Link
|
||||||
|
href="/quizzes"
|
||||||
|
className="mb-4 inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="size-4" /> All quizzes
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{stage.kind === "loading" && (
|
||||||
|
<p className="text-sm text-muted-foreground">Loading…</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{stage.kind === "missing" && (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
This quiz isn't available.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(stage.kind === "intro" ||
|
||||||
|
stage.kind === "playing" ||
|
||||||
|
stage.kind === "result") && (
|
||||||
|
<header className="mb-6">
|
||||||
|
<h1 className="font-[family-name:var(--font-title)] text-2xl font-semibold text-foreground">
|
||||||
|
{stage.card.title}
|
||||||
|
</h1>
|
||||||
|
{stage.card.target_path && (
|
||||||
|
<Link
|
||||||
|
href={stage.card.target_path}
|
||||||
|
className="mt-1 inline-block text-sm text-primary hover:underline"
|
||||||
|
>
|
||||||
|
{stage.card.target_title || stage.card.target_path}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{stage.kind === "intro" && (
|
||||||
|
<section className="space-y-5 rounded-2xl border border-border bg-card p-5">
|
||||||
|
{stage.card.description && (
|
||||||
|
<div className="text-sm leading-relaxed text-foreground">
|
||||||
|
<RichText text={stage.card.description} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{stage.card.question_count}{" "}
|
||||||
|
{stage.card.question_count === 1 ? "question" : "questions"}, one
|
||||||
|
attempt. Each answer is final once submitted, and your time on each
|
||||||
|
question is recorded.
|
||||||
|
</p>
|
||||||
|
{stage.card.my.state === "in_progress" && (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
You've answered {stage.card.my.answered} of{" "}
|
||||||
|
{stage.card.my.total}.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void begin(stage.card)}
|
||||||
|
className="inline-flex items-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 dark:text-white"
|
||||||
|
>
|
||||||
|
<ClipboardCheck className="size-4" />
|
||||||
|
{user
|
||||||
|
? quizActionLabel(stage.card.my.state)
|
||||||
|
: "Sign in to take the quiz"}
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{stage.kind === "playing" && stage.play.question && (
|
||||||
|
<section className="space-y-5">
|
||||||
|
<div>
|
||||||
|
<div className="mb-1.5 flex justify-between text-xs text-muted-foreground">
|
||||||
|
<span>
|
||||||
|
Question {stage.play.answered + 1} of {stage.play.total}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-1.5 overflow-hidden rounded-full bg-muted">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-primary transition-all"
|
||||||
|
style={{
|
||||||
|
width: `${(stage.play.answered / stage.play.total) * 100}%`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="text-lg leading-relaxed text-foreground">
|
||||||
|
<RichText text={stage.play.question.prompt} />
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
{stage.play.question.multi
|
||||||
|
? "Select all that apply"
|
||||||
|
: "Select one answer"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul
|
||||||
|
className="space-y-2"
|
||||||
|
role={stage.play.question.multi ? "group" : "radiogroup"}
|
||||||
|
>
|
||||||
|
{stage.play.question.options.map((o) => {
|
||||||
|
const on = selected.includes(o.id);
|
||||||
|
const multi = stage.play.question?.multi;
|
||||||
|
return (
|
||||||
|
<li key={o.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role={multi ? "checkbox" : "radio"}
|
||||||
|
aria-checked={on}
|
||||||
|
disabled={submitting}
|
||||||
|
onClick={() => toggle(stage.play, o.id)}
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-center gap-3 rounded-xl border px-4 py-3 text-left text-sm transition-colors",
|
||||||
|
on
|
||||||
|
? "border-primary bg-primary/10"
|
||||||
|
: "border-border bg-card hover:border-primary/40 hover:bg-muted/40",
|
||||||
|
"disabled:cursor-default",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Radio dot for one answer, checkbox for several. */}
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className={cn(
|
||||||
|
"flex size-4 shrink-0 items-center justify-center border transition-colors",
|
||||||
|
multi ? "rounded" : "rounded-full",
|
||||||
|
on
|
||||||
|
? "border-primary bg-primary text-primary-foreground"
|
||||||
|
: "border-muted-foreground/40",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{on &&
|
||||||
|
(multi ? (
|
||||||
|
<Check className="size-3" strokeWidth={3} />
|
||||||
|
) : (
|
||||||
|
<span className="size-1.5 rounded-full bg-primary-foreground" />
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<RichText text={o.body} />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={selected.length === 0 || submitting}
|
||||||
|
onClick={() => void submit(stage.card, stage.play)}
|
||||||
|
className="inline-flex items-center gap-2 rounded-lg bg-primary px-5 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50 dark:text-white"
|
||||||
|
>
|
||||||
|
{submitting && <Loader2 className="size-4 animate-spin" />}
|
||||||
|
{stage.play.answered + 1 === stage.play.total
|
||||||
|
? "Submit and finish"
|
||||||
|
: "Next question"}
|
||||||
|
{!submitting && stage.play.answered + 1 < stage.play.total && (
|
||||||
|
<ArrowRight className="size-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{stage.kind === "result" && <QuizResultView result={stage.result} />}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* /quizzes — every published quiz, with where you are in each.
|
||||||
|
*
|
||||||
|
* Reached from the Quizzes card on the search page. Quizzes are deliberately
|
||||||
|
* not in search results: they're something to do, not something to look up.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { ClipboardCheck } from "lucide-react";
|
||||||
|
import { useAuth } from "@/components/auth/auth-provider";
|
||||||
|
import { quiz, quizActionLabel, type QuizCard } from "@/lib/quiz";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const KIND: Record<QuizCard["target_kind"], string> = {
|
||||||
|
chapter: "Book chapter",
|
||||||
|
lecture: "Lecture",
|
||||||
|
conversation: "Conversation",
|
||||||
|
standalone: "Quiz",
|
||||||
|
};
|
||||||
|
|
||||||
|
type Filter = "all" | "todo" | "done";
|
||||||
|
|
||||||
|
export default function QuizzesPage() {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const [list, setList] = useState<QuizCard[] | null>(null);
|
||||||
|
const [filter, setFilter] = useState<Filter>("all");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
quiz
|
||||||
|
.list()
|
||||||
|
.then((l) => !cancelled && setList(l))
|
||||||
|
.catch(() => !cancelled && setList([]));
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [user?.id]);
|
||||||
|
|
||||||
|
const shown = useMemo(
|
||||||
|
() =>
|
||||||
|
(list ?? []).filter((q) =>
|
||||||
|
filter === "all"
|
||||||
|
? true
|
||||||
|
: filter === "done"
|
||||||
|
? q.my.state === "completed"
|
||||||
|
: q.my.state !== "completed",
|
||||||
|
),
|
||||||
|
[list, filter],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="mx-auto w-full max-w-3xl flex-1 px-4 py-6 sm:px-6 sm:py-10">
|
||||||
|
<header className="mb-6">
|
||||||
|
<h1 className="font-[family-name:var(--font-title)] text-2xl font-semibold text-foreground sm:text-3xl">
|
||||||
|
Quizzes
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
Test what you've read and heard. Each quiz can be taken once, and
|
||||||
|
only you can see your results.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{user && (
|
||||||
|
<div className="mb-4 flex gap-1">
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
["all", "All"],
|
||||||
|
["todo", "To do"],
|
||||||
|
["done", "Finished"],
|
||||||
|
] as const
|
||||||
|
).map(([k, label]) => (
|
||||||
|
<button
|
||||||
|
key={k}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setFilter(k)}
|
||||||
|
className={cn(
|
||||||
|
"rounded-full px-3 py-1 text-sm transition-colors",
|
||||||
|
filter === k
|
||||||
|
? "bg-primary text-primary-foreground dark:text-white"
|
||||||
|
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{list === null ? (
|
||||||
|
<p className="text-sm text-muted-foreground">Loading…</p>
|
||||||
|
) : shown.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{list.length === 0
|
||||||
|
? "No quizzes yet — check back soon."
|
||||||
|
: "Nothing here."}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="grid gap-3 sm:grid-cols-2">
|
||||||
|
{shown.map((q) => {
|
||||||
|
const done = q.my.state === "completed";
|
||||||
|
return (
|
||||||
|
<li key={q.id}>
|
||||||
|
<Link
|
||||||
|
href={`/quizzes/${q.id}`}
|
||||||
|
className="flex h-full flex-col rounded-2xl border border-border bg-card p-4 transition-colors hover:border-primary/40 hover:bg-muted/30"
|
||||||
|
>
|
||||||
|
<p className="text-xs font-medium text-primary">
|
||||||
|
{KIND[q.target_kind]}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 font-semibold leading-snug text-foreground">
|
||||||
|
{q.title}
|
||||||
|
</p>
|
||||||
|
{q.target_title && (
|
||||||
|
<p className="mt-0.5 truncate text-sm text-muted-foreground">
|
||||||
|
{q.target_title}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="mt-auto flex items-center justify-between gap-2 pt-4 text-xs">
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{q.question_count}{" "}
|
||||||
|
{q.question_count === 1 ? "question" : "questions"}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-1 font-medium",
|
||||||
|
done ? "text-muted-foreground" : "text-primary",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ClipboardCheck className="size-3.5" />
|
||||||
|
{quizActionLabel(q.my.state)}
|
||||||
|
{done &&
|
||||||
|
q.my.score !== undefined &&
|
||||||
|
` · ${q.my.score}/${q.my.total}`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ export default function LegalSettingsPage() {
|
|||||||
title="Legal"
|
title="Legal"
|
||||||
>
|
>
|
||||||
<NavList>
|
<NavList>
|
||||||
|
<NavRow href="/about" label="About Us" />
|
||||||
<NavRow href="/terms" label="Terms of Service" />
|
<NavRow href="/terms" label="Terms of Service" />
|
||||||
<NavRow href="/privacy" label="Privacy Policy" />
|
<NavRow href="/privacy" label="Privacy Policy" />
|
||||||
<NavRow href="/data-deletion" label="Steps for data deletion" />
|
<NavRow href="/data-deletion" label="Steps for data deletion" />
|
||||||
|
|||||||
@@ -13,13 +13,16 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { Paperclip, Smile, SendHorizonal, X } from "lucide-react";
|
import { Paperclip, Sticker, SendHorizonal, X } from "lucide-react";
|
||||||
import { LinkPreviewCard } from "@/components/chat/link-preview-card";
|
import { LinkPreviewCard } from "@/components/chat/link-preview-card";
|
||||||
import {
|
import {
|
||||||
ComposerAttachments,
|
ComposerAttachments,
|
||||||
type StagedAttachment,
|
type StagedAttachment,
|
||||||
} from "@/components/chat/composer-attachments";
|
} from "@/components/chat/composer-attachments";
|
||||||
import { StickerPicker } from "@/components/chat/sticker-picker";
|
import {
|
||||||
|
StickerPicker,
|
||||||
|
type StickerAnchor,
|
||||||
|
} from "@/components/chat/sticker-picker";
|
||||||
import { compressImage } from "@/lib/compress-image";
|
import { compressImage } from "@/lib/compress-image";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import {
|
import {
|
||||||
@@ -70,7 +73,9 @@ export function ChatComposer({
|
|||||||
const [staged, setStaged] = useState<StagedAttachment[]>([]);
|
const [staged, setStaged] = useState<StagedAttachment[]>([]);
|
||||||
const ref = useRef<HTMLTextAreaElement>(null);
|
const ref = useRef<HTMLTextAreaElement>(null);
|
||||||
const fileRef = useRef<HTMLInputElement>(null);
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
const [pickingSticker, setPickingSticker] = useState(false);
|
// Non-null opens the sticker picker AND positions it: the popover hangs
|
||||||
|
// off the button's viewport rect, which only exists at click time.
|
||||||
|
const [stickerAnchor, setStickerAnchor] = useState<StickerAnchor | null>(null);
|
||||||
const lookupSeq = useRef(0);
|
const lookupSeq = useRef(0);
|
||||||
|
|
||||||
// Grow with the content, up to a cap.
|
// Grow with the content, up to a cap.
|
||||||
@@ -248,61 +253,84 @@ export function ChatComposer({
|
|||||||
e.target.value = "";
|
e.target.value = "";
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<button
|
{/* One field group: attach on the left, sticker on the right, the
|
||||||
type="button"
|
text between them. The border and background live here rather than
|
||||||
onClick={() => fileRef.current?.click()}
|
on the textarea so all three read as a single control, and
|
||||||
disabled={disabled}
|
focus-within moves the ring to the group. */}
|
||||||
aria-label="Attach a file"
|
<div
|
||||||
className="inline-flex size-9 shrink-0 items-center justify-center rounded-full text-muted-foreground hover:text-foreground disabled:opacity-40"
|
|
||||||
>
|
|
||||||
<Paperclip className="size-4" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setPickingSticker(true)}
|
|
||||||
disabled={disabled}
|
|
||||||
aria-label="Send a sticker"
|
|
||||||
className="inline-flex size-9 shrink-0 items-center justify-center rounded-full text-muted-foreground hover:text-foreground disabled:opacity-40"
|
|
||||||
>
|
|
||||||
<Smile className="size-4" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<textarea
|
|
||||||
ref={ref}
|
|
||||||
rows={1}
|
|
||||||
value={value}
|
|
||||||
disabled={disabled}
|
|
||||||
placeholder={placeholder}
|
|
||||||
onChange={(e) => {
|
|
||||||
setValue(e.target.value);
|
|
||||||
// Fired on every keystroke; the handler throttles itself, so the
|
|
||||||
// thread hears "still typing" every few seconds rather than per
|
|
||||||
// character.
|
|
||||||
onTyping?.();
|
|
||||||
}}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
// Enter sends on a physical keyboard; Shift+Enter makes a newline.
|
|
||||||
// On touch keyboards Enter must insert a newline instead — there is
|
|
||||||
// no Shift to reach for — so the send button is the only way there.
|
|
||||||
if (e.key !== "Enter" || e.shiftKey) return;
|
|
||||||
const touch =
|
|
||||||
typeof window !== "undefined" &&
|
|
||||||
window.matchMedia("(pointer: coarse)").matches;
|
|
||||||
if (touch) return;
|
|
||||||
e.preventDefault();
|
|
||||||
submit();
|
|
||||||
}}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
// 16px on touch devices: iOS Safari zooms the whole page when a
|
// Minimal horizontal padding: the icon buttons keep a full-height
|
||||||
// focused field computes below that, which on a composer means the
|
// tap target, but sit close to the group's edges and to the text
|
||||||
// thread jumps every time the keyboard opens. Pointer-based devices
|
// rather than floating in their own gutters.
|
||||||
// keep the app's 14px chrome.
|
"flex flex-1 items-end gap-0.5 rounded-2xl border border-border bg-muted/40 px-1 py-1",
|
||||||
"flex-1 resize-none rounded-2xl border border-border bg-muted/40 px-3 py-2",
|
"focus-within:ring-1 focus-within:ring-primary/40",
|
||||||
"text-sm pointer-coarse:text-base",
|
disabled && "opacity-60",
|
||||||
"placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-primary/40",
|
|
||||||
"disabled:opacity-60",
|
|
||||||
)}
|
)}
|
||||||
/>
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => fileRef.current?.click()}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-label="Attach a file"
|
||||||
|
className="inline-flex h-8 w-8 mr-1 shrink-0 items-center justify-center rounded-full text-muted-foreground hover:text-foreground disabled:opacity-40"
|
||||||
|
>
|
||||||
|
<Paperclip className="size-5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
ref={ref}
|
||||||
|
rows={1}
|
||||||
|
value={value}
|
||||||
|
disabled={disabled}
|
||||||
|
placeholder={placeholder}
|
||||||
|
onChange={(e) => {
|
||||||
|
setValue(e.target.value);
|
||||||
|
// Fired on every keystroke; the handler throttles itself, so the
|
||||||
|
// thread hears "still typing" every few seconds rather than per
|
||||||
|
// character.
|
||||||
|
onTyping?.();
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
// Enter sends on a physical keyboard; Shift+Enter makes a
|
||||||
|
// newline. On touch keyboards Enter must insert a newline
|
||||||
|
// instead — there is no Shift to reach for — so the send button
|
||||||
|
// is the only way there.
|
||||||
|
if (e.key !== "Enter" || e.shiftKey) return;
|
||||||
|
const touch =
|
||||||
|
typeof window !== "undefined" &&
|
||||||
|
window.matchMedia("(pointer: coarse)").matches;
|
||||||
|
if (touch) return;
|
||||||
|
e.preventDefault();
|
||||||
|
submit();
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
// Bare: the group above owns the frame. Only the type size and
|
||||||
|
// the growth behaviour belong to the field itself.
|
||||||
|
"min-w-0 flex-1 resize-none bg-transparent py-1.5",
|
||||||
|
// 16px on touch devices: iOS Safari zooms the whole page when a
|
||||||
|
// focused field computes below that, which on a composer means
|
||||||
|
// the thread jumps every time the keyboard opens. Pointer-based
|
||||||
|
// devices keep the app's 14px chrome.
|
||||||
|
"text-sm pointer-coarse:text-base",
|
||||||
|
"placeholder:text-muted-foreground focus:outline-none",
|
||||||
|
"disabled:opacity-60",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
const r = e.currentTarget.getBoundingClientRect();
|
||||||
|
// Top-centre of the button: the popover opens upward from here.
|
||||||
|
setStickerAnchor({ x: r.left + r.width / 2, y: r.top });
|
||||||
|
}}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-label="Send a sticker"
|
||||||
|
className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-muted-foreground hover:text-foreground disabled:opacity-40"
|
||||||
|
>
|
||||||
|
<Sticker className="size-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={submit}
|
onClick={submit}
|
||||||
@@ -317,13 +345,14 @@ export function ChatComposer({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{pickingSticker && (
|
{stickerAnchor && (
|
||||||
<StickerPicker
|
<StickerPicker
|
||||||
|
anchor={stickerAnchor}
|
||||||
// Sent on its own, with no text: a sticker IS the message.
|
// Sent on its own, with no text: a sticker IS the message.
|
||||||
onPick={(sticker) =>
|
onPick={(sticker) =>
|
||||||
onSend("", undefined, undefined, undefined, sticker.key)
|
onSend("", undefined, undefined, undefined, sticker.key)
|
||||||
}
|
}
|
||||||
onClose={() => setPickingSticker(false)}
|
onClose={() => setStickerAnchor(null)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { useState } from "react";
|
|||||||
import { BellOff, Bell, ShieldBan, ShieldCheck } from "lucide-react";
|
import { BellOff, Bell, ShieldBan, ShieldCheck } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { ConfirmModal } from "@/components/ui/confirm-modal";
|
||||||
import { relationships } from "@/lib/social";
|
import { relationships } from "@/lib/social";
|
||||||
import {
|
import {
|
||||||
chat,
|
chat,
|
||||||
@@ -44,6 +45,9 @@ export function ChatProfileHeader({
|
|||||||
onChanged: () => void;
|
onChanged: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
// Blocking asks first; unblocking is immediate, since it only restores what
|
||||||
|
// was there before.
|
||||||
|
const [confirmBlock, setConfirmBlock] = useState(false);
|
||||||
const rel = detail.relationship;
|
const rel = detail.relationship;
|
||||||
const peer = detail.peer;
|
const peer = detail.peer;
|
||||||
const muted = isMuted(detail);
|
const muted = isMuted(detail);
|
||||||
@@ -64,9 +68,6 @@ export function ChatProfileHeader({
|
|||||||
const toggleBlock = async () => {
|
const toggleBlock = async () => {
|
||||||
if (!peer) return;
|
if (!peer) return;
|
||||||
const blocking = !rel?.blocked;
|
const blocking = !rel?.blocked;
|
||||||
if (blocking && !window.confirm(`Block ${peer.name}? They won't be able to message you.`)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
if (blocking) await relationships.block(peer.handle);
|
if (blocking) await relationships.block(peer.handle);
|
||||||
@@ -134,7 +135,9 @@ export function ChatProfileHeader({
|
|||||||
variant={rel?.blocked ? "outline" : "destructive"}
|
variant={rel?.blocked ? "outline" : "destructive"}
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
onClick={toggleBlock}
|
onClick={() =>
|
||||||
|
rel?.blocked ? void toggleBlock() : setConfirmBlock(true)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{rel?.blocked ? (
|
{rel?.blocked ? (
|
||||||
<>
|
<>
|
||||||
@@ -148,6 +151,14 @@ export function ChatProfileHeader({
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<ConfirmModal
|
||||||
|
open={confirmBlock}
|
||||||
|
onClose={() => setConfirmBlock(false)}
|
||||||
|
title={`Block ${peer?.name ?? "this person"}?`}
|
||||||
|
message="They won't be able to message you, and you won't see their messages. You can unblock them here later."
|
||||||
|
confirmLabel="Block"
|
||||||
|
onConfirm={toggleBlock}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { toast } from "sonner";
|
|||||||
import { UserAvatar } from "@/components/ui/user-avatar";
|
import { UserAvatar } from "@/components/ui/user-avatar";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { AddMembersDialog } from "@/components/chat/add-members-dialog";
|
import { AddMembersDialog } from "@/components/chat/add-members-dialog";
|
||||||
|
import { ConfirmModal } from "@/components/ui/confirm-modal";
|
||||||
import { chat, isGroupAdmin, type ConversationDetail } from "@/lib/chat";
|
import { chat, isGroupAdmin, type ConversationDetail } from "@/lib/chat";
|
||||||
|
|
||||||
const ROLE_LABEL: Record<string, string> = {
|
const ROLE_LABEL: Record<string, string> = {
|
||||||
@@ -34,6 +35,9 @@ export function GroupMembersTab({
|
|||||||
onChanged: () => void;
|
onChanged: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [busy, setBusy] = useState<string | null>(null);
|
const [busy, setBusy] = useState<string | null>(null);
|
||||||
|
// The member being removed. Non-null asks first.
|
||||||
|
const [confirmRemove, setConfirmRemove] =
|
||||||
|
useState<ConversationDetail["participants"][number] | null>(null);
|
||||||
const [adding, setAdding] = useState(false);
|
const [adding, setAdding] = useState(false);
|
||||||
const iAmAdmin = isGroupAdmin(detail.my_role);
|
const iAmAdmin = isGroupAdmin(detail.my_role);
|
||||||
const iAmOwner = detail.my_role === "owner";
|
const iAmOwner = detail.my_role === "owner";
|
||||||
@@ -132,14 +136,8 @@ export function GroupMembersTab({
|
|||||||
type="button"
|
type="button"
|
||||||
disabled={busy === m.handle}
|
disabled={busy === m.handle}
|
||||||
aria-label={`Remove ${m.name}`}
|
aria-label={`Remove ${m.name}`}
|
||||||
onClick={() => {
|
// Ask first — the modal calls act() on confirm.
|
||||||
if (!window.confirm(`Remove ${m.name} from the group?`)) return;
|
onClick={() => setConfirmRemove(m)}
|
||||||
void act(
|
|
||||||
m.handle,
|
|
||||||
() => chat.removeMember(conversationId, m.handle),
|
|
||||||
`${m.name} was removed`,
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
className="shrink-0 rounded-md p-1.5 text-destructive hover:opacity-80 disabled:opacity-40"
|
className="shrink-0 rounded-md p-1.5 text-destructive hover:opacity-80 disabled:opacity-40"
|
||||||
>
|
>
|
||||||
<UserMinus className="size-4" />
|
<UserMinus className="size-4" />
|
||||||
@@ -158,6 +156,21 @@ export function GroupMembersTab({
|
|||||||
onAdded={onChanged}
|
onAdded={onChanged}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
<ConfirmModal
|
||||||
|
open={confirmRemove !== null}
|
||||||
|
onClose={() => setConfirmRemove(null)}
|
||||||
|
title={`Remove ${confirmRemove?.name ?? "this member"}?`}
|
||||||
|
message="They'll lose access to the conversation and its history from here on."
|
||||||
|
confirmLabel="Remove"
|
||||||
|
onConfirm={async () => {
|
||||||
|
if (!confirmRemove) return;
|
||||||
|
await act(
|
||||||
|
confirmRemove.handle,
|
||||||
|
() => chat.removeMember(conversationId, confirmRemove.handle),
|
||||||
|
`${confirmRemove.name} was removed`,
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { toast } from "sonner";
|
|||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { UserAvatar } from "@/components/ui/user-avatar";
|
import { UserAvatar } from "@/components/ui/user-avatar";
|
||||||
|
import { ConfirmModal } from "@/components/ui/confirm-modal";
|
||||||
import { compressImage } from "@/lib/compress-image";
|
import { compressImage } from "@/lib/compress-image";
|
||||||
import {
|
import {
|
||||||
chat,
|
chat,
|
||||||
@@ -38,6 +39,7 @@ export function GroupSettingsTab({
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [title, setTitle] = useState(detail.title ?? "");
|
const [title, setTitle] = useState(detail.title ?? "");
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [confirmLeave, setConfirmLeave] = useState(false);
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
const fileRef = useRef<HTMLInputElement>(null);
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
const iAmAdmin = isGroupAdmin(detail.my_role);
|
const iAmAdmin = isGroupAdmin(detail.my_role);
|
||||||
@@ -96,15 +98,6 @@ export function GroupSettingsTab({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const leave = async () => {
|
const leave = async () => {
|
||||||
if (
|
|
||||||
!window.confirm(
|
|
||||||
detail.my_role === "owner"
|
|
||||||
? "Leave this group? Another admin will become the owner."
|
|
||||||
: "Leave this group?",
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
await chat.removeMember(conversationId, selfHandle);
|
await chat.removeMember(conversationId, selfHandle);
|
||||||
@@ -219,7 +212,7 @@ export function GroupSettingsTab({
|
|||||||
variant="destructive"
|
variant="destructive"
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
onClick={leave}
|
onClick={() => setConfirmLeave(true)}
|
||||||
>
|
>
|
||||||
<LogOut className="size-4" /> Leave group
|
<LogOut className="size-4" /> Leave group
|
||||||
</Button>
|
</Button>
|
||||||
@@ -230,6 +223,18 @@ export function GroupSettingsTab({
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
<ConfirmModal
|
||||||
|
open={confirmLeave}
|
||||||
|
onClose={() => setConfirmLeave(false)}
|
||||||
|
title="Leave this group?"
|
||||||
|
message={
|
||||||
|
detail.my_role === "owner"
|
||||||
|
? "You own this group. The longest-serving admin becomes the owner in your place."
|
||||||
|
: "You'll stop receiving messages from this conversation."
|
||||||
|
}
|
||||||
|
confirmLabel="Leave"
|
||||||
|
onConfirm={leave}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,7 +37,10 @@ export function MediaGrid({
|
|||||||
onClick={() => onOpen(0)}
|
onClick={() => onOpen(0)}
|
||||||
className="block overflow-hidden rounded-lg"
|
className="block overflow-hidden rounded-lg"
|
||||||
>
|
>
|
||||||
<Tile item={items[0]} className="max-h-72 w-auto" />
|
<Tile
|
||||||
|
item={items[0]}
|
||||||
|
className="max-h-64 w-auto sm:max-h-52 sm:max-w-52"
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -46,7 +49,7 @@ export function MediaGrid({
|
|||||||
// Two columns throughout: two items make a row, three or four make the
|
// Two columns throughout: two items make a row, three or four make the
|
||||||
// 2×2. A three-item grid deliberately leaves the fourth cell empty rather
|
// 2×2. A three-item grid deliberately leaves the fourth cell empty rather
|
||||||
// than stretching one tile — an uneven crop reads worse than a gap.
|
// than stretching one tile — an uneven crop reads worse than a gap.
|
||||||
<div className="grid w-56 grid-cols-2 gap-0.5 overflow-hidden rounded-lg sm:w-64">
|
<div className="grid w-56 grid-cols-2 gap-0.5 overflow-hidden rounded-lg sm:w-52">
|
||||||
{visible.map((item, i) => {
|
{visible.map((item, i) => {
|
||||||
const isLast = i === visible.length - 1;
|
const isLast = i === visible.length - 1;
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -7,9 +7,16 @@
|
|||||||
* comes free on touch, the scrollbar reflects position, and the browser handles
|
* comes free on touch, the scrollbar reflects position, and the browser handles
|
||||||
* momentum. Arrow keys and the on-screen buttons scroll to a slide rather than
|
* momentum. Arrow keys and the on-screen buttons scroll to a slide rather than
|
||||||
* setting state, so every input path drives the same mechanism.
|
* setting state, so every input path drives the same mechanism.
|
||||||
|
*
|
||||||
|
* Portalled to <body>, and that is load-bearing rather than tidiness: every
|
||||||
|
* bubble sits inside MessageGestures, which sets a transform for swipe-to-reply
|
||||||
|
* — and a transformed ancestor becomes the containing block for `position:
|
||||||
|
* fixed`. Rendered in place, `inset-0` would resolve against the bubble, and
|
||||||
|
* the message list's overflow would then clip it.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
import { ChevronLeft, ChevronRight, Download, Loader2, X } from "lucide-react";
|
import { ChevronLeft, ChevronRight, Download, Loader2, X } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { attachmentUrl, type Attachment } from "@/lib/chat";
|
import { attachmentUrl, type Attachment } from "@/lib/chat";
|
||||||
@@ -53,6 +60,16 @@ export function MediaLightbox({
|
|||||||
return () => window.removeEventListener("keydown", onKey);
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
}, [index, items.length, onClose, scrollTo]);
|
}, [index, items.length, onClose, scrollTo]);
|
||||||
|
|
||||||
|
// A full-screen viewer over a scrollable thread: without this the thread
|
||||||
|
// scrolls behind the image once the carousel reaches its own edge.
|
||||||
|
useEffect(() => {
|
||||||
|
const prev = document.body.style.overflow;
|
||||||
|
document.body.style.overflow = "hidden";
|
||||||
|
return () => {
|
||||||
|
document.body.style.overflow = prev;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const current = items[index];
|
const current = items[index];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -85,8 +102,10 @@ export function MediaLightbox({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
if (typeof document === "undefined") return null;
|
||||||
<div className="fixed inset-0 z-50 flex flex-col bg-black/95">
|
|
||||||
|
return createPortal(
|
||||||
|
<div className="fixed inset-0 z-[100] flex flex-col bg-black/95">
|
||||||
<div className="flex items-center justify-between gap-2 px-3 py-2 text-white">
|
<div className="flex items-center justify-between gap-2 px-3 py-2 text-white">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -167,7 +186,8 @@ export function MediaLightbox({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>,
|
||||||
|
document.body,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -166,7 +166,7 @@ export function MessageGestures({
|
|||||||
// Full width so the whole row is draggable — WhatsApp lets you start the
|
// Full width so the whole row is draggable — WhatsApp lets you start the
|
||||||
// gesture anywhere on the line, including the empty space opposite the
|
// gesture anywhere on the line, including the empty space opposite the
|
||||||
// bubble. The bubble caps its own width inside this.
|
// bubble. The bubble caps its own width inside this.
|
||||||
className="relative w-full"
|
className="relative w-full select-none [-webkit-touch-callout:none]"
|
||||||
onPointerDown={onPointerDown}
|
onPointerDown={onPointerDown}
|
||||||
onPointerMove={onPointerMove}
|
onPointerMove={onPointerMove}
|
||||||
onPointerUp={onPointerUp}
|
onPointerUp={onPointerUp}
|
||||||
|
|||||||
@@ -54,6 +54,22 @@ function isStickerOnly(m: ChatMessage): boolean {
|
|||||||
return !m.body && atts.length > 0 && atts.every((a) => a.kind === "sticker");
|
return !m.body && atts.length > 0 && atts.every((a) => a.kind === "sticker");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** True when a message carries photos or video. */
|
||||||
|
function carriesMedia(m: ChatMessage): boolean {
|
||||||
|
return (m.attachments ?? []).some(
|
||||||
|
(a) => a.kind === "image" || a.kind === "video",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when a message's content brings its own frame — a shared post card,
|
||||||
|
* stickers, or media. All three are images with their own edges, and a
|
||||||
|
* coloured bubble around them reads as a second frame.
|
||||||
|
*/
|
||||||
|
function rendersBare(m: ChatMessage): boolean {
|
||||||
|
return sharesAnEntity(m) || isStickerOnly(m) || carriesMedia(m);
|
||||||
|
}
|
||||||
|
|
||||||
/** Distinct emoji shown before the rest collapse into "+N". */
|
/** Distinct emoji shown before the rest collapse into "+N". */
|
||||||
const MAX_TRAIL_EMOJI = 4;
|
const MAX_TRAIL_EMOJI = 4;
|
||||||
|
|
||||||
@@ -229,7 +245,7 @@ export function MessageGroup({
|
|||||||
// own border, rounding and background, so the bubble drops
|
// own border, rounding and background, so the bubble drops
|
||||||
// its chrome rather than drawing a second frame around it.
|
// its chrome rather than drawing a second frame around it.
|
||||||
// Any caption gets the padding back on its own line below.
|
// Any caption gets the padding back on its own line below.
|
||||||
sharesAnEntity(m) || isStickerOnly(m)
|
rendersBare(m)
|
||||||
? "overflow-hidden bg-transparent"
|
? "overflow-hidden bg-transparent"
|
||||||
: cn(
|
: cn(
|
||||||
"px-3 py-2",
|
"px-3 py-2",
|
||||||
@@ -264,11 +280,16 @@ export function MessageGroup({
|
|||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"block",
|
"block",
|
||||||
// The bubble gave up its padding for the card, so a
|
// The bubble gave up its padding and colour, so a
|
||||||
// caption supplies its own.
|
// caption carries its own — otherwise it would sit
|
||||||
sharesAnEntity(m)
|
// as bare text on the thread background.
|
||||||
? "rounded-b-lg border border-t-0 border-border px-3 py-2"
|
rendersBare(m) &&
|
||||||
: undefined,
|
cn(
|
||||||
|
"mt-0.5 rounded-lg px-3 py-2",
|
||||||
|
mine
|
||||||
|
? "bg-primary text-primary-foreground dark:text-white"
|
||||||
|
: "bg-muted text-foreground",
|
||||||
|
),
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<MessageText body={m.body} entities={m.entities} />
|
<MessageText body={m.body} entities={m.entities} />
|
||||||
@@ -316,43 +337,53 @@ export function MessageGroup({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{m.reactions?.length > 0 && (
|
{m.reactions?.length > 0 && (
|
||||||
<button
|
// Aligned like the bubble it belongs to. The gesture wrapper is
|
||||||
type="button"
|
// full-width, so without this the trail sits at the left edge
|
||||||
// ONE control, not one per emoji. Two different jobs depending
|
// while your own bubble is over on the right.
|
||||||
// on whether you are in the reaction list already: if you have
|
<div
|
||||||
// reacted, this retracts it; if you have not, it opens the
|
|
||||||
// list of who reacted. Per-chip toggling would make "see who
|
|
||||||
// reacted" unreachable for anyone who had.
|
|
||||||
onClick={() => onTrailClick(m)}
|
|
||||||
aria-label={
|
|
||||||
m.reactions.some((r) => r.mine)
|
|
||||||
? "Remove your reaction"
|
|
||||||
: "See who reacted"
|
|
||||||
}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"-mt-1 inline-flex items-center gap-0.5 rounded-full border bg-background px-1.5 py-0.5 text-xs",
|
"flex w-full",
|
||||||
mine ? "self-end mr-1" : "self-start ml-1",
|
mine ? "justify-end" : "justify-start",
|
||||||
m.reactions.some((r) => r.mine)
|
|
||||||
? "border-primary/40 bg-primary/10"
|
|
||||||
: "border-border",
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{m.reactions.slice(0, MAX_TRAIL_EMOJI).map((r) => (
|
<button
|
||||||
<span key={r.emoji}>{r.emoji}</span>
|
type="button"
|
||||||
))}
|
// ONE control, not one per emoji. Two different jobs depending
|
||||||
{m.reactions.length > MAX_TRAIL_EMOJI && (
|
// on whether you are in the reaction list already: if you have
|
||||||
<span className="tabular-nums text-muted-foreground">
|
// reacted, this retracts it; if you have not, it opens the
|
||||||
+{m.reactions.length - MAX_TRAIL_EMOJI}
|
// list of who reacted. Per-chip toggling would make "see who
|
||||||
</span>
|
// reacted" unreachable for anyone who had.
|
||||||
)}
|
onClick={() => onTrailClick(m)}
|
||||||
{/* Total people, not distinct emoji — "😂❤️ 5" reads as five
|
aria-label={
|
||||||
|
m.reactions.some((r) => r.mine)
|
||||||
|
? "Remove your reaction"
|
||||||
|
: "See who reacted"
|
||||||
|
}
|
||||||
|
className={cn(
|
||||||
|
"-mt-1 inline-flex items-center gap-0.5 rounded-full border bg-background px-1.5 py-0.5 text-xs",
|
||||||
|
mine ? "mr-1" : "ml-1",
|
||||||
|
m.reactions.some((r) => r.mine)
|
||||||
|
? "border-primary/40 bg-primary/10"
|
||||||
|
: "border-border",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{m.reactions.slice(0, MAX_TRAIL_EMOJI).map((r) => (
|
||||||
|
<span key={r.emoji}>{r.emoji}</span>
|
||||||
|
))}
|
||||||
|
{m.reactions.length > MAX_TRAIL_EMOJI && (
|
||||||
|
<span className="tabular-nums text-muted-foreground">
|
||||||
|
+{m.reactions.length - MAX_TRAIL_EMOJI}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{/* Total people, not distinct emoji — "😂❤️ 5" reads as five
|
||||||
reactions, which is what it is. */}
|
reactions, which is what it is. */}
|
||||||
{totalReactions(m) > 1 && (
|
{totalReactions(m) > 1 && (
|
||||||
<span className="ml-0.5 tabular-nums text-muted-foreground">
|
<span className="ml-0.5 tabular-nums text-muted-foreground">
|
||||||
{totalReactions(m)}
|
{totalReactions(m)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</MessageGestures>
|
</MessageGestures>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ export function MessageMenu({
|
|||||||
// min-w-56 rather than something tighter: "Delete for everyone" is the
|
// min-w-56 rather than something tighter: "Delete for everyone" is the
|
||||||
// longest label and, with its icon and padding, was wrapping or sitting
|
// longest label and, with its icon and padding, was wrapping or sitting
|
||||||
// flush against the edge at a narrower width.
|
// flush against the edge at a narrower width.
|
||||||
className="fixed z-50 min-w-56 overflow-hidden rounded-xl border border-border bg-background pb-1 shadow-lg"
|
className="fixed z-50 min-w-56 select-none overflow-hidden rounded-xl border border-border bg-background pb-1 shadow-lg"
|
||||||
>
|
>
|
||||||
{/* Reactions lead: they are the most common thing to want from a
|
{/* Reactions lead: they are the most common thing to want from a
|
||||||
long press, and putting them in this menu keeps one gesture
|
long press, and putting them in this menu keeps one gesture
|
||||||
|
|||||||
@@ -4,20 +4,33 @@
|
|||||||
* The emoji row at the top of a message's long-press menu.
|
* The emoji row at the top of a message's long-press menu.
|
||||||
*
|
*
|
||||||
* Five quick reactions plus a "+" that opens the full set — a drawer on
|
* Five quick reactions plus a "+" that opens the full set — a drawer on
|
||||||
* mobile, a modal on desktop, reusing ResponsiveDialog so it behaves like
|
* mobile, a popover anchored to the "+" on desktop. A modal would take over
|
||||||
* every other chat surface.
|
* the screen and hide the message being reacted to, which is the one thing
|
||||||
|
* that needs to stay visible while choosing.
|
||||||
*
|
*
|
||||||
* It sits inside the context menu rather than being its own popover: long
|
* It sits inside the context menu rather than being its own popover: long
|
||||||
* press is one gesture, and two surfaces competing for it would mean neither
|
* press is one gesture, and two surfaces competing for it would mean neither
|
||||||
* felt reliable.
|
* felt reliable.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
import { Plus } from "lucide-react";
|
import { Plus } from "lucide-react";
|
||||||
import { EMOJI_GROUPS } from "@/components/ui/emoji-picker";
|
import { EMOJI_GROUPS } from "@/components/ui/emoji-picker";
|
||||||
import { ResponsiveDialog } from "@/components/chat/responsive-dialog";
|
import { useMediaQuery } from "@/components/selection-toolbar/shared";
|
||||||
|
import {
|
||||||
|
Drawer,
|
||||||
|
DrawerContent,
|
||||||
|
DrawerDescription,
|
||||||
|
DrawerHeader,
|
||||||
|
DrawerTitle,
|
||||||
|
} from "@/components/ui/drawer";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const POPOVER_W = 320;
|
||||||
|
const POPOVER_H = 320;
|
||||||
|
const EDGE_PAD = 8;
|
||||||
|
|
||||||
/** What a double-tap sends. Matches the server's own default. */
|
/** What a double-tap sends. Matches the server's own default. */
|
||||||
export const HEART_EMOJI = "❤️";
|
export const HEART_EMOJI = "❤️";
|
||||||
|
|
||||||
@@ -33,7 +46,12 @@ export function ReactionRow({
|
|||||||
/** Picking the current emoji again clears it — the caller decides. */
|
/** Picking the current emoji again clears it — the caller decides. */
|
||||||
onPick: (emoji: string) => void;
|
onPick: (emoji: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const [browsing, setBrowsing] = useState(false);
|
// Non-null both opens the full set and positions it: the popover hangs off
|
||||||
|
// the "+" button's rect, which only exists at click time.
|
||||||
|
const [browsing, setBrowsing] = useState<{ x: number; y: number } | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const close = () => setBrowsing(null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -55,7 +73,11 @@ export function ReactionRow({
|
|||||||
))}
|
))}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setBrowsing(true)}
|
onClick={(e) => {
|
||||||
|
const r = e.currentTarget.getBoundingClientRect();
|
||||||
|
// Top-centre of the "+": the popover opens upward from here.
|
||||||
|
setBrowsing({ x: r.left + r.width / 2, y: r.top });
|
||||||
|
}}
|
||||||
aria-label="More reactions"
|
aria-label="More reactions"
|
||||||
className="inline-flex size-9 items-center justify-center rounded-full text-muted-foreground hover:bg-muted hover:text-foreground"
|
className="inline-flex size-9 items-center justify-center rounded-full text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||||
>
|
>
|
||||||
@@ -63,39 +85,118 @@ export function ReactionRow({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{browsing && (
|
{browsing && <FullSet anchor={browsing} onPick={onPick} onClose={close} />}
|
||||||
<ResponsiveDialog
|
|
||||||
title="Pick a reaction"
|
|
||||||
description="Choose any emoji to react with."
|
|
||||||
onClose={() => setBrowsing(false)}
|
|
||||||
>
|
|
||||||
<div className="space-y-4 p-4">
|
|
||||||
{EMOJI_GROUPS.map((group) => (
|
|
||||||
<section key={group.label}>
|
|
||||||
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
|
||||||
{group.label}
|
|
||||||
</h3>
|
|
||||||
<div className="grid grid-cols-8 gap-1">
|
|
||||||
{group.emojis.map((emoji) => (
|
|
||||||
<button
|
|
||||||
key={emoji}
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
setBrowsing(false);
|
|
||||||
onPick(emoji);
|
|
||||||
}}
|
|
||||||
aria-label={`React ${emoji}`}
|
|
||||||
className="inline-flex size-9 items-center justify-center rounded-md text-lg hover:bg-muted"
|
|
||||||
>
|
|
||||||
{emoji}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</ResponsiveDialog>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The whole emoji set: popover on desktop, drawer on touch. */
|
||||||
|
function FullSet({
|
||||||
|
anchor,
|
||||||
|
onPick,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
anchor: { x: number; y: number };
|
||||||
|
onPick: (emoji: string) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const isMobile = useMediaQuery("(max-width: 767px)");
|
||||||
|
|
||||||
|
// Desktop only: Escape closes. The drawer handles its own dismissal, and a
|
||||||
|
// popover deliberately doesn't lock scrolling behind it.
|
||||||
|
useEffect(() => {
|
||||||
|
if (isMobile) return;
|
||||||
|
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
|
||||||
|
document.addEventListener("keydown", onKey);
|
||||||
|
return () => document.removeEventListener("keydown", onKey);
|
||||||
|
}, [isMobile, onClose]);
|
||||||
|
|
||||||
|
const grid = (
|
||||||
|
<div className="space-y-4 p-3">
|
||||||
|
{EMOJI_GROUPS.map((group) => (
|
||||||
|
<section key={group.label}>
|
||||||
|
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
|
{group.label}
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-8 gap-1">
|
||||||
|
{group.emojis.map((emoji) => (
|
||||||
|
<button
|
||||||
|
key={emoji}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
onClose();
|
||||||
|
onPick(emoji);
|
||||||
|
}}
|
||||||
|
aria-label={`React ${emoji}`}
|
||||||
|
className="inline-flex size-9 items-center justify-center rounded-md text-lg hover:bg-muted"
|
||||||
|
>
|
||||||
|
{emoji}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
return (
|
||||||
|
<Drawer open onOpenChange={(o) => !o && onClose()}>
|
||||||
|
<DrawerContent className="bg-card">
|
||||||
|
<DrawerHeader className="text-left">
|
||||||
|
<DrawerTitle>Pick a reaction</DrawerTitle>
|
||||||
|
<DrawerDescription>
|
||||||
|
Choose any emoji to react with.
|
||||||
|
</DrawerDescription>
|
||||||
|
</DrawerHeader>
|
||||||
|
<div className="max-h-[45vh] overflow-y-auto overscroll-contain">
|
||||||
|
{grid}
|
||||||
|
</div>
|
||||||
|
</DrawerContent>
|
||||||
|
</Drawer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof document === "undefined") return null;
|
||||||
|
|
||||||
|
// Opens upward: the "+" sits in a menu raised over a message, so there is
|
||||||
|
// usually more room above it than below. Flips down only if that would clip.
|
||||||
|
let left = anchor.x - POPOVER_W / 2;
|
||||||
|
if (left + POPOVER_W > window.innerWidth - EDGE_PAD) {
|
||||||
|
left = window.innerWidth - POPOVER_W - EDGE_PAD;
|
||||||
|
}
|
||||||
|
if (left < EDGE_PAD) left = EDGE_PAD;
|
||||||
|
|
||||||
|
// Room above the button, less a small gap and the viewport edge.
|
||||||
|
const roomAbove = anchor.y - 8 - EDGE_PAD;
|
||||||
|
// Open upward when there is any reasonable room, which by the composer
|
||||||
|
// there almost always is.
|
||||||
|
const openUp = roomAbove > 160;
|
||||||
|
// Anchored by whichever edge faces the button, so the panel hugs it
|
||||||
|
// regardless of how tall its contents actually are. Positioning by top
|
||||||
|
// minus a MAX height floats it away whenever the content is shorter.
|
||||||
|
const vertical = openUp
|
||||||
|
? { bottom: window.innerHeight - anchor.y + 8 }
|
||||||
|
: { top: anchor.y + 8 };
|
||||||
|
// Never taller than the space it has.
|
||||||
|
const maxH = Math.min(
|
||||||
|
POPOVER_H,
|
||||||
|
openUp ? roomAbove : window.innerHeight - anchor.y - 8 - EDGE_PAD,
|
||||||
|
);
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<>
|
||||||
|
{/* Catches the outside click. Transparent — a popover dims nothing. */}
|
||||||
|
<div className="fixed inset-0 z-[60]" onClick={onClose} aria-hidden />
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-label="Pick a reaction"
|
||||||
|
style={{ left, ...vertical, width: POPOVER_W, maxHeight: maxH }}
|
||||||
|
className="fixed z-[61] flex flex-col overflow-hidden rounded-xl border border-border bg-card shadow-lg"
|
||||||
|
>
|
||||||
|
<div className="flex-1 overflow-y-auto">{grid}</div>
|
||||||
|
</div>
|
||||||
|
</>,
|
||||||
|
document.body,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export function SharedPostCard({ attachment }: { attachment: Attachment }) {
|
|||||||
|
|
||||||
if (!post || attachment.entity_unavailable) {
|
if (!post || attachment.entity_unavailable) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2 rounded-lg border border-border bg-muted/30 px-3 py-2.5">
|
<div className="flex w-full items-center gap-2 rounded-lg border border-border bg-muted/30 px-3 py-2.5 sm:max-w-64">
|
||||||
<ImageOff className="size-4 shrink-0 text-muted-foreground" aria-hidden />
|
<ImageOff className="size-4 shrink-0 text-muted-foreground" aria-hidden />
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">
|
||||||
This post is no longer available
|
This post is no longer available
|
||||||
@@ -31,7 +31,11 @@ export function SharedPostCard({ attachment }: { attachment: Attachment }) {
|
|||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
href={`/u/${encodeURIComponent(post.author_handle)}/posts#post-${post.id}`}
|
href={`/u/${encodeURIComponent(post.author_handle)}/posts#post-${post.id}`}
|
||||||
className="block overflow-hidden rounded-lg border border-border bg-card transition-colors hover:bg-muted/40"
|
// Capped on wide screens. The bubble allows 78% of the column, which on
|
||||||
|
// a phone is a sensible ~265px but on desktop is over 500px — and with
|
||||||
|
// a square image that makes a shared post taller than the viewport.
|
||||||
|
// Mobile keeps the bubble's own width; only sm+ needs the ceiling.
|
||||||
|
className="block w-full overflow-hidden rounded-lg border border-border bg-card transition-colors hover:bg-muted/40 sm:max-w-64"
|
||||||
>
|
>
|
||||||
<span className="flex items-center gap-2 px-3 py-2">
|
<span className="flex items-center gap-2 px-3 py-2">
|
||||||
<UserAvatar
|
<UserAvatar
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stickers on a message: a 2-column grid, rendered without a chat bubble.
|
* Stickers on a message, rendered without a chat bubble.
|
||||||
|
*
|
||||||
|
* Laid out as rows of at most two, NOT a fixed 2-column grid: a grid reserves
|
||||||
|
* the second cell whether or not anything fills it, so a single sticker sat
|
||||||
|
* beside a gap the width of another sticker. Rows size themselves — a lone
|
||||||
|
* sticker takes the row and is drawn larger, a pair shares it and both shrink.
|
||||||
|
* An odd count therefore ends on one big sticker rather than a hole.
|
||||||
*
|
*
|
||||||
* Unlike the media grid, this never collapses into "+N more" — every sticker
|
* Unlike the media grid, this never collapses into "+N more" — every sticker
|
||||||
* sent is drawn. They are small and deliberately playful, and hiding some
|
* sent is drawn. They are small and deliberately playful, and hiding some
|
||||||
@@ -16,6 +22,10 @@ import { useState } from "react";
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { chat, type Attachment } from "@/lib/chat";
|
import { chat, type Attachment } from "@/lib/chat";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
/** At most two per row, as WhatsApp and Telegram do. */
|
||||||
|
const STICKERS_PER_ROW = 2;
|
||||||
|
|
||||||
export function StickerGrid({
|
export function StickerGrid({
|
||||||
conversationId,
|
conversationId,
|
||||||
@@ -47,32 +57,55 @@ export function StickerGrid({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Chunk into rows of at most two. Doing this in JS rather than CSS is what
|
||||||
|
// lets a row know how many it holds, which is what sets the size.
|
||||||
|
const rows: Attachment[][] = [];
|
||||||
|
for (let i = 0; i < stickers.length; i += STICKERS_PER_ROW) {
|
||||||
|
rows.push(stickers.slice(i, i + STICKERS_PER_ROW));
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid w-fit grid-cols-2 gap-1">
|
// w-fit so the block hugs its rows: any fixed width would reintroduce the
|
||||||
{stickers.map((a) => (
|
// empty space this layout exists to remove.
|
||||||
<button
|
<div className="flex w-fit flex-col gap-1">
|
||||||
key={a.id}
|
{rows.map((row, i) => (
|
||||||
type="button"
|
<div key={i} className="flex gap-1">
|
||||||
disabled={mine}
|
{row.map((a) => {
|
||||||
onContextMenu={(e) => {
|
const alone = row.length === 1;
|
||||||
if (mine) return;
|
return (
|
||||||
e.preventDefault();
|
<button
|
||||||
void save(a);
|
key={a.id}
|
||||||
}}
|
type="button"
|
||||||
title={mine ? (a.title ?? "") : `${a.title ?? "Sticker"} — hold to save`}
|
disabled={mine}
|
||||||
aria-label={a.title ?? "Sticker"}
|
onContextMenu={(e) => {
|
||||||
className="relative size-20 disabled:cursor-default sm:size-24"
|
if (mine) return;
|
||||||
>
|
e.preventDefault();
|
||||||
{a.image_url && (
|
void save(a);
|
||||||
<Image
|
}}
|
||||||
src={a.image_url}
|
title={
|
||||||
alt={a.title ?? ""}
|
mine
|
||||||
fill
|
? (a.title ?? "")
|
||||||
sizes="96px"
|
: `${a.title ?? "Sticker"} — hold to save`
|
||||||
className="object-contain"
|
}
|
||||||
/>
|
aria-label={a.title ?? "Sticker"}
|
||||||
)}
|
className={cn(
|
||||||
</button>
|
"relative disabled:cursor-default",
|
||||||
|
alone ? "size-32 sm:size-36" : "size-24 sm:size-28",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{a.image_url && (
|
||||||
|
<Image
|
||||||
|
src={a.image_url}
|
||||||
|
alt={a.title ?? ""}
|
||||||
|
fill
|
||||||
|
sizes={alone ? "144px" : "112px"}
|
||||||
|
className="object-contain"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,28 +1,54 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The sticker drawer, opened from the composer.
|
* The sticker picker: a popover anchored to the composer's sticker button on
|
||||||
|
* desktop, a bottom drawer on mobile.
|
||||||
|
*
|
||||||
|
* A popover rather than a modal on desktop because picking a sticker is a
|
||||||
|
* quick aside from typing — a modal takes over the screen and hides the
|
||||||
|
* thread you're replying to. On touch, where there is nothing to anchor to
|
||||||
|
* and the keyboard is already occupying the lower half, the drawer is the
|
||||||
|
* right shape.
|
||||||
*
|
*
|
||||||
* The collection is whatever the server says it is — rewards earned through
|
* The collection is whatever the server says it is — rewards earned through
|
||||||
* sādhana plus stickers saved from a chat — so a sticker you were sent
|
* sādhana plus stickers saved from a chat — so a sticker you were sent
|
||||||
* appears here the moment you keep it, with no client-side bookkeeping.
|
* appears the moment you keep it, with no client-side bookkeeping.
|
||||||
*
|
*
|
||||||
* Empty is a real state worth explaining: stickers are earned, so someone
|
* Empty is a real state worth explaining: stickers are earned, so someone
|
||||||
* with none needs to know how to get one rather than seeing a blank sheet.
|
* with none needs to know how to get one rather than seeing a blank sheet.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { ResponsiveDialog } from "@/components/chat/responsive-dialog";
|
import { useMediaQuery } from "@/components/selection-toolbar/shared";
|
||||||
|
import {
|
||||||
|
Drawer,
|
||||||
|
DrawerContent,
|
||||||
|
DrawerDescription,
|
||||||
|
DrawerHeader,
|
||||||
|
DrawerTitle,
|
||||||
|
} from "@/components/ui/drawer";
|
||||||
import { chat, type Sticker } from "@/lib/chat";
|
import { chat, type Sticker } from "@/lib/chat";
|
||||||
|
|
||||||
|
/** Where the popover hangs from — the sticker button's viewport rect. */
|
||||||
|
export type StickerAnchor = { x: number; y: number };
|
||||||
|
|
||||||
|
const POPOVER_W = 320;
|
||||||
|
const POPOVER_H = 300;
|
||||||
|
const EDGE_PAD = 8;
|
||||||
|
|
||||||
export function StickerPicker({
|
export function StickerPicker({
|
||||||
|
anchor,
|
||||||
onPick,
|
onPick,
|
||||||
onClose,
|
onClose,
|
||||||
}: {
|
}: {
|
||||||
|
/** Ignored on mobile, which uses a drawer. */
|
||||||
|
anchor: StickerAnchor | null;
|
||||||
onPick: (sticker: Sticker) => void;
|
onPick: (sticker: Sticker) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
const isMobile = useMediaQuery("(max-width: 767px)");
|
||||||
const [stickers, setStickers] = useState<Sticker[] | null>(null);
|
const [stickers, setStickers] = useState<Sticker[] | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -40,21 +66,26 @@ export function StickerPicker({
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
// Desktop only: Escape closes. The drawer handles its own dismissal, and no
|
||||||
<ResponsiveDialog
|
// scroll lock here — a popover shouldn't freeze the thread behind it.
|
||||||
title="Stickers"
|
useEffect(() => {
|
||||||
description="Earned through sādhana, or saved from a chat."
|
if (isMobile) return;
|
||||||
onClose={onClose}
|
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
|
||||||
>
|
document.addEventListener("keydown", onKey);
|
||||||
|
return () => document.removeEventListener("keydown", onKey);
|
||||||
|
}, [isMobile, onClose]);
|
||||||
|
|
||||||
|
const grid = (
|
||||||
|
<>
|
||||||
{stickers === null ? (
|
{stickers === null ? (
|
||||||
<p className="p-4 text-sm text-muted-foreground">Loading…</p>
|
<p className="p-4 text-sm text-muted-foreground">Loading…</p>
|
||||||
) : stickers.length === 0 ? (
|
) : stickers.length === 0 ? (
|
||||||
<p className="p-4 text-sm text-muted-foreground">
|
<p className="p-4 text-sm text-muted-foreground">
|
||||||
No stickers yet. Keep your sādhana streak — a sacred reward arrives
|
No stickers yet. Earn through your sādhana streak, or save one when
|
||||||
every 7 days — or save one someone sends you.
|
someone sends you.
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-4 gap-2 p-4 sm:grid-cols-5">
|
<div className="grid grid-cols-4 gap-2 p-3 sm:grid-cols-5">
|
||||||
{stickers.map((s) => (
|
{stickers.map((s) => (
|
||||||
<button
|
<button
|
||||||
key={s.key}
|
key={s.key}
|
||||||
@@ -78,6 +109,74 @@ export function StickerPicker({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</ResponsiveDialog>
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
return (
|
||||||
|
<Drawer open onOpenChange={(o) => !o && onClose()}>
|
||||||
|
<DrawerContent className="bg-card">
|
||||||
|
<DrawerHeader className="text-left">
|
||||||
|
<DrawerTitle>Stickers</DrawerTitle>
|
||||||
|
<DrawerDescription>
|
||||||
|
Earned through sādhana, or saved from a chat.
|
||||||
|
</DrawerDescription>
|
||||||
|
</DrawerHeader>
|
||||||
|
<div className="max-h-[45vh] overflow-y-auto overscroll-contain">
|
||||||
|
{grid}
|
||||||
|
</div>
|
||||||
|
</DrawerContent>
|
||||||
|
</Drawer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof document === "undefined" || !anchor) return null;
|
||||||
|
|
||||||
|
// Opens UPWARD by default: the composer sits at the bottom of the viewport,
|
||||||
|
// so there is rarely room below it. Flips down only if the anchor is high
|
||||||
|
// enough that upward would clip.
|
||||||
|
let left = anchor.x - POPOVER_W / 2;
|
||||||
|
if (left + POPOVER_W > window.innerWidth - EDGE_PAD) {
|
||||||
|
left = window.innerWidth - POPOVER_W - EDGE_PAD;
|
||||||
|
}
|
||||||
|
if (left < EDGE_PAD) left = EDGE_PAD;
|
||||||
|
|
||||||
|
// Room above the button, less a small gap and the viewport edge.
|
||||||
|
const roomAbove = anchor.y - 8 - EDGE_PAD;
|
||||||
|
// Open upward when there is any reasonable room, which by the composer
|
||||||
|
// there almost always is.
|
||||||
|
const openUp = roomAbove > 160;
|
||||||
|
// Anchored by whichever edge faces the button, so the panel hugs it
|
||||||
|
// regardless of how tall its contents actually are. Positioning by top
|
||||||
|
// minus a MAX height floats it away whenever the content is shorter.
|
||||||
|
const vertical = openUp
|
||||||
|
? { bottom: window.innerHeight - anchor.y + 8 }
|
||||||
|
: { top: anchor.y + 8 };
|
||||||
|
// Never taller than the space it has.
|
||||||
|
const maxH = Math.min(
|
||||||
|
POPOVER_H,
|
||||||
|
openUp ? roomAbove : window.innerHeight - anchor.y - 8 - EDGE_PAD,
|
||||||
|
);
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<>
|
||||||
|
{/* Catches the outside click. Transparent — a popover dims nothing. */}
|
||||||
|
<div className="fixed inset-0 z-50" onClick={onClose} aria-hidden />
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-label="Stickers"
|
||||||
|
style={{ left, ...vertical, width: POPOVER_W, maxHeight: maxH }}
|
||||||
|
className="fixed z-[51] flex flex-col overflow-hidden rounded-xl border border-border bg-card shadow-lg"
|
||||||
|
>
|
||||||
|
<div className="border-b border-border px-3 py-2">
|
||||||
|
<p className="text-xs font-medium text-foreground">Stickers</p>
|
||||||
|
<p className="text-[11px] text-muted-foreground">
|
||||||
|
Earned through sādhana, or saved from a chat.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-y-auto">{grid}</div>
|
||||||
|
</div>
|
||||||
|
</>,
|
||||||
|
document.body,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,8 @@
|
|||||||
* library.
|
* library.
|
||||||
*
|
*
|
||||||
* Two sections, top to bottom:
|
* Two sections, top to bottom:
|
||||||
* 1. Start here — three primary entry points (Books, Letters, Lectures)
|
* 1. Start here — a Quizzes tile, then three primary entry points
|
||||||
|
* (Books, Letters, Lectures), all one width
|
||||||
* rendered as full-width feature tiles with longer copy.
|
* rendered as full-width feature tiles with longer copy.
|
||||||
* 2. Explore — a six-card asymmetric grid for the remaining sections.
|
* 2. Explore — a six-card asymmetric grid for the remaining sections.
|
||||||
*
|
*
|
||||||
@@ -26,6 +27,7 @@ import {
|
|||||||
PenLine,
|
PenLine,
|
||||||
Users,
|
Users,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { QuizzesCTA } from "@/components/quiz/quizzes-cta";
|
||||||
|
|
||||||
type Feature = {
|
type Feature = {
|
||||||
href: string;
|
href: string;
|
||||||
@@ -112,7 +114,11 @@ export function KnowledgebaseIndex() {
|
|||||||
<div>
|
<div>
|
||||||
<section className="py-4">
|
<section className="py-4">
|
||||||
<SectionHeading eyebrow="Start here" title="Three doors in" />
|
<SectionHeading eyebrow="Start here" title="Three doors in" />
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 sm:gap-5">
|
{/* One grid so all four share a width. Quizzes lead: something to
|
||||||
|
do, ahead of the three ways in to read. Absent until one is
|
||||||
|
published, leaving the original three across. */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-none lg:grid-flow-col lg:auto-cols-fr gap-4 sm:gap-5">
|
||||||
|
<QuizzesCTA variant="tile" />
|
||||||
{FEATURED.map((f) => (
|
{FEATURED.map((f) => (
|
||||||
<FeaturedTile key={f.href} feature={f} />
|
<FeaturedTile key={f.href} feature={f} />
|
||||||
))}
|
))}
|
||||||
@@ -145,7 +151,7 @@ function FeaturedTile({ feature }: { feature: Feature }) {
|
|||||||
<ArrowUpRight className="size-4 text-muted-foreground/60 group-hover:text-foreground transition-colors" />
|
<ArrowUpRight className="size-4 text-muted-foreground/60 group-hover:text-foreground transition-colors" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3 className="mt-5 font-[family-name:var(--font-title)] font-semibold text-2xl sm:text-[1.6rem] leading-tight text-foreground">
|
<h3 className="mt-5 font-[family-name:var(--font-title)] font-semibold text-2xl sm:text-[1.6rem] lg:text-[1.35rem] leading-tight text-foreground">
|
||||||
{feature.label}
|
{feature.label}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import { useRouter, usePathname } from "next/navigation";
|
|||||||
import { Check, Link2 } from "lucide-react";
|
import { Check, Link2 } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { SaveOfflineButton } from "@/components/layout/save-offline-button";
|
import { SaveOfflineButton } from "@/components/layout/save-offline-button";
|
||||||
|
import { QuizButton } from "@/components/quiz/quiz-button";
|
||||||
import type { OfflineDescriptor } from "@/lib/offline";
|
import type { OfflineDescriptor } from "@/lib/offline";
|
||||||
|
|
||||||
const SEARCH_RETURN_KEY = "spsays_from_search";
|
const SEARCH_RETURN_KEY = "spsays_from_search";
|
||||||
@@ -71,6 +72,7 @@ export function PageHeader({
|
|||||||
bare = false,
|
bare = false,
|
||||||
sticky = false,
|
sticky = false,
|
||||||
offline,
|
offline,
|
||||||
|
quiz = true,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
/** Drop the bordered chrome — useful for pages that want flush title text. */
|
/** Drop the bordered chrome — useful for pages that want flush title text. */
|
||||||
@@ -89,6 +91,13 @@ export function PageHeader({
|
|||||||
* visible gap.
|
* visible gap.
|
||||||
*/
|
*/
|
||||||
sticky?: boolean;
|
sticky?: boolean;
|
||||||
|
/**
|
||||||
|
* Show this page's quiz button (if it has a published quiz) under the
|
||||||
|
* title. On by default for content pages; a page that places the button
|
||||||
|
* itself — the transcript, beside its audio player — turns this off so it
|
||||||
|
* never appears twice.
|
||||||
|
*/
|
||||||
|
quiz?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -216,6 +225,9 @@ export function PageHeader({
|
|||||||
|
|
||||||
{/* Page-level controls, anchored at the header's bottom. */}
|
{/* Page-level controls, anchored at the header's bottom. */}
|
||||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||||
|
{/* First, so it reads as belonging to the title above it. Renders
|
||||||
|
nothing on pages without a published quiz. */}
|
||||||
|
{quiz && isContentPath(pathname) ? <QuizButton path={pathname} /> : null}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={copyPageLink}
|
onClick={copyPageLink}
|
||||||
|
|||||||
@@ -53,11 +53,11 @@ const EVENTS: {
|
|||||||
{
|
{
|
||||||
key: "messages",
|
key: "messages",
|
||||||
label: "Messages",
|
label: "Messages",
|
||||||
description: "New direct messages and group chats.",
|
description:
|
||||||
// Push only. In-app delivery for chat is the inbox itself — a message
|
"New messages. Email is only ever sent when someone starts a conversation with you.",
|
||||||
// highlights its row rather than entering the notification tray — and
|
// No in-app: chat's in-app delivery is the inbox itself, where a message
|
||||||
// there is no such thing as an emailed chat message.
|
// highlights its row rather than entering the notification tray.
|
||||||
channels: ["push"],
|
channels: ["email", "push"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "community",
|
key: "community",
|
||||||
@@ -139,7 +139,11 @@ export function NotificationMatrix() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-[1fr_auto_auto_auto] items-center gap-x-4 gap-y-0 sm:gap-x-6">
|
// Fixed, EQUAL channel columns. With `auto` each one sized to its own
|
||||||
|
// widest content — "In-app" against the 44px toggle, "Push" against the
|
||||||
|
// same toggle — so the three columns came out different widths and
|
||||||
|
// neither the headers nor the switches lined up down the grid.
|
||||||
|
<div className="grid grid-cols-[1fr_repeat(3,3.25rem)] items-center gap-x-2 gap-y-0 sm:grid-cols-[1fr_repeat(3,4rem)] sm:gap-x-4">
|
||||||
{/* Header row */}
|
{/* Header row */}
|
||||||
<span aria-hidden />
|
<span aria-hidden />
|
||||||
{CHANNELS.map((c) => (
|
{CHANNELS.map((c) => (
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The quiz button on a content page.
|
||||||
|
*
|
||||||
|
* Asks the BFF whether this page has a published quiz and renders nothing if
|
||||||
|
* not, so it can sit on every content page without leaving an empty slot.
|
||||||
|
* Its label tracks progress: Take quiz → Continue quiz → View results.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { ClipboardCheck } from "lucide-react";
|
||||||
|
import { useAuth } from "@/components/auth/auth-provider";
|
||||||
|
import { quiz, quizActionLabel, type QuizCard } from "@/lib/quiz";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export function QuizButton({
|
||||||
|
path,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
/** The content page's own path, e.g. /books/bg/2. */
|
||||||
|
path: string;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const [card, setCard] = useState<QuizCard | null>(null);
|
||||||
|
|
||||||
|
// Re-asked when the user changes, so progress appears right after sign-in.
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
quiz
|
||||||
|
.forPath(path)
|
||||||
|
.then((c) => {
|
||||||
|
if (!cancelled) setCard(c);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
/* no quiz button is the right failure */
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [path, user?.id]);
|
||||||
|
|
||||||
|
if (!card) return null;
|
||||||
|
|
||||||
|
const done = card.my.state === "completed";
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={`/quizzes/${card.id}`}
|
||||||
|
title={card.title}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs font-medium transition-colors",
|
||||||
|
done
|
||||||
|
? "border-border text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||||
|
: "border-primary/40 bg-primary/10 text-primary hover:bg-primary/15",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ClipboardCheck className="size-3.5" />
|
||||||
|
{quizActionLabel(card.my.state)}
|
||||||
|
{card.my.state === "in_progress" && (
|
||||||
|
<span className="tabular-nums opacity-70">
|
||||||
|
{card.my.answered}/{card.my.total}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{done && card.my.score !== undefined && (
|
||||||
|
<span className="tabular-nums opacity-70">
|
||||||
|
{card.my.score}/{card.my.total}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Your finished attempt: the score, then every question with your answer,
|
||||||
|
* the correct one, its explanation, and how long you took.
|
||||||
|
*
|
||||||
|
* Correct options are filled solid so they read at a glance; a wrong pick is
|
||||||
|
* outlined in red. "Your answer" marks whatever you chose either way.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Check, Clock, X } from "lucide-react";
|
||||||
|
import { RichText } from "@/lib/rich-text";
|
||||||
|
import type { QuizResult } from "@/lib/quiz";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export function fmtMs(ms?: number | null): string {
|
||||||
|
if (ms == null) return "—";
|
||||||
|
if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
|
||||||
|
return `${Math.floor(ms / 60_000)}m ${Math.round((ms % 60_000) / 1000)}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function QuizResultView({ result }: { result: QuizResult }) {
|
||||||
|
const pct = result.total
|
||||||
|
? Math.round((result.score / result.total) * 100)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<section className="rounded-2xl border border-border bg-card p-5 text-center">
|
||||||
|
<p className="text-xs font-medium uppercase tracking-wide text-primary">
|
||||||
|
Your result
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-4xl font-semibold tabular-nums text-foreground">
|
||||||
|
{result.score}
|
||||||
|
<span className="text-2xl text-muted-foreground">
|
||||||
|
/{result.total}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
{pct}% correct · {fmtMs(result.total_ms)} in total
|
||||||
|
</p>
|
||||||
|
{result.quiz.target_path && (
|
||||||
|
<Link
|
||||||
|
href={result.quiz.target_path}
|
||||||
|
className="mt-3 inline-block text-sm font-medium text-primary hover:underline"
|
||||||
|
>
|
||||||
|
Back to {result.quiz.target_title || "the reading"}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<ol className="space-y-4">
|
||||||
|
{result.items.map((it, i) => (
|
||||||
|
<li key={it.id} className="rounded-xl border border-border p-4">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<p className="text-sm font-medium text-foreground">
|
||||||
|
<span className="text-muted-foreground">{i + 1}. </span>
|
||||||
|
<RichText text={it.prompt} />
|
||||||
|
</p>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"inline-flex shrink-0 items-center gap-1 text-xs",
|
||||||
|
it.is_correct ? "text-primary" : "text-destructive",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{it.is_correct ? (
|
||||||
|
<Check className="size-3.5" />
|
||||||
|
) : (
|
||||||
|
<X className="size-3.5" />
|
||||||
|
)}
|
||||||
|
{it.is_correct ? "Correct" : "Incorrect"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{it.multi && (
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
Select all that apply
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<ul className="mt-3 space-y-1.5">
|
||||||
|
{it.options.map((o) => {
|
||||||
|
const chosen = it.chosen_option_ids.includes(o.id);
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={o.id}
|
||||||
|
className={cn(
|
||||||
|
"flex items-start gap-2 rounded-lg border px-3 py-2 text-sm",
|
||||||
|
o.is_correct
|
||||||
|
? "border-primary bg-primary font-medium text-primary-foreground dark:text-white"
|
||||||
|
: chosen
|
||||||
|
? "border-destructive/50 bg-destructive/10 text-foreground"
|
||||||
|
: "border-border text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{o.is_correct ? (
|
||||||
|
<Check className="mt-0.5 size-3.5 shrink-0" strokeWidth={3} />
|
||||||
|
) : chosen ? (
|
||||||
|
<X className="mt-0.5 size-3.5 shrink-0 text-destructive" />
|
||||||
|
) : (
|
||||||
|
<span className="size-3.5 shrink-0" />
|
||||||
|
)}
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<RichText text={o.body} />
|
||||||
|
</span>
|
||||||
|
{chosen && (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 text-xs font-normal",
|
||||||
|
o.is_correct ? "opacity-85" : "text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Your answer
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
{it.explanation && (
|
||||||
|
<p className="mt-3 rounded-lg bg-muted/40 px-3 py-2 text-sm text-muted-foreground">
|
||||||
|
<RichText text={it.explanation} />
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="mt-2 flex items-center gap-1 text-xs text-muted-foreground">
|
||||||
|
<Clock className="size-3" /> {fmtMs(it.response_ms)}
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* QuizzesCTA — the way into /quizzes, in two shapes:
|
||||||
|
*
|
||||||
|
* - "tile": a card that leads the search page's "Start here" row.
|
||||||
|
* - "banner": a full-width row for the homepage, under sādhana / vāṇī.
|
||||||
|
*
|
||||||
|
* Renders nothing until there is at least one published quiz; with
|
||||||
|
* `pendingOnly` it also hides once every quiz has been finished, so the
|
||||||
|
* homepage only nudges when there's something left to do.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { ArrowUpRight, ChevronRight, ClipboardCheck } from "lucide-react";
|
||||||
|
import { useAuth } from "@/components/auth/auth-provider";
|
||||||
|
import { quiz, type QuizCard } from "@/lib/quiz";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export function QuizzesCTA({
|
||||||
|
variant = "banner",
|
||||||
|
pendingOnly = false,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
variant?: "banner" | "tile";
|
||||||
|
pendingOnly?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const [cards, setCards] = useState<QuizCard[] | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
quiz
|
||||||
|
.list()
|
||||||
|
.then((c) => {
|
||||||
|
if (!cancelled) setCards(c);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
/* CTA simply doesn't render */
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
// Refetch on sign-in/out: progress is per user.
|
||||||
|
}, [user?.id]);
|
||||||
|
|
||||||
|
if (!cards?.length) return null;
|
||||||
|
|
||||||
|
if (pendingOnly && cards.every((c) => c.my.state === "completed")) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tagline = "Test your understanding of chapters, lectures and more";
|
||||||
|
|
||||||
|
if (variant === "tile") {
|
||||||
|
// Matches the search page's FeaturedTile so it sits in that grid as an
|
||||||
|
// equal: same padding, icon chip, title scale and corner arrow.
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href="/quizzes"
|
||||||
|
className={cn(
|
||||||
|
"group relative flex flex-col h-full rounded-2xl bg-card ring-1 ring-foreground/10 hover:ring-foreground/25 hover:shadow-md transition-all p-6",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex items-center justify-center size-11 rounded-xl bg-primary/12 text-primary">
|
||||||
|
<ClipboardCheck className="size-5" />
|
||||||
|
</div>
|
||||||
|
<ArrowUpRight className="size-4 text-muted-foreground/60 group-hover:text-foreground transition-colors" />
|
||||||
|
</div>
|
||||||
|
<h3 className="mt-5 font-[family-name:var(--font-title)] font-semibold text-2xl sm:text-[1.6rem] lg:text-[1.35rem] leading-tight text-foreground">
|
||||||
|
Quizzes
|
||||||
|
</h3>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground leading-relaxed flex-1">
|
||||||
|
{tagline}
|
||||||
|
</p>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href="/quizzes"
|
||||||
|
className={cn(
|
||||||
|
"group flex items-center gap-3.5 rounded-2xl border border-border bg-card p-4 text-left transition-colors hover:border-primary/40 hover:bg-muted/40",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="inline-flex size-12 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary">
|
||||||
|
<ClipboardCheck className="size-6" />
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-base font-semibold leading-snug text-foreground">
|
||||||
|
Quizzes
|
||||||
|
</p>
|
||||||
|
<p className="truncate text-sm leading-snug text-muted-foreground">
|
||||||
|
{tagline}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<ChevronRight className="size-5 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5 group-hover:text-foreground" />
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,13 +8,23 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { ChevronRight } from "lucide-react";
|
import { ChevronRight } from "lucide-react";
|
||||||
import { useAuth } from "@/components/auth/auth-provider";
|
import { useAuth } from "@/components/auth/auth-provider";
|
||||||
import { sadhna, type SadhnaDashboard } from "@/lib/sadhna";
|
import { sadhna, type SadhnaDashboard } from "@/lib/sadhna";
|
||||||
import { StreakFlame } from "@/components/sadhna/streak-flame";
|
import { StreakFlame } from "@/components/sadhna/streak-flame";
|
||||||
|
|
||||||
export function SadhnaStreakCTA() {
|
export function SadhnaStreakCTA({
|
||||||
|
compact = false,
|
||||||
|
}: {
|
||||||
|
/**
|
||||||
|
* Icon-only tile for the homepage row beside Vāṇī of the Day. The streak
|
||||||
|
* and today's status move into the accessible label and tooltip, and the
|
||||||
|
* tile tints while a streak is running.
|
||||||
|
*/
|
||||||
|
compact?: boolean;
|
||||||
|
} = {}) {
|
||||||
const { user, loading } = useAuth();
|
const { user, loading } = useAuth();
|
||||||
const authed = !loading && !!user;
|
const authed = !loading && !!user;
|
||||||
const [dash, setDash] = useState<SadhnaDashboard | null>(null);
|
const [dash, setDash] = useState<SadhnaDashboard | null>(null);
|
||||||
@@ -55,6 +65,35 @@ export function SadhnaStreakCTA() {
|
|||||||
? "One offering done — finish today"
|
? "One offering done — finish today"
|
||||||
: "Chant and post a quote to complete today";
|
: "Chant and post a quote to complete today";
|
||||||
|
|
||||||
|
if (compact) {
|
||||||
|
const active = streak > 0;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href="/sadhna-streak"
|
||||||
|
aria-label={`${title}. ${subtitle}`}
|
||||||
|
title={`${title} — ${subtitle}`}
|
||||||
|
className={
|
||||||
|
// An explicit square matching the homepage row's h-24. Deriving the
|
||||||
|
// width from height (aspect-square + h-full) collapses to the icon in
|
||||||
|
// Safari, so both sides are fixed.
|
||||||
|
"group p-2 flex flex-col size-24 shrink-0 items-center justify-center rounded-2xl border transition-colors " +
|
||||||
|
(active
|
||||||
|
? "border-primary/30 bg-primary/10 hover:border-primary/50"
|
||||||
|
: "border-border bg-card hover:border-primary/40 hover:bg-muted/40")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src="/icons/chanting-beads.svg"
|
||||||
|
alt=""
|
||||||
|
width={44}
|
||||||
|
height={44}
|
||||||
|
className="size-12 transition-transform group-hover:scale-105"
|
||||||
|
unoptimized
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
href="/sadhna-streak"
|
href="/sadhna-streak"
|
||||||
|
|||||||
@@ -331,7 +331,20 @@ export function TextSelectionToolbar() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!toolbarPos || !selectedText) return null;
|
// The share dialog deliberately OUTLIVES the selection that opened it:
|
||||||
|
// onChat snapshots the passage and then calls finishAction, which clears
|
||||||
|
// selectedText. Rendered only below the guard, it would never mount — the
|
||||||
|
// component returns null on that same render, and the dialog would appear
|
||||||
|
// on the next selection instead, when selectedText is non-empty again.
|
||||||
|
//
|
||||||
|
// The other surfaces avoid this by using saveAndClearSelection, which keeps
|
||||||
|
// selectedText in state; this one lets the selection go entirely.
|
||||||
|
const shareToChat =
|
||||||
|
chatQuote !== null ? (
|
||||||
|
<ShareToChatDialog quote={chatQuote} onClose={() => setChatQuote(null)} />
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
if (!toolbarPos || !selectedText) return shareToChat;
|
||||||
|
|
||||||
// ─── Action handlers ──────────────────────────────────────────────────
|
// ─── Action handlers ──────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -628,12 +641,7 @@ export function TextSelectionToolbar() {
|
|||||||
selectedText={selectedText}
|
selectedText={selectedText}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{chatQuote !== null && (
|
{shareToChat}
|
||||||
<ShareToChatDialog
|
|
||||||
quote={chatQuote}
|
|
||||||
onClose={() => setChatQuote(null)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { useEffect, useState } from "react";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { ChevronRight } from "lucide-react";
|
import { ChevronRight } from "lucide-react";
|
||||||
import { getVaniOfTheDay, type Vani } from "@/lib/vani";
|
import { getVaniOfTheDay, type Vani } from "@/lib/vani";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
/** Quote-marks glyph (svgrepo). Inherits color via `currentColor`. */
|
/** Quote-marks glyph (svgrepo). Inherits color via `currentColor`. */
|
||||||
function QuotesIcon({ className }: { className?: string }) {
|
function QuotesIcon({ className }: { className?: string }) {
|
||||||
@@ -28,7 +29,7 @@ function QuotesIcon({ className }: { className?: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function VaniOfTheDayCTA() {
|
export function VaniOfTheDayCTA({ className }: { className?: string } = {}) {
|
||||||
const [vani, setVani] = useState<Vani | null>(null);
|
const [vani, setVani] = useState<Vani | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -57,11 +58,11 @@ export function VaniOfTheDayCTA() {
|
|||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
href={vani.href}
|
href={vani.href}
|
||||||
className="group flex w-full max-w-md items-center gap-3.5 rounded-2xl border border-border bg-card p-4 text-left transition-colors hover:border-primary/40 hover:bg-muted/40"
|
className={cn(
|
||||||
|
"group flex w-full max-w-md items-center gap-3.5 rounded-2xl border border-border bg-card p-4 text-left transition-colors hover:border-primary/40 hover:bg-muted/40",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<span className="inline-flex size-12 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary">
|
|
||||||
<QuotesIcon className="size-6" />
|
|
||||||
</span>
|
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="text-xs font-medium text-primary">Vani of the day</p>
|
<p className="text-xs font-medium text-primary">Vani of the day</p>
|
||||||
<p className="mt-0.5 truncate text-base font-semibold text-foreground leading-snug">
|
<p className="mt-0.5 truncate text-base font-semibold text-foreground leading-snug">
|
||||||
|
|||||||
+121
@@ -0,0 +1,121 @@
|
|||||||
|
/**
|
||||||
|
* Browser-side client for quizzes.
|
||||||
|
*
|
||||||
|
* Correct answers never come down while an attempt is in progress — the BFF
|
||||||
|
* withholds them — so nothing here can reveal one early. Results are only
|
||||||
|
* ever the caller's own: the BFF reads the user from the session.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const BFF_BASE = process.env.NEXT_PUBLIC_BFF_URL || "http://localhost:8080";
|
||||||
|
|
||||||
|
export class QuizError extends Error {
|
||||||
|
constructor(
|
||||||
|
public status: number,
|
||||||
|
message: string,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function call<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
|
const res = await fetch(`${BFF_BASE}/api/v1${path}`, {
|
||||||
|
credentials: "include",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
...init,
|
||||||
|
});
|
||||||
|
let json: { data?: T; error?: { message?: string } } | null = null;
|
||||||
|
try {
|
||||||
|
json = await res.json();
|
||||||
|
} catch {
|
||||||
|
/* empty body */
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new QuizError(
|
||||||
|
res.status,
|
||||||
|
json?.error?.message || "Quiz request failed",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// `data` can legitimately be null (no quiz on this page), so test for the
|
||||||
|
// key rather than falling back with `??`, which would return the envelope.
|
||||||
|
return (json && "data" in json ? json.data : json) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type QuizState = "not_started" | "in_progress" | "completed";
|
||||||
|
|
||||||
|
export type QuizOption = {
|
||||||
|
id: string;
|
||||||
|
ord: number;
|
||||||
|
body: string;
|
||||||
|
is_correct?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type QuizQuestion = {
|
||||||
|
id: string;
|
||||||
|
ord: number;
|
||||||
|
prompt: string;
|
||||||
|
explanation?: string;
|
||||||
|
/** "Select all that apply": several options may be correct. */
|
||||||
|
multi: boolean;
|
||||||
|
options: QuizOption[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type QuizCard = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
target_kind: "chapter" | "lecture" | "conversation" | "standalone";
|
||||||
|
target_path?: string | null;
|
||||||
|
target_title: string;
|
||||||
|
question_count: number;
|
||||||
|
my: { state: QuizState; answered: number; total: number; score?: number };
|
||||||
|
};
|
||||||
|
|
||||||
|
export type QuizPlay = {
|
||||||
|
quiz_id: string;
|
||||||
|
title: string;
|
||||||
|
answered: number;
|
||||||
|
total: number;
|
||||||
|
done: boolean;
|
||||||
|
question?: QuizQuestion;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type QuizResult = {
|
||||||
|
quiz: QuizCard;
|
||||||
|
score: number;
|
||||||
|
total: number;
|
||||||
|
started_at: string;
|
||||||
|
completed_at?: string;
|
||||||
|
total_ms: number;
|
||||||
|
items: (QuizQuestion & {
|
||||||
|
chosen_option_ids: string[];
|
||||||
|
is_correct: boolean;
|
||||||
|
response_ms?: number;
|
||||||
|
})[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const quiz = {
|
||||||
|
/** The quiz on a content page, or null when it has none. */
|
||||||
|
forPath: (path: string) =>
|
||||||
|
call<QuizCard | null>(`/quizzes/for?path=${encodeURIComponent(path)}`),
|
||||||
|
list: () => call<QuizCard[]>("/quizzes"),
|
||||||
|
get: (id: string) => call<QuizCard>(`/quizzes/${id}`),
|
||||||
|
/** Opens or resumes your attempt and returns the current question. */
|
||||||
|
start: (id: string) =>
|
||||||
|
call<QuizPlay>(`/quizzes/${id}/start`, { method: "POST" }),
|
||||||
|
/** One option, or several for a multi question. Final once sent. */
|
||||||
|
answer: (id: string, questionId: string, optionIds: string[]) =>
|
||||||
|
call<QuizPlay>(`/quizzes/${id}/answer`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ question_id: questionId, option_ids: optionIds }),
|
||||||
|
}),
|
||||||
|
result: (id: string) => call<QuizResult>(`/quizzes/${id}/result`),
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The button's wording follows where you are in the quiz. */
|
||||||
|
export function quizActionLabel(state: QuizState): string {
|
||||||
|
return state === "completed"
|
||||||
|
? "View results"
|
||||||
|
: state === "in_progress"
|
||||||
|
? "Continue quiz"
|
||||||
|
: "Take quiz";
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
/**
|
||||||
|
* Renders the light formatting the admin message fields allow:
|
||||||
|
*
|
||||||
|
* *bold* _italic_ __underline__ ~strike~ [label](https://…)
|
||||||
|
*
|
||||||
|
* plus bare http(s) URLs. The rules mirror pkg/richtext in the BFF, which
|
||||||
|
* renders the same markers into email — so the preview shown here is what
|
||||||
|
* the recipient's inbox gets.
|
||||||
|
*
|
||||||
|
* Output is React nodes, never an HTML string: React escapes all text, so
|
||||||
|
* author input cannot become markup, and links are limited to http(s).
|
||||||
|
*
|
||||||
|
* NOTE: a copy of spsays-admin/lib/rich-text.tsx. The two are separate Next
|
||||||
|
* apps with no shared package — the same arrangement as compress-image.ts.
|
||||||
|
* Change both, and pkg/richtext in the BFF, together.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
const LINK_RE =
|
||||||
|
/\[([^\]\n]+)\]\((https?:\/\/[^\s)]+)\)|(https?:\/\/[^\s<]+)/g;
|
||||||
|
const TRAILING_PUNCT = /[.,;:!?)]+$/;
|
||||||
|
|
||||||
|
type Marker = { re: RegExp; wrap: (n: ReactNode, k: string) => ReactNode };
|
||||||
|
|
||||||
|
// Order matters: __underline__ before _italic_, so the double underscore
|
||||||
|
// isn't read as two italic markers.
|
||||||
|
const MARKERS: Marker[] = [
|
||||||
|
{ re: /__([^_\n]+?)__/, wrap: (n, k) => <u key={k}>{n}</u> },
|
||||||
|
{ re: /\*([^*\n]+?)\*/, wrap: (n, k) => <strong key={k}>{n}</strong> },
|
||||||
|
{ re: /~([^~\n]+?)~/, wrap: (n, k) => <s key={k}>{n}</s> },
|
||||||
|
{ re: /_([^_\n]+?)_/, wrap: (n, k) => <em key={k}>{n}</em> },
|
||||||
|
];
|
||||||
|
|
||||||
|
const isWord = (c: string | undefined) => !!c && /\w/.test(c);
|
||||||
|
|
||||||
|
/** Markers within a run of text that contains no links. */
|
||||||
|
function renderMarkers(text: string, keyBase: string): ReactNode[] {
|
||||||
|
// Earliest match across all markers wins, so nesting resolves outside-in.
|
||||||
|
let best: { idx: number; len: number; inner: string; m: Marker } | null =
|
||||||
|
null;
|
||||||
|
for (const m of MARKERS) {
|
||||||
|
const re = new RegExp(m.re.source, "g");
|
||||||
|
let hit: RegExpExecArray | null;
|
||||||
|
while ((hit = re.exec(text))) {
|
||||||
|
// Italic needs a non-word character either side, so snake_case and
|
||||||
|
// handles keep their underscores.
|
||||||
|
if (m.re.source.startsWith("_(")) {
|
||||||
|
const before = text[hit.index - 1];
|
||||||
|
const after = text[hit.index + hit[0].length];
|
||||||
|
if (isWord(before) || isWord(after)) continue;
|
||||||
|
}
|
||||||
|
if (!best || hit.index < best.idx) {
|
||||||
|
best = { idx: hit.index, len: hit[0].length, inner: hit[1], m };
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!best) return text ? [text] : [];
|
||||||
|
|
||||||
|
const before = text.slice(0, best.idx);
|
||||||
|
const after = text.slice(best.idx + best.len);
|
||||||
|
const k = `${keyBase}-${best.idx}`;
|
||||||
|
return [
|
||||||
|
...(before ? [before] : []),
|
||||||
|
best.m.wrap(renderMarkers(best.inner, `${k}i`), k),
|
||||||
|
...renderMarkers(after, `${k}a`),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Renders formatted text, newlines included. */
|
||||||
|
export function RichText({ text }: { text: string }) {
|
||||||
|
const lines = text.replace(/\r\n/g, "\n").split("\n");
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{lines.map((line, li) => {
|
||||||
|
const out: ReactNode[] = [];
|
||||||
|
let last = 0;
|
||||||
|
for (const m of line.matchAll(LINK_RE)) {
|
||||||
|
const idx = m.index ?? 0;
|
||||||
|
out.push(...renderMarkers(line.slice(last, idx), `${li}-${last}`));
|
||||||
|
let href = m[2] ?? m[3];
|
||||||
|
let label = m[1] ?? href;
|
||||||
|
let tail = "";
|
||||||
|
if (!m[1]) {
|
||||||
|
// Sentence punctuation after a bare URL isn't part of it.
|
||||||
|
tail = href.match(TRAILING_PUNCT)?.[0] ?? "";
|
||||||
|
href = href.slice(0, href.length - tail.length);
|
||||||
|
label = href;
|
||||||
|
}
|
||||||
|
out.push(
|
||||||
|
<a
|
||||||
|
key={`${li}-l${idx}`}
|
||||||
|
href={href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="font-medium text-primary underline-offset-2 hover:underline"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</a>,
|
||||||
|
);
|
||||||
|
if (tail) out.push(tail);
|
||||||
|
last = idx + m[0].length;
|
||||||
|
}
|
||||||
|
out.push(...renderMarkers(line.slice(last), `${li}-${last}`));
|
||||||
|
return (
|
||||||
|
<span key={li}>
|
||||||
|
{li > 0 && <br />}
|
||||||
|
{out}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user