From 621d116302275ed217f8675579ef4eed37165c25 Mon Sep 17 00:00:00 2001 From: jessevl Date: Mon, 20 Apr 2026 14:13:27 +0200 Subject: [PATCH 1/2] feat: add isTopLevel field to distinguish top-level pages from unfiled inbox items - Introduced `isTopLevel` field in the pages collection to identify canonical top-level pages. - Removed `isPinned` and `pinnedOrder` fields as the favorites feature has been deprecated. - Updated various components and stores to accommodate the new `isTopLevel` logic. - Implemented inbox route for unfiled parentless pages. - Adjusted page filtering and tree building logic to utilize the new field. - Updated tests and migration scripts to reflect these changes. --- backend/boox_integration.go | 4 - backend/migrations/001_initial_schema.go | 2 + .../migrations/023_add_pages_is_top_level.go | 44 + .../migrations/024_remove_pinned_fields.go | 44 + backend/migrations/migrations_test.go | 2 + backend/templates/apply.go | 2 +- backend/templates/new_user_content.go | 10 +- frontend/src/api/pagesApi.ts | 46 +- .../src/components/common/CommandPalette.tsx | 13 + .../components/common/MoveToParentPicker.tsx | 24 +- .../components/common/MoveToParentSheet.tsx | 24 +- .../src/components/common/TreeSidebarItem.tsx | 12 +- .../src/components/home/FavoritesSection.tsx | 174 ---- .../components/home/TodaySectionsBoard.tsx | 14 +- frontend/src/components/home/index.ts | 1 - .../src/components/layout/SidebarHeader.tsx | 473 +++++----- .../src/components/layout/TreeSection.tsx | 79 +- .../src/components/layout/UnifiedHeader.tsx | 12 +- .../src/components/layout/UnifiedSidebar.tsx | 838 +++++++++++------- .../src/components/pages/PageActionsMenu.tsx | 18 +- frontend/src/components/pages/PageCard.tsx | 38 +- .../src/components/pages/PageCollection.tsx | 11 +- .../src/components/pages/PageDetailView.tsx | 9 - frontend/src/components/pages/PageRow.tsx | 36 + .../src/components/pages/PageTableView.tsx | 5 - .../settings/sections/GeneralSettings.tsx | 20 + frontend/src/components/tasks/KanbanView.tsx | 2 +- frontend/src/hooks/useNoteContextMenu.tsx | 13 +- frontend/src/hooks/usePageActions.tsx | 22 +- frontend/src/hooks/usePageContextMenu.tsx | 40 +- frontend/src/lib/crdt.ts | 3 +- frontend/src/lib/pageUtils.test.ts | 2 - frontend/src/lib/pageUtils.ts | 16 +- frontend/src/lib/relationshipGraph.test.ts | 3 +- frontend/src/lib/syncAdapter.ts | 9 +- frontend/src/lib/syncEngine/dataLoader.ts | 18 +- frontend/src/lib/treeUtils.ts | 20 + frontend/src/routeTree.gen.ts | 21 + frontend/src/routes/inbox.tsx | 12 + frontend/src/stores/index.ts | 1 - frontend/src/stores/navigationStore.ts | 22 + frontend/src/stores/pagesStore.test.ts | 2 - frontend/src/stores/pagesStore.ts | 80 +- frontend/src/stores/settingsStore.ts | 23 +- frontend/src/types/page.ts | 13 +- frontend/src/views/HomeView.tsx | 49 +- frontend/src/views/PagesView.tsx | 23 +- scripts/dev.sh | 2 +- 48 files changed, 1366 insertions(+), 985 deletions(-) create mode 100644 backend/migrations/023_add_pages_is_top_level.go create mode 100644 backend/migrations/024_remove_pinned_fields.go delete mode 100644 frontend/src/components/home/FavoritesSection.tsx create mode 100644 frontend/src/routes/inbox.tsx diff --git a/backend/boox_integration.go b/backend/boox_integration.go index 39c196c..b2cdd0e 100644 --- a/backend/boox_integration.go +++ b/backend/boox_integration.go @@ -267,8 +267,6 @@ func syncBooxWorkspace(app *pocketbase.PocketBase, workspaceId string, configRec record.Set("childrenViewMode", "gallery") record.Set("isDailyNote", false) record.Set("isExpanded", false) - record.Set("isPinned", false) - record.Set("pinnedOrder", 0) record.Set("showChildrenInSidebar", false) record.Set("content", "") record.Set("excerpt", "") @@ -422,8 +420,6 @@ func ensureBooxRootPage(app *pocketbase.PocketBase, workspaceId string) (*core.R record.Set("childrenViewMode", "gallery") record.Set("isDailyNote", false) record.Set("isExpanded", true) - record.Set("isPinned", false) - record.Set("pinnedOrder", 0) record.Set("showChildrenInSidebar", true) record.Set("isReadOnly", true) record.Set("sourceOrigin", booxSourceOrigin) diff --git a/backend/migrations/001_initial_schema.go b/backend/migrations/001_initial_schema.go index de94b2a..4052c30 100644 --- a/backend/migrations/001_initial_schema.go +++ b/backend/migrations/001_initial_schema.go @@ -110,6 +110,7 @@ func init() { &core.TextField{Name: "excerpt", Max: 500}, &core.TextField{Name: "bodyText", Max: bodyTextFieldMax}, &core.NumberField{Name: "order"}, + &core.BoolField{Name: "isTopLevel"}, &core.TextField{Name: "icon", Max: 50}, &core.TextField{Name: "color", Max: 20}, // Cover image support @@ -239,6 +240,7 @@ func init() { }) pages.AddIndex("idx_pages_workspace", false, "workspace", "") pages.AddIndex("idx_pages_parentId", false, "parentId", "") + pages.AddIndex("idx_pages_workspace_parent_top", false, "workspace,parentId,isTopLevel", "") pages.AddIndex("idx_pages_viewMode", false, "viewMode", "") pages.AddIndex("idx_pages_workspace_viewMode", false, "workspace, viewMode", "") pages.AddIndex("idx_pages_dailyNoteDate", false, "dailyNoteDate", "") diff --git a/backend/migrations/023_add_pages_is_top_level.go b/backend/migrations/023_add_pages_is_top_level.go new file mode 100644 index 0000000..0d815ab --- /dev/null +++ b/backend/migrations/023_add_pages_is_top_level.go @@ -0,0 +1,44 @@ +package migrations + +import ( + "github.com/pocketbase/pocketbase/core" + m "github.com/pocketbase/pocketbase/migrations" +) + +// 023_add_pages_is_top_level distinguishes canonical top-level pages from +// unfiled parentless pages that belong in Inbox. +func init() { + m.Register(func(app core.App) error { + pages, err := app.FindCollectionByNameOrId("pages") + if err != nil { + return nil + } + + needsField := pages.Fields.GetByName("isTopLevel") == nil + if needsField { + pages.Fields.Add(&core.BoolField{Name: "isTopLevel"}) + pages.AddIndex("idx_pages_workspace_parent_top", false, "workspace,parentId,isTopLevel", "") + if err := app.Save(pages); err != nil { + return err + } + } + + if !needsField { + return nil + } + + records, err := app.FindAllRecords("pages") + if err != nil { + return nil + } + + for _, record := range records { + record.Set("isTopLevel", record.GetString("parentId") == "") + if err := app.Save(record); err != nil { + return err + } + } + + return nil + }, nil) +} \ No newline at end of file diff --git a/backend/migrations/024_remove_pinned_fields.go b/backend/migrations/024_remove_pinned_fields.go new file mode 100644 index 0000000..60a8ebd --- /dev/null +++ b/backend/migrations/024_remove_pinned_fields.go @@ -0,0 +1,44 @@ +package migrations + +import ( + "github.com/pocketbase/pocketbase/core" + m "github.com/pocketbase/pocketbase/migrations" +) + +// 024_remove_pinned_fields drops the isPinned and pinnedOrder fields and their +// indexes from the pages collection. The "favorites" feature has been removed; +// top-level pages are now managed via the isTopLevel field instead. +func init() { + m.Register(func(app core.App) error { + pages, err := app.FindCollectionByNameOrId("pages") + if err != nil { + return nil + } + + changed := false + + // Drop isPinned field if it exists + if field := pages.Fields.GetByName("isPinned"); field != nil { + pages.Fields.RemoveById(field.GetId()) + changed = true + } + + // Drop pinnedOrder field if it exists + if field := pages.Fields.GetByName("pinnedOrder"); field != nil { + pages.Fields.RemoveById(field.GetId()) + changed = true + } + + // Remove index on isPinned + pages.RemoveIndex("idx_pages_isPinned") + + // Remove index on pinnedOrder + pages.RemoveIndex("idx_pages_pinnedOrder") + + if !changed { + return nil + } + + return app.Save(pages) + }, nil) +} diff --git a/backend/migrations/migrations_test.go b/backend/migrations/migrations_test.go index 1edcab5..20e769f 100644 --- a/backend/migrations/migrations_test.go +++ b/backend/migrations/migrations_test.go @@ -12,6 +12,7 @@ func TestMigrationsExist(t *testing.T) { "003_data_cleanup", "022_remove_plan_limits", "021_restore_post_v17_schema", + "023_add_pages_is_top_level", } for _, name := range expectedMigrations { @@ -64,6 +65,7 @@ func TestPagesCollectionFields(t *testing.T) { "bodyText", "workspace", "parentId", + "isTopLevel", "order", "icon", "color", diff --git a/backend/templates/apply.go b/backend/templates/apply.go index f726e5a..a94bbfa 100644 --- a/backend/templates/apply.go +++ b/backend/templates/apply.go @@ -146,7 +146,7 @@ func applyContent(app core.App, userId string, forceReset bool) error { page.Set("childrenViewMode", pt.ChildrenViewMode) page.Set("createdBy", userId) page.Set("isExpanded", true) - page.Set("isPinned", pt.IsPinned) + page.Set("isTopLevel", pt.IsTopLevel) // Set cover gradient if provided if pt.CoverGradient != "" { diff --git a/backend/templates/new_user_content.go b/backend/templates/new_user_content.go index 38e83e0..7a23802 100644 --- a/backend/templates/new_user_content.go +++ b/backend/templates/new_user_content.go @@ -37,7 +37,7 @@ type PageTemplate struct { TasksGroupBy string // 'section', 'dueDate', 'priority' Tasks []TaskTemplate Children []PageTemplate - IsPinned bool + IsTopLevel bool } // TaskTemplate defines the structure for creating a new task @@ -81,7 +81,7 @@ func GetDefaultWorkspace() WorkspaceTemplate { CoverAttribution: "Photo by John Doe on Unsplash", Order: 0, ChildrenViewMode: "gallery", - IsPinned: true, + IsTopLevel: true, Children: []PageTemplate{ { Title: "Product Launch Q1", @@ -247,7 +247,7 @@ func GetDefaultWorkspace() WorkspaceTemplate { CoverImage: "https://images.unsplash.com/photo-1505142468610-359e7d316be0?w=1400&q=80", Order: 1, ChildrenViewMode: "list", - IsPinned: true, + IsTopLevel: true, Children: []PageTemplate{ { Title: "Daily Habits", @@ -477,7 +477,7 @@ func GetDefaultWorkspace() WorkspaceTemplate { Color: "#10b981", CoverImage: "https://images.unsplash.com/photo-1557672172-298e090bd0f1?w=1400&q=80", Order: 3, - IsPinned: true, + IsTopLevel: true, ChildrenViewMode: "gallery", Children: []PageTemplate{ { @@ -515,7 +515,7 @@ func GetDefaultWorkspace() WorkspaceTemplate { Order: 4, Content: gettingStartedContent, Excerpt: "Learn how to make the most of Planneer", - IsPinned: true, + IsTopLevel: true, Children: []PageTemplate{ { Title: "Keyboard Shortcuts", diff --git a/frontend/src/api/pagesApi.ts b/frontend/src/api/pagesApi.ts index bd085c8..c0143aa 100644 --- a/frontend/src/api/pagesApi.ts +++ b/frontend/src/api/pagesApi.ts @@ -79,6 +79,8 @@ import { devLog, devWarn } from '@/lib/config'; export interface FetchPagesOptions { /** Only fetch root-level pages (parentId is null/empty). Mutually exclusive with parentId. */ rootOnly?: boolean; + /** Only fetch parentless Inbox pages. Mutually exclusive with rootOnly and parentId. */ + inboxOnly?: boolean; /** Fetch children of a specific parent ID. Mutually exclusive with rootOnly. */ parentId?: string; /** Filter by view mode (e.g., 'tasks' to get only task collections) */ @@ -135,8 +137,14 @@ export async function fetchPages(options?: FetchPagesOptions): Promise { } } -/** - * Fetch pinned pages specifically - ensures pinned pages at any level are loaded. - * Used as a supplementary fetch during initial load. - */ -export async function fetchPinnedPages(): Promise { - const workspaceId = getCurrentWorkspaceIdOrNull(); - if (!workspaceId) return []; - - try { - const result = await pb.collection('pages').getFullList({ - filter: buildWorkspaceFilter(workspaceId, 'isPinned = true'), - sort: 'pinnedOrder', - fields: METADATA_FIELDS, - }); - - const pages = result.map(record => ({ - ...pbToFrontend(record as Record), - content: null, - })); - - devLog(`[pagesApi] Fetched ${pages.length} pinned pages`); - return pages; - } catch (e) { - console.error('[pagesApi] fetchPinnedPages error:', e); - return []; - } -} - // ============================================================================ // CRUD OPERATIONS // ============================================================================ @@ -490,7 +470,7 @@ export interface PagePatchRequest { /** Block IDs with only order changes - maps to new order value (bandwidth optimization) */ blockOrders?: Record; /** Metadata fields to update (title, parentId, etc.) */ - metadata?: Partial>; + metadata?: Partial>; } /** @@ -524,8 +504,8 @@ export async function deletePage(id: string): Promise { // ============================================================================ /** - * Move orphaned child pages to root level when their parent is deleted. - * Returns the IDs of pages that were moved to root level. + * Move orphaned child pages to Inbox when their parent is deleted. + * Returns the IDs of pages that were moved to Inbox. */ export async function moveOrphanedPagesToRoot( parentId: string @@ -540,7 +520,7 @@ export async function moveOrphanedPagesToRoot( // Update each to remove parent await Promise.all( children.map((p) => - updatePage(p.id, { parentId: null }) + updatePage(p.id, { parentId: null, isTopLevel: false }) ) ); diff --git a/frontend/src/components/common/CommandPalette.tsx b/frontend/src/components/common/CommandPalette.tsx index 97266f4..9286b57 100644 --- a/frontend/src/components/common/CommandPalette.tsx +++ b/frontend/src/components/common/CommandPalette.tsx @@ -21,6 +21,7 @@ import { PlusIcon, CheckIcon, PagesIcon, + InboxIcon, HomeIcon, CalendarIcon, ClockIcon, @@ -321,6 +322,18 @@ const CommandPalette: React.FC = ({ onClose(); }, }, + { + id: 'nav-inbox', + category: 'navigation', + icon: , + title: 'Go to Inbox', + subtitle: 'Review unfiled pages', + keywords: ['inbox', 'unfiled', 'pages'], + onSelect: () => { + navigate({ to: '/inbox' }); + onClose(); + }, + }, { id: 'nav-tasks', category: 'navigation', diff --git a/frontend/src/components/common/MoveToParentPicker.tsx b/frontend/src/components/common/MoveToParentPicker.tsx index 5b135a3..a9f7baf 100644 --- a/frontend/src/components/common/MoveToParentPicker.tsx +++ b/frontend/src/components/common/MoveToParentPicker.tsx @@ -18,7 +18,7 @@ import { useIsMobile } from '@frameer/hooks/useMobileDetection'; import { StylizedCollectionIcon } from '@/components/common/StylizedIcons'; import { fetchPages } from '@/api/pagesApi'; import type { Page } from '@/types/page'; -import { Folder, Loader2, ChevronRight } from 'lucide-react'; +import { Folder, Inbox, Loader2, ChevronRight } from 'lucide-react'; interface MoveToParentPickerProps { isOpen: boolean; @@ -72,6 +72,11 @@ export const MoveToParentPicker: React.FC = ({ movePage(pageId, null); onClose(); }; + + const handleMoveToInbox = () => { + movePage(pageId, null, { isTopLevel: false }); + onClose(); + }; // Get parent title for breadcrumb display (one level only) const getParentBreadcrumb = (page: Page): string | null => { @@ -112,6 +117,23 @@ export const MoveToParentPicker: React.FC = ({ + + {loading ? (
diff --git a/frontend/src/components/common/MoveToParentSheet.tsx b/frontend/src/components/common/MoveToParentSheet.tsx index d09a578..679c594 100644 --- a/frontend/src/components/common/MoveToParentSheet.tsx +++ b/frontend/src/components/common/MoveToParentSheet.tsx @@ -12,7 +12,7 @@ import { StylizedCollectionIcon, StylizedNoteIcon } from '@/components/common/StylizedIcons'; -import { Folder } from 'lucide-react'; +import { Folder, Inbox } from 'lucide-react'; interface MoveToParentSheetProps { isOpen: boolean; @@ -60,6 +60,11 @@ export const MoveToParentSheet: React.FC = ({ movePage(pageId, null); onClose(); }; + + const handleMoveToInbox = () => { + movePage(pageId, null, { isTopLevel: false }); + onClose(); + }; const renderIcon = (page: typeof availableParents[0]) => { if (page.icon) { @@ -101,6 +106,23 @@ export const MoveToParentSheet: React.FC = ({
+ + {availableParents.length > 0 && ( <> diff --git a/frontend/src/components/common/TreeSidebarItem.tsx b/frontend/src/components/common/TreeSidebarItem.tsx index e498b9d..a5470ee 100644 --- a/frontend/src/components/common/TreeSidebarItem.tsx +++ b/frontend/src/components/common/TreeSidebarItem.tsx @@ -230,10 +230,10 @@ const TreeSidebarItem: React.FC = React.memo(({ position = 'inside'; } - // Check if it's an external note drag (from collection view - uses 'noteId') - const isExternalNoteDrag = e.dataTransfer.types.includes('noteid'); + // Check if it's an external page drag (from collection view - uses 'pageId') + const isExternalPageDrag = e.dataTransfer.types.includes('pageid') || e.dataTransfer.types.includes('noteid'); - if (isExternalNoteDrag) { + if (isExternalPageDrag) { onExternalDragOver?.(id, parentId, position, e); } else { // Internal tree drag @@ -270,10 +270,10 @@ const TreeSidebarItem: React.FC = React.memo(({ position = 'inside'; } - // Check if it's an external note drop (from collection view - uses 'noteId') - const isExternalNoteDrag = e.dataTransfer.types.includes('noteid'); + // Check if it's an external page drop (from collection view - uses 'pageId') + const isExternalPageDrag = e.dataTransfer.types.includes('pageid') || e.dataTransfer.types.includes('noteid'); - if (isExternalNoteDrag) { + if (isExternalPageDrag) { onExternalDrop?.(id, position, e); } else { // Internal tree drop diff --git a/frontend/src/components/home/FavoritesSection.tsx b/frontend/src/components/home/FavoritesSection.tsx deleted file mode 100644 index c9830ec..0000000 --- a/frontend/src/components/home/FavoritesSection.tsx +++ /dev/null @@ -1,174 +0,0 @@ -/** - * @file FavoritesSection.tsx - * @description Pinned/favorite pages displayed as icon tiles with context menu - * @app SHARED - Used on HomeView for quick access to pinned pages - * - * Features: - * - Grid of 72×72 icon tiles with glassmorphic hover effect - * - Page type indicator badge in corner - * - Quick-add FAB button on hover (create subpage or task) - * - Full right-click context menu via usePageContextMenu - * - Click to navigate - * - Staggered entrance animation - * - * Used by: - * - HomeView - */ -import React from 'react'; -import { Star, Plus } from 'lucide-react'; -import ItemIcon from '../common/ItemIcon'; -import { CheckIcon } from '../common/Icons'; -import { ContextMenu } from '@/components/ui'; -import { usePageContextMenu } from '@/hooks/usePageContextMenu'; -import type { Page } from '@/types/page'; - -interface FavoritesSectionProps { - /** Array of pinned/favorite pages */ - pages: Page[]; - /** Handler when a page is clicked */ - onPageClick: (pageId: string) => void; - /** Handler to create a child page */ - onCreateChild?: (parentId: string) => void; - /** Handler to create a task in a task collection */ - onCreateTask?: (parentPageId: string) => void; -} - -/** Map viewMode to ItemIcon type */ -const getIconType = (page: Page) => { - if (page.isDailyNote) return 'daily' as const; - if (page.viewMode === 'tasks') return 'tasks' as const; - if (page.viewMode === 'collection') return 'collection' as const; - return 'note' as const; -}; - -/** Single favorite tile with context menu */ -const FavoriteTile: React.FC<{ - page: Page; - index: number; - onPageClick: (pageId: string) => void; - onCreateChild?: (parentId: string) => void; - onCreateTask?: (parentPageId: string) => void; -}> = ({ page, index, onPageClick, onCreateChild, onCreateTask }) => { - const { menuItems } = usePageContextMenu({ - page, - onCreateChild, - onCreateTask, - childCount: page.childCount || 0, - }); - - return ( - -
- - - {/* Quick create overlay - FAB-style button on hover */} - -
-
- ); -}; - -const FavoritesSection: React.FC = ({ - pages, - onPageClick, - onCreateChild, - onCreateTask, -}) => { - if (pages.length === 0) return null; - - return ( -
- {/* Header */} -
-

- - Favorites -

-
- - {/* Icon tiles grid */} -
- {pages.map((page, index) => ( - - ))} -
-
- ); -}; - -export default FavoritesSection; diff --git a/frontend/src/components/home/TodaySectionsBoard.tsx b/frontend/src/components/home/TodaySectionsBoard.tsx index 442a945..610c61b 100644 --- a/frontend/src/components/home/TodaySectionsBoard.tsx +++ b/frontend/src/components/home/TodaySectionsBoard.tsx @@ -24,7 +24,7 @@ import type { Page } from '@/types/page'; import type { DateGroupKey } from '@/lib/dateGroups'; const PREVIEW_LIMIT = 5; -const HOME_AGENDA_GROUPS: DateGroupKey[] = ['overdue', 'today', 'tomorrow']; +const HOME_AGENDA_GROUPS: DateGroupKey[] = ['overdue', 'today', 'tomorrow', 'thisWeek']; interface TodaySectionsBoardProps { /** Tasks due before today */ @@ -33,6 +33,8 @@ interface TodaySectionsBoardProps { todayTasks: Task[]; /** Tasks due tomorrow */ tomorrowTasks: Task[]; + /** Tasks due this week (days 2-7 from today) */ + thisWeekTasks: Task[]; /** Today's ISO date for task row overdue handling */ todayISO: string; /** Task pages for badge lookup */ @@ -52,6 +54,7 @@ const TodaySectionsBoard: React.FC = ({ overdueTasks, todayTasks, tomorrowTasks, + thisWeekTasks, todayISO, taskPages, onToggleComplete, @@ -60,8 +63,8 @@ const TodaySectionsBoard: React.FC = ({ onCreateTask, }) => { const agendaTasks = useMemo( - () => [...overdueTasks, ...todayTasks, ...tomorrowTasks], - [overdueTasks, todayTasks, tomorrowTasks], + () => [...overdueTasks, ...todayTasks, ...tomorrowTasks, ...thisWeekTasks], + [overdueTasks, todayTasks, tomorrowTasks, thisWeekTasks], ); const hasTasks = agendaTasks.length > 0; const hiddenCounts = useMemo( @@ -69,10 +72,11 @@ const TodaySectionsBoard: React.FC = ({ overdue: Math.max(0, overdueTasks.length - PREVIEW_LIMIT), today: Math.max(0, todayTasks.length - PREVIEW_LIMIT), tomorrow: Math.max(0, tomorrowTasks.length - PREVIEW_LIMIT), + thisWeek: Math.max(0, thisWeekTasks.length - PREVIEW_LIMIT), }), - [overdueTasks.length, todayTasks.length, tomorrowTasks.length], + [overdueTasks.length, todayTasks.length, tomorrowTasks.length, thisWeekTasks.length], ); - const totalHiddenCount = hiddenCounts.overdue + hiddenCounts.today + hiddenCounts.tomorrow; + const totalHiddenCount = hiddenCounts.overdue + hiddenCounts.today + hiddenCounts.tomorrow + hiddenCounts.thisWeek; return (
diff --git a/frontend/src/components/home/index.ts b/frontend/src/components/home/index.ts index 6a0736f..1b89059 100644 --- a/frontend/src/components/home/index.ts +++ b/frontend/src/components/home/index.ts @@ -1,3 +1,2 @@ export { default as RecentPagesGallery } from './RecentPagesGallery'; -export { default as FavoritesSection } from './FavoritesSection'; export { default as TodaySectionsBoard } from './TodaySectionsBoard'; diff --git a/frontend/src/components/layout/SidebarHeader.tsx b/frontend/src/components/layout/SidebarHeader.tsx index 1f0f08a..f8f51ad 100644 --- a/frontend/src/components/layout/SidebarHeader.tsx +++ b/frontend/src/components/layout/SidebarHeader.tsx @@ -1,18 +1,15 @@ /** * @file SidebarHeader.tsx - * @description Unified sidebar header with workspace, sync, and settings in one panel - * @app SHARED - Single entry point for all sidebar context controls + * @description Unified sidebar account and workspace menu trigger + * @app SHARED - Reusable footer control for sidebar workspace and account actions * * Features: - * - Single row showing workspace name + sync status indicator - * - Click to expand unified panel with: - * - Workspace list and switching - * - Sync status details - * - Quick access to settings - * - User account info (future) - * - Consistent styling and behavior + * - One shared trigger for both expanded and rail sidebar variants + * - Unified dropdown/sheet with workspace switching, sync state, and account actions + * - Account-first header with direct access to account settings and logout + * - Consistent behavior across desktop and mobile */ -import React, { useState, useRef, useEffect, useCallback } from 'react'; +import React, { useState, useRef, useEffect, useCallback, useLayoutEffect } from 'react'; import { createPortal } from 'react-dom'; import { useShallow } from 'zustand/react/shallow'; import { @@ -25,8 +22,6 @@ import { ChevronDown, Plus, Settings, - Users, - X, LogOut, User, } from 'lucide-react'; @@ -34,10 +29,9 @@ import { useWorkspaceStore, selectCurrentWorkspace, selectWorkspaces } from '@/s import { useAuthStore } from '@/stores/authStore'; import { useSyncStore, computeSyncDisplayState, type SyncDisplayState } from '@/stores/syncStore'; import { pb } from '@/lib/pocketbase'; -import { Divider } from '@/components/ui'; import { MobileSheet } from '@/components/ui'; import { useIsMobile } from '@frameer/hooks/useMobileDetection'; -import { CheckIcon, UserIcon } from '@/components/common/Icons'; +import { cn } from '@/lib/design-system'; import type { SettingsSection } from './SettingsModal'; // ============================================================================ @@ -46,6 +40,7 @@ import type { SettingsSection } from './SettingsModal'; interface SidebarHeaderProps { onCreateWorkspace: () => void; onOpenSettings: (section: SettingsSection) => void; + triggerVariant?: 'expanded' | 'rail'; } type SyncConfig = { @@ -99,10 +94,11 @@ const syncConfig: Record = { const SidebarHeader: React.FC = ({ onCreateWorkspace, onOpenSettings, + triggerVariant = 'expanded', }) => { const isMobile = useIsMobile(); const [isOpen, setIsOpen] = useState(false); - const [panelPosition, setPanelPosition] = useState({ top: 0, left: 0 }); + const [panelPosition, setPanelPosition] = useState<{ bottom: number; left: number } | null>(null); const triggerRef = useRef(null); const panelRef = useRef(null); @@ -128,16 +124,43 @@ const SidebarHeader: React.FC = ({ const config = syncConfig[displayState]; const SyncIcon = config.icon; - // Position panel below trigger + const calculatePanelPosition = useCallback(() => { + if (!triggerRef.current) return null; + + const triggerRect = triggerRef.current.getBoundingClientRect(); + const sidebarElement = triggerRef.current.closest('aside'); + const sidebarRect = sidebarElement?.getBoundingClientRect() ?? triggerRect; + const panelWidth = 352; + const sidebarGap = 10; + const viewportInset = 12; + const preferredLeft = sidebarRect.right + sidebarGap; + const maxLeft = window.innerWidth - panelWidth - viewportInset; + + return { + bottom: viewportInset, + left: Math.max(viewportInset, Math.min(preferredLeft, maxLeft)), + }; + }, []); + + useLayoutEffect(() => { + if (!isOpen || isMobile) return; + setPanelPosition(calculatePanelPosition()); + }, [calculatePanelPosition, isMobile, isOpen, triggerVariant]); + useEffect(() => { - if (isOpen && triggerRef.current) { - const rect = triggerRef.current.getBoundingClientRect(); - setPanelPosition({ - top: rect.bottom + 4, - left: rect.left, - }); - } - }, [isOpen]); + if (!isOpen || isMobile) return; + + const handleWindowChange = () => { + setPanelPosition(calculatePanelPosition()); + }; + + window.addEventListener('resize', handleWindowChange); + window.addEventListener('scroll', handleWindowChange, true); + return () => { + window.removeEventListener('resize', handleWindowChange); + window.removeEventListener('scroll', handleWindowChange, true); + }; + }, [calculatePanelPosition, isMobile, isOpen]); // Close on click outside useEffect(() => { @@ -201,6 +224,24 @@ const SidebarHeader: React.FC = ({ } }, [syncState]); + const handleLogout = useCallback(() => { + setIsOpen(false); + logout(); + }, [logout]); + + const handleToggleOpen = useCallback(() => { + if (isOpen) { + setIsOpen(false); + return; + } + + if (!isMobile) { + setPanelPosition(calculatePanelPosition()); + } + + setIsOpen(true); + }, [calculatePanelPosition, isMobile, isOpen]); + const formatTime = (timestamp: number | null) => { if (!timestamp) return 'Never'; const diff = Date.now() - timestamp; @@ -222,201 +263,237 @@ const SidebarHeader: React.FC = ({ const displayName = currentWorkspace?.name || 'Select Workspace'; const showSyncBadge = syncState.pendingCount > 0 || displayState === 'error'; + const userName = user.name || user.email || 'Account'; + const userEmail = user.email || 'Signed in'; + const userAvatarUrl = user.avatar ? pb.files.getUrl(user, user.avatar) : null; const panelContent = ( -
- {/* ============================================ */} - {/* SYNC STATUS SECTION */} - {/* ============================================ */} -
- Sync Status -
-
-
-
- {config.label} - - {formatTime(syncState.lastSyncAt)} - -
- - {(displayState === 'error' || displayState === 'pending') && syncState.isOnline && ( +
+
+
- )} -
- - {/* Sync errors/pending details */} - {(syncState.errors.length > 0 || (syncState.pendingCount > 0 && displayState !== 'error')) && ( -
- {syncState.errors.length > 0 ? ( -
- - {syncState.errors[0]?.message} - - +
+ {userAvatarUrl ? ( + {userName} + ) : ( + + )}
- ) : ( - - {syncState.pendingCount} pending change{syncState.pendingCount !== 1 ? 's' : ''} - - )} -
- )} - - {/* ============================================ */} - {/* WORKSPACES SECTION */} - {/* ============================================ */} -
-
- Workspaces -
-
- {workspaces.map((ws) => ( +
+
{userName}
+
{userEmail}
+
+ +
+ - ))} +
- - - {/* ============================================ */} - {/* ACTIONS SECTION */} - {/* ============================================ */} -
- {/* Create workspace */} - - - {/* Workspace settings */} - {currentWorkspace && ( +
+
+
+
Workspaces
+
Switch context without loosing your work.
+
- )} +
+
+ {workspaces.map((ws) => { + const isCurrent = ws.id === currentWorkspace?.id; + return ( + + {(displayState === 'error' || displayState === 'pending') && syncState.isOnline ? ( + + ) : null} + +
+ ) : null} + + ); + })} +
); return ( <> - {/* Trigger Row */} - + ) : ( +
- - {/* Chevron */} - - + ) : null} +
+ {userAvatarUrl ? ( + {userName} + ) : ( + + )} +
+ + + )} {/* Unified Panel */} {isMobile ? ( @@ -425,19 +502,19 @@ const SidebarHeader: React.FC = ({ onClose={() => setIsOpen(false)} title="Account & Workspace" > -
+
{panelContent}
- ) : isOpen && ( + ) : isOpen && panelPosition && ( createPortal(
{panelContent} diff --git a/frontend/src/components/layout/TreeSection.tsx b/frontend/src/components/layout/TreeSection.tsx index e790d9d..0b64e5b 100644 --- a/frontend/src/components/layout/TreeSection.tsx +++ b/frontend/src/components/layout/TreeSection.tsx @@ -26,6 +26,7 @@ import { usePageOperations } from '@/hooks/usePageOperations'; import { useIsMobile } from '@frameer/hooks/useMobileDetection'; import { useSettingsStore } from '@/stores/settingsStore'; import { useTabsStore } from '@/stores/tabsStore'; +import { useUIStore } from '@/stores/uiStore'; import { buildPageMenuItems } from '@/hooks/usePageContextMenu'; import type { Page, PageViewMode } from '@/types/page'; @@ -44,7 +45,6 @@ export interface TreeItemConfig { getColor: (item: T) => string | null; getItemType: (item: T) => 'note' | 'collection' | 'tasks'; getViewMode?: (item: T) => PageViewMode | undefined; - getIsPinned?: (item: T) => boolean; getShowChildrenInSidebar?: (item: T) => boolean | undefined; /** Whether this item should show children in the tree (e.g., collections don't expand) */ shouldShowChildren?: (item: T) => boolean; @@ -84,18 +84,13 @@ export interface TreeSectionProps { color?: string | null; parentId?: string | null; viewMode?: string; - isPinned?: boolean; showChildrenInSidebar?: boolean; }) => void; onCreate?: (data: { title?: string; icon?: string | null; color?: string | null; parentId?: string | null }) => void; /** Delete callback - cascade: true means delete all descendants, false means move children to root */ onDelete?: (id: string, cascade?: boolean) => void; - /** Pin/unpin callback */ - onPin?: (id: string, isPinned: boolean) => void; - /** Get current pin state for an item */ - getIsPinned?: (id: string) => boolean; onReorder?: (draggedId: string, targetId: string, parentId: string | null, position: 'before' | 'after') => void; - onReparent?: (draggedId: string, newParentId: string) => void; + onReparent?: (draggedId: string, newParentId: string | null) => void; /** Optional count per item */ counts?: Record; /** Optional overdue count per item */ @@ -146,8 +141,6 @@ function TreeSection({ onUpdate, onCreate, onDelete, - onPin, - getIsPinned, onReorder, onReparent, counts, @@ -171,6 +164,7 @@ function TreeSection({ const [isOpen, setIsOpen] = useState(defaultOpen); const [focusedId, setFocusedId] = useState(externalFocusedId ?? null); const [isRootDropZoneActive, setIsRootDropZoneActive] = useState(false); + const [isExternalDragActive, setIsExternalDragActive] = useState(false); // Use centralized page operations hook for task counting and migration (for deletion) const { countTasksForPage, migrateTasksToInbox } = usePageOperations(); @@ -233,13 +227,44 @@ function TreeSection({ isDescendant: checkIsDescendant, }); - // Clear root drop zone when drag ends + // Clear root drop zone when internal drag ends useEffect(() => { if (!dragState.draggedId) { setIsRootDropZoneActive(false); } }, [dragState.draggedId]); + // Track external page drags at the window level so the root drop zone is + // always visible while a page card / row is being dragged, regardless of + // which child element the cursor is over. + useEffect(() => { + if (!enableExternalDrag) return; + + const onDragStart = (e: DragEvent) => { + const types = e.dataTransfer?.types; + if (!types) return; + const isPageDrag = + Array.from(types).includes('pageid') || + Array.from(types).includes('noteid'); + if (isPageDrag) setIsExternalDragActive(true); + }; + + const onDragEnd = () => { + setIsExternalDragActive(false); + }; + + window.addEventListener('dragstart', onDragStart); + window.addEventListener('dragend', onDragEnd); + // drop fires when released over a valid target (dragend may not fire) + window.addEventListener('drop', onDragEnd); + + return () => { + window.removeEventListener('dragstart', onDragStart); + window.removeEventListener('dragend', onDragEnd); + window.removeEventListener('drop', onDragEnd); + }; + }, [enableExternalDrag]); + // Flatten tree for keyboard navigation const flattenedIds = useMemo(() => { const result: string[] = []; @@ -302,11 +327,20 @@ function TreeSection({ const handleRootDrop = useCallback((e: React.DragEvent) => { e.preventDefault(); setIsRootDropZoneActive(false); - const draggedId = dragState.draggedId; - if (!draggedId || !onUpdate) return; - onUpdate(draggedId, { parentId: null }); - handleDragEnd(); - }, [dragState.draggedId, onUpdate, handleDragEnd]); + setIsExternalDragActive(false); + // Internal drag + if (dragState.draggedId) { + if (!onUpdate) return; + onUpdate(dragState.draggedId, { parentId: null }); + handleDragEnd(); + return; + } + // External drag (from PageCard / PageRow) + const externalId = e.dataTransfer.getData('text/plain'); + if (externalId && onReparent) { + onReparent(externalId, null); + } + }, [dragState.draggedId, onUpdate, onReparent, handleDragEnd]); @@ -318,7 +352,6 @@ function TreeSection({ const viewMode = config.getViewMode?.(treeItem); const tabsEnabled = useSettingsStore.getState().tabsEnabled; const openTab = useTabsStore.getState().openTab; - const isPinned = getIsPinned?.(itemId) ?? false; const showChildren = config.shouldShowChildren?.(treeItem) ?? true; const pageChildCount = config.getChildCount ? config.getChildCount(treeItem) @@ -330,7 +363,6 @@ function TreeSection({ isMultiSelect: false, selectionCount: 1, tabsEnabled, - isPinned, showChildrenInSidebar: showChildren, onOpenInNewTab: tabsEnabled ? () => { @@ -348,10 +380,12 @@ function TreeSection({ onCreateTask: (onCreateTask && viewMode === 'tasks') ? () => onCreateTask(itemId) : undefined, - onTogglePin: onPin ? () => onPin(itemId, !isPinned) : undefined, - onToggleShowChildren: onUpdate ? () => onUpdate(itemId, { showChildrenInSidebar: !showChildren }) : undefined, + onMoveTo: () => { + useUIStore.getState().openPageMovePicker(itemId, config.getTitle(treeItem)); + }, + onDelete: onDelete ? () => { requestDelete({ itemType: 'page', @@ -367,7 +401,7 @@ function TreeSection({ }); } : undefined, }); - }, [onDelete, onPin, getIsPinned, requestDelete, countDescendants, items, config, countTasksForPage, migrateTasksToInbox, onCreateChild, onCreateTask, onUpdate]); + }, [onDelete, requestDelete, countDescendants, items, config, countTasksForPage, migrateTasksToInbox, onCreateChild, onCreateTask, onUpdate]); // Render tree node const renderTreeNode = (node: TreeNode, level: number = 0): React.ReactNode => { @@ -518,8 +552,9 @@ function TreeSection({ )} - {/* Root drop zone */} - {isOpen && dragState.draggedId && dragState.draggedParentId && ( + {/* Root drop zone — shown when an internal dragged item has a parent (so it CAN move to root) + or when an external page card / row is being dragged anywhere */} + {isOpen && ((dragState.draggedId && dragState.draggedParentId) || isExternalDragActive) && (
void; - // --- DELETE BUTTON --- showDeleteButton?: boolean; onDelete?: () => void; @@ -265,11 +260,6 @@ const UnifiedHeader: React.FC = ({ currentPage, - // Pin - showPinButton = false, - isPinned = false, - onTogglePin, - // Delete showDeleteButton = false, onDelete, diff --git a/frontend/src/components/layout/UnifiedSidebar.tsx b/frontend/src/components/layout/UnifiedSidebar.tsx index a0aa86b..96f6975 100644 --- a/frontend/src/components/layout/UnifiedSidebar.tsx +++ b/frontend/src/components/layout/UnifiedSidebar.tsx @@ -23,37 +23,37 @@ import { useNavigationStore } from '@/stores/navigationStore'; import { usePagesStore, usePages, selectPageState, selectPageActions, type PagesState } from '@/stores/pagesStore'; import { useTasksStore, useTasks } from '@/stores/tasksStore'; import { useUIStore } from '@/stores/uiStore'; -import { useAuthStore } from '@/stores/authStore'; import { useDeleteConfirmStore } from '@/stores/deleteConfirmStore'; import { useSettingsStore } from '@/stores/settingsStore'; import { useTabsStore } from '@/stores/tabsStore'; import { selectSidebarCounts } from '@/lib/selectors'; import { getTodayISO, dayjs } from '@/lib/dateUtils'; -import { isRootLevel } from '@/lib/treeUtils'; -import { pb } from '@/lib/pocketbase'; +import { isInboxPlacement, isRootLevel } from '@/lib/treeUtils'; import { cn } from '@/lib/design-system'; import { FLOATING_PANEL_GUTTER_PX, getFloatingPanelReserveWidth } from '@/lib/layout'; // Components import TreeSection, { type TreeNode, type TreeItemConfig } from './TreeSection'; -import { NavItem, Divider, SectionHeader, Label, ContextMenu, type ContextMenuItem, ResizeHandle } from '@/components/ui'; +import { Divider, ContextMenu, type ContextMenuItem, ResizeHandle } from '@/components/ui'; import { MobileSheet } from '@/components/ui'; -import { Settings, LogOut, User, ExternalLink, Network, PanelLeftClose, PanelLeftOpen, PenLine, Pin, PinOff } from 'lucide-react'; +import { ExternalLink, Network, PanelLeftClose, PanelLeftOpen, PenLine, Pin, PinOff } from 'lucide-react'; import { HomeIcon, + InboxIcon, PagesIcon, CheckIcon, + ClockIcon, SearchIcon, } from '../common/Icons'; import ItemIcon from '../common/ItemIcon'; +import PageTypeDropdown from '../common/PageTypeDropdown'; import { openCommandPalette } from '@/hooks/useCommandPalette'; import { useMobileLayout } from '@/contexts/MobileLayoutContext'; import { useSplitViewStore } from '@/contexts/SplitViewContext'; import SidebarHeader from './SidebarHeader'; import { useIsMobile } from '@frameer/hooks/useMobileDetection'; -import { useWorkspaceStore, selectCurrentWorkspace } from '@/stores/workspaceStore'; import { usePageOperations } from '@/hooks/usePageOperations'; import { buildPageMenuItems } from '@/hooks/usePageContextMenu'; -import { filterOutBooxPages, filterOutBooxTree, findBooxRootPage } from '@/lib/pageUtils'; +import { filterOutBooxPages, filterOutBooxTree } from '@/lib/pageUtils'; // Types import type { Page, PageTreeNode, PageViewMode } from '@/types/page'; @@ -70,7 +70,6 @@ interface SidebarPage { viewMode?: PageViewMode; childCount?: number; showChildrenInSidebar?: boolean; - isPinned?: boolean; isReadOnly?: boolean; } @@ -119,7 +118,7 @@ const SidebarRailButton: React.FC = ({ title={label} aria-label={label} className={cn( - 'relative flex h-10 w-10 items-center justify-center rounded-full transition-all duration-200 ease-out', + 'relative flex h-9 w-9 items-center justify-center rounded-[1.15rem] transition-all duration-200 ease-out', 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-accent-fg)]/35 focus-visible:ring-offset-2 focus-visible:ring-offset-transparent', 'active:scale-[0.97] hover:-translate-y-0.5', isActive @@ -127,7 +126,7 @@ const SidebarRailButton: React.FC = ({ : 'glass-item-subtle text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)]' )} > - {icon} + {icon} {showTooltip ? (
@@ -246,7 +245,6 @@ const pageConfig: TreeItemConfig = { return 'note'; }, getViewMode: (n) => n.viewMode, - getIsPinned: (n) => n.isPinned ?? false, getShowChildrenInSidebar: (n) => n.showChildrenInSidebar, // Use showChildrenInSidebar if defined, otherwise default based on viewMode // Notes show subpages in sidebar by default, others don't @@ -289,7 +287,6 @@ const convertPageTree = (nodes: PageTreeNode[]): TreeNode[] => { cached.item.viewMode === node.page.viewMode && cached.item.childCount === node.page.childCount && cached.item.showChildrenInSidebar === node.page.showChildrenInSidebar && - cached.item.isPinned === node.page.isPinned && cached.item.isReadOnly === node.page.isReadOnly && cached.children === children ) { @@ -306,7 +303,6 @@ const convertPageTree = (nodes: PageTreeNode[]): TreeNode[] => { viewMode: node.page.viewMode, childCount: node.page.childCount, showChildrenInSidebar: node.page.showChildrenInSidebar, - isPinned: node.page.isPinned, isReadOnly: node.page.isReadOnly, }, children, @@ -325,7 +321,9 @@ interface MainNavigationProps { isPages: boolean; isGraph: boolean; isTasks: boolean; - selectedPageId: string | null; + isHandwritten: boolean; + shouldShowHandwrittenNav: boolean; + currentDateLabel: string; pagesEditedRecently: number; sidebarCounts: { allCount: number; @@ -336,78 +334,166 @@ interface MainNavigationProps { onNavigateToPages: () => void; onNavigateToGraph: () => void; onNavigateToTasks: () => void; + onNavigateToHandwritten: () => void; taskFilterFromStore: string | null; } +interface MainNavigationTileProps { + icon: React.ReactNode; + label: string; + isActive: boolean; + onClick: () => void; + trailing?: React.ReactNode; +} + +const NavPill: React.FC<{ tone?: 'neutral' | 'accent' | 'warning' | 'danger'; children: React.ReactNode }> = ({ + tone = 'neutral', + children, +}) => ( + + {children} + +); + +const MainNavigationTile: React.FC = ({ + icon, + label, + isActive, + onClick, + trailing, +}) => ( + +); + const MainNavigation: React.FC = React.memo(({ isHome, isPages, isGraph, isTasks, - selectedPageId, + isHandwritten, + shouldShowHandwrittenNav, + currentDateLabel, pagesEditedRecently, sidebarCounts, onNavigateToHome, onNavigateToPages, onNavigateToGraph, onNavigateToTasks, + onNavigateToHandwritten, taskFilterFromStore, }) => { - const isMobile = useIsMobile(); - const isEink = useSettingsStore((s) => s.einkMode); const { tabsEnabled, homeContextMenu, pagesContextMenu, graphContextMenu, tasksContextMenu } = usePrimaryNavContextMenus(taskFilterFromStore); - - if (isMobile) return null; + + const items: Array = [ + { + key: 'home', + icon: , + label: 'Home', + isActive: isHome, + onClick: onNavigateToHome, + trailing: {currentDateLabel}, + contextMenuItems: homeContextMenu, + }, + { + key: 'search', + icon: , + label: 'Search', + isActive: false, + onClick: openCommandPalette, + trailing: ( + + {typeof navigator !== 'undefined' && navigator.platform.toUpperCase().indexOf('MAC') >= 0 ? '⌘' : 'Ctrl'}K + + ), + }, + { + key: 'pages', + icon: , + label: 'Pages', + isActive: isPages, + onClick: onNavigateToPages, + trailing: {pagesEditedRecently} in 7D, + contextMenuItems: pagesContextMenu, + }, + { + key: 'tasks', + icon: , + label: 'Tasks', + isActive: isTasks, + onClick: onNavigateToTasks, + trailing: ( + sidebarCounts.overdueCount > 0 + ? {sidebarCounts.overdueCount} late + : sidebarCounts.todayCount > 0 + ? {sidebarCounts.todayCount} today + : {sidebarCounts.allCount} + ), + contextMenuItems: tasksContextMenu, + }, + { + key: 'graph', + icon: , + label: 'Graph', + isActive: isGraph, + onClick: onNavigateToGraph, + trailing: Explore, + contextMenuItems: graphContextMenu, + }, + ]; + + if (shouldShowHandwrittenNav) { + items.push({ + key: 'handwritten', + icon: , + label: 'BOOX', + isActive: isHandwritten, + onClick: onNavigateToHandwritten, + trailing: notes, + }); + } return ( -