From b91aded1ba3e925d13f3eff6de632bf69e4e970b Mon Sep 17 00:00:00 2001 From: lucaronin Date: Sun, 22 Feb 2026 12:08:53 +0100 Subject: [PATCH] feat: add sort direction (asc/desc) to note list sorting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add SortDirection type and SortConfig interface to noteListHelpers - getSortComparator now accepts optional direction parameter - SortDropdown shows ↑/↓ arrow buttons per option in the menu - Button label shows current direction arrow (ArrowUp/ArrowDown) - Persistence updated to store {option, direction} with backward compat for old string-only preferences - Default directions: modified/created=desc, title/status=asc - Status sort tiebreaker always uses newest-first regardless of direction Co-Authored-By: Claude Opus 4.6 --- src/components/NoteList.tsx | 49 ++++++++++++------------ src/components/SortDropdown.tsx | 67 +++++++++++++++++++++++---------- src/utils/noteListHelpers.ts | 46 +++++++++++++++++----- 3 files changed, 109 insertions(+), 53 deletions(-) diff --git a/src/components/NoteList.tsx b/src/components/NoteList.tsx index d35552b0..bed98d63 100644 --- a/src/components/NoteList.tsx +++ b/src/components/NoteList.tsx @@ -8,14 +8,14 @@ import { getTypeColor, getTypeLightColor } from '../utils/typeColors' import { NoteItem, getTypeIcon } from './NoteItem' import { SortDropdown } from './SortDropdown' import { - type SortOption, type RelationshipGroup, - getSortComparator, + type SortOption, type SortDirection, type SortConfig, type RelationshipGroup, + DEFAULT_DIRECTIONS, getSortComparator, buildRelationshipGroups, filterEntries, sortByModified, relativeDate, getDisplayDate, loadSortPreferences, saveSortPreferences, } from '../utils/noteListHelpers' -// eslint-disable-next-line react-refresh/only-export-components -- re-exports for consumers +// Re-export for consumers export { sortByModified, filterEntries, buildRelationshipGroups, getSortComparator } export type { SortOption } @@ -38,10 +38,9 @@ function PinnedCard({ entry, typeEntryMap, onSelectNote, showDate }: { const te = typeEntryMap[entry.isA ?? ''] const color = getTypeColor(entry.isA ?? '', te?.color) const bgColor = getTypeLightColor(entry.isA ?? '', te?.color) - const Icon = useMemo(() => getTypeIcon(entry.isA, te?.icon), [entry.isA, te?.icon]) + const Icon = getTypeIcon(entry.isA, te?.icon) return (
onSelectNote(entry)}> - {/* eslint-disable-next-line react-hooks/static-components -- icon lookup from static map, no internal state */}
{entry.title}
{entry.snippet}
@@ -53,13 +52,13 @@ function PinnedCard({ entry, typeEntryMap, onSelectNote, showDate }: { function RelationshipGroupSection({ group, isCollapsed, sortPrefs, onToggle, handleSortChange, renderItem }: { group: RelationshipGroup isCollapsed: boolean - sortPrefs: Record + sortPrefs: Record onToggle: () => void - handleSortChange: (groupLabel: string, option: SortOption) => void + handleSortChange: (groupLabel: string, option: SortOption, direction: SortDirection) => void renderItem: (entry: VaultEntry) => React.ReactNode }) { - const groupSort = sortPrefs[group.label] ?? 'modified' - const sortedEntries = [...group.entries].sort(getSortComparator(groupSort)) + const groupConfig = sortPrefs[group.label] ?? { option: 'modified' as SortOption, direction: 'desc' as SortDirection } + const sortedEntries = [...group.entries].sort(getSortComparator(groupConfig.option, groupConfig.direction)) return (
@@ -68,7 +67,7 @@ function RelationshipGroupSection({ group, isCollapsed, sortPrefs, onToggle, han {group.entries.length} - + @@ -118,8 +117,8 @@ function useTypeEntryMap(entries: VaultEntry[]) { function EntityView({ entity, groups, query, collapsedGroups, sortPrefs, onToggleGroup, onSortChange, renderItem, typeEntryMap, onSelectNote }: { entity: VaultEntry; groups: RelationshipGroup[]; query: string - collapsedGroups: Set; sortPrefs: Record - onToggleGroup: (label: string) => void; onSortChange: (label: string, opt: SortOption) => void + collapsedGroups: Set; sortPrefs: Record + onToggleGroup: (label: string) => void; onSortChange: (label: string, opt: SortOption, dir: SortDirection) => void renderItem: (entry: VaultEntry) => React.ReactNode typeEntryMap: Record; onSelectNote: (entry: VaultEntry) => void }) { @@ -175,10 +174,10 @@ function countExpiredTrash(entries: VaultEntry[]): number { interface NoteListDataParams { entries: VaultEntry[]; selection: SidebarSelection; allContent: Record - query: string; listSort: SortOption + query: string; listSort: SortOption; listDirection: SortDirection; modifiedFiles?: ModifiedFile[] } -function useNoteListData({ entries, selection, allContent, query, listSort }: NoteListDataParams) { +function useNoteListData({ entries, selection, allContent, query, listSort, listDirection, modifiedFiles }: NoteListDataParams) { const isEntityView = selection.kind === 'entity' const isTrashView = selection.kind === 'filter' && selection.filter === 'trash' @@ -189,9 +188,9 @@ function useNoteListData({ entries, selection, allContent, query, listSort }: No const searched = useMemo(() => { if (isEntityView) return [] - const sorted = [...filterEntries(entries, selection)].sort(getSortComparator(listSort)) + const sorted = [...filterEntries(entries, selection, modifiedFiles)].sort(getSortComparator(listSort, listDirection)) return filterByQuery(sorted, query) - }, [entries, selection, isEntityView, listSort, query]) + }, [entries, selection, modifiedFiles, isEntityView, listSort, listDirection, query]) const searchedGroups = useMemo(() => { if (!isEntityView) return [] @@ -209,24 +208,26 @@ function useNoteListData({ entries, selection, allContent, query, listSort }: No // --- Main component --- -function NoteListInner({ entries, selection, selectedNote, allContent, onSelectNote, onCreateNote }: NoteListProps) { +function NoteListInner({ entries, selection, selectedNote, allContent, modifiedFiles, onSelectNote, onCreateNote }: NoteListProps) { const [search, setSearch] = useState('') const [searchVisible, setSearchVisible] = useState(false) const [collapsedGroups, setCollapsedGroups] = useState>(new Set()) - const [sortPrefs, setSortPrefs] = useState>(loadSortPreferences) + const [sortPrefs, setSortPrefs] = useState>(loadSortPreferences) - const handleSortChange = useCallback((groupLabel: string, option: SortOption) => { - setSortPrefs((prev) => { const next = { ...prev, [groupLabel]: option }; saveSortPreferences(next); return next }) + const handleSortChange = useCallback((groupLabel: string, option: SortOption, direction: SortDirection) => { + setSortPrefs((prev) => { const next = { ...prev, [groupLabel]: { option, direction } }; saveSortPreferences(next); return next }) }, []) const toggleGroup = useCallback((label: string) => { - setCollapsedGroups((prev) => { const next = new Set(prev); if (next.has(label)) next.delete(label); else next.add(label); return next }) + setCollapsedGroups((prev) => { const next = new Set(prev); next.has(label) ? next.delete(label) : next.add(label); return next }) }, []) const typeEntryMap = useTypeEntryMap(entries) const query = search.trim().toLowerCase() - const listSort = sortPrefs['__list__'] ?? 'modified' - const { isEntityView, isTrashView, typeDocument, searched, searchedGroups, expiredTrashCount } = useNoteListData({ entries, selection, allContent, query, listSort }) + const listConfig = sortPrefs['__list__'] ?? { option: 'modified' as SortOption, direction: 'desc' as SortDirection } + const listSort = listConfig.option + const listDirection = listConfig.direction + const { isEntityView, isTrashView, typeDocument, searched, searchedGroups, expiredTrashCount } = useNoteListData({ entries, selection, allContent, query, listSort, listDirection, modifiedFiles }) const renderItem = useCallback((entry: VaultEntry) => ( @@ -237,7 +238,7 @@ function NoteListInner({ entries, selection, selectedNote, allContent, onSelectN

{resolveHeaderTitle(selection, typeDocument)}

- {!isEntityView && } + {!isEntityView && } diff --git a/src/components/SortDropdown.tsx b/src/components/SortDropdown.tsx index 3b44969f..13e37133 100644 --- a/src/components/SortDropdown.tsx +++ b/src/components/SortDropdown.tsx @@ -1,12 +1,13 @@ import { useState, useEffect, useRef } from 'react' import { cn } from '@/lib/utils' -import { ArrowsDownUp, Check } from '@phosphor-icons/react' -import { type SortOption, SORT_OPTIONS } from '../utils/noteListHelpers' +import { ArrowUp, ArrowDown } from '@phosphor-icons/react' +import { type SortOption, type SortDirection, DEFAULT_DIRECTIONS, SORT_OPTIONS } from '../utils/noteListHelpers' -export function SortDropdown({ groupLabel, current, onChange }: { +export function SortDropdown({ groupLabel, current, direction, onChange }: { groupLabel: string current: SortOption - onChange: (groupLabel: string, option: SortOption) => void + direction: SortDirection + onChange: (groupLabel: string, option: SortOption, direction: SortDirection) => void }) { const [open, setOpen] = useState(false) const ref = useRef(null) @@ -20,11 +21,13 @@ export function SortDropdown({ groupLabel, current, onChange }: { return () => document.removeEventListener('mousedown', handleClick) }, [open]) - const handleSelect = (opt: SortOption) => { - onChange(groupLabel, opt) + const handleSelect = (opt: SortOption, dir: SortDirection) => { + onChange(groupLabel, opt, dir) setOpen(false) } + const DirectionIcon = direction === 'asc' ? ArrowUp : ArrowDown + return (
{open && ( -
- {SORT_OPTIONS.map((opt) => ( - - ))} +
+ {SORT_OPTIONS.map((opt) => { + const isActive = opt.value === current + return ( +
{ e.stopPropagation(); handleSelect(opt.value, isActive ? direction : DEFAULT_DIRECTIONS[opt.value]) }} + > + + {opt.label} + + + + + +
+ ) + })}
)}
diff --git a/src/utils/noteListHelpers.ts b/src/utils/noteListHelpers.ts index 2d9edbb6..8bc0068e 100644 --- a/src/utils/noteListHelpers.ts +++ b/src/utils/noteListHelpers.ts @@ -53,6 +53,19 @@ export function sortByModified(a: VaultEntry, b: VaultEntry): number { } export type SortOption = 'modified' | 'created' | 'title' | 'status' +export type SortDirection = 'asc' | 'desc' + +export interface SortConfig { + option: SortOption + direction: SortDirection +} + +export const DEFAULT_DIRECTIONS: Record = { + modified: 'desc', + created: 'desc', + title: 'asc', + status: 'asc', +} export const SORT_OPTIONS: { value: SortOption; label: string }[] = [ { value: 'modified', label: 'Modified' }, @@ -65,36 +78,51 @@ const STATUS_ORDER: Record = { Active: 0, Paused: 1, Done: 2, Finished: 3, } -export function getSortComparator(option: SortOption): (a: VaultEntry, b: VaultEntry) => number { +export function getSortComparator(option: SortOption, direction?: SortDirection): (a: VaultEntry, b: VaultEntry) => number { + const dir = direction ?? DEFAULT_DIRECTIONS[option] + const flip = dir === 'asc' ? 1 : -1 switch (option) { case 'modified': - return sortByModified + return (a, b) => flip * ((getDisplayDate(a) ?? 0) - (getDisplayDate(b) ?? 0)) case 'created': - return (a, b) => (b.createdAt ?? b.modifiedAt ?? 0) - (a.createdAt ?? a.modifiedAt ?? 0) + return (a, b) => flip * ((a.createdAt ?? a.modifiedAt ?? 0) - (b.createdAt ?? b.modifiedAt ?? 0)) case 'title': - return (a, b) => a.title.localeCompare(b.title) + return (a, b) => flip * a.title.localeCompare(b.title) case 'status': return (a, b) => { const sa = STATUS_ORDER[a.status ?? ''] ?? 999 const sb = STATUS_ORDER[b.status ?? ''] ?? 999 - if (sa !== sb) return sa - sb - return sortByModified(a, b) + if (sa !== sb) return flip * (sa - sb) + // Tiebreaker: always newest first regardless of direction + return (getDisplayDate(b) ?? 0) - (getDisplayDate(a) ?? 0) } } } const SORT_STORAGE_KEY = 'laputa-sort-preferences' -export function loadSortPreferences(): Record { +export function loadSortPreferences(): Record { try { const raw = localStorage.getItem(SORT_STORAGE_KEY) - return raw ? JSON.parse(raw) : {} + if (!raw) return {} + const parsed = JSON.parse(raw) + const result: Record = {} + for (const [key, value] of Object.entries(parsed)) { + if (typeof value === 'string') { + // Migrate old format: bare SortOption string → SortConfig + const opt = value as SortOption + result[key] = { option: opt, direction: DEFAULT_DIRECTIONS[opt] } + } else { + result[key] = value as SortConfig + } + } + return result } catch { return {} } } -export function saveSortPreferences(prefs: Record) { +export function saveSortPreferences(prefs: Record) { try { localStorage.setItem(SORT_STORAGE_KEY, JSON.stringify(prefs)) } catch { /* ignore */ }