feat(ui): usable on phones without changing the desktop layout
Deploy / deploy (push) Successful in 3m19s

At phone width the sidebar stayed 240px wide, leaving pages a ~135px strip.
Below md it is now hidden behind a menu button in the header and opens as a
drawer (closes on navigation, backdrop, X or Escape); from md up the sidebar
is unchanged. Page wrappers use p-4 on phones and keep p-6/p-8 from md.

Shot detail: on phones the thumbnail takes the full width with the details
below it instead of pushing them off screen, the action buttons wrap, and
the tab bar scrolls sideways. Desktop sizing is kept as it was.

Every change is behind a md: breakpoint, so the desktop layout is the same.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
twotalesanimation
2026-09-18 06:56:38 +02:00
parent 33bc6a935c
commit 40b99f3add
21 changed files with 148 additions and 67 deletions
+1 -1
View File
@@ -59,7 +59,7 @@ export default async function ClientDetailPage({
};
return (
<div className="p-6 max-w-5xl mx-auto space-y-6">
<div className="p-4 md:p-6 max-w-5xl mx-auto space-y-6">
{/* Back */}
<Link
href="/clients"
+1 -1
View File
@@ -26,7 +26,7 @@ export default async function ClientsPage() {
const clients = await getClients();
return (
<div className="p-6 max-w-5xl mx-auto space-y-6">
<div className="p-4 md:p-6 max-w-5xl mx-auto space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
+1 -1
View File
@@ -161,7 +161,7 @@ export default async function DashboardPage() {
} = await getDashboardData(session.user.id, session.user.role);
return (
<div className="p-8 space-y-6 max-w-[1600px] mx-auto">
<div className="p-4 md:p-8 space-y-6 max-w-[1600px] mx-auto">
<div className="mb-4">
<h1 className="text-3xl font-bold text-white">Dashboard</h1>
<p className="text-zinc-400 mt-1">
+13 -10
View File
@@ -2,6 +2,7 @@ import { auth } from "@/auth";
import { redirect } from "next/navigation";
import { Sidebar } from "@/components/layout/Sidebar";
import { Header } from "@/components/layout/Header";
import { MobileNavProvider } from "@/components/layout/MobileNav";
export default async function DashboardLayout({
children,
@@ -12,18 +13,20 @@ export default async function DashboardLayout({
if (!session?.user) redirect("/login");
return (
<div className="flex h-screen bg-zinc-950 overflow-hidden print:block print:h-auto print:overflow-visible">
<div className="print:hidden">
<Sidebar />
</div>
<div className="flex-1 flex flex-col overflow-hidden print:block print:overflow-visible">
<MobileNavProvider>
<div className="flex h-screen bg-zinc-950 overflow-hidden print:block print:h-auto print:overflow-visible">
<div className="print:hidden">
<Header />
<Sidebar />
</div>
<div className="flex-1 flex flex-col overflow-hidden print:block print:overflow-visible">
<div className="print:hidden">
<Header />
</div>
<main className="flex-1 overflow-auto print:overflow-visible">
{children}
</main>
</div>
<main className="flex-1 overflow-auto print:overflow-visible">
{children}
</main>
</div>
</div>
</MobileNavProvider>
);
}
@@ -150,7 +150,7 @@ export function PipelineQueueClient({ projects, initialStatus = "all" }: Props)
const lastRow = pagination ? Math.min(pagination.page * PAGE_SIZE, pagination.total) : 0;
return (
<div className="p-6 space-y-6">
<div className="p-4 md:p-6 space-y-6">
<div className="flex items-center justify-between gap-4 flex-wrap">
<div>
<h1 className="text-2xl font-semibold text-white">Render Queue</h1>
@@ -140,7 +140,7 @@ export function DeliveriesClient({ canBuild }: { canBuild: boolean }) {
const shown = episode ? episodes.filter((e) => e.episode === episode) : episodes;
return (
<div className="p-6 space-y-6 max-w-6xl">
<div className="p-4 md:p-6 space-y-6 max-w-6xl">
<div className="flex items-center gap-3">
<Link href="/pipeline">
<Button variant="ghost" size="icon-sm">
@@ -155,10 +155,10 @@ export function ExportDetailClient({ exportId }: { exportId: string }) {
}
}
if (isLoading) return <div className="p-6 text-zinc-500">Loading…</div>;
if (isLoading) return <div className="p-4 md:p-6 text-zinc-500">Loading…</div>;
if (error || !data) {
return (
<div className="p-6 text-red-400">
<div className="p-4 md:p-6 text-red-400">
{(error as Error)?.message ?? "Export not found"}
</div>
);
@@ -180,7 +180,7 @@ export function ExportDetailClient({ exportId }: { exportId: string }) {
];
return (
<div className="p-6 space-y-6 max-w-5xl">
<div className="p-4 md:p-6 space-y-6 max-w-5xl">
<div className="flex items-center justify-between gap-4 flex-wrap">
<div className="flex items-center gap-3">
<Link href="/pipeline">
@@ -76,7 +76,7 @@ export function MachinesClient() {
const machines = data?.machines ?? [];
return (
<div className="p-6 space-y-6">
<div className="p-4 md:p-6 space-y-6">
<div className="flex items-center gap-3">
<Link href="/pipeline">
<Button variant="ghost" size="icon-sm">
@@ -69,7 +69,7 @@ export default async function KanbanPage({
]);
return (
<div className="p-6 space-y-6 max-w-[1800px] mx-auto">
<div className="p-4 md:p-6 space-y-6 max-w-[1800px] mx-auto">
{/* Breadcrumb */}
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Link href="/projects" className="hover:text-foreground transition-colors">
+1 -1
View File
@@ -123,7 +123,7 @@ export default async function ProjectPage({ params }: { params: Promise<{ id: st
const doneTasks = project.tasks.filter((t) => t.status === "DONE").length;
return (
<div className="p-8 space-y-6 max-w-[1600px] mx-auto">
<div className="p-4 md:p-8 space-y-6 max-w-[1600px] mx-auto">
{/* Breadcrumb */}
<div className="flex items-center gap-2 text-sm text-zinc-500">
<Link href="/projects" className="hover:text-white transition-colors">
@@ -230,7 +230,7 @@ export default function ShotDetailPage() {
const { Icon: StatusIcon } = statusCfg;
return (
<div className="p-6 space-y-6 max-w-[1400px] mx-auto">
<div className="p-4 md:p-6 space-y-6 max-w-[1400px] mx-auto">
{/* Breadcrumb */}
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Link href="/projects" className="hover:text-foreground transition-colors">
@@ -245,7 +245,9 @@ export default function ShotDetailPage() {
</div>
{/* Header */}
<div className="flex items-start gap-6">
{/* Phones: back button, then a full-width thumbnail, then the details
below it. From md: one row, as on desktop. */}
<div className="flex flex-wrap md:flex-nowrap items-start gap-4 md:gap-6">
{/* Thumbnail – cinema scope 2.39:1 */}
<Button variant="ghost" size="icon" asChild className="-ml-2 h-8 w-8">
<Link href={`/projects/${params.id}`}>
@@ -253,7 +255,7 @@ export default function ShotDetailPage() {
</Link>
</Button>
{shotThumbnail(shot) && (
<div className="relative flex-shrink-0 w-72 aspect-[2.39] rounded-lg overflow-hidden border border-border">
<div className="relative flex-shrink-0 w-full md:w-72 aspect-[2.39] rounded-lg overflow-hidden border border-border">
<Image
src={shotThumbnail(shot)!}
@@ -263,10 +265,10 @@ export default function ShotDetailPage() {
/>
</div>
)}
<div className="space-y-2">
<div className="flex items-center gap-3">
<h1 className="text-2xl font-bold font-mono">{shot.shotCode}</h1>
<div className="space-y-2 min-w-0 w-full md:w-auto md:min-w-min">
<div className="flex flex-wrap items-center gap-3">
<h1 className="text-2xl font-bold font-mono break-all md:break-normal">{shot.shotCode}</h1>
{/* Shot version badge — click to edit for managers */}
{canManage ? (
@@ -424,7 +426,7 @@ export default function ShotDetailPage() {
)}
{canManage && (
<div className="ml-auto shrink-0 flex items-center gap-2">
<div className="ml-auto shrink-0 flex flex-wrap md:flex-nowrap items-center gap-2 w-full md:w-auto">
{/* Internal approval action */}
{canInternallyApprove && shot.shotApprovalStatus === "PENDING" && (
<Button
@@ -510,7 +512,8 @@ export default function ShotDetailPage() {
{/* Tabs */}
<div>
<div className="flex border-b border-border mb-5">
{/* Phones: five tabs are wider than the screen, so the bar scrolls sideways. */}
<div className="flex border-b border-border mb-5 overflow-x-auto overflow-y-hidden md:overflow-visible [&>button]:shrink-0 [&>button]:whitespace-nowrap">
<button
onClick={() => setActiveTab("tasks")}
className={cn(
+1 -1
View File
@@ -48,7 +48,7 @@ export default function ProjectsPage() {
useScrollMemory("projects", pageRef, !isLoading);
return (
<div ref={pageRef} className="p-8 space-y-6 max-w-[1600px] mx-auto">
<div ref={pageRef} className="p-4 md:p-8 space-y-6 max-w-[1600px] mx-auto">
<div className="flex items-center justify-between gap-4 mb-8">
<div>
<h1 className="text-3xl font-bold text-white">Projects</h1>
+3 -3
View File
@@ -186,7 +186,7 @@ export default function MigrationPage() {
if (fetchError) {
return (
<div className="p-6 max-w-2xl">
<div className="p-4 md:p-6 max-w-2xl">
<div className="flex items-center gap-2 text-sm text-red-400 rounded-md border border-red-500/20 bg-red-500/10 px-3 py-2">
<AlertCircle className="h-4 w-4 shrink-0" />
{fetchError}
@@ -200,7 +200,7 @@ export default function MigrationPage() {
if (!hetznerConfigured) {
return (
<div className="p-6 max-w-2xl">
<div className="p-4 md:p-6 max-w-2xl">
<div className="flex items-center gap-2 text-sm text-amber-400 rounded-md border border-amber-500/20 bg-amber-500/10 px-3 py-2">
<AlertCircle className="h-4 w-4 shrink-0" />
Hetzner object storage is not configured. Set{" "}
@@ -217,7 +217,7 @@ export default function MigrationPage() {
const allFilteredSelected = filtered.length > 0 && filtered.every((f) => selected.has(f.key));
return (
<div className="p-6 space-y-4 max-w-5xl">
<div className="p-4 md:p-6 space-y-4 max-w-5xl">
{/* Header */}
<div className="flex items-start justify-between gap-4">
<div>
+1 -1
View File
@@ -19,7 +19,7 @@ export default async function SettingsPage() {
const hetznerConfig = isAdmin ? await getHetznerConfig() : null;
return (
<div className="p-6 space-y-6 max-w-2xl mx-auto">
<div className="p-4 md:p-6 space-y-6 max-w-2xl mx-auto">
<h1 className="text-2xl font-bold">Account Settings</h1>
<Card>
@@ -72,7 +72,7 @@ export default function StorageTestPage() {
};
return (
<div className="p-6 max-w-3xl space-y-6">
<div className="p-4 md:p-6 max-w-3xl space-y-6">
<div>
<h1 className="text-xl font-semibold">Object Storage Playback Test</h1>
<p className="text-sm text-muted-foreground mt-1">
@@ -1124,7 +1124,7 @@ export function ShotStatusClient({
const selectionCount = selectedIds.size;
return (
<div ref={pageRef} className="p-8 space-y-6 max-w-[1600px] mx-auto">
<div ref={pageRef} className="p-4 md:p-8 space-y-6 max-w-[1600px] mx-auto">
{/* Header */}
<div>
<h1 className="text-3xl font-bold text-white">Shot Status</h1>
+1 -1
View File
@@ -110,7 +110,7 @@ export function TasksPageClient({
};
return (
<div className="p-6 max-w-5xl mx-auto space-y-6">
<div className="p-4 md:p-6 max-w-5xl mx-auto space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
+1 -1
View File
@@ -32,7 +32,7 @@ export default async function UsersPage() {
}));
return (
<div className="p-6 max-w-5xl mx-auto space-y-6">
<div className="p-4 md:p-6 max-w-5xl mx-auto space-y-6">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-amber-500/10 border border-amber-500/20 flex items-center justify-center shrink-0">
<Users className="h-5 w-5 text-amber-400" />
+13 -2
View File
@@ -12,8 +12,9 @@ import {
} from "@/components/ui/dropdown-menu";
import { NotificationBell } from "@/components/notifications/NotificationBell";
import { getInitials } from "@/lib/utils";
import { LogOut, User, Settings } from "lucide-react";
import { LogOut, User, Settings, Menu } from "lucide-react";
import Link from "next/link";
import { useMobileNav } from "@/components/layout/MobileNav";
interface HeaderProps {
title?: string;
@@ -23,11 +24,21 @@ interface HeaderProps {
export function Header({ title, breadcrumbs }: HeaderProps) {
const { data: session } = useSession();
const user = session?.user;
const { setOpen: setMobileNavOpen } = useMobileNav();
return (
<header className="flex h-14 items-center justify-between border-b border-zinc-800 bg-zinc-950 px-6">
<header className="flex h-14 items-center justify-between border-b border-zinc-800 bg-zinc-950 px-4 md:px-6">
{/* Left: Title / Breadcrumbs */}
<div className="flex items-center gap-2 min-w-0">
{/* Phones only: the sidebar is hidden below md and opens as a drawer. */}
<button
type="button"
onClick={() => setMobileNavOpen(true)}
className="md:hidden -ml-2 rounded-md p-2 text-zinc-400 hover:bg-zinc-800 hover:text-white"
aria-label="Open menu"
>
<Menu className="h-5 w-5" />
</button>
{breadcrumbs ? (
<nav className="flex items-center gap-1.5 text-sm">
{breadcrumbs.map((crumb, i) => (
+20
View File
@@ -0,0 +1,20 @@
'use client';
import { createContext, useContext, useState } from 'react';
/**
* Open/closed state of the phone-width navigation drawer, shared between the
* Header (which has the menu button) and the Sidebar (which is the drawer).
* At md and up the sidebar is always visible and this state is unused.
*/
const MobileNavContext = createContext<{ open: boolean; setOpen: (open: boolean) => void }>({
open: false,
setOpen: () => {},
});
export function MobileNavProvider({ children }: { children: React.ReactNode }) {
const [open, setOpen] = useState(false);
return <MobileNavContext.Provider value={{ open, setOpen }}>{children}</MobileNavContext.Provider>;
}
export const useMobileNav = () => useContext(MobileNavContext);
+70 -26
View File
@@ -22,10 +22,12 @@ import {
Clapperboard,
LayoutGrid,
Server,
X,
} from 'lucide-react';
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { useSession } from 'next-auth/react';
import { Button } from '@/components/ui/button';
import { useMobileNav } from '@/components/layout/MobileNav';
const navItems = [
{ href: '/dashboard', label: 'Dashboard', icon: LayoutDashboard },
@@ -54,17 +56,76 @@ const montserrat = Montserrat({
export function Sidebar() {
const pathname = usePathname();
const { data: session } = useSession();
const [collapsed, setCollapsed] = useState(false);
const { open: mobileOpen, setOpen: setMobileOpen } = useMobileNav();
// Picking a page closes the phone drawer; so does Escape.
useEffect(() => {
setMobileOpen(false);
}, [pathname, setMobileOpen]);
useEffect(() => {
if (!mobileOpen) return;
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && setMobileOpen(false);
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [mobileOpen, setMobileOpen]);
return (
<>
{/* md and up: the sidebar exactly as before. */}
<aside
className={cn(
'hidden md:flex flex-col border-r border-zinc-800 bg-zinc-900 transition-all duration-200',
collapsed ? 'w-16' : 'w-60',
)}
>
<SidebarContent collapsed={collapsed} />
{/* Collapse toggle */}
<div className="border-t border-zinc-800 p-2">
<Button
variant="ghost"
size="icon-sm"
className="w-full"
onClick={() => setCollapsed(!collapsed)}
title={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
>
{collapsed ? (
<ChevronRight className="h-4 w-4" />
) : (
<ChevronLeft className="h-4 w-4" />
)}
</Button>
</div>
</aside>
{/* Phones: the same navigation as a drawer, opened from the header. */}
{mobileOpen && (
<div className="fixed inset-0 z-50 md:hidden">
<div className="absolute inset-0 bg-black/60" onClick={() => setMobileOpen(false)} />
<aside className="absolute inset-y-0 left-0 flex w-64 max-w-[80vw] flex-col overflow-y-auto border-r border-zinc-800 bg-zinc-900">
<button
type="button"
onClick={() => setMobileOpen(false)}
className="absolute right-2 top-2 rounded-md p-2 text-zinc-400 hover:bg-zinc-800 hover:text-white"
aria-label="Close menu"
>
<X className="h-4 w-4" />
</button>
<SidebarContent collapsed={false} />
</aside>
</div>
)}
</>
);
}
function SidebarContent({ collapsed }: { collapsed: boolean }) {
const pathname = usePathname();
const { data: session } = useSession();
const isAdmin = ['ADMIN', 'PRODUCER'].includes(session?.user?.role ?? '');
return (
<aside
className={cn(
'flex flex-col border-r border-zinc-800 bg-zinc-900 transition-all duration-200',
collapsed ? 'w-16' : 'w-60',
)}
>
<>
{/* Logo */}
<div
className={cn(
@@ -119,23 +180,6 @@ export function Sidebar() {
);
})}
</nav>
{/* Collapse toggle */}
<div className="border-t border-zinc-800 p-2">
<Button
variant="ghost"
size="icon-sm"
className="w-full"
onClick={() => setCollapsed(!collapsed)}
title={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
>
{collapsed ? (
<ChevronRight className="h-4 w-4" />
) : (
<ChevronLeft className="h-4 w-4" />
)}
</Button>
</div>
</aside>
</>
);
}