diff --git a/app/(dashboard)/broadcasts/page.tsx b/app/(dashboard)/broadcasts/page.tsx index 9cb86e2..6da79ad 100644 --- a/app/(dashboard)/broadcasts/page.tsx +++ b/app/(dashboard)/broadcasts/page.tsx @@ -17,6 +17,7 @@ import { Can } from "@/components/staff-context"; import { toast } from "sonner"; import { broadcasts, type Broadcast } from "@/lib/api"; import { compressImage } from "@/lib/compress-image"; +import { Clamp } from "@/components/ui/clamp"; export default function BroadcastsPage() { const [items, setItems] = useState([]); @@ -256,12 +257,12 @@ function BroadcastRow({ return (
  • -

    + {item.title} -

    -

    + + {item.body} -

    +

    {live ? `Published · dismissed by ${item.seen_count}` diff --git a/app/(dashboard)/collections/page.tsx b/app/(dashboard)/collections/page.tsx index ca4c3ca..3cd0565 100644 --- a/app/(dashboard)/collections/page.tsx +++ b/app/(dashboard)/collections/page.tsx @@ -6,6 +6,7 @@ import { usePagedList } from "@/lib/use-paged-list"; import { ListShell, EmptyRow } from "@/components/list-shell"; import { Table, THead, TR, TH, TD } from "@/components/ui/table"; import { fmtDate } from "@/lib/utils"; +import { Clamp } from "@/components/ui/clamp"; export default function CollectionsPage() { const [q, setQ] = useState(""); @@ -40,18 +41,20 @@ export default function CollectionsPage() { list.items.map((c) => ( -

    {c.title}
    + + {c.title} + {c.description ? ( -
    + {c.description} -
    + ) : null} - -
    {c.owner_name}
    -
    - @{c.owner_handle} -
    + + {c.owner_name} + + {`@${c.owner_handle}`} + {c.item_count} diff --git a/app/(dashboard)/feedback/page.tsx b/app/(dashboard)/feedback/page.tsx index 0f9a920..bb26ab0 100644 --- a/app/(dashboard)/feedback/page.tsx +++ b/app/(dashboard)/feedback/page.tsx @@ -13,6 +13,7 @@ import { useConfirm } from "@/components/confirm"; import { RichTextarea } from "@/components/ui/rich-textarea"; import { RichText } from "@/lib/rich-text"; import { cn, fmtDate } from "@/lib/utils"; +import { Clamp } from "@/components/ui/clamp"; const STATUSES = ["all", "open", "resolved"] as const; type Status = (typeof STATUSES)[number]; @@ -87,7 +88,9 @@ export default function FeedbackPage() {
    } > - + {/* Phone: each field stacked, label above value, so a long message + gets the card's full width. Desktop: ordinary table rows. */} +
    @@ -103,18 +106,18 @@ export default function FeedbackPage() { ) : ( list.items.map((f) => ( - - @@ -184,7 +183,7 @@ export function Participants({ quizId }: { quizId: string }) { {detail.items.map((it, i) => (
    -

    +

    {i + 1}.

    @@ -251,10 +250,10 @@ export function Responses({ quizId }: { quizId: string }) { return (
  • -

    +

    {i + 1}. {q.multi && ( - + Multiple answers )} diff --git a/app/(dashboard)/quizzes/page.tsx b/app/(dashboard)/quizzes/page.tsx index a389ac9..6b0267a 100644 --- a/app/(dashboard)/quizzes/page.tsx +++ b/app/(dashboard)/quizzes/page.tsx @@ -13,6 +13,7 @@ import { Table, THead, TH, TR, TD } from "@/components/ui/table"; import { Can } from "@/components/staff-context"; import { fmtDate } from "@/lib/utils"; import { KIND_LABEL } from "./labels"; +import { Clamp } from "@/components/ui/clamp"; export default function QuizzesPage() { const [list, setList] = useState(null); @@ -68,9 +69,9 @@ export default function QuizzesPage() {

  • - - @@ -188,7 +194,7 @@ export function StaffTab() { access, so a superadmin can't demote themselves into a locked-out panel by accident. */} {self ? ( - {m.role_name} + {m.role_name} ) : ( diff --git a/app/(dashboard)/vani/page.tsx b/app/(dashboard)/vani/page.tsx index 08a79e2..d3e4eff 100644 --- a/app/(dashboard)/vani/page.tsx +++ b/app/(dashboard)/vani/page.tsx @@ -22,6 +22,7 @@ import { type VaniCandidate, type VaniPick, } from "@/lib/api"; +import { Clamp } from "@/components/ui/clamp"; /** Today in UTC — picks are keyed on a UTC calendar day. */ function todayUTC(): string { @@ -200,12 +201,10 @@ export default function VaniPage() { className="flex items-center gap-3 px-3 py-2.5" >
    -

    - {c.title} -

    -

    - {vaniKindFor(c.collection)} · {c.snippet} -

    + {c.title} + + {`${vaniKindFor(c.collection)} · ${c.snippet}`} +
    -
    +
    {children}
    diff --git a/components/ui/table.tsx b/components/ui/table.tsx index 9e92f82..2c7253f 100644 --- a/components/ui/table.tsx +++ b/components/ui/table.tsx @@ -6,17 +6,31 @@ import { cn } from "@/lib/utils"; /** * The panel's data table. * - * From md up it's an ordinary table. Below xl, long values (emails, URLs) - * may wrap anywhere so the columns fit a tablet; if they still can't, the - * table scrolls sideways inside its card as a last resort. + * From md up it's an ordinary table. Long values (emails, URLs) may wrap + * anywhere so the columns always fit; if they still can't, the table scrolls + * sideways inside its card as a last resort. Labels inside cells should use + * , never `truncate` (see clamp.tsx for why). * * 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
    From
    + {f.author_handle ? ( <> -
    {f.author_name}
    -
    - @{f.author_handle} -
    + {f.author_name} + + {`@${f.author_handle}`} + ) : ( -
    + {f.contact_email ?? "Anonymous"} -
    + )}
    @@ -134,7 +137,7 @@ export default function FeedbackPage() { {fmtDate(f.created_at)} -
    +
    {!f.resolved_at && !f.acknowledged_at ? (
    -
    {p.caption || "—"}
    -
    - {p.source_title} + {p.caption || "—"} +
    + {p.source_title ? ( + + {p.source_title} + + ) : null} {p.deleted_at ? ( - + Deleted ) : p.is_hidden ? ( - + Hidden ) : null}
    -
    {p.author_name}
    -
    - @{p.author_handle} -
    +
    + {p.author_name} + + {`@${p.author_handle}`} + {p.likes_count} likes · {p.comments_count} comments diff --git a/app/(dashboard)/quizzes/[id]/page.tsx b/app/(dashboard)/quizzes/[id]/page.tsx index d034301..d458ce7 100644 --- a/app/(dashboard)/quizzes/[id]/page.tsx +++ b/app/(dashboard)/quizzes/[id]/page.tsx @@ -12,6 +12,7 @@ import { cn } from "@/lib/utils"; import { QuizEditor } from "../quiz-editor"; import { KIND_LABEL } from "../labels"; import { Participants, Responses } from "./results"; +import { Clamp } from "@/components/ui/clamp"; type Tab = "edit" | "participants" | "responses"; @@ -105,7 +106,7 @@ export default function QuizDetailPage() { return (
    -
    +

    - {quiz.title} + {quiz.title}

    -

    + {KIND_LABEL[quiz.target_kind]} {quiz.target_title && ` · ${quiz.target_title}`} {quiz.target_path && ( @@ -123,9 +130,9 @@ export default function QuizDetailPage() { ({quiz.target_path}) )} -

    +
    -
    +
    - {q.title} + {q.title}

    Updated {fmtDate(q.updated_at)} @@ -79,13 +80,13 @@ export default function QuizzesPage() {

    {KIND_LABEL[q.target_kind]}

    {q.target_title && ( -

    + {q.target_title} -

    + )}
    {q.question_count} + {q.completed_count ?? 0} finished {" "} @@ -94,7 +95,7 @@ export default function QuizzesPage() { {q.published_at ? ( - + Published ) : ( diff --git a/app/(dashboard)/reports/page.tsx b/app/(dashboard)/reports/page.tsx index f7e2807..193d537 100644 --- a/app/(dashboard)/reports/page.tsx +++ b/app/(dashboard)/reports/page.tsx @@ -13,6 +13,7 @@ import { RichTextarea } from "@/components/ui/rich-textarea"; import { RichText } from "@/lib/rich-text"; import { useConfirm } from "@/components/confirm"; import { cn, fmtDate } from "@/lib/utils"; +import { Clamp } from "@/components/ui/clamp"; const TYPES: { key: ReportType; label: string }[] = [ { key: "post", label: "Posts" }, @@ -195,18 +196,16 @@ export default function ReportsPage() {
    {type === "user" ? ( - + {r.target_text ? `@${r.target_text}` : "—"} - + ) : ( <> -
    - {r.target_text || "—"} -
    + {r.target_text || "—"} {r.target_author_handle ? ( -
    - by @{r.target_author_handle} -
    + + {`by @${r.target_author_handle}`} + ) : null} )} @@ -221,11 +220,15 @@ export default function ReportsPage() { {r.category} {r.reason ? ( -
    {r.reason}
    + + {r.reason} + ) : null}
    - {r.reporter_handle ? `@${r.reporter_handle}` : "—"} + + + {r.reporter_handle ? `@${r.reporter_handle}` : "—"} + {fmtDate(r.created_at)} diff --git a/app/(dashboard)/reward-gifts/page.tsx b/app/(dashboard)/reward-gifts/page.tsx index ca51e25..3cc338e 100644 --- a/app/(dashboard)/reward-gifts/page.tsx +++ b/app/(dashboard)/reward-gifts/page.tsx @@ -17,6 +17,7 @@ import Image from "next/image"; import { toast } from "sonner"; import { rewardGifts, type GiftableReward } from "@/lib/api"; import { compressImage } from "@/lib/compress-image"; +import { Clamp } from "@/components/ui/clamp"; export default function RewardGiftsPage() { const [catalog, setCatalog] = useState(null); @@ -123,9 +124,9 @@ export default function RewardGiftsPage() { unoptimized /> -

    + {r.name} -

    +
    diff --git a/app/(dashboard)/sidebar.tsx b/app/(dashboard)/sidebar.tsx index 6f1c557..ab42629 100644 --- a/app/(dashboard)/sidebar.tsx +++ b/app/(dashboard)/sidebar.tsx @@ -174,7 +174,7 @@ export function Sidebar({

    {me.name}

    @{me.handle}

    -

    +

    {staffMe.role_name}

    diff --git a/app/(dashboard)/staff/roles-tab.tsx b/app/(dashboard)/staff/roles-tab.tsx index fff0aa2..9aa48c3 100644 --- a/app/(dashboard)/staff/roles-tab.tsx +++ b/app/(dashboard)/staff/roles-tab.tsx @@ -15,6 +15,7 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Modal } from "@/components/ui/modal"; import { useConfirm } from "@/components/confirm"; +import { Clamp } from "@/components/ui/clamp"; const errMsg = (err: unknown, fallback: string) => err instanceof AdminError ? err.message : fallback; @@ -129,18 +130,20 @@ export function RolesTab() {
  • -

    - {r.name}{" "} +

    + + {r.name} + {r.is_system && ( - + System )} -

    +
    {r.description && ( -

    + {r.description} -

    + )}

    {r.member_count} {r.member_count === 1 ? "member" : "members"} diff --git a/app/(dashboard)/staff/staff-tab.tsx b/app/(dashboard)/staff/staff-tab.tsx index 7ed00d4..3e29eed 100644 --- a/app/(dashboard)/staff/staff-tab.tsx +++ b/app/(dashboard)/staff/staff-tab.tsx @@ -14,6 +14,7 @@ import { Input } from "@/components/ui/input"; import { Avatar } from "@/components/ui/avatar"; import { Table, THead, TH, TR, TD } from "@/components/ui/table"; import { useConfirm } from "@/components/confirm"; +import { Clamp } from "@/components/ui/clamp"; const errMsg = (err: unknown, fallback: string) => err instanceof AdminError ? err.message : fallback; @@ -132,7 +133,9 @@ export function StaffTab() { 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) => (

  • -
    +
    -

    + {m.name}{" "} {self && ( (you) )} -

    -

    - @{m.handle} -

    + + + {`@${m.handle}`} +
    {m.blocked_at ? ( - + Blocked ) : ( diff --git a/app/(dashboard)/users/disciple-link-modal.tsx b/app/(dashboard)/users/disciple-link-modal.tsx index c443f21..b47d6fd 100644 --- a/app/(dashboard)/users/disciple-link-modal.tsx +++ b/app/(dashboard)/users/disciple-link-modal.tsx @@ -21,6 +21,7 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Modal } from "@/components/ui/modal"; import { cn } from "@/lib/utils"; +import { Clamp } from "@/components/ui/clamp"; /** "Vṛndāvana · 1972" — whichever halves we know. */ function placeYear(d: AdminDisciple): string | null { @@ -138,13 +139,13 @@ export function DiscipleLinkModal({
    Currently linked to
    -
    + {user.disciple_name ?? ( unknown disciple ({user.disciple_id.slice(0, 8)}…) )} -
    +
    -
    +
    -
    - {u.name} +
    + {u.name} {u.banned_at ? ( - + Suspended ) : null}
    -
    - @{u.handle} -
    + + {`@${u.handle}`} + {u.disciple_id ? (
    - - {u.disciple_name ?? "Direct disciple"} - + {u.disciple_name ?? "Direct disciple"}
    ) : null}
    @@ -171,11 +170,11 @@ export default function UsersPage() {
    -
    - {u.email ?? ( - — - )} -
    + {u.email ? ( + {u.email} + ) : ( + — + )}
    s * 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 * 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("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(null); // Runs after every render: rows arrive and change with the data. @@ -39,14 +53,16 @@ export function Table({ children }: { children: React.ReactNode }) { }); return ( -
    - - {children} -
    -
    + +
    + + {children} +
    +
    +
    ); } @@ -100,6 +116,7 @@ export function TD({ children?: React.ReactNode; className?: string; }) { + const card = React.useContext(CardLayoutContext); return (