Add Bookmarks to Explore, edit mode in bottom sheet, Settings in drawer

- Add __bookmarks as a built-in Explore item (reorderable/removable
  alongside Feed, Trends, and content types) on both desktop and mobile
- Fix built-in removal bug: removing Feed/Trends no longer re-appends
  them to the end of the list. Built-ins not in sidebarOrder are now
  treated as explicitly removed (appear in More... to re-add)
- Add full edit mode to mobile Explore bottom sheet: pencil/Done toggle,
  DnD with TouchSensor for reordering, remove buttons, add-back list
  for hidden items. Normal mode unchanged (2-column grid)
- Replace Explore section in mobile drawer with Settings sub-categories
  (Profile, Appearance, Wallet, Notifications, Advanced) filtered by
  auth state. Remove standalone Bookmarks and Settings links from drawer
- Remove Bookmarks from desktop 'You' section (now in Explore)
- Export settingsSections and SettingsSection type from SettingsPage
This commit is contained in:
Mary Kate Fain
2026-02-25 09:51:22 -06:00
parent fa422c21ee
commit 4122139bef
5 changed files with 218 additions and 273 deletions
+1 -6
View File
@@ -48,6 +48,7 @@ const ITEM_ICONS: Record<string, React.ReactElement> = {
// Built-ins
__feed: <Home className="size-6" />,
__trends: <TrendingUp className="size-6" />,
__bookmarks: <Bookmark className="size-6" />,
// Extra-kind routes
vines: <Clapperboard className="size-6" />,
polls: <BarChart3 className="size-6" />,
@@ -427,12 +428,6 @@ export function LeftSidebar() {
label="Profile"
active={location.pathname === userProfileUrl}
/>
<NavItem
to="/bookmarks"
icon={<Bookmark className="size-6" />}
label="Bookmarks"
active={location.pathname === '/bookmarks'}
/>
<NavItem
to="/settings"
icon={<Settings className="size-6" />}
+178 -29
View File
@@ -1,6 +1,9 @@
import { useCallback, useMemo, useState } from 'react';
import { Link, useLocation, useNavigate } from 'react-router-dom';
import { Home, Compass, Bell, User, Search, TrendingUp, Clapperboard, BarChart3, Palette, PartyPopper, Radio, FileText } from 'lucide-react';
import { Link, useLocation } from 'react-router-dom';
import { Home, Compass, Bell, User, Search, Bookmark, TrendingUp, Clapperboard, BarChart3, Palette, PartyPopper, Radio, FileText, Pencil, GripVertical, X, Plus } from 'lucide-react';
import { DndContext, closestCenter, TouchSensor, PointerSensor, useSensor, useSensors, type DragEndEvent } from '@dnd-kit/core';
import { SortableContext, verticalListSortingStrategy, useSortable, arrayMove } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { Drawer, DrawerContent, DrawerTitle } from '@/components/ui/drawer';
import { ChestIcon } from '@/components/icons/ChestIcon';
import { CardsIcon } from '@/components/icons/CardsIcon';
@@ -16,6 +19,7 @@ import { useProfileUrl } from '@/hooks/useProfileUrl';
const ITEM_ICONS: Record<string, React.ReactElement> = {
__feed: <Home className="size-5" />,
__trends: <TrendingUp className="size-5" />,
__bookmarks: <Bookmark className="size-5" />,
vines: <Clapperboard className="size-5" />,
polls: <BarChart3 className="size-5" />,
treasures: <ChestIcon className="size-5" />,
@@ -41,6 +45,7 @@ function itemPath(id: string): string {
function isItemActive(id: string, pathname: string, search: string): boolean {
if (id === '__feed') return pathname === '/';
if (id === '__trends') return pathname === '/search' && search.includes('tab=trends');
if (id === '__bookmarks') return pathname === '/bookmarks';
return pathname === `/${id}`;
}
@@ -88,16 +93,81 @@ function NavTab({ icon, label, active, showIndicator, onClick, to }: NavTabProps
);
}
// ── Sortable explore item (edit mode) ─────────────────────────────────────────
interface SortableExploreSheetItemProps {
id: string;
onRemove: (id: string) => void;
}
function SortableExploreSheetItem({ id, onRemove }: SortableExploreSheetItemProps) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
const icon = ITEM_ICONS[id] ?? <Palette className="size-5" />;
const label = itemLabel(id);
return (
<div
ref={setNodeRef}
style={style}
className={cn(
'flex items-center rounded-lg transition-colors',
isDragging && 'z-10 opacity-80 shadow-lg bg-background',
)}
>
<button
className="flex items-center justify-center w-8 shrink-0 py-3 cursor-grab active:cursor-grabbing text-muted-foreground hover:text-foreground transition-colors touch-none"
{...attributes}
{...listeners}
>
<GripVertical className="size-4" />
</button>
<div className="flex items-center gap-3 py-3 flex-1 min-w-0 text-[15px]">
<span className="text-muted-foreground shrink-0">{icon}</span>
<span className="font-medium truncate">{label}</span>
</div>
<button
onClick={() => onRemove(id)}
className="flex items-center justify-center size-8 shrink-0 rounded-full text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors"
>
<X className="size-4" />
</button>
</div>
);
}
// ── Main component ────────────────────────────────────────────────────────────
export function MobileBottomNav() {
const location = useLocation();
const navigate = useNavigate();
const { user, metadata } = useCurrentUser();
const hasUnread = useHasUnreadNotifications();
const { orderedItems } = useFeedSettings();
const {
orderedItems, hiddenItems, updateSidebarOrder, addToSidebar, removeFromSidebar,
} = useFeedSettings();
const userProfileUrl = useProfileUrl(user?.pubkey ?? '', metadata);
const [exploreOpen, setExploreOpen] = useState(false);
const [editing, setEditing] = useState(false);
// DnD sensors — touch sensor with delay to distinguish scroll from drag
const sensors = useSensors(
useSensor(TouchSensor, { activationConstraint: { delay: 150, tolerance: 5 } }),
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
);
const handleHomeClick = useCallback(() => {
if (location.pathname === '/') {
@@ -105,6 +175,18 @@ export function MobileBottomNav() {
}
}, [location.pathname]);
const handleDragEnd = useCallback((event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIndex = orderedItems.indexOf(active.id as string);
const newIndex = orderedItems.indexOf(over.id as string);
if (oldIndex === -1 || newIndex === -1) return;
const newOrder = arrayMove(orderedItems, oldIndex, newIndex);
updateSidebarOrder(newOrder);
}, [orderedItems, updateSidebarOrder]);
// Build explore items from ordered items (includes built-ins)
const exploreItems = useMemo(() => {
return orderedItems.map((id) => ({
@@ -120,6 +202,11 @@ export function MobileBottomNav() {
id !== '__feed' && isItemActive(id, location.pathname, location.search),
);
const handleDrawerClose = (open: boolean) => {
if (!open) setEditing(false);
setExploreOpen(open);
};
return (
<>
<nav className="fixed bottom-0 left-0 right-0 z-20 flex items-center bg-background/80 backdrop-blur-md border-t border-border sidebar:hidden safe-area-bottom">
@@ -163,36 +250,98 @@ export function MobileBottomNav() {
</nav>
{/* Explore bottom sheet */}
<Drawer open={exploreOpen} onOpenChange={setExploreOpen} dismissible>
<Drawer open={exploreOpen} onOpenChange={handleDrawerClose} dismissible>
<DrawerContent className="max-h-[60vh]">
<DrawerTitle className="sr-only">Explore</DrawerTitle>
<div className="px-4 pt-2 pb-6">
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground/70 mb-3 px-2">
Explore
</h3>
{exploreItems.length > 0 ? (
<div className="grid grid-cols-2 gap-1">
{exploreItems.map((item) => (
<Link
key={item.id}
to={item.path}
onClick={() => setExploreOpen(false)}
className={cn(
'flex items-center gap-3 px-4 py-3.5 rounded-xl transition-colors',
isItemActive(item.id, location.pathname, location.search)
? 'bg-primary/10 text-primary font-semibold'
: 'text-foreground hover:bg-secondary/60',
)}
{/* Section header with edit toggle */}
<div className="flex items-center gap-2 px-2 mb-3">
<span className="text-sm font-semibold uppercase tracking-wider text-muted-foreground/70">
Explore
</span>
<div className="flex-1 h-px bg-border/50" />
<button
onClick={() => setEditing(!editing)}
className={cn(
'text-xs font-medium transition-colors px-2 py-0.5 rounded-full',
editing
? 'text-primary hover:bg-primary/10'
: 'text-muted-foreground/70 hover:text-muted-foreground hover:bg-secondary/60',
)}
>
{editing ? 'Done' : <Pencil className="size-3.5" />}
</button>
</div>
{editing ? (
/* ── Edit mode: single-column sortable list ── */
<>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={orderedItems}
strategy={verticalListSortingStrategy}
>
<span className="shrink-0 text-muted-foreground">{item.icon}</span>
<span className="text-[15px] truncate">{item.label}</span>
</Link>
))}
</div>
{orderedItems.map((id) => (
<SortableExploreSheetItem
key={id}
id={id}
onRemove={removeFromSidebar}
/>
))}
</SortableContext>
</DndContext>
{/* Add hidden items back */}
{hiddenItems.length > 0 && (
<div className="mt-2 space-y-0.5">
{hiddenItems.map((item) => (
<button
key={item.id}
onClick={() => addToSidebar(item.id)}
className="flex items-center gap-3 w-full py-3 px-2 rounded-lg text-[15px] text-muted-foreground/60 hover:text-muted-foreground hover:bg-secondary/40 transition-colors"
>
<Plus className="size-4 ml-2" />
<span className="flex items-center gap-3">
{ITEM_ICONS[item.id] && (
<span className="[&>svg]:size-4">{ITEM_ICONS[item.id]}</span>
)}
<span>{item.label}</span>
</span>
</button>
))}
</div>
)}
</>
) : (
<p className="text-sm text-muted-foreground text-center py-6">
No content sections enabled. Go to Settings to add some.
</p>
/* ── Normal mode: 2-column grid of links ── */
exploreItems.length > 0 ? (
<div className="grid grid-cols-2 gap-1">
{exploreItems.map((item) => (
<Link
key={item.id}
to={item.path}
onClick={() => setExploreOpen(false)}
className={cn(
'flex items-center gap-3 px-4 py-3.5 rounded-xl transition-colors',
isItemActive(item.id, location.pathname, location.search)
? 'bg-primary/10 text-primary font-semibold'
: 'text-foreground hover:bg-secondary/60',
)}
>
<span className="shrink-0 text-muted-foreground">{item.icon}</span>
<span className="text-[15px] truncate">{item.label}</span>
</Link>
))}
</div>
) : (
<p className="text-sm text-muted-foreground text-center py-6">
No content sections enabled. Tap the pencil to add some.
</p>
)
)}
</div>
</DrawerContent>
+33 -229
View File
@@ -1,54 +1,21 @@
import { Link, useNavigate } from 'react-router-dom';
import { Home, TrendingUp, Bookmark, Settings, LogOut, ChevronDown, ChevronUp, Sun, Moon, Monitor, Clapperboard, BarChart3, Palette, PartyPopper, Radio, FileText, Pencil, GripVertical, X, Plus } from 'lucide-react';
import { DndContext, closestCenter, TouchSensor, PointerSensor, useSensor, useSensors, type DragEndEvent } from '@dnd-kit/core';
import { SortableContext, verticalListSortingStrategy, useSortable, arrayMove } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { LogOut, ChevronDown, ChevronUp, Sun, Moon, Monitor, Palette } from 'lucide-react';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { Sheet, SheetContent, SheetTitle } from '@/components/ui/sheet';
import { Separator } from '@/components/ui/separator';
import { ChestIcon } from '@/components/icons/ChestIcon';
import { CardsIcon } from '@/components/icons/CardsIcon';
import { DittoLogo } from '@/components/DittoLogo';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useLoginActions } from '@/hooks/useLoginActions';
import { useLoggedInAccounts } from '@/hooks/useLoggedInAccounts';
import { useTheme } from '@/hooks/useTheme';
import { useFeedSettings, getBuiltinItem } from '@/hooks/useFeedSettings';
import { EXTRA_KINDS } from '@/lib/extraKinds';
import { LoginArea } from '@/components/auth/LoginArea';
import { genUserName } from '@/lib/genUserName';
import { useCallback, useMemo, useState } from 'react';
import { useMemo, useState } from 'react';
import type { Theme } from '@/contexts/AppContext';
import { themePresets } from '@/themes';
import { cn } from '@/lib/utils';
import { settingsSections, type SettingsSection } from '@/pages/SettingsPage';
/** Map item ID to icon for drawer items. Covers both built-ins and extra-kind routes. */
const ITEM_ICONS: Record<string, React.ReactNode> = {
__feed: <Home className="size-5" />,
__trends: <TrendingUp className="size-5" />,
vines: <Clapperboard className="size-5" />,
polls: <BarChart3 className="size-5" />,
treasures: <ChestIcon className="size-5" />,
colors: <Palette className="size-5" />,
packs: <PartyPopper className="size-5" />,
streams: <Radio className="size-5" />,
articles: <FileText className="size-5" />,
decks: <CardsIcon className="size-5" />,
};
function itemLabel(id: string): string {
const builtin = getBuiltinItem(id);
if (builtin) return builtin.label;
return EXTRA_KINDS.find((d) => d.route === id)?.label ?? id;
}
function itemPath(id: string): string {
const builtin = getBuiltinItem(id);
if (builtin) return builtin.path;
return `/${id}`;
}
// ── Drawer menu item (normal mode) ───────────────────────────────────────────
// ── Drawer menu item ─────────────────────────────────────────────────────────
interface DrawerMenuItemProps {
to: string;
@@ -70,67 +37,7 @@ function DrawerMenuItem({ to, icon, label, onClick }: DrawerMenuItemProps) {
);
}
// ── Sortable drawer item (edit mode) ─────────────────────────────────────────
interface SortableDrawerItemProps {
id: string;
onRemove: (id: string) => void;
}
function SortableDrawerItem({ id, onRemove }: SortableDrawerItemProps) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
const icon = ITEM_ICONS[id] ?? <Palette className="size-5" />;
const label = itemLabel(id);
return (
<div
ref={setNodeRef}
style={style}
className={cn(
'flex items-center rounded-lg transition-colors',
isDragging && 'z-10 opacity-80 shadow-lg bg-background',
)}
>
{/* Drag handle */}
<button
className="flex items-center justify-center w-8 shrink-0 py-3.5 cursor-grab active:cursor-grabbing text-muted-foreground hover:text-foreground transition-colors touch-none"
{...attributes}
{...listeners}
>
<GripVertical className="size-4" />
</button>
{/* Icon + label (non-interactive in edit mode) */}
<div className="flex items-center gap-4 py-3.5 flex-1 min-w-0 text-[15px]">
<span className="text-muted-foreground shrink-0">{icon}</span>
<span className="font-medium truncate">{label}</span>
</div>
{/* Remove button */}
<button
onClick={() => onRemove(id)}
className="flex items-center justify-center size-8 shrink-0 rounded-full text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors"
>
<X className="size-4" />
</button>
</div>
);
}
// ── Mobile drawer props ──────────────────────────────────────────────────────
// ── Mobile drawer ────────────────────────────────────────────────────────────
interface MobileDrawerProps {
open: boolean;
@@ -142,41 +49,13 @@ export function MobileDrawer({ open, onOpenChange }: MobileDrawerProps) {
const { logout } = useLoginActions();
const { otherUsers, setLogin } = useLoggedInAccounts();
const { theme, setTheme, applyCustomTheme, customTheme } = useTheme();
const {
orderedItems, hiddenItems, updateSidebarOrder, addToSidebar, removeFromSidebar,
} = useFeedSettings();
const navigate = useNavigate();
const [showAccountSwitcher, setShowAccountSwitcher] = useState(false);
const [editing, setEditing] = useState(false);
// DnD sensors — touch sensor with delay to distinguish scroll from drag
const sensors = useSensors(
useSensor(TouchSensor, { activationConstraint: { delay: 150, tolerance: 5 } }),
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
);
/** Build explore items from ordered items (includes built-ins). */
const exploreItems = useMemo(() => {
return orderedItems.map((id) => ({
id,
to: itemPath(id),
icon: ITEM_ICONS[id] ?? <Palette className="size-5" />,
label: itemLabel(id),
}));
}, [orderedItems]);
/** Handle drag-and-drop reorder. */
const handleDragEnd = useCallback((event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIndex = orderedItems.indexOf(active.id as string);
const newIndex = orderedItems.indexOf(over.id as string);
if (oldIndex === -1 || newIndex === -1) return;
const newOrder = arrayMove(orderedItems, oldIndex, newIndex);
updateSidebarOrder(newOrder);
}, [orderedItems, updateSidebarOrder]);
/** Settings sections filtered by auth state. */
const visibleSettings = useMemo<SettingsSection[]>(() => {
return settingsSections.filter((s) => !s.requiresAuth || user);
}, [user]);
// Theme cycling logic
const builtinCycle: { id: Theme; label: string; icon: React.ReactNode }[] = [
@@ -225,10 +104,7 @@ export function MobileDrawer({ open, onOpenChange }: MobileDrawerProps) {
}
};
const handleClose = () => {
setEditing(false);
onOpenChange(false);
};
const handleClose = () => onOpenChange(false);
const handleLogout = async () => {
await logout();
@@ -237,7 +113,7 @@ export function MobileDrawer({ open, onOpenChange }: MobileDrawerProps) {
};
return (
<Sheet open={open} onOpenChange={(v) => { if (!v) setEditing(false); onOpenChange(v); }}>
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="left" className="w-[300px] p-0 border-r-border">
<SheetTitle className="sr-only">Navigation menu</SheetTitle>
@@ -254,102 +130,30 @@ export function MobileDrawer({ open, onOpenChange }: MobileDrawerProps) {
{/* Menu items */}
<nav className="flex-1 overflow-y-auto px-3 py-2">
{/* Explore section */}
{(exploreItems.length > 0 || editing) && (
<>
{/* Section header with edit/done toggle */}
<div className="flex items-center gap-2 px-2 pt-3 pb-1">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground/70">
Explore
</span>
<div className="flex-1 h-px bg-border/50" />
<button
onClick={() => setEditing(!editing)}
className={cn(
'text-xs font-medium transition-colors px-2 py-0.5 rounded-full',
editing
? 'text-primary hover:bg-primary/10'
: 'text-muted-foreground/70 hover:text-muted-foreground hover:bg-secondary/60',
)}
>
{editing ? 'Done' : <Pencil className="size-3.5" />}
</button>
</div>
{/* Settings section */}
<div className="flex items-center gap-2 px-2 pt-3 pb-1">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground/70">
Settings
</span>
<div className="flex-1 h-px bg-border/50" />
</div>
{editing ? (
/* ── Edit mode: sortable items with drag handles + remove ── */
<>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={orderedItems}
strategy={verticalListSortingStrategy}
>
{orderedItems.map((id) => (
<SortableDrawerItem
key={id}
id={id}
onRemove={removeFromSidebar}
/>
))}
</SortableContext>
</DndContext>
{visibleSettings.map((section) => {
const Icon = section.icon;
return (
<DrawerMenuItem
key={section.id}
to={section.path}
icon={<Icon className="size-5" />}
label={section.label}
onClick={handleClose}
/>
);
})}
{/* Add hidden items back */}
{hiddenItems.length > 0 && (
<div className="mt-2 space-y-0.5">
{hiddenItems.map((item) => (
<button
key={item.id}
onClick={() => addToSidebar(item.id)}
className="flex items-center gap-4 w-full py-3 px-2 rounded-lg text-[15px] text-muted-foreground/60 hover:text-muted-foreground hover:bg-secondary/40 transition-colors"
>
<Plus className="size-4 ml-2" />
<span className="flex items-center gap-3">
{ITEM_ICONS[item.id] && (
<span className="[&>svg]:size-4">{ITEM_ICONS[item.id]}</span>
)}
<span>{item.label}</span>
</span>
</button>
))}
</div>
)}
</>
) : (
/* ── Normal mode: navigation links ── */
exploreItems.map((item) => (
<DrawerMenuItem
key={item.to}
to={item.to}
icon={item.icon}
label={item.label}
onClick={handleClose}
/>
))
)}
<div className="my-2 mx-2">
<Separator />
</div>
</>
)}
<DrawerMenuItem
to="/bookmarks"
icon={<Bookmark className="size-5" />}
label="Bookmarks"
onClick={handleClose}
/>
<DrawerMenuItem
to="/settings"
icon={<Settings className="size-5" />}
label="Settings"
onClick={handleClose}
/>
<div className="my-2 mx-2">
<Separator />
</div>
<button
onClick={handleLogout}
+3 -6
View File
@@ -22,6 +22,7 @@ export interface BuiltinSidebarItem {
export const BUILTIN_SIDEBAR_ITEMS: BuiltinSidebarItem[] = [
{ id: '__feed', label: 'Feed', path: '/' },
{ id: '__trends', label: 'Trends', path: '/search?tab=trends' },
{ id: '__bookmarks', label: 'Bookmarks', path: '/bookmarks' },
];
/** Set of all built-in IDs for quick lookup. */
@@ -108,12 +109,8 @@ function computeOrderedItems(
}
}
// Append any new built-ins not in persisted order (e.g. __trends added later)
for (const b of BUILTIN_SIDEBAR_ITEMS) {
if (builtinIds.has(b.id)) {
ordered.push(b.id);
}
}
// NOTE: Built-ins NOT in sidebarOrder are treated as explicitly removed.
// They appear in the hiddenItems list so users can re-add them via "More...".
return ordered;
}
+3 -3
View File
@@ -4,7 +4,7 @@ import { Link, useNavigate } from 'react-router-dom';
import { Card, CardContent } from '@/components/ui/card';
import { useCurrentUser } from '@/hooks/useCurrentUser';
interface SettingsSection {
export interface SettingsSection {
id: string;
label: string;
description: string;
@@ -13,7 +13,7 @@ interface SettingsSection {
requiresAuth?: boolean;
}
const sections: SettingsSection[] = [
export const settingsSections: SettingsSection[] = [
{
id: 'profile',
label: 'Profile',
@@ -63,7 +63,7 @@ export function SettingsPage() {
description: 'Manage your Ditto settings',
});
const visibleSections = sections.filter(
const visibleSections = settingsSections.filter(
(section) => !section.requiresAuth || user,
);