fix: UX audit P0/P1 pass — publish reason inline, language pref on first-run, grouped sidebar, nav cleanup
P0-1 compose: inline 'Connect a channel to publish' beside the disabled Publish (desktop) and above the mobile bar — the silent-disabled state is gone. P0-2 first-run home reads the landing's tmbh.manual.lang preference instead of hard-coding Spanish. P1-3 sidebar: 16 flat items → 5 labeled sections (Publish/Content/Operations/ Admin); dead Onboarding item removed; Groups surfaces in Operations. P1-4 More menu: Groups row added; Onboarding row removed. P1-6 duplicate Security & sessions row removed. P1-7 mobile Post → Publish (label drift); More menu labels unified (Video library / Files / Connections / Audit trail). P2-10 username pattern regex fixed ([a-z0-9._-] with the dash last).
This commit is contained in:
parent
ad912828ea
commit
98cf36fc51
5 changed files with 66 additions and 32 deletions
|
|
@ -274,7 +274,10 @@ export default function ComposePage(): React.ReactElement {
|
|||
className="w-52 py-1 text-xs"
|
||||
/>
|
||||
</span>
|
||||
<div className="ml-auto flex gap-2">
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{targets.length === 0 && !over && (
|
||||
<span className="text-xs text-warn">Connect a channel to publish — open Channels in the sidebar.</span>
|
||||
)}
|
||||
<Button variant="outline" disabled={busy || mediaBusy || chars === 0} onClick={() => void saveDraft()}>Save draft</Button>
|
||||
<Button variant="primary" disabled={busy || mediaBusy || targets.length === 0 || chars === 0 || over} onClick={() => void publish()}>
|
||||
{scheduledAt !== "" ? "Schedule" : approval === "review" ? "Submit" : "Publish"}
|
||||
|
|
@ -292,7 +295,8 @@ export default function ComposePage(): React.ReactElement {
|
|||
)}
|
||||
<div className="fixed left-0 right-0 z-30 border-t border-border bg-surface px-4 py-2 lg:hidden" style={{ bottom: `calc(var(--mobile-tab-height) + var(--mobile-safe-bottom) + ${keyboardOffset}px)` }}>
|
||||
<p className="mb-2 truncate text-2xs text-text-faint">{approval === "review" || !can("approval.grant") ? "→ Editors approve" : "→ Ready to publish"} · {scheduledAt ? `publishes ${new Date(scheduledAt).toLocaleString()}` : "post now"} · {selected.length} accounts</p>
|
||||
<div className="flex gap-2"><Button variant="ghost" className="min-h-11" onClick={() => router.back()}>Cancel</Button><Button variant="outline" className="min-h-11 flex-1" disabled={busy || mediaBusy || chars === 0} onClick={() => void saveDraft()}>Draft</Button><Button variant="primary" className="min-h-11 flex-1" disabled={busy || selected.length === 0 || chars === 0 || over || mediaBusy} onClick={() => void publish()}>{scheduledAt ? "Schedule" : approval === "review" || !can("approval.grant") ? "Submit" : "Post"}</Button></div>
|
||||
{selected.length === 0 && <p className="mb-1 text-2xs text-warn">Connect a channel to publish — open Channels in the sidebar.</p>}
|
||||
<div className="flex gap-2"><Button variant="ghost" className="min-h-11" onClick={() => router.back()}>Cancel</Button><Button variant="outline" className="min-h-11 flex-1" disabled={busy || mediaBusy || chars === 0} onClick={() => void saveDraft()}>Draft</Button><Button variant="primary" className="min-h-11 flex-1" disabled={busy || selected.length === 0 || chars === 0 || over || mediaBusy} onClick={() => void publish()}>{scheduledAt ? "Schedule" : approval === "review" || !can("approval.grant") ? "Submit" : "Publish"}</Button></div>
|
||||
</div>
|
||||
<Sheet open={scheduleOpen} onClose={() => setScheduleOpen(false)} title="Schedule post" footer={<Button variant="primary" className="min-h-11 w-full justify-center" onClick={() => setScheduleOpen(false)}>Done</Button>}><label className="block text-xs text-text-faint" htmlFor="mobile-schedule">Date and time</label><Input id="mobile-schedule" type="datetime-local" value={scheduledAt} onChange={(event) => setScheduledAt(event.target.value)} className="mt-2 min-h-12" /><div className="mt-4 flex gap-2">{[1, 24, 48].map((hours) => <button key={hours} type="button" className="min-h-11 rounded-md border border-border px-3 text-xs" onClick={() => { const date = new Date(Date.now() + hours * 3600000); setScheduledAt(`${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}T${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`); }}>{hours === 1 ? "In 1 hour" : hours === 24 ? "Tomorrow" : "In 2 days"}</button>)}</div><button type="button" onClick={() => setScheduledAt("")} className="mt-4 min-h-11 text-xs text-text-faint">Clear schedule</button></Sheet>
|
||||
<Sheet open={previewOpen} onClose={() => setPreviewOpen(false)} title="Target previews">{selected.map((target) => <div key={target.key} className="mb-3 rounded-md border border-border p-3"><p className="text-xs text-text-faint">{target.label}</p><p className="mt-2 whitespace-pre-wrap text-sm">{editor?.getText() || "Your post preview appears here…"}</p></div>)}</Sheet>
|
||||
|
|
|
|||
|
|
@ -72,13 +72,12 @@ export default function MorePage(): React.ReactElement {
|
|||
<div className="border-y border-border-subtle bg-surface">
|
||||
<Row href="/connections" title="Connections" detail="Accounts and health" />
|
||||
<Row href="/settings" title="Settings" />
|
||||
<Row href="/settings" title="Security & sessions" />
|
||||
<Row href="/groups" title="Groups" detail="Collective moderation — submissions, approvals" />
|
||||
<Row href="/calendar" title="Calendar" />
|
||||
<Row href="/analytics" title="Analytics" />
|
||||
<Row href="/video" title="Video library" />
|
||||
<Row href="/files" title="Files" detail="Group file library" />
|
||||
<Row href="/chat" title="Chat" detail="Matrix chat" />
|
||||
<Row href="/onboarding" title="Onboarding" />
|
||||
<button onClick={() => void logout()} className="flex min-h-[56px] w-full items-center px-4 text-left text-sm font-medium text-danger">Sign out</button>
|
||||
</div>
|
||||
</div>;
|
||||
|
|
|
|||
|
|
@ -271,7 +271,7 @@ export default function OnboardingPage(): React.ReactElement {
|
|||
onChange={(e) => setUsername(e.target.value.toLowerCase())}
|
||||
placeholder="Username — this is how you sign in"
|
||||
autoComplete="username"
|
||||
pattern="[a-z0-9][a-z0-9_.-]{1,31}"
|
||||
pattern="[a-z0-9][a-z0-9._-]{1,31}"
|
||||
title="2-32 characters: lowercase letters, digits, dot, underscore or hyphen"
|
||||
/>
|
||||
<Input
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useAuth } from "../../lib/auth-store";
|
||||
|
||||
/**
|
||||
|
|
@ -59,7 +60,16 @@ export function NoCollectivesHome(): React.ReactElement {
|
|||
const { session } = useAuth();
|
||||
// Spanish-first product; the landing's ES/EN preference carries when the
|
||||
// account stores one (P2).
|
||||
const t = <T extends { es: string; en: string }>(x: T): string => x.es;
|
||||
// Respect the reading language the user picked on the landing page (tmbh.lang
|
||||
// in localStorage); Spanish stays the fallback for a fresh visitor.
|
||||
const [lang, setLang] = useState<"es" | "en">("es");
|
||||
useEffect(() => {
|
||||
try {
|
||||
const stored = window.localStorage.getItem("tmbh.manual.lang");
|
||||
if (stored === "en" || stored === "es") setLang(stored);
|
||||
} catch { /* private mode: keep the default */ }
|
||||
}, []);
|
||||
const t = <T extends { es: string; en: string }>(x: T): string => x[lang];
|
||||
const name = session?.user.displayName?.trim() || session?.user.username || "";
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -9,23 +9,44 @@ import type { DraftRecord } from "../../lib/api-types";
|
|||
import { LogoMark } from "../brand/logo";
|
||||
import { NavIcon } from "../brand";
|
||||
|
||||
const NAV: { href: string; label: string; icon: string; external?: boolean }[] = [
|
||||
{ href: "/streams", label: "Streams", icon: "video" },
|
||||
{ href: "/control-room", label: "Control Room", icon: "video" },
|
||||
{ href: "/compose", label: "Compose", icon: "compose" },
|
||||
{ href: "/calendar", label: "Calendar", icon: "calendar" },
|
||||
{ href: "/chat", label: "Chat", icon: "chat" },
|
||||
{ href: "/files", label: "Files", icon: "files" },
|
||||
{ href: "/archive", label: "Archive", icon: "files" },
|
||||
{ href: "/inbox", label: "Inbox", icon: "inbox" },
|
||||
{ href: "/approvals", label: "Approvals", icon: "approvals" },
|
||||
{ href: "/video", label: "Video", icon: "video" },
|
||||
{ href: "/analytics", label: "Analytics", icon: "analytics" },
|
||||
{ href: "/connections", label: "Channels", icon: "connections" },
|
||||
{ href: "/team", label: "Team", icon: "team" },
|
||||
{ href: "/audit", label: "Audit log", icon: "audit" },
|
||||
{ href: "/onboarding", label: "Onboarding", icon: "home" },
|
||||
{ href: "/settings", label: "Settings", icon: "settings" },
|
||||
/** Grouped per the 2026-10 UX audit (16 flat items → 5 scannable sections). */
|
||||
const NAV: { section: string; items: { href: string; label: string; icon: string; external?: boolean }[] }[] = [
|
||||
{
|
||||
section: "Publish",
|
||||
items: [
|
||||
{ href: "/compose", label: "Compose", icon: "compose" },
|
||||
{ href: "/calendar", label: "Calendar", icon: "calendar" },
|
||||
{ href: "/streams", label: "Home", icon: "video" },
|
||||
],
|
||||
},
|
||||
{
|
||||
section: "Content",
|
||||
items: [
|
||||
{ href: "/video", label: "Video library", icon: "video" },
|
||||
{ href: "/files", label: "Files", icon: "files" },
|
||||
{ href: "/archive", label: "Archive", icon: "archive" },
|
||||
{ href: "/chat", label: "Chat", icon: "chat" },
|
||||
],
|
||||
},
|
||||
{
|
||||
section: "Operations",
|
||||
items: [
|
||||
{ href: "/inbox", label: "Inbox", icon: "inbox" },
|
||||
{ href: "/approvals", label: "Approvals", icon: "approvals" },
|
||||
{ href: "/groups", label: "Groups", icon: "team" },
|
||||
{ href: "/control-room", label: "Control Room", icon: "control" },
|
||||
{ href: "/analytics", label: "Analytics", icon: "analytics" },
|
||||
{ href: "/audit", label: "Audit trail", icon: "audit" },
|
||||
],
|
||||
},
|
||||
{
|
||||
section: "Admin",
|
||||
items: [
|
||||
{ href: "/connections", label: "Connections", icon: "connections" },
|
||||
{ href: "/team", label: "Team", icon: "team" },
|
||||
{ href: "/settings", label: "Settings", icon: "settings" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const identity = (drafts: DraftRecord[]): DraftRecord[] => drafts;
|
||||
|
|
@ -72,18 +93,16 @@ export function Sidebar(): React.ReactElement {
|
|||
</div>
|
||||
</div>
|
||||
<nav className="flex-1 overflow-y-auto scrollbar-thin p-2">
|
||||
{NAV.map((item) => {
|
||||
const active = !item.external && pathname.startsWith(item.href);
|
||||
{NAV.map((group) => (
|
||||
<div key={group.section} className="mb-3">
|
||||
<p className="px-3 pb-1 pt-2 text-2xs font-semibold uppercase tracking-wide text-text-faint">{group.section}</p>
|
||||
{group.items.map((item) => {
|
||||
const active = pathname.startsWith(item.href);
|
||||
const className = `group flex items-center justify-between rounded-md px-3 py-1.5 text-sm transition-colors duration-150 ${item.href === "/control-room" ? "min-h-11" : ""} ${
|
||||
active ? "bg-accent-dim text-accent" : "text-text-dim hover:bg-surface-2 hover:text-text"
|
||||
}`;
|
||||
const icon = <NavIcon name={item.icon} size={16} />;
|
||||
return item.external ? (
|
||||
<a key={item.href} href={item.href} target="_blank" rel="noreferrer" className={className}>
|
||||
<span className="flex items-center gap-2.5">{icon}{item.label}</span>
|
||||
<span className="text-2xs text-text-faint">↗</span>
|
||||
</a>
|
||||
) : (
|
||||
return (
|
||||
<Link key={item.href} href={item.href} className={className}>
|
||||
<span className="flex items-center gap-2.5">{icon}{item.label}</span>
|
||||
{item.href === "/approvals" && approvals > 0 && (
|
||||
|
|
@ -91,7 +110,9 @@ export function Sidebar(): React.ReactElement {
|
|||
)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue