this repo has no description
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

Mobile-responsive cabinet: collapsible sidebar, adaptive panel header, phone mockup

- Add collapsible sidebar with hamburger menu and mobile topbar (logo + toggle)
- Move search bar into sidebar on mobile, hide status badges and tags
- Split panel header: stacked breadcrumbs + toolbar on mobile, single row on desktop
- Make breadcrumbs expandable (collapse to first + … + last when deep)
- Spread toolbar buttons evenly on mobile, keep right-aligned on desktop
- Add phone-frame variant to CabinetMockup with ghost panel layers
- Replace "Your Cabinet" breadcrumb text with folder icon on mobile
- Remove zoom button shadows in image preview
- Redirect to /cabinet after pairing, redirect to /devices after logout

+328 -105
+1
CHANGELOG.md
··· 12 12 - Remove bearer token authentication fallback from AppView [#26](https://issues.opake.app/issues/26.html)s 13 13 14 14 ### Added 15 + - Add web inbox for incoming grants [#150](https://issues.opake.app/issues/150.html) 15 16 - Rewrite opake-appview in Elixir/Phoenix with PostgreSQL [#290](https://issues.opake.app/issues/290.html) 16 17 - Add web sharing UI with grant management [#149](https://issues.opake.app/issues/149.html) 17 18 - Add user banner to profile dropdown and cache profile images in IndexedDB [#288](https://issues.opake.app/issues/288.html)
+107 -18
web/src/components/CabinetMockup.tsx
··· 259 259 ); 260 260 } 261 261 262 + // ─── Mobile mockup — phone frame with compact file list ────────────────────── 263 + 264 + const MOBILE_FILES = FILES; 265 + 266 + function MockMobileFrame() { 267 + return ( 268 + <div 269 + className="border-base-300/50 bg-base-300 mx-auto flex w-80 flex-col overflow-hidden rounded-[1.75rem] border-4 shadow-xl" 270 + style={{ aspectRatio: "9 / 19" }} 271 + > 272 + {/* Status bar */} 273 + <div className="bg-base-200 flex items-center justify-between px-5 pt-2 pb-1"> 274 + <span className="text-text-faint text-[0.6rem] font-medium">9:41</span> 275 + <div className="flex items-center gap-1"> 276 + <div className="bg-text-faint h-1.5 w-4 rounded-full" /> 277 + <div className="bg-text-faint size-2 rounded-full" /> 278 + </div> 279 + </div> 280 + 281 + {/* App header */} 282 + <div className="bg-base-200 flex items-center justify-between border-b border-[rgba(112,83,40,0.1)] px-4 py-2.5"> 283 + <OpakeLogo size="sm" /> 284 + <div className="flex items-center gap-2"> 285 + <MagnifyingGlassIcon size={14} className="text-text-muted" /> 286 + <div className="bg-accent text-primary flex size-6 items-center justify-center rounded-full text-[0.55rem] font-semibold"> 287 + V 288 + </div> 289 + </div> 290 + </div> 291 + 292 + {/* Panel area with ghost layers */} 293 + <div className="bg-base-300 relative flex-1 overflow-hidden px-3 pt-3 pb-2"> 294 + {/* Ghost panels */} 295 + <div className="border-primary/15 bg-bg-ghost-1 absolute inset-x-3 inset-y-3 z-0 -translate-x-1.5 -translate-y-1.5 rounded-xl border" /> 296 + <div className="border-base-300/50 bg-bg-ghost-2 shadow-panel-sm absolute inset-x-3 inset-y-3 z-1 -translate-x-0.75 -translate-y-0.75 rounded-xl border" /> 297 + 298 + {/* Active panel */} 299 + <div className="border-base-300/50 bg-base-100 shadow-panel-lg relative z-10 flex flex-col overflow-hidden rounded-xl border"> 300 + {/* Breadcrumb */} 301 + <div className="border-b border-[rgba(112,83,40,0.08)] px-3 py-2"> 302 + <div className="text-ui flex items-center gap-1.5"> 303 + <FolderIcon size={12} className="text-text-faint" /> 304 + <span className="text-text-faint text-label">›</span> 305 + <span className="text-base-content text-[0.7rem] font-medium">Documents</span> 306 + </div> 307 + </div> 308 + 309 + {/* File list */} 310 + <div className="px-1.5 py-1"> 311 + {MOBILE_FILES.map((file) => { 312 + const Icon = file.icon; 313 + return ( 314 + <div key={file.name} className="flex items-center gap-2.5 rounded-lg px-2 py-2"> 315 + <div 316 + className={`flex size-7 shrink-0 items-center justify-center rounded-md ${file.iconBg} ${file.iconText}`} 317 + > 318 + <Icon size={13} weight={file.folder ? "fill" : "regular"} /> 319 + </div> 320 + <div className="min-w-0 flex-1"> 321 + <div className="text-base-content truncate text-[0.7rem]">{file.name}</div> 322 + <div className="text-text-faint text-[0.55rem]">{file.meta}</div> 323 + </div> 324 + {file.folder && <CaretRightIcon size={11} className="text-text-faint shrink-0" />} 325 + </div> 326 + ); 327 + })} 328 + </div> 329 + 330 + {/* Footer */} 331 + <div className="flex items-center gap-1.5 border-t border-[rgba(112,83,40,0.08)] px-3 py-1.5"> 332 + <ShieldCheckIcon size={9} className="text-primary" /> 333 + <span className="text-text-faint text-[0.55rem]">End-to-end encrypted</span> 334 + </div> 335 + </div> 336 + </div> 337 + 338 + {/* Home indicator */} 339 + <div className="bg-base-100 flex justify-center pt-1 pb-2"> 340 + <div className="bg-text-faint/30 h-1 w-24 rounded-full" /> 341 + </div> 342 + </div> 343 + ); 344 + } 345 + 262 346 // ─── Exported mockup ────────────────────────────────────────────────────────── 263 347 264 348 export function CabinetMockup() { 265 349 return ( 266 - <div className="shadow-panel-lg overflow-hidden rounded-2xl border border-[rgba(112,83,40,0.13)]"> 267 - {/* Browser chrome */} 268 - <div className="bg-base-100 flex items-center gap-1.5 border-b border-[rgba(112,83,40,0.13)] px-4 py-2.5"> 269 - <div className="size-2.5 rounded-full bg-[#D9B8A0]" /> 270 - <div className="size-2.5 rounded-full bg-[#D4C4A8]" /> 271 - <div className="size-2.5 rounded-full bg-[#C8D4B8]" /> 272 - <div className="flex flex-1 justify-center"> 273 - <div className="bg-base-300 flex h-5.5 w-48 items-center gap-1.5 rounded-md border border-[rgba(112,83,40,0.13)] px-3"> 274 - <LockIcon size={9} className="text-text-faint" /> 275 - <span className="text-text-faint text-[10px]">opake.app/cabinet</span> 350 + <> 351 + {/* Desktop — browser chrome */} 352 + <div className="shadow-panel-lg hidden overflow-hidden rounded-2xl border border-[rgba(112,83,40,0.13)] md:block"> 353 + <div className="bg-base-100 flex items-center gap-1.5 border-b border-[rgba(112,83,40,0.13)] px-4 py-2.5"> 354 + <div className="size-2.5 rounded-full bg-[#D9B8A0]" /> 355 + <div className="size-2.5 rounded-full bg-[#D4C4A8]" /> 356 + <div className="size-2.5 rounded-full bg-[#C8D4B8]" /> 357 + <div className="flex flex-1 justify-center"> 358 + <div className="bg-base-300 flex h-5.5 w-48 items-center gap-1.5 rounded-md border border-[rgba(112,83,40,0.13)] px-3"> 359 + <LockIcon size={9} className="text-text-faint" /> 360 + <span className="text-text-faint text-label">opake.app/cabinet</span> 361 + </div> 362 + </div> 363 + </div> 364 + <div className="bg-base-300 flex h-96"> 365 + <MockSidebar /> 366 + <div className="flex flex-1 flex-col overflow-hidden"> 367 + <MockTopBar /> 368 + <MockPanelShell /> 276 369 </div> 277 370 </div> 278 371 </div> 279 372 280 - {/* App layout */} 281 - <div className="bg-base-300 flex h-96"> 282 - <MockSidebar /> 283 - <div className="flex flex-1 flex-col overflow-hidden"> 284 - <MockTopBar /> 285 - <MockPanelShell /> 286 - </div> 373 + {/* Mobile — phone frame */} 374 + <div className="md:hidden"> 375 + <MockMobileFrame /> 287 376 </div> 288 - </div> 377 + </> 289 378 ); 290 379 }
+1 -1
web/src/components/SegmentedToggle.tsx
··· 17 17 onChange, 18 18 }: SegmentedToggleProps<T>) { 19 19 return ( 20 - <div className="join bg-primary/10 rounded-lg p-0.5"> 20 + <div className="join bg-primary/10 items-center rounded-lg p-0.5"> 21 21 {options.map((option) => ( 22 22 <button 23 23 key={option.value}
+25 -2
web/src/components/cabinet/Breadcrumbs.tsx
··· 1 - import type { ReactNode } from "react"; 1 + import { type ReactNode, useState, Children } from "react"; 2 2 3 3 export function Breadcrumbs({ children }: { readonly children: ReactNode }) { 4 + const [expanded, setExpanded] = useState(false); 5 + const items = Children.toArray(children); 6 + const canCollapse = items.length > 2; 7 + 4 8 return ( 5 9 <div className="breadcrumbs text-ui min-w-0 flex-1 overflow-hidden"> 6 - <ul>{children}</ul> 10 + <ul> 11 + {canCollapse && !expanded ? ( 12 + <> 13 + {items[0]} 14 + <li> 15 + <button 16 + type="button" 17 + onClick={() => setExpanded(true)} 18 + className="text-text-faint hover:text-base-content transition-colors" 19 + aria-label="Show full path" 20 + > 21 + 22 + </button> 23 + </li> 24 + {items[items.length - 1]} 25 + </> 26 + ) : ( 27 + children 28 + )} 29 + </ul> 7 30 </div> 8 31 ); 9 32 }
+5 -1
web/src/components/cabinet/FileGridCard.tsx
··· 109 109 )} 110 110 <div className="flex items-center justify-between"> 111 111 <span className="text-caption text-text-faint">{item.modified}</span> 112 - {!hideStatus && <StatusBadge status={item.status} />} 112 + {!hideStatus && ( 113 + <span className="hidden md:inline"> 114 + <StatusBadge status={item.status} /> 115 + </span> 116 + )} 113 117 </div> 114 118 </div> 115 119 );
+4 -4
web/src/components/cabinet/FileListRow.tsx
··· 123 123 </div> 124 124 </div> 125 125 126 - {/* Tags */} 126 + {/* Tags — hidden on mobile */} 127 127 {item.decrypted && item.tags.length > 0 && ( 128 - <div className="flex shrink-0 items-center gap-1"> 128 + <div className="hidden shrink-0 items-center gap-1 md:flex"> 129 129 {item.tags.slice(0, 3).map((tag) => ( 130 130 <span 131 131 key={tag} ··· 137 137 </div> 138 138 )} 139 139 140 - {/* Status */} 140 + {/* Status — hidden on mobile */} 141 141 {!hideStatus && ( 142 - <div className="flex shrink-0 items-center gap-2"> 142 + <div className="hidden shrink-0 items-center gap-2 md:flex"> 143 143 <StatusBadge status={item.status} /> 144 144 </div> 145 145 )}
+2 -2
web/src/components/cabinet/ImagePreview.tsx
··· 58 58 buttonNext: () => null, 59 59 buttonClose: () => null, 60 60 iconZoomIn: () => ( 61 - <button className="btn btn-sm btn-square" aria-label="Zoom in"> 61 + <button className="btn btn-sm btn-square shadow-none" aria-label="Zoom in"> 62 62 <MagnifyingGlassPlusIcon size={16} /> 63 63 </button> 64 64 ), 65 65 iconZoomOut: () => ( 66 - <button className="btn btn-sm btn-square" aria-label="Zoom out"> 66 + <button className="btn btn-sm btn-square shadow-none" aria-label="Zoom out"> 67 67 <MagnifyingGlassMinusIcon size={16} /> 68 68 </button> 69 69 ),
+8 -3
web/src/components/cabinet/PanelShell.tsx
··· 26 26 <div 27 27 className={`${PANEL_CHROME} ${sidePanel ? "order-2 min-h-0 flex-1 lg:order-1 lg:basis-2/5" : "h-full"}`} 28 28 > 29 - {/* Panel header */} 30 - <div className="border-base-300/50 bg-base-100/70 flex shrink-0 items-center gap-2.5 border-b px-4 py-2.75"> 29 + {/* Panel header — desktop: single row, breadcrumbs left + toolbar right */} 30 + <div className="border-base-300/50 bg-base-100/70 hidden shrink-0 items-center justify-between gap-2.5 border-b px-4 py-2.75 md:flex"> 31 31 {breadcrumbs} 32 32 {toolbar && <div className="flex shrink-0 items-center gap-2">{toolbar}</div>} 33 + </div> 34 + {/* Panel header — mobile: breadcrumbs on own row, toolbar below spread evenly */} 35 + <div className="border-base-300/50 bg-base-100/70 shrink-0 space-y-2 border-b px-4 py-2.5 md:hidden"> 36 + {toolbar && <div className="flex items-center justify-between gap-2">{toolbar}</div>} 37 + {breadcrumbs} 33 38 </div> 34 39 35 40 {/* Panel body */} ··· 48 53 ); 49 54 50 55 return ( 51 - <div className="relative flex-1 overflow-hidden p-5.5 pl-7"> 56 + <div className="relative flex-1 overflow-hidden"> 52 57 {/* Ghost panels — filing cabinet depth */} 53 58 {depth >= 4 && ( 54 59 <div className="border-primary/10 bg-bg-ghost-1/60 animate-ghost-panel absolute inset-y-5.5 right-5.5 left-7 z-0 -translate-x-3.75 -translate-y-3.75 rounded-2xl border delay-150" />
+43 -7
web/src/components/cabinet/Sidebar.tsx
··· 1 - import { FolderIcon, UsersIcon, BookOpenIcon, GearIcon } from "@phosphor-icons/react"; 1 + import { 2 + FolderIcon, 3 + UsersIcon, 4 + BookOpenIcon, 5 + GearIcon, 6 + MagnifyingGlassIcon, 7 + XIcon, 8 + } from "@phosphor-icons/react"; 2 9 import { Link } from "@tanstack/react-router"; 3 10 import { OpakeLogo } from "../OpakeLogo"; 4 11 import { useAppStore } from "@/stores/app"; ··· 19 26 { id: "ws-team", name: "Team Alpha", count: 2 }, 20 27 ]; 21 28 22 - export function Sidebar() { 29 + interface SidebarProps { 30 + readonly onNavigate?: () => void; 31 + readonly searchQuery?: string; 32 + readonly onSearchChange?: (query: string) => void; 33 + } 34 + 35 + export function Sidebar({ onNavigate, searchQuery, onSearchChange }: SidebarProps) { 23 36 const anyLoading = useAppStore((s) => s.anythingLoading()); 24 37 25 38 return ( 26 - <aside className="border-base-300/50 bg-base-200 flex w-53 shrink-0 flex-col border-r px-3 py-4"> 27 - {/* Logo */} 28 - <div className="mb-5 px-0.5"> 39 + <aside className="border-base-300/50 bg-base-200 flex h-full w-53 shrink-0 flex-col border-r px-3 py-4"> 40 + {/* Logo — hidden on mobile (shown in mobile topbar instead) */} 41 + <div className="mb-5 hidden px-0.5 md:block"> 29 42 <Link to="/" className="inline-block"> 30 43 <OpakeLogo loading={anyLoading} /> 31 44 </Link> 32 45 </div> 33 46 47 + {/* Search — mobile only */} 48 + {onSearchChange && ( 49 + <label className="input input-bordered border-base-300/50 bg-base-100/80 text-ui mb-3 flex items-center gap-2 rounded-lg py-1.75 md:hidden"> 50 + <MagnifyingGlassIcon size={13} className="text-text-faint" /> 51 + <input 52 + type="text" 53 + placeholder="Search…" 54 + value={searchQuery ?? ""} 55 + onChange={(e) => onSearchChange(e.target.value)} 56 + className="text-secondary grow bg-transparent" 57 + /> 58 + {searchQuery && ( 59 + <button 60 + onClick={() => onSearchChange("")} 61 + className="btn btn-ghost btn-xs text-text-faint p-0" 62 + > 63 + <XIcon size={12} /> 64 + </button> 65 + )} 66 + </label> 67 + )} 68 + 34 69 {/* Main nav */} 35 70 <nav className="flex min-h-0 flex-1 flex-col gap-0.5 overflow-y-auto"> 36 71 {MAIN_NAV.map(({ to, icon, label }) => ( 37 - <SidebarItem key={to} to={to} icon={icon} label={label} /> 72 + <SidebarItem key={to} to={to} icon={icon} label={label} onClick={onNavigate} /> 38 73 ))} 39 74 40 75 {/* Workspaces */} ··· 44 79 {WORKSPACES.map((ws) => ( 45 80 <button 46 81 key={ws.id} 82 + onClick={onNavigate} 47 83 className="text-ui text-text-muted hover:bg-bg-hover flex w-full items-center gap-2.5 rounded-lg px-2.5 py-1.75 text-left" 48 84 > 49 85 <div className="bg-accent text-micro text-primary flex size-5 shrink-0 items-center justify-center rounded-md font-semibold"> ··· 60 96 <div className="divider mx-1 my-0" /> 61 97 <div className="flex flex-col gap-0.5"> 62 98 {BOTTOM_NAV.map(({ to, icon, label }) => ( 63 - <SidebarItem key={to} to={to} icon={icon} label={label} /> 99 + <SidebarItem key={to} to={to} icon={icon} label={label} onClick={onNavigate} /> 64 100 ))} 65 101 </div> 66 102 </div>
+3 -1
web/src/components/cabinet/SidebarItem.tsx
··· 6 6 readonly icon: PhosphorIcon; 7 7 readonly label: string; 8 8 readonly badge?: string | number; 9 + readonly onClick?: () => void; 9 10 } 10 11 11 - export function SidebarItem({ to, icon: Icon, label, badge }: SidebarItemProps) { 12 + export function SidebarItem({ to, icon: Icon, label, badge, onClick }: SidebarItemProps) { 12 13 const matchRoute = useMatchRoute(); 13 14 const active = Boolean(matchRoute({ to, fuzzy: true })); 14 15 15 16 return ( 16 17 <Link 17 18 to={to} 19 + onClick={onClick} 18 20 className={`text-ui flex w-full items-center gap-2.5 rounded-lg px-2.5 py-1.75 text-left transition-colors ${ 19 21 active ? "bg-accent text-primary" : "text-text-muted hover:bg-bg-hover" 20 22 }`}
+5 -3
web/src/components/cabinet/TopBar.tsx
··· 50 50 51 51 return ( 52 52 <header className="border-base-300/50 bg-base-300/90 flex shrink-0 items-center gap-3 border-b px-5 py-2.5 backdrop-blur-[10px]"> 53 - {/* Search */} 54 - <label className="input input-bordered border-base-300/50 bg-base-100/80 text-ui flex max-w-90 flex-1 items-center gap-2 rounded-lg py-1.75"> 53 + {/* Search — hidden on mobile (lives in sidebar instead) */} 54 + <label className="input input-bordered border-base-300/50 bg-base-100/80 text-ui hidden max-w-90 flex-1 items-center gap-2 rounded-lg py-1.75 md:flex"> 55 55 <MagnifyingGlassIcon size={13} className="text-text-faint" /> 56 56 <input 57 57 type="text" ··· 145 145 <button 146 146 onClick={() => { 147 147 closeMenu(); 148 - void logout(); 148 + void logout().then(() => { 149 + window.location.href = "/devices"; 150 + }); 149 151 }} 150 152 className="text-error gap-2.5 text-xs" 151 153 >
+8 -1
web/src/components/devices/ReadyView.tsx
··· 25 25 Back to cabinet 26 26 </Link> 27 27 <button 28 - onClick={() => void useAuthStore.getState().logout()} 28 + onClick={() => 29 + void useAuthStore 30 + .getState() 31 + .logout() 32 + .then(() => { 33 + window.location.href = "/devices"; 34 + }) 35 + } 29 36 className="text-error/60 hover:text-error/80 text-sm" 30 37 > 31 38 Log out
+68 -57
web/src/routes/cabinet/files/route.tsx
··· 194 194 // Breadcrumb always shows directory path — document name is in the preview pane header 195 195 const breadcrumbSegments = isPreviewMode ? parentSegments : segments; 196 196 197 + const cabinetLabel = ( 198 + <> 199 + <FolderIcon size={14} className="md:hidden" /> 200 + <span className="hidden md:inline">Your Cabinet</span> 201 + </> 202 + ); 203 + 197 204 const breadcrumbsContent = ( 198 - <Breadcrumbs> 199 - {contextRkey ? ( 200 - <li> 201 - <Link to="/cabinet/files" className="text-text-faint"> 202 - Your Cabinet 203 - </Link> 204 - </li> 205 - ) : ( 206 - <BreadcrumbActive>Your Cabinet</BreadcrumbActive> 207 - )} 208 - {ancestors.map((ancestor, index) => ( 209 - <li key={ancestor.uri}> 210 - <Link 211 - to="/cabinet/files/$" 212 - params={{ _splat: breadcrumbSegments.slice(0, index + 1).join("/") }} 213 - className="text-text-faint" 214 - > 215 - {ancestor.name} 216 - </Link> 217 - </li> 218 - ))} 219 - {contextRkey && currentDirectoryName && ( 220 - <BreadcrumbActive>{currentDirectoryName}</BreadcrumbActive> 221 - )} 222 - {contextRkey && !currentDirectoryName && <BreadcrumbSkeleton />} 223 - </Breadcrumbs> 205 + <div className="flex items-center gap-2"> 206 + <Breadcrumbs> 207 + {contextRkey ? ( 208 + <li> 209 + <Link to="/cabinet/files" className="text-text-faint"> 210 + {cabinetLabel} 211 + </Link> 212 + </li> 213 + ) : ( 214 + <BreadcrumbActive>{cabinetLabel}</BreadcrumbActive> 215 + )} 216 + {ancestors.map((ancestor, index) => ( 217 + <li key={ancestor.uri}> 218 + <Link 219 + to="/cabinet/files/$" 220 + params={{ _splat: breadcrumbSegments.slice(0, index + 1).join("/") }} 221 + className="text-text-faint" 222 + > 223 + {ancestor.name} 224 + </Link> 225 + </li> 226 + ))} 227 + {contextRkey && currentDirectoryName && ( 228 + <BreadcrumbActive>{currentDirectoryName}</BreadcrumbActive> 229 + )} 230 + {contextRkey && !currentDirectoryName && <BreadcrumbSkeleton />} 231 + </Breadcrumbs> 232 + </div> 224 233 ); 225 234 226 235 const toolbar = ( 227 236 <> 228 - <SegmentedToggle 229 - options={[ 230 - { value: "list" as const, icon: ListBulletsIcon }, 231 - { value: "grid" as const, icon: SquaresFourIcon }, 232 - ]} 233 - value={viewMode} 234 - onChange={setViewMode} 235 - /> 237 + <div className="flex gap-2"> 238 + <SegmentedToggle 239 + options={[ 240 + { value: "list" as const, icon: ListBulletsIcon }, 241 + { value: "grid" as const, icon: SquaresFourIcon }, 242 + ]} 243 + value={viewMode} 244 + onChange={setViewMode} 245 + /> 236 246 237 - <DropdownMenu 238 - trigger={ 239 - <> 240 - <PlusIcon size={13} /> 241 - New 242 - </> 243 - } 244 - items={[ 245 - { 246 - icon: UploadSimpleIcon, 247 - label: "Upload file", 248 - onClick: () => fileInputRef.current?.click(), 249 - }, 250 - { 251 - icon: FolderIcon, 252 - label: "New folder", 253 - onClick: () => newFolderDialogRef.current?.show(), 254 - }, 255 - { icon: FileTextIcon, label: "New document" }, 256 - { icon: BookOpenIcon, label: "New note" }, 257 - ]} 258 - /> 247 + <DropdownMenu 248 + trigger={ 249 + <> 250 + <PlusIcon size={13} /> 251 + New 252 + </> 253 + } 254 + items={[ 255 + { 256 + icon: UploadSimpleIcon, 257 + label: "Upload file", 258 + onClick: () => fileInputRef.current?.click(), 259 + }, 260 + { 261 + icon: FolderIcon, 262 + label: "New folder", 263 + onClick: () => newFolderDialogRef.current?.show(), 264 + }, 265 + { icon: FileTextIcon, label: "New document" }, 266 + { icon: BookOpenIcon, label: "New note" }, 267 + ]} 268 + /> 269 + </div> 259 270 260 271 {depth > 1 && ( 261 - <button onClick={handleClose} className="btn btn-ghost btn-sm btn-square rounded-md"> 272 + <button onClick={handleClose} className="btn btn-ghost btn-sm btn-square flex rounded-md"> 262 273 <XIcon size={14} className="text-text-muted" /> 263 274 </button> 264 275 )}
+47 -4
web/src/routes/cabinet/route.tsx
··· 1 - import { useState, useEffect } from "react"; 2 - import { createFileRoute, redirect, Outlet } from "@tanstack/react-router"; 1 + import { useState, useEffect, useCallback } from "react"; 2 + import { createFileRoute, redirect, Outlet, Link } from "@tanstack/react-router"; 3 3 import { Sidebar } from "@/components/cabinet/Sidebar"; 4 4 import { TopBar } from "@/components/cabinet/TopBar"; 5 + import { OpakeLogo } from "@/components/OpakeLogo"; 5 6 import { useDocumentsStore } from "@/stores/documents"; 6 7 import { useAuthStore } from "@/stores/auth"; 8 + import { useAppStore } from "@/stores/app"; 7 9 8 10 function CabinetLayout() { 9 11 const [searchQuery, setSearchQuery] = useState(""); 12 + const [sidebarOpen, setSidebarOpen] = useState(false); 10 13 const fetchAll = useDocumentsStore((s) => s.fetchAll); 14 + const anyLoading = useAppStore((s) => s.anythingLoading()); 15 + 16 + const toggleSidebar = useCallback(() => setSidebarOpen((v) => !v), []); 17 + const closeSidebar = useCallback(() => setSidebarOpen(false), []); 11 18 12 19 useEffect(() => { 13 20 void fetchAll(); ··· 15 22 16 23 return ( 17 24 <div className="bg-base-300 flex h-screen overflow-hidden font-sans"> 18 - <Sidebar /> 19 - <main className="flex flex-1 flex-col overflow-hidden"> 25 + {/* Mobile topbar — logo + hamburger */} 26 + <div className="border-border-accent/30 bg-base-200 fixed inset-x-0 top-0 z-50 flex items-center justify-between border-b px-4 py-3 md:hidden"> 27 + <Link to="/"> 28 + <OpakeLogo size="sm" loading={anyLoading} /> 29 + </Link> 30 + <button 31 + type="button" 32 + onClick={toggleSidebar} 33 + aria-label={sidebarOpen ? "Close menu" : "Open menu"} 34 + className="text-base-content flex size-8 items-center justify-center text-lg" 35 + > 36 + {sidebarOpen ? "×" : "="} 37 + </button> 38 + </div> 39 + 40 + {/* Backdrop — mobile only */} 41 + {sidebarOpen && ( 42 + <div 43 + className="fixed inset-0 z-40 bg-black/40 md:hidden" 44 + onClick={closeSidebar} 45 + aria-hidden 46 + /> 47 + )} 48 + 49 + {/* Sidebar — always visible on desktop, slide-in on mobile */} 50 + <div 51 + className={`fixed inset-y-0 left-0 z-40 w-53 pt-14 transition-transform duration-200 ease-out md:static md:pt-0 md:transition-none ${ 52 + sidebarOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0" 53 + }`} 54 + > 55 + <Sidebar 56 + onNavigate={closeSidebar} 57 + searchQuery={searchQuery} 58 + onSearchChange={setSearchQuery} 59 + /> 60 + </div> 61 + 62 + <main className="flex flex-1 flex-col overflow-hidden pt-14 md:pt-0"> 20 63 <TopBar searchQuery={searchQuery} onSearchChange={setSearchQuery} /> 21 64 <Outlet /> 22 65 </main>
+1 -1
web/src/routes/devices/pair.request.tsx
··· 114 114 115 115 setState({ step: "success" }); 116 116 removeLoading("pair-request-receive"); 117 - setTimeout(() => navigate({ to: "/devices" }), 1500); 117 + setTimeout(() => navigate({ to: "/cabinet" }), 1500); 118 118 } catch (err) { 119 119 cleanup(); 120 120 removeLoading("pair-request-receive");