Desktop library pass: alignment, card weight, sidebar and theme
Layout (desktop / lg+ only): - 40px top padding above the page title. - Deck grid uses minmax(320px, 1fr), so two cards fill the content width and share a right edge with the header (same max-width container). - Group headings only render once real folders exist; with none, a single quiet "All decks" label replaces "Unorganized". - Import JSON becomes a plain text button (mobile keeps the pill). Deck cards (desktop): - The whole card is a link; Study is a compact secondary button instead of a full-width solid pill. - The ... control is a 32px ghost button with a real ellipsis icon, aligned to the title's centre. - Meta line always shows the same fields (activities · runs / Not played yet); the score is a separate quiet "Best 53%" stat. - 12px rhythm between title/meta/tags/summary, 16px before the actions. - Hover: lighter surface, stronger border, soft shadow and a 2px lift in light mode (pointer devices only, so touch has no sticky hover). Sidebar (desktop rail): - All decks + Unorganized are views above the FOLDERS label; only user-created folders sit under it. - Count only on All decks. The mobile drawer is unchanged. Theme: - Dark: sidebar is a step darker (#181818 to #141414 vs #212121 page). - Light: cards get a slightly stronger border (#e2e2e2) and a soft shadow; the sidebar divider and edge shadow are stronger too. - Medium difficulty darkened to #7a5300 (6.2:1 on the light chip). Mobile is untouched: layout changes are behind lg: (or the desktop prop); theme tweaks are shared tokens. Verified: key-gate 49/49, install sheet 64/64, retry 14/14, zoom 6/6, deck chrome 15/15, deck fixes 15/15, final ux 12/12, contrast 20/20, security 8/8, pwa 16/16.
This commit is contained in:
parent
190664c1a5
commit
3aae6f3199
5 changed files with 196 additions and 67 deletions
|
|
@ -85,8 +85,11 @@
|
|||
--sidebar: #f9f9f9;
|
||||
--surface: #ffffff;
|
||||
--surface-raised: #ffffff;
|
||||
--card-hover-bg: #fafafa;
|
||||
--surface-sunken: rgb(13 13 13 / 0.05);
|
||||
--hairline: #e5e5e5;
|
||||
--card-border: #e2e2e2;
|
||||
--sidebar-divider: #e2e2e2;
|
||||
--hairline-soft: rgb(13 13 13 / 0.07);
|
||||
--glass-tint: rgb(255 255 255 / 0.6);
|
||||
--text: #0d0d0d; /* 17.6:1 on #ffffff */
|
||||
|
|
@ -95,7 +98,7 @@
|
|||
--hover: rgb(13 13 13 / 0.05);
|
||||
--sidebar-top: #fbfbfc;
|
||||
--sidebar-bottom: #f6f6f7;
|
||||
--sidebar-edge: 0 1px 0 var(--hairline), 6px 0 16px -14px rgb(13 13 13 / 0.28);
|
||||
--sidebar-edge: 0 1px 0 var(--hairline), 6px 0 18px -12px rgb(13 13 13 / 0.38);
|
||||
/* API-key gate: near-white stage with soft blank glows, tinted input. */
|
||||
--gate-base: #f6f6f8;
|
||||
--gate-glow-a: rgb(84 102 130 / 0.22);
|
||||
|
|
@ -113,7 +116,8 @@
|
|||
--install-chip: #f2f2f2;
|
||||
--install-border: #e0e0e0;
|
||||
--shadow-card: 0 1px 2px rgb(13 13 13 / 0.06), 0 1px 3px rgb(13 13 13 / 0.04);
|
||||
--shadow-card-hover: 0 10px 28px -14px rgb(13 13 13 / 0.22), 0 1px 2px rgb(13 13 13 / 0.06);
|
||||
--card-shadow: 0 1px 2px rgb(13 13 13 / 0.05), 0 5px 14px -6px rgb(13 13 13 / 0.12);
|
||||
--shadow-card-hover: 0 12px 30px -12px rgb(13 13 13 / 0.2), 0 2px 6px rgb(13 13 13 / 0.07);
|
||||
}
|
||||
|
||||
.dark {
|
||||
|
|
@ -122,16 +126,19 @@
|
|||
--sidebar: #171717;
|
||||
--surface: #2f2f2f;
|
||||
--surface-raised: #383838;
|
||||
--card-hover-bg: #383838;
|
||||
--surface-sunken: rgb(255 255 255 / 0.08);
|
||||
--hairline: rgb(255 255 255 / 0.14);
|
||||
--card-border: var(--hairline);
|
||||
--sidebar-divider: var(--hairline);
|
||||
--hairline-soft: rgb(255 255 255 / 0.08);
|
||||
--glass-tint: rgb(255 255 255 / 0.08);
|
||||
--text: #ececec; /* 12.9:1 on #212121 */
|
||||
--text-muted: #b4b4b4; /* 7.6:1 */
|
||||
--text-subtle: #a0a0a0; /* 6.0:1 */
|
||||
--hover: rgb(255 255 255 / 0.07);
|
||||
--sidebar-top: #1a1a1a;
|
||||
--sidebar-bottom: #151515;
|
||||
--sidebar-top: #181818;
|
||||
--sidebar-bottom: #141414;
|
||||
--sidebar-edge: 0 1px 0 var(--hairline), 6px 0 16px -14px rgb(0 0 0 / 0.75);
|
||||
/* API-key gate: near-black stage, monochrome glows, raised input. */
|
||||
--gate-base: #0f0f0f;
|
||||
|
|
@ -150,6 +157,7 @@
|
|||
--install-chip: #3a3a3c;
|
||||
--install-border: rgb(255 255 255 / 0.08);
|
||||
--shadow-card: 0 1px 2px rgb(0 0 0 / 0.25);
|
||||
--card-shadow: var(--shadow-card);
|
||||
--shadow-card-hover: 0 10px 28px -16px rgb(0 0 0 / 0.55), 0 1px 2px rgb(0 0 0 / 0.3);
|
||||
}
|
||||
|
||||
|
|
@ -316,19 +324,25 @@ body {
|
|||
.card {
|
||||
border-radius: 1.125rem; /* 18px */
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--hairline);
|
||||
box-shadow: var(--shadow-card);
|
||||
border: 1px solid var(--card-border);
|
||||
box-shadow: var(--card-shadow);
|
||||
}
|
||||
|
||||
/* Opt-in hover lift for interactive cards (deck cards) */
|
||||
/* Opt-in hover lift for interactive cards (deck cards). Pointer devices only,
|
||||
so touch screens never get sticky hover states. */
|
||||
.card-hover {
|
||||
transition: box-shadow 180ms ease, border-color 180ms ease, transform 180ms ease;
|
||||
transition: background-color 180ms ease, box-shadow 180ms ease, border-color 180ms ease, transform 180ms ease;
|
||||
}
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.card-hover:hover {
|
||||
background: var(--surface-raised);
|
||||
border-color: color-mix(in oklab, var(--hairline) 55%, var(--text) 18%);
|
||||
background: var(--card-hover-bg);
|
||||
border-color: color-mix(in oklab, var(--hairline) 45%, var(--text) 20%);
|
||||
box-shadow: var(--shadow-card-hover);
|
||||
}
|
||||
:root:not(.dark) .card-hover:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
}
|
||||
.card-hover:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
|
@ -409,7 +423,7 @@ body {
|
|||
/* Sidebar surface: a touch darker than the page, with a real edge. */
|
||||
.sidebar-surface {
|
||||
background: linear-gradient(180deg, var(--sidebar-top), var(--sidebar-bottom));
|
||||
border-right: 1px solid var(--hairline);
|
||||
border-right: 1px solid var(--sidebar-divider);
|
||||
box-shadow: var(--sidebar-edge);
|
||||
}
|
||||
|
||||
|
|
@ -488,7 +502,7 @@ body {
|
|||
|
||||
/* Difficulty: neutral pill, colour lives in the text only. */
|
||||
.diff-easy { color: #16714f; }
|
||||
.diff-medium { color: #8a5e00; }
|
||||
.diff-medium { color: #7a5300; }
|
||||
.diff-hard { color: #b4342f; }
|
||||
.dark .diff-easy { color: #6fd3a8; }
|
||||
.dark .diff-medium { color: #e3b341; }
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ export default function HomePage() {
|
|||
<MobileSidebar />
|
||||
<main className="flex min-h-dvh min-w-0 flex-1 flex-col">
|
||||
<AppHeader />
|
||||
<div className="mx-auto w-full max-w-6xl flex-1 px-4 pb-tabbar lg:px-10 lg:pb-16">
|
||||
<div className="mx-auto w-full max-w-6xl flex-1 px-4 pb-tabbar lg:px-10 lg:pb-16 lg:pt-10">
|
||||
<LibraryScreen />
|
||||
</div>
|
||||
<TabBar />
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import { createFolder as makeFolder, folderName, listFolders } from "@/lib/folde
|
|||
import { UNORGANIZED_ID, type Deck } from "@/lib/schemas";
|
||||
import { appendActivities, deleteDeck, downloadDeck, renameDeck, upsertDeck } from "@/lib/storage";
|
||||
import { cn, formatDate, truncate } from "@/lib/utils";
|
||||
import { DownloadIcon, FolderIcon, PlayIcon, RefreshIcon } from "@/components/icons";
|
||||
import { DownloadIcon, EllipsisIcon, FolderIcon, PlayIcon, RefreshIcon } from "@/components/icons";
|
||||
import { Button, ProgressBar } from "@/components/ui";
|
||||
|
||||
type Busy = null | "more" | "regenerate";
|
||||
|
|
@ -109,11 +109,19 @@ export function DeckCard({ deck, onMove }: { deck: Deck; onMove?: (folderId: str
|
|||
|
||||
return (
|
||||
<li className="card card-hover group relative flex flex-col p-5">
|
||||
{/* Desktop: the whole card is a link; interactive children sit above it. */}
|
||||
<Link
|
||||
href={`/deck/${deck.id}`}
|
||||
aria-hidden="true"
|
||||
tabIndex={-1}
|
||||
className="absolute inset-0 hidden rounded-[1.125rem] lg:block"
|
||||
/>
|
||||
|
||||
{/* header ------------------------------------------------------- */}
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
{renaming ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative z-10 flex items-center gap-2">
|
||||
<label className="sr-only" htmlFor={`rename-${deck.id}`}>
|
||||
Deck title
|
||||
</label>
|
||||
|
|
@ -144,16 +152,28 @@ export function DeckCard({ deck, onMove }: { deck: Deck; onMove?: (folderId: str
|
|||
{truncate(deck.title, 90)}
|
||||
</h3>
|
||||
)}
|
||||
<p className="mt-1 text-[12.5px] text-[var(--text-subtle)]">
|
||||
<p className="mt-1 text-[12.5px] text-[var(--text-subtle)] lg:mt-3">
|
||||
{/* Phones keep the original date · activities · runs line. */}
|
||||
<span className="lg:hidden">
|
||||
{formatDate(deck.updatedAt)}
|
||||
<span aria-hidden> · </span>
|
||||
</span>
|
||||
{deck.activities.length} activities
|
||||
<span className="lg:hidden">
|
||||
{deck.attempts > 0 ? (
|
||||
<>
|
||||
<span aria-hidden> · </span>
|
||||
{deck.attempts} {deck.attempts === 1 ? "run" : "runs"}
|
||||
</>
|
||||
) : null}
|
||||
</span>
|
||||
{/* Desktop always shows the same two fields. */}
|
||||
<span className="hidden lg:inline">
|
||||
<span aria-hidden> · </span>
|
||||
{deck.attempts > 0
|
||||
? `${deck.attempts} ${deck.attempts === 1 ? "run" : "runs"}`
|
||||
: "Not played yet"}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -163,11 +183,12 @@ export function DeckCard({ deck, onMove }: { deck: Deck; onMove?: (folderId: str
|
|||
aria-expanded={menuOpen}
|
||||
onClick={() => setMenuOpen((value) => !value)}
|
||||
className={cn(
|
||||
"grid size-8 shrink-0 place-items-center rounded-full text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]",
|
||||
"relative z-10 grid size-8 shrink-0 place-items-center rounded-full text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)] lg:-mt-1.5",
|
||||
menuOpen && "bg-[var(--hover)] text-[var(--text)]",
|
||||
)}
|
||||
>
|
||||
⋯
|
||||
<span aria-hidden className="lg:hidden">⋯</span>
|
||||
<EllipsisIcon className="hidden size-4 lg:block" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
|
@ -184,9 +205,14 @@ export function DeckCard({ deck, onMove }: { deck: Deck; onMove?: (folderId: str
|
|||
</Tag>
|
||||
) : null}
|
||||
{deck.bestScore !== undefined ? (
|
||||
<Tag>
|
||||
<>
|
||||
<Tag className="lg:hidden">
|
||||
Best <span className="text-emerald-700 dark:text-emerald-300">{deck.bestScore}%</span>
|
||||
</Tag>
|
||||
<span className="hidden text-[12px] font-medium tabular-nums text-[var(--text-muted)] lg:ml-1 lg:inline">
|
||||
Best {deck.bestScore}%
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
|
|
@ -219,18 +245,24 @@ export function DeckCard({ deck, onMove }: { deck: Deck; onMove?: (folderId: str
|
|||
) : null}
|
||||
|
||||
{/* single action ------------------------------------------------ */}
|
||||
<div className="mt-5">
|
||||
<Link href={`/deck/${deck.id}`} className="btn-primary h-10 w-full px-4 text-sm">
|
||||
<div className="mt-5 lg:mt-4">
|
||||
<Link href={`/deck/${deck.id}`} className="btn-primary h-10 w-full px-4 text-sm lg:hidden">
|
||||
<PlayIcon className="size-4" />
|
||||
Study
|
||||
</Link>
|
||||
<div className="relative z-10 hidden lg:flex lg:justify-end">
|
||||
<Link href={`/deck/${deck.id}`} className="btn-secondary h-9 px-4 text-[13px]">
|
||||
<PlayIcon className="size-4" />
|
||||
Study
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* actions panel ------------------------------------------------ */}
|
||||
{menuOpen ? (
|
||||
<div
|
||||
data-testid="deck-actions"
|
||||
className="animate-rise mt-3 space-y-1 border-t border-[var(--hairline)] pt-3"
|
||||
className="animate-rise relative z-10 mt-3 space-y-1 border-t border-[var(--hairline)] pt-3"
|
||||
>
|
||||
<button type="button" className={menuRow} onClick={() => setRenaming(true)} disabled={Boolean(busy)}>
|
||||
<span aria-hidden className="w-4 text-center text-[var(--text-subtle)]">
|
||||
|
|
@ -398,9 +430,14 @@ function difficultyClass(difficulty: Deck["difficulty"]): string {
|
|||
}
|
||||
|
||||
/** Neutral pill; colour is only ever used on the text inside it. */
|
||||
function Tag({ children }: { children: React.ReactNode }) {
|
||||
function Tag({ children, className }: { children: React.ReactNode; className?: string }) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-[var(--surface-sunken)] px-2.5 py-1 text-[12px] font-medium leading-none text-[var(--text-muted)]">
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-full bg-[var(--surface-sunken)] px-2.5 py-1 text-[12px] font-medium leading-none text-[var(--text-muted)]",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -98,6 +98,9 @@ export function LibraryScreen() {
|
|||
};
|
||||
|
||||
const isEmpty = sorted.length === 0 && folderList.length === 0;
|
||||
// Group headings only earn their keep once real folders exist; with none,
|
||||
// desktop shows a single quiet “All decks” label instead of “Unorganized”.
|
||||
const hasFolders = folderList.length > 0;
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
|
|
@ -114,10 +117,23 @@ export function LibraryScreen() {
|
|||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button variant="secondary" size="md" onClick={() => importRef.current?.click()}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="md"
|
||||
className="lg:hidden"
|
||||
onClick={() => importRef.current?.click()}
|
||||
>
|
||||
<UploadIcon className="size-4" />
|
||||
Import JSON
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => importRef.current?.click()}
|
||||
className="hidden items-center gap-1.5 text-[13px] font-medium text-[var(--text-muted)] transition hover:text-[var(--text)] hover:underline lg:inline-flex"
|
||||
>
|
||||
<UploadIcon className="size-4" />
|
||||
Import JSON
|
||||
</button>
|
||||
<input
|
||||
ref={importRef}
|
||||
type="file"
|
||||
|
|
@ -211,7 +227,7 @@ export function LibraryScreen() {
|
|||
return (
|
||||
<section key={group.id} aria-label={group.name}>
|
||||
{/* group header -------------------------------------- */}
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<div className={cn("mb-4 flex items-center gap-2", !hasFolders && "lg:hidden")}>
|
||||
<FolderIcon className="size-4 shrink-0 text-[var(--text-subtle)]" />
|
||||
|
||||
{isEditing && editingFolder ? (
|
||||
|
|
@ -288,6 +304,13 @@ export function LibraryScreen() {
|
|||
) : null}
|
||||
</div>
|
||||
|
||||
{/* One quiet label replaces the redundant single-group heading. */}
|
||||
{!hasFolders ? (
|
||||
<p className="mb-4 hidden text-[12px] font-medium text-[var(--text-subtle)] lg:block">
|
||||
All decks
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{confirmDelete === group.id ? (
|
||||
<p className="mb-3 text-[12.5px] text-[var(--text-muted)]">
|
||||
The {group.decks.length} deck{group.decks.length === 1 ? "" : "s"} inside will move to
|
||||
|
|
@ -302,7 +325,7 @@ export function LibraryScreen() {
|
|||
: "Empty folder — freeze a deck into it or move one over from Unorganized."}
|
||||
</div>
|
||||
) : (
|
||||
<ul className="grid list-none grid-cols-[repeat(auto-fill,minmax(280px,1fr))] gap-4">
|
||||
<ul className="grid list-none grid-cols-[repeat(auto-fill,minmax(280px,1fr))] gap-4 lg:grid-cols-[repeat(auto-fill,minmax(320px,1fr))]">
|
||||
{group.decks.map((deck) => (
|
||||
<DeckCard
|
||||
key={deck.id}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useMemo, useState, useSyncExternalStore } from "react";
|
||||
import { useMemo, useState, useSyncExternalStore, type ReactNode } from "react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
|
||||
import {
|
||||
|
|
@ -26,7 +26,15 @@ import { cn } from "@/lib/utils";
|
|||
const COLLAPSE_KEY = "freezmo.sidebarCollapsed";
|
||||
|
||||
/** Shared nav content for the desktop rail and the mobile drawer. */
|
||||
function NavContent({ collapsed = false, onNavigate }: { collapsed?: boolean; onNavigate?: () => void }) {
|
||||
function NavContent({
|
||||
collapsed = false,
|
||||
onNavigate,
|
||||
desktop = false,
|
||||
}: {
|
||||
collapsed?: boolean;
|
||||
onNavigate?: () => void;
|
||||
desktop?: boolean;
|
||||
}) {
|
||||
const decks = useSyncExternalStore(deckStore.subscribe, deckStore.getSnapshot, deckStore.getServerSnapshot);
|
||||
const folders = useSyncExternalStore(
|
||||
folderStore.subscribe,
|
||||
|
|
@ -76,6 +84,21 @@ function NavContent({ collapsed = false, onNavigate }: { collapsed?: boolean; on
|
|||
const count = (value: number) =>
|
||||
!collapsed && value > 0 ? <span className="text-[12px] tabular-nums opacity-60">{value}</span> : null;
|
||||
|
||||
/** One shared view row (All decks / Unorganized) for both nav layouts. */
|
||||
const viewRow = (id: string, label: string, icon: ReactNode, trailing: ReactNode) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => select(id)}
|
||||
aria-pressed={selected === id}
|
||||
title={label}
|
||||
className={cn("nav-row", collapsed && "justify-center px-2")}
|
||||
>
|
||||
{icon}
|
||||
{!collapsed ? <span className="flex-1 truncate text-left">{label}</span> : null}
|
||||
{trailing}
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
{/* brand ---------------------------------------------------------- */}
|
||||
|
|
@ -122,7 +145,8 @@ function NavContent({ collapsed = false, onNavigate }: { collapsed?: boolean; on
|
|||
</Link>
|
||||
|
||||
{/* folders --------------------------------------------------------- */}
|
||||
{!collapsed ? (
|
||||
{/* Mobile keeps the original layout: one Folders label above the list. */}
|
||||
{!desktop && !collapsed ? (
|
||||
<div className="flex items-center justify-between px-2 pb-1">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-[var(--text-subtle)]">
|
||||
Folders
|
||||
|
|
@ -139,7 +163,10 @@ function NavContent({ collapsed = false, onNavigate }: { collapsed?: boolean; on
|
|||
</div>
|
||||
) : null}
|
||||
|
||||
<nav aria-label="Folders" className="thin-scroll min-h-0 flex-1 space-y-0.5 overflow-y-auto">
|
||||
<nav
|
||||
aria-label={desktop ? "Library" : "Folders"}
|
||||
className="thin-scroll min-h-0 flex-1 space-y-0.5 overflow-y-auto"
|
||||
>
|
||||
{collapsed ? (
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -152,29 +179,57 @@ function NavContent({ collapsed = false, onNavigate }: { collapsed?: boolean; on
|
|||
</button>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => select(ALL_FOLDERS)}
|
||||
aria-pressed={selected === ALL_FOLDERS}
|
||||
title="All decks"
|
||||
className={cn("nav-row", collapsed && "justify-center px-2")}
|
||||
>
|
||||
<LayersIcon className="size-4 shrink-0 opacity-70" />
|
||||
{!collapsed ? <span className="flex-1 truncate text-left">All decks</span> : null}
|
||||
{count(decks.length)}
|
||||
</button>
|
||||
{/* Desktop: All decks + Unorganized are views, so they sit above the
|
||||
Folders label; mobile keeps them inside the folder list. */}
|
||||
{desktop ? (
|
||||
<div className={cn("space-y-0.5", !collapsed && "mb-1")}>
|
||||
{viewRow(
|
||||
ALL_FOLDERS,
|
||||
"All decks",
|
||||
<LayersIcon className="size-4 shrink-0 opacity-70" />,
|
||||
count(decks.length),
|
||||
)}
|
||||
{viewRow(
|
||||
UNORGANIZED_ID,
|
||||
"Unorganized",
|
||||
<FolderIcon className="size-4 shrink-0 opacity-70" />,
|
||||
null,
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{viewRow(
|
||||
ALL_FOLDERS,
|
||||
"All decks",
|
||||
<LayersIcon className="size-4 shrink-0 opacity-70" />,
|
||||
count(decks.length),
|
||||
)}
|
||||
{viewRow(
|
||||
UNORGANIZED_ID,
|
||||
"Unorganized",
|
||||
<FolderIcon className="size-4 shrink-0 opacity-70" />,
|
||||
count(counts[UNORGANIZED_ID] ?? 0),
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Desktop: the Folders label sits between the views and the folders. */}
|
||||
{desktop && !collapsed ? (
|
||||
<div className="flex items-center justify-between px-2 pb-1 pt-1">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-[var(--text-subtle)]">
|
||||
Folders
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => select(UNORGANIZED_ID)}
|
||||
aria-pressed={selected === UNORGANIZED_ID}
|
||||
title="Unorganized"
|
||||
className={cn("nav-row", collapsed && "justify-center px-2")}
|
||||
onClick={() => setCreating(true)}
|
||||
aria-label="New folder"
|
||||
title="New folder"
|
||||
className="grid size-6 place-items-center rounded-md text-[var(--text-subtle)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
|
||||
>
|
||||
<FolderIcon className="size-4 shrink-0 opacity-70" />
|
||||
{!collapsed ? <span className="flex-1 truncate text-left">Unorganized</span> : null}
|
||||
{count(counts[UNORGANIZED_ID] ?? 0)}
|
||||
<PlusIcon className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{sortedFolders.map((folder) => (
|
||||
<button
|
||||
|
|
@ -187,7 +242,7 @@ function NavContent({ collapsed = false, onNavigate }: { collapsed?: boolean; on
|
|||
>
|
||||
<FolderIcon className="size-4 shrink-0 opacity-70" />
|
||||
{!collapsed ? <span className="flex-1 truncate text-left">{folder.name}</span> : null}
|
||||
{count(counts[folder.id] ?? 0)}
|
||||
{desktop ? null : count(counts[folder.id] ?? 0)}
|
||||
</button>
|
||||
))}
|
||||
|
||||
|
|
@ -311,7 +366,7 @@ export function Sidebar() {
|
|||
isCollapsed ? "w-[68px]" : "w-[260px]",
|
||||
)}
|
||||
>
|
||||
<NavContent collapsed={isCollapsed} />
|
||||
<NavContent collapsed={isCollapsed} desktop />
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue