mobile responsive

This commit is contained in:
gosangam
2026-10-16 15:45:29 +05:30
parent 1e551764e1
commit b194d4aa03
21 changed files with 249 additions and 138 deletions
+5 -4
View File
@@ -17,6 +17,7 @@ import { Can } from "@/components/staff-context";
import { toast } from "sonner"; import { toast } from "sonner";
import { broadcasts, type Broadcast } from "@/lib/api"; import { broadcasts, type Broadcast } from "@/lib/api";
import { compressImage } from "@/lib/compress-image"; import { compressImage } from "@/lib/compress-image";
import { Clamp } from "@/components/ui/clamp";
export default function BroadcastsPage() { export default function BroadcastsPage() {
const [items, setItems] = useState<Broadcast[]>([]); const [items, setItems] = useState<Broadcast[]>([]);
@@ -256,12 +257,12 @@ function BroadcastRow({
return ( return (
<li className="flex items-start gap-3 p-3"> <li className="flex items-start gap-3 p-3">
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-foreground"> <Clamp className="text-sm font-medium text-foreground">
{item.title} {item.title}
</p> </Clamp>
<p className="mt-0.5 line-clamp-2 text-xs text-muted-foreground"> <Clamp lines={2} className="mt-0.5 text-xs text-muted-foreground">
{item.body} {item.body}
</p> </Clamp>
<p className="mt-1 text-xs text-muted-foreground"> <p className="mt-1 text-xs text-muted-foreground">
{live {live
? `Published · dismissed by ${item.seen_count}` ? `Published · dismissed by ${item.seen_count}`
+11 -8
View File
@@ -6,6 +6,7 @@ import { usePagedList } from "@/lib/use-paged-list";
import { ListShell, EmptyRow } from "@/components/list-shell"; import { ListShell, EmptyRow } from "@/components/list-shell";
import { Table, THead, TR, TH, TD } from "@/components/ui/table"; import { Table, THead, TR, TH, TD } from "@/components/ui/table";
import { fmtDate } from "@/lib/utils"; import { fmtDate } from "@/lib/utils";
import { Clamp } from "@/components/ui/clamp";
export default function CollectionsPage() { export default function CollectionsPage() {
const [q, setQ] = useState(""); const [q, setQ] = useState("");
@@ -40,18 +41,20 @@ export default function CollectionsPage() {
list.items.map((c) => ( list.items.map((c) => (
<TR key={c.id}> <TR key={c.id}>
<TD className="max-w-md"> <TD className="max-w-md">
<div className="font-medium">{c.title}</div> <Clamp lines={2} className="font-medium">
{c.title}
</Clamp>
{c.description ? ( {c.description ? (
<div className="mt-0.5 line-clamp-1 text-xs text-muted-foreground"> <Clamp className="mt-0.5 text-xs text-muted-foreground">
{c.description} {c.description}
</div> </Clamp>
) : null} ) : null}
</TD> </TD>
<TD className="whitespace-nowrap"> <TD className="max-w-[14rem]">
<div className="font-medium">{c.owner_name}</div> <Clamp className="font-medium">{c.owner_name}</Clamp>
<div className="text-xs text-muted-foreground"> <Clamp className="text-xs text-muted-foreground">
@{c.owner_handle} {`@${c.owner_handle}`}
</div> </Clamp>
</TD> </TD>
<TD>{c.item_count}</TD> <TD>{c.item_count}</TD>
<TD> <TD>
+12 -9
View File
@@ -13,6 +13,7 @@ import { useConfirm } from "@/components/confirm";
import { RichTextarea } from "@/components/ui/rich-textarea"; import { RichTextarea } from "@/components/ui/rich-textarea";
import { RichText } from "@/lib/rich-text"; import { RichText } from "@/lib/rich-text";
import { cn, fmtDate } from "@/lib/utils"; import { cn, fmtDate } from "@/lib/utils";
import { Clamp } from "@/components/ui/clamp";
const STATUSES = ["all", "open", "resolved"] as const; const STATUSES = ["all", "open", "resolved"] as const;
type Status = (typeof STATUSES)[number]; type Status = (typeof STATUSES)[number];
@@ -87,7 +88,9 @@ export default function FeedbackPage() {
</div> </div>
} }
> >
<Table> {/* Phone: each field stacked, label above value, so a long message
gets the card's full width. Desktop: ordinary table rows. */}
<Table card="stacked">
<THead> <THead>
<TR> <TR>
<TH>From</TH> <TH>From</TH>
@@ -103,18 +106,18 @@ export default function FeedbackPage() {
) : ( ) : (
list.items.map((f) => ( list.items.map((f) => (
<TR key={f.id}> <TR key={f.id}>
<TD className="whitespace-nowrap"> <TD className="max-w-[14rem]">
{f.author_handle ? ( {f.author_handle ? (
<> <>
<div className="font-medium">{f.author_name}</div> <Clamp className="font-medium">{f.author_name}</Clamp>
<div className="text-xs text-muted-foreground"> <Clamp className="text-xs text-muted-foreground">
@{f.author_handle} {`@${f.author_handle}`}
</div> </Clamp>
</> </>
) : ( ) : (
<div className="text-xs text-muted-foreground"> <Clamp className="text-xs text-muted-foreground">
{f.contact_email ?? "Anonymous"} {f.contact_email ?? "Anonymous"}
</div> </Clamp>
)} )}
</TD> </TD>
<TD className="max-w-md"> <TD className="max-w-md">
@@ -134,7 +137,7 @@ export default function FeedbackPage() {
</TD> </TD>
<TD className="whitespace-nowrap">{fmtDate(f.created_at)}</TD> <TD className="whitespace-nowrap">{fmtDate(f.created_at)}</TD>
<TD className="text-right"> <TD className="text-right">
<div className="flex flex-wrap justify-end gap-2"> <div className="flex flex-wrap justify-start gap-2 md:justify-end">
<Can perm="feedback:resolve"> <Can perm="feedback:resolve">
{!f.resolved_at && !f.acknowledged_at ? ( {!f.resolved_at && !f.acknowledged_at ? (
<Button <Button
+3 -1
View File
@@ -109,7 +109,9 @@ export default function DashboardLayout({
</p> </p>
</header> </header>
<main className="min-w-0 flex-1 px-4 py-5 sm:px-6 sm:py-6 lg:px-10"> {/* break-words: a safety net so no unbroken string (a URL, a long
handle) can push a page sideways. Labels use <Clamp> on top. */}
<main className="min-w-0 flex-1 break-words px-4 py-5 sm:px-6 sm:py-6 lg:px-10">
<div className="mx-auto max-w-6xl"> <div className="mx-auto max-w-6xl">
<StaffProvider value={staffMe}> <StaffProvider value={staffMe}>
<ConfirmProvider> <ConfirmProvider>
+15 -10
View File
@@ -9,6 +9,7 @@ import { Table, THead, TR, TH, TD } from "@/components/ui/table";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useConfirm } from "@/components/confirm"; import { useConfirm } from "@/components/confirm";
import { fmtDate } from "@/lib/utils"; import { fmtDate } from "@/lib/utils";
import { Clamp } from "@/components/ui/clamp";
export default function PostsPage() { export default function PostsPage() {
const [q, setQ] = useState(""); const [q, setQ] = useState("");
@@ -67,25 +68,29 @@ export default function PostsPage() {
list.items.map((p) => ( list.items.map((p) => (
<TR key={p.id} className={p.deleted_at ? "opacity-60" : ""}> <TR key={p.id} className={p.deleted_at ? "opacity-60" : ""}>
<TD className="max-w-md"> <TD className="max-w-md">
<div className="line-clamp-2">{p.caption || "—"}</div> <Clamp lines={2}>{p.caption || "—"}</Clamp>
<div className="mt-0.5 flex flex-wrap items-center gap-2 text-xs text-muted-foreground"> <div className="mt-0.5 flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground">
{p.source_title} {p.source_title ? (
<Clamp lines={2} className="max-w-full">
{p.source_title}
</Clamp>
) : null}
{p.deleted_at ? ( {p.deleted_at ? (
<span className="rounded bg-destructive/10 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-destructive"> <span className="rounded bg-destructive/10 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-destructive whitespace-nowrap">
Deleted Deleted
</span> </span>
) : p.is_hidden ? ( ) : p.is_hidden ? (
<span className="rounded bg-muted px-1.5 py-0.5 text-[10px] uppercase tracking-wide"> <span className="rounded bg-muted px-1.5 py-0.5 text-[10px] uppercase tracking-wide whitespace-nowrap">
Hidden Hidden
</span> </span>
) : null} ) : null}
</div> </div>
</TD> </TD>
<TD className="whitespace-nowrap"> <TD className="max-w-[14rem]">
<div className="font-medium">{p.author_name}</div> <Clamp className="font-medium">{p.author_name}</Clamp>
<div className="text-xs text-muted-foreground"> <Clamp className="text-xs text-muted-foreground">
@{p.author_handle} {`@${p.author_handle}`}
</div> </Clamp>
</TD> </TD>
<TD className="whitespace-nowrap text-xs text-muted-foreground"> <TD className="whitespace-nowrap text-xs text-muted-foreground">
{p.likes_count} likes · {p.comments_count} comments {p.likes_count} likes · {p.comments_count} comments
+12 -5
View File
@@ -12,6 +12,7 @@ import { cn } from "@/lib/utils";
import { QuizEditor } from "../quiz-editor"; import { QuizEditor } from "../quiz-editor";
import { KIND_LABEL } from "../labels"; import { KIND_LABEL } from "../labels";
import { Participants, Responses } from "./results"; import { Participants, Responses } from "./results";
import { Clamp } from "@/components/ui/clamp";
type Tab = "edit" | "participants" | "responses"; type Tab = "edit" | "participants" | "responses";
@@ -105,7 +106,7 @@ export default function QuizDetailPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<header className="flex flex-wrap items-start justify-between gap-4"> <header className="flex flex-wrap items-start justify-between gap-4">
<div> <div className="min-w-0 flex-1 basis-64">
<Link <Link
href="/quizzes" href="/quizzes"
className="text-xs text-muted-foreground hover:text-foreground" className="text-xs text-muted-foreground hover:text-foreground"
@@ -113,9 +114,15 @@ export default function QuizDetailPage() {
← Quizzes ← Quizzes
</Link> </Link>
<h1 className="mt-1 text-xl font-semibold text-foreground"> <h1 className="mt-1 text-xl font-semibold text-foreground">
{quiz.title} <Clamp lines={2}>{quiz.title}</Clamp>
</h1> </h1>
<p className="mt-1 text-sm text-muted-foreground"> <Clamp
lines={2}
className="mt-1 text-sm text-muted-foreground"
title={[quiz.target_title, quiz.target_path]
.filter(Boolean)
.join(" — ")}
>
{KIND_LABEL[quiz.target_kind]} {KIND_LABEL[quiz.target_kind]}
{quiz.target_title && ` · ${quiz.target_title}`} {quiz.target_title && ` · ${quiz.target_title}`}
{quiz.target_path && ( {quiz.target_path && (
@@ -123,9 +130,9 @@ export default function QuizDetailPage() {
({quiz.target_path}) ({quiz.target_path})
</span> </span>
)} )}
</p> </Clamp>
</div> </div>
<div className="flex gap-2"> <div className="flex shrink-0 gap-2">
<Can perm="quizzes:write"> <Can perm="quizzes:write">
<Button <Button
variant={quiz.published_at ? "outline" : "primary"} variant={quiz.published_at ? "outline" : "primary"}
+9 -10
View File
@@ -29,6 +29,7 @@ import { Table, THead, TH, TR, TD } from "@/components/ui/table";
import { RichText } from "@/lib/rich-text"; import { RichText } from "@/lib/rich-text";
import { cn, fmtDate } from "@/lib/utils"; import { cn, fmtDate } from "@/lib/utils";
import { fmtMs } from "../labels"; import { fmtMs } from "../labels";
import { Clamp } from "@/components/ui/clamp";
const errMsg = (err: unknown, fallback: string) => const errMsg = (err: unknown, fallback: string) =>
err instanceof AdminError ? err.message : fallback; err instanceof AdminError ? err.message : fallback;
@@ -110,7 +111,7 @@ export function Participants({ quizId }: { quizId: string }) {
<button <button
type="button" type="button"
onClick={() => view(p)} onClick={() => view(p)}
className="flex items-center gap-3 text-left hover:underline" className="flex min-w-0 max-w-full items-center gap-3 text-left hover:underline"
> >
<Avatar <Avatar
src={p.avatar_url ?? null} src={p.avatar_url ?? null}
@@ -118,12 +119,10 @@ export function Participants({ quizId }: { quizId: string }) {
className="size-8" className="size-8"
/> />
<span className="min-w-0"> <span className="min-w-0">
<span className="block truncate text-sm font-medium"> <Clamp className="text-sm font-medium">{p.name}</Clamp>
{p.name} <Clamp className="text-xs text-muted-foreground">
</span> {`@${p.handle}`}
<span className="block truncate text-xs text-muted-foreground"> </Clamp>
@{p.handle}
</span>
</span> </span>
</button> </button>
</TD> </TD>
@@ -184,7 +183,7 @@ export function Participants({ quizId }: { quizId: string }) {
{detail.items.map((it, i) => ( {detail.items.map((it, i) => (
<div key={it.id ?? i} className="space-y-1.5"> <div key={it.id ?? i} className="space-y-1.5">
<div className="flex items-start justify-between gap-3 text-sm"> <div className="flex items-start justify-between gap-3 text-sm">
<p className="font-medium"> <p className="min-w-0 font-medium">
{i + 1}. <RichText text={it.prompt} /> {i + 1}. <RichText text={it.prompt} />
</p> </p>
<span className="shrink-0 text-xs tabular-nums text-muted-foreground"> <span className="shrink-0 text-xs tabular-nums text-muted-foreground">
@@ -251,10 +250,10 @@ export function Responses({ quizId }: { quizId: string }) {
return ( return (
<li key={q.id} className="rounded-xl border border-border p-4"> <li key={q.id} className="rounded-xl border border-border p-4">
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<p className="text-sm font-medium"> <p className="min-w-0 text-sm font-medium">
{i + 1}. <RichText text={q.prompt} /> {i + 1}. <RichText text={q.prompt} />
{q.multi && ( {q.multi && (
<span className="ml-2 rounded bg-muted px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground"> <span className="ml-2 rounded bg-muted px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground whitespace-nowrap">
Multiple answers Multiple answers
</span> </span>
)} )}
+7 -6
View File
@@ -13,6 +13,7 @@ import { Table, THead, TH, TR, TD } from "@/components/ui/table";
import { Can } from "@/components/staff-context"; import { Can } from "@/components/staff-context";
import { fmtDate } from "@/lib/utils"; import { fmtDate } from "@/lib/utils";
import { KIND_LABEL } from "./labels"; import { KIND_LABEL } from "./labels";
import { Clamp } from "@/components/ui/clamp";
export default function QuizzesPage() { export default function QuizzesPage() {
const [list, setList] = useState<Quiz[] | null>(null); const [list, setList] = useState<Quiz[] | null>(null);
@@ -68,9 +69,9 @@ export default function QuizzesPage() {
<TD> <TD>
<Link <Link
href={`/quizzes/${q.id}`} href={`/quizzes/${q.id}`}
className="font-medium text-foreground hover:underline" className="block font-medium text-foreground hover:underline"
> >
{q.title} <Clamp lines={2}>{q.title}</Clamp>
</Link> </Link>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Updated {fmtDate(q.updated_at)} Updated {fmtDate(q.updated_at)}
@@ -79,13 +80,13 @@ export default function QuizzesPage() {
<TD> <TD>
<p className="text-sm">{KIND_LABEL[q.target_kind]}</p> <p className="text-sm">{KIND_LABEL[q.target_kind]}</p>
{q.target_title && ( {q.target_title && (
<p className="truncate text-xs text-muted-foreground"> <Clamp className="text-xs text-muted-foreground">
{q.target_title} {q.target_title}
</p> </Clamp>
)} )}
</TD> </TD>
<TD>{q.question_count}</TD> <TD>{q.question_count}</TD>
<TD> <TD className="[overflow-wrap:normal]">
{q.completed_count ?? 0} finished {q.completed_count ?? 0} finished
<span className="text-muted-foreground"> <span className="text-muted-foreground">
{" "} {" "}
@@ -94,7 +95,7 @@ export default function QuizzesPage() {
</TD> </TD>
<TD> <TD>
{q.published_at ? ( {q.published_at ? (
<span className="rounded bg-primary/10 px-2 py-0.5 text-xs text-primary"> <span className="rounded bg-primary/10 px-2 py-0.5 text-xs text-primary whitespace-nowrap">
Published Published
</span> </span>
) : ( ) : (
+14 -11
View File
@@ -13,6 +13,7 @@ import { RichTextarea } from "@/components/ui/rich-textarea";
import { RichText } from "@/lib/rich-text"; import { RichText } from "@/lib/rich-text";
import { useConfirm } from "@/components/confirm"; import { useConfirm } from "@/components/confirm";
import { cn, fmtDate } from "@/lib/utils"; import { cn, fmtDate } from "@/lib/utils";
import { Clamp } from "@/components/ui/clamp";
const TYPES: { key: ReportType; label: string }[] = [ const TYPES: { key: ReportType; label: string }[] = [
{ key: "post", label: "Posts" }, { key: "post", label: "Posts" },
@@ -195,18 +196,16 @@ export default function ReportsPage() {
<TR key={r.id} className={resolved ? "opacity-70" : ""}> <TR key={r.id} className={resolved ? "opacity-70" : ""}>
<TD className="max-w-xs align-top"> <TD className="max-w-xs align-top">
{type === "user" ? ( {type === "user" ? (
<span className="font-medium"> <Clamp className="font-medium">
{r.target_text ? `@${r.target_text}` : "—"} {r.target_text ? `@${r.target_text}` : "—"}
</span> </Clamp>
) : ( ) : (
<> <>
<div className="line-clamp-2"> <Clamp lines={2}>{r.target_text || "—"}</Clamp>
{r.target_text || "—"}
</div>
{r.target_author_handle ? ( {r.target_author_handle ? (
<div className="mt-0.5 text-xs text-muted-foreground"> <Clamp className="mt-0.5 text-xs text-muted-foreground">
by @{r.target_author_handle} {`by @${r.target_author_handle}`}
</div> </Clamp>
) : null} ) : null}
</> </>
)} )}
@@ -221,11 +220,15 @@ export default function ReportsPage() {
{r.category} {r.category}
</div> </div>
{r.reason ? ( {r.reason ? (
<div className="mt-0.5 line-clamp-2">{r.reason}</div> <Clamp lines={2} className="mt-0.5">
{r.reason}
</Clamp>
) : null} ) : null}
</TD> </TD>
<TD className="whitespace-nowrap align-top text-xs text-muted-foreground"> <TD className="max-w-[12rem] align-top text-xs text-muted-foreground">
{r.reporter_handle ? `@${r.reporter_handle}` : "—"} <Clamp>
{r.reporter_handle ? `@${r.reporter_handle}` : "—"}
</Clamp>
</TD> </TD>
<TD className="whitespace-nowrap align-top"> <TD className="whitespace-nowrap align-top">
{fmtDate(r.created_at)} {fmtDate(r.created_at)}
+3 -2
View File
@@ -17,6 +17,7 @@ import Image from "next/image";
import { toast } from "sonner"; import { toast } from "sonner";
import { rewardGifts, type GiftableReward } from "@/lib/api"; import { rewardGifts, type GiftableReward } from "@/lib/api";
import { compressImage } from "@/lib/compress-image"; import { compressImage } from "@/lib/compress-image";
import { Clamp } from "@/components/ui/clamp";
export default function RewardGiftsPage() { export default function RewardGiftsPage() {
const [catalog, setCatalog] = useState<GiftableReward[] | null>(null); const [catalog, setCatalog] = useState<GiftableReward[] | null>(null);
@@ -123,9 +124,9 @@ export default function RewardGiftsPage() {
unoptimized unoptimized
/> />
</div> </div>
<p className="line-clamp-2 text-sm font-medium text-foreground"> <Clamp lines={2} className="w-full text-sm font-medium text-foreground">
{r.name} {r.name}
</p> </Clamp>
<div className="mt-auto w-full space-y-1.5"> <div className="mt-auto w-full space-y-1.5">
<Can perm="rewards:send"> <Can perm="rewards:send">
<button <button
+3 -3
View File
@@ -6,6 +6,7 @@ import { usePagedList } from "@/lib/use-paged-list";
import { ListShell, EmptyRow } from "@/components/list-shell"; import { ListShell, EmptyRow } from "@/components/list-shell";
import { Table, THead, TR, TH, TD } from "@/components/ui/table"; import { Table, THead, TR, TH, TD } from "@/components/ui/table";
import { fmtDate } from "@/lib/utils"; import { fmtDate } from "@/lib/utils";
import { Clamp } from "@/components/ui/clamp";
export default function ShortUrlsPage() { export default function ShortUrlsPage() {
// ── Stats ──────────────────────────────────────────────────────────────── // ── Stats ────────────────────────────────────────────────────────────────
@@ -137,10 +138,9 @@ export default function ShortUrlsPage() {
href={s.long_url} href={s.long_url}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="line-clamp-1 break-all text-primary hover:underline" className="block text-primary hover:underline"
title={s.long_url}
> >
{s.long_url} <Clamp>{s.long_url}</Clamp>
</a> </a>
</TD> </TD>
<TD className="whitespace-nowrap text-muted-foreground"> <TD className="whitespace-nowrap text-muted-foreground">
+1 -1
View File
@@ -174,7 +174,7 @@ export function Sidebar({
<div className="px-3 pb-2 text-xs text-muted-foreground"> <div className="px-3 pb-2 text-xs text-muted-foreground">
<p className="truncate font-medium text-foreground">{me.name}</p> <p className="truncate font-medium text-foreground">{me.name}</p>
<p className="truncate">@{me.handle}</p> <p className="truncate">@{me.handle}</p>
<p className="mt-1 inline-block rounded bg-muted px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground"> <p className="mt-1 inline-block rounded bg-muted px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground whitespace-nowrap">
{staffMe.role_name} {staffMe.role_name}
</p> </p>
</div> </div>
+9 -6
View File
@@ -15,6 +15,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Modal } from "@/components/ui/modal"; import { Modal } from "@/components/ui/modal";
import { useConfirm } from "@/components/confirm"; import { useConfirm } from "@/components/confirm";
import { Clamp } from "@/components/ui/clamp";
const errMsg = (err: unknown, fallback: string) => const errMsg = (err: unknown, fallback: string) =>
err instanceof AdminError ? err.message : fallback; err instanceof AdminError ? err.message : fallback;
@@ -129,18 +130,20 @@ export function RolesTab() {
<li key={r.id} className="rounded-xl border border-border p-4"> <li key={r.id} className="rounded-xl border border-border p-4">
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div className="min-w-0"> <div className="min-w-0">
<p className="text-sm font-medium text-foreground"> <div className="flex min-w-0 items-center gap-2">
{r.name}{" "} <Clamp className="text-sm font-medium text-foreground">
{r.name}
</Clamp>
{r.is_system && ( {r.is_system && (
<span className="ml-1 rounded bg-muted px-1.5 py-0.5 text-[10px] uppercase tracking-wide text-muted-foreground"> <span className="shrink-0 rounded bg-muted px-1.5 py-0.5 text-[10px] uppercase tracking-wide text-muted-foreground whitespace-nowrap">
System System
</span> </span>
)} )}
</p> </div>
{r.description && ( {r.description && (
<p className="mt-0.5 text-xs text-muted-foreground"> <Clamp lines={2} className="mt-0.5 text-xs text-muted-foreground">
{r.description} {r.description}
</p> </Clamp>
)} )}
<p className="mt-1 text-xs text-muted-foreground"> <p className="mt-1 text-xs text-muted-foreground">
{r.member_count} {r.member_count === 1 ? "member" : "members"} {r.member_count} {r.member_count === 1 ? "member" : "members"}
+16 -10
View File
@@ -14,6 +14,7 @@ import { Input } from "@/components/ui/input";
import { Avatar } from "@/components/ui/avatar"; import { Avatar } from "@/components/ui/avatar";
import { Table, THead, TH, TR, TD } from "@/components/ui/table"; import { Table, THead, TH, TR, TD } from "@/components/ui/table";
import { useConfirm } from "@/components/confirm"; import { useConfirm } from "@/components/confirm";
import { Clamp } from "@/components/ui/clamp";
const errMsg = (err: unknown, fallback: string) => const errMsg = (err: unknown, fallback: string) =>
err instanceof AdminError ? err.message : fallback; err instanceof AdminError ? err.message : fallback;
@@ -132,7 +133,9 @@ export function StaffTab() {
<select <select
value={grantRole} value={grantRole}
onChange={(e) => setGrantRole(e.target.value)} onChange={(e) => setGrantRole(e.target.value)}
className="h-10 rounded-lg border border-input bg-card px-3 text-sm" // A <select> sizes itself to its longest option; a long custom
// role name must not widen the form past the screen.
className="h-10 min-w-0 max-w-full flex-1 truncate rounded-lg border border-input bg-card px-3 text-sm sm:max-w-xs sm:flex-none"
> >
{roles.map((r) => ( {roles.map((r) => (
<option key={r.id} value={r.id}> <option key={r.id} value={r.id}>
@@ -164,22 +167,25 @@ export function StaffTab() {
return ( return (
<TR key={m.user_id}> <TR key={m.user_id}>
<TD> <TD>
<div className="flex items-center gap-3"> <div className="flex min-w-0 items-center gap-3">
<Avatar <Avatar
src={m.avatar_url ?? null} src={m.avatar_url ?? null}
name={m.name} name={m.name}
className="size-8" className="size-8"
/> />
<div className="min-w-0"> <div className="min-w-0">
<p className="truncate text-sm font-medium text-foreground"> <Clamp
className="text-sm font-medium text-foreground"
title={m.name}
>
{m.name}{" "} {m.name}{" "}
{self && ( {self && (
<span className="text-muted-foreground">(you)</span> <span className="text-muted-foreground">(you)</span>
)} )}
</p> </Clamp>
<p className="truncate text-xs text-muted-foreground"> <Clamp className="text-xs text-muted-foreground">
@{m.handle} {`@${m.handle}`}
</p> </Clamp>
</div> </div>
</div> </div>
</TD> </TD>
@@ -188,7 +194,7 @@ export function StaffTab() {
access, so a superadmin can't demote themselves into access, so a superadmin can't demote themselves into
a locked-out panel by accident. */} a locked-out panel by accident. */}
{self ? ( {self ? (
<span className="text-sm">{m.role_name}</span> <Clamp className="text-sm">{m.role_name}</Clamp>
) : ( ) : (
<select <select
value={m.role_id} value={m.role_id}
@@ -200,7 +206,7 @@ export function StaffTab() {
"Role changed", "Role changed",
) )
} }
className="h-8 rounded-md border border-input bg-card px-2 text-xs" className="h-8 w-full max-w-[14rem] truncate rounded-md border border-input bg-card px-2 text-xs"
> >
{roles.map((r) => ( {roles.map((r) => (
<option key={r.id} value={r.id}> <option key={r.id} value={r.id}>
@@ -212,7 +218,7 @@ export function StaffTab() {
</TD> </TD>
<TD> <TD>
{m.blocked_at ? ( {m.blocked_at ? (
<span className="rounded bg-destructive/10 px-2 py-0.5 text-xs text-destructive"> <span className="rounded bg-destructive/10 px-2 py-0.5 text-xs text-destructive whitespace-nowrap">
Blocked Blocked
</span> </span>
) : ( ) : (
+10 -6
View File
@@ -21,6 +21,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Modal } from "@/components/ui/modal"; import { Modal } from "@/components/ui/modal";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Clamp } from "@/components/ui/clamp";
/** "Vṛndāvana · 1972" — whichever halves we know. */ /** "Vṛndāvana · 1972" — whichever halves we know. */
function placeYear(d: AdminDisciple): string | null { function placeYear(d: AdminDisciple): string | null {
@@ -138,13 +139,13 @@ export function DiscipleLinkModal({
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">
Currently linked to Currently linked to
</div> </div>
<div className="truncate text-sm font-medium"> <Clamp className="text-sm font-medium">
{user.disciple_name ?? ( {user.disciple_name ?? (
<span className="text-muted-foreground"> <span className="text-muted-foreground">
unknown disciple ({user.disciple_id.slice(0, 8)}…) unknown disciple ({user.disciple_id.slice(0, 8)}…)
</span> </span>
)} )}
</div> </Clamp>
</div> </div>
<Button <Button
variant="outline" variant="outline"
@@ -200,16 +201,19 @@ export function DiscipleLinkModal({
)} )}
> >
<span className="min-w-0"> <span className="min-w-0">
<span className="block truncate text-sm font-medium"> <Clamp
className="text-sm font-medium"
title={`${d.initiated_name} ${d.suffix ?? ""}`.trim()}
>
{d.initiated_name}{" "} {d.initiated_name}{" "}
<span className="font-normal text-muted-foreground"> <span className="font-normal text-muted-foreground">
{d.suffix} {d.suffix}
</span> </span>
</span> </Clamp>
{sub ? ( {sub ? (
<span className="block truncate text-xs text-muted-foreground"> <Clamp className="text-xs text-muted-foreground">
{sub} {sub}
</span> </Clamp>
) : null} ) : null}
</span> </span>
<span className="shrink-0 text-xs"> <span className="shrink-0 text-xs">
+14 -15
View File
@@ -23,6 +23,7 @@ import { RichTextarea } from "@/components/ui/rich-textarea";
import { fmtDate } from "@/lib/utils"; import { fmtDate } from "@/lib/utils";
import { DiscipleLinkModal } from "./disciple-link-modal"; import { DiscipleLinkModal } from "./disciple-link-modal";
import { useStaff } from "@/components/staff-context"; import { useStaff } from "@/components/staff-context";
import { Clamp } from "@/components/ui/clamp";
export default function UsersPage() { export default function UsersPage() {
const { can } = useStaff(); const { can } = useStaff();
@@ -138,20 +139,20 @@ export default function UsersPage() {
list.items.map((u) => ( list.items.map((u) => (
<TR key={u.id}> <TR key={u.id}>
<TD> <TD>
<div className="flex items-center gap-3"> <div className="flex min-w-0 items-center gap-3">
<Avatar src={u.avatar_url} name={u.name} /> <Avatar src={u.avatar_url} name={u.name} />
<div className="min-w-0"> <div className="min-w-0">
<div className="flex items-center gap-2"> <div className="flex min-w-0 items-center gap-2">
<span className="font-medium">{u.name}</span> <Clamp className="font-medium">{u.name}</Clamp>
{u.banned_at ? ( {u.banned_at ? (
<span className="rounded bg-destructive/10 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-destructive"> <span className="shrink-0 rounded bg-destructive/10 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-destructive whitespace-nowrap">
Suspended Suspended
</span> </span>
) : null} ) : null}
</div> </div>
<div className="text-xs text-muted-foreground"> <Clamp className="text-xs text-muted-foreground">
@{u.handle} {`@${u.handle}`}
</div> </Clamp>
{u.disciple_id ? ( {u.disciple_id ? (
<div <div
className="mt-1 inline-flex max-w-full items-center gap-1 rounded bg-primary/10 px-1.5 py-0.5 text-[10px] font-medium text-primary" className="mt-1 inline-flex max-w-full items-center gap-1 rounded bg-primary/10 px-1.5 py-0.5 text-[10px] font-medium text-primary"
@@ -161,9 +162,7 @@ export default function UsersPage() {
: "Linked to an unknown disciple record" : "Linked to an unknown disciple record"
} }
> >
<span className="truncate"> <Clamp>{u.disciple_name ?? "Direct disciple"}</Clamp>
{u.disciple_name ?? "Direct disciple"}
</span>
</div> </div>
) : null} ) : null}
</div> </div>
@@ -171,11 +170,11 @@ export default function UsersPage() {
</TD> </TD>
<TD> <TD>
<div className="space-y-1.5"> <div className="space-y-1.5">
<div> {u.email ? (
{u.email ?? ( <Clamp>{u.email}</Clamp>
<span className="text-muted-foreground">—</span> ) : (
)} <span className="text-muted-foreground">—</span>
</div> )}
<AuthIcons user={u} /> <AuthIcons user={u} />
</div> </div>
</TD> </TD>
+10 -12
View File
@@ -22,6 +22,7 @@ import {
type VaniCandidate, type VaniCandidate,
type VaniPick, type VaniPick,
} from "@/lib/api"; } from "@/lib/api";
import { Clamp } from "@/components/ui/clamp";
/** Today in UTC — picks are keyed on a UTC calendar day. */ /** Today in UTC — picks are keyed on a UTC calendar day. */
function todayUTC(): string { function todayUTC(): string {
@@ -200,12 +201,10 @@ export default function VaniPage() {
className="flex items-center gap-3 px-3 py-2.5" className="flex items-center gap-3 px-3 py-2.5"
> >
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="truncate text-sm text-foreground"> <Clamp className="text-sm text-foreground">{c.title}</Clamp>
{c.title} <Clamp className="text-xs text-muted-foreground">
</p> {`${vaniKindFor(c.collection)} · ${c.snippet}`}
<p className="truncate text-xs text-muted-foreground"> </Clamp>
{vaniKindFor(c.collection)} · {c.snippet}
</p>
</div> </div>
<button <button
type="button" type="button"
@@ -240,13 +239,12 @@ export default function VaniPage() {
{p.day} {p.day}
</span> </span>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="truncate text-sm text-foreground"> <Clamp className="text-sm text-foreground">
{p.payload.title} {p.payload.title}
</p> </Clamp>
<p className="truncate text-xs text-muted-foreground"> <Clamp className="text-xs text-muted-foreground">
{p.payload.label} {`${p.payload.label}${p.payload.book_title ? ` · ${p.payload.book_title}` : ""}`}
{p.payload.book_title ? ` · ${p.payload.book_title}` : ""} </Clamp>
</p>
</div> </div>
<Can perm="vani:write"> <Can perm="vani:write">
<button <button
+1 -1
View File
@@ -27,7 +27,7 @@ export function Button({
return ( return (
<button <button
className={cn( className={cn(
"inline-flex items-center justify-center gap-2 rounded-lg font-medium transition-colors outline-none", "inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-lg font-medium transition-colors outline-none",
"focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50 disabled:pointer-events-none", "focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50 disabled:pointer-events-none",
variants[variant], variants[variant],
sizes[size], sizes[size],
+50
View File
@@ -0,0 +1,50 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const LINES = {
1: "line-clamp-1",
2: "line-clamp-2",
3: "line-clamp-3",
} as const;
/**
* Clamp — a dynamic label (a quiz title, a name, an email, a link) cut off
* with "…" after a set number of lines, with the full text on hover.
*
* Use this rather than Tailwind's `truncate` for anything user- or content-
* supplied. `truncate` forbids wrapping, and inside a table cell that makes
* the column grow to the text's full width — the table then overflows its
* card instead of truncating. A line clamp wraps first and cuts after N
* lines, and `overflow-wrap: anywhere` lets even an unbroken string (a URL,
* a handle) break, so it can never widen whatever it sits in.
*/
export function Clamp({
children,
lines = 1,
title,
className,
}: {
children: React.ReactNode;
lines?: keyof typeof LINES;
/** Hover text; defaults to the label itself when it's plain text. */
title?: string;
className?: string;
}) {
const hover =
title ??
(typeof children === "string" || typeof children === "number"
? String(children)
: undefined);
return (
<span
title={hover}
className={cn(
"block min-w-0 [overflow-wrap:anywhere]",
LINES[lines],
className,
)}
>
{children}
</span>
);
}
+7 -2
View File
@@ -41,7 +41,12 @@ export function Modal({
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<div className="flex shrink-0 items-center justify-between gap-3 border-b border-border px-4 py-3.5 sm:px-5 sm:py-4"> <div className="flex shrink-0 items-center justify-between gap-3 border-b border-border px-4 py-3.5 sm:px-5 sm:py-4">
<h2 className="text-base font-semibold text-foreground">{title}</h2> <h2
title={title}
className="min-w-0 truncate text-base font-semibold text-foreground"
>
{title}
</h2>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
@@ -52,7 +57,7 @@ export function Modal({
<X className="size-4" /> <X className="size-4" />
</Button> </Button>
</div> </div>
<div className="min-h-0 overflow-y-auto px-4 py-4 pb-[max(1rem,env(safe-area-inset-bottom))] sm:px-5"> <div className="min-h-0 overflow-y-auto [overflow-wrap:anywhere] px-4 py-4 pb-[max(1rem,env(safe-area-inset-bottom))] sm:px-5">
{children} {children}
</div> </div>
</div> </div>
+37 -16
View File
@@ -6,17 +6,31 @@ import { cn } from "@/lib/utils";
/** /**
* The panel's data table. * The panel's data table.
* *
* From md up it's an ordinary table. Below xl, long values (emails, URLs) * From md up it's an ordinary table. Long values (emails, URLs) may wrap
* may wrap anywhere so the columns fit a tablet; if they still can't, the * anywhere so the columns always fit; if they still can't, the table scrolls
* table scrolls sideways inside its card as a last resort. * sideways inside its card as a last resort. Labels inside cells should use
* <Clamp>, never `truncate` (see clamp.tsx for why).
* *
* On a phone (below md) each row becomes a stacked card: one line per cell, labelled * On a phone (below md) each row becomes a stacked card: one line per cell, labelled
* with its column heading. The labels are read from the table's own <th>s * with its column heading. The labels are read from the table's own <th>s
* and stamped onto the cells as `data-label`, so pages need no extra markup * and stamped onto the cells as `data-label`, so pages need no extra markup
* — every table in the panel gets the card layout for free. Cells under an * — every table in the panel gets the card layout for free. Cells under an
* empty heading (an actions column) simply go unlabelled. * empty heading (an actions column) simply go unlabelled.
*
* `card="stacked"` puts each label above its value instead of beside it, so
* long content (a feedback message) gets the card's full width.
*/ */
export function Table({ children }: { children: React.ReactNode }) { type CardLayout = "inline" | "stacked";
const CardLayoutContext = React.createContext<CardLayout>("inline");
export function Table({
children,
card = "inline",
}: {
children: React.ReactNode;
/** Phone card layout: labels beside values (default) or above them. */
card?: CardLayout;
}) {
const ref = React.useRef<HTMLTableElement>(null); const ref = React.useRef<HTMLTableElement>(null);
// Runs after every render: rows arrive and change with the data. // Runs after every render: rows arrive and change with the data.
@@ -39,14 +53,16 @@ export function Table({ children }: { children: React.ReactNode }) {
}); });
return ( return (
<div className="rounded-xl border border-border bg-card md:overflow-x-auto md:overscroll-x-contain"> <CardLayoutContext.Provider value={card}>
<table <div className="rounded-xl border border-border bg-card md:overflow-x-auto md:overscroll-x-contain">
ref={ref} <table
className="w-full border-collapse text-sm max-md:block" ref={ref}
> className="w-full border-collapse text-sm max-md:block"
{children} >
</table> {children}
</div> </table>
</div>
</CardLayoutContext.Provider>
); );
} }
@@ -100,6 +116,7 @@ export function TD({
children?: React.ReactNode; children?: React.ReactNode;
className?: string; className?: string;
}) { }) {
const card = React.useContext(CardLayoutContext);
return ( return (
<td <td
className={cn( className={cn(
@@ -109,10 +126,14 @@ export function TD({
// table (stacked lines stay stacked). Alignment classes meant for a // table (stacked lines stay stacked). Alignment classes meant for a
// column (text-right on actions) give way to the card's flow. // column (text-right on actions) give way to the card's flow.
"max-md:relative max-md:block max-md:px-0 max-md:py-1.5 max-md:text-left", "max-md:relative max-md:block max-md:px-0 max-md:py-1.5 max-md:text-left",
"max-md:data-label:pl-28", card === "inline"
"max-md:data-label:before:absolute max-md:data-label:before:left-0 max-md:data-label:before:top-2 max-md:data-label:before:w-24 max-md:data-label:before:text-[11px] max-md:data-label:before:font-medium max-md:data-label:before:uppercase max-md:data-label:before:leading-4 max-md:data-label:before:tracking-wide max-md:data-label:before:text-muted-foreground max-md:data-label:before:content-[attr(data-label)]", ? "max-md:data-label:pl-28 max-md:data-label:before:absolute max-md:data-label:before:left-0 max-md:data-label:before:top-2 max-md:data-label:before:w-24"
// Long values (emails, URLs) wrap rather than widen the table or card. : // Stacked: the label is its own line above the value.
"max-xl:[overflow-wrap:anywhere]", "max-md:py-2 max-md:data-label:before:mb-1 max-md:data-label:before:block",
"max-md:data-label:before:text-[11px] max-md:data-label:before:font-medium max-md:data-label:before:uppercase max-md:data-label:before:leading-4 max-md:data-label:before:tracking-wide max-md:data-label:before:text-muted-foreground max-md:data-label:before:content-[attr(data-label)]",
// Long values (emails, URLs) wrap rather than widen the table or
// card — at every width, so no string can push a column off-screen.
"[overflow-wrap:anywhere]",
className, className,
)} )}
> >