feat: trash notes — sidebar filter, breadcrumb buttons, cmd+del shortcut, demo vault data

This commit is contained in:
lucaronin
2026-02-21 18:34:40 +01:00
20 changed files with 6713 additions and 35 deletions

View File

@@ -0,0 +1,21 @@
---
Is A: Experiment
Status: Dropped
trashed: true
trashed_at: "2026-02-05"
---
# Failed Podcast Experiment
Tried launching a weekly podcast alongside the newsletter. The production overhead was too high for the audience growth it generated.
## Hypothesis
A companion podcast would increase newsletter subscribers by 30% within 3 months.
## Results
- Produced 8 episodes over 2 months
- Gained only ~200 new subscribers (vs 2000 target)
- Each episode took 4-6 hours to produce
- ROI was negative compared to writing time
## Decision
Dropped the podcast and reallocated time back to writing. The newsletter remains the primary channel.

View File

@@ -0,0 +1,18 @@
---
Is A: Note
trashed: true
trashed_at: "2026-01-15"
Topics: ["[[topic-productivity-systems]]"]
---
# Deprecated Workflow
An old workflow for managing sponsorship outreach that was replaced by the new CRM-based process. Keeping in trash in case any of the templates are useful later.
## Old Process
1. Find potential sponsors via LinkedIn
2. Send cold email using template A
3. Follow up after 3 days
4. Schedule call if interested
## Why Deprecated
The manual process was too slow and error-prone. Switched to automated pipeline with CRM integration.

View File

@@ -0,0 +1,18 @@
---
Is A: Note
trashed: true
trashed_at: "2026-02-10"
Topics: ["[[topic-productivity-systems]]"]
---
# Old Meeting Notes
These are meeting notes from a brainstorming session that didn't lead anywhere. The project was shelved and these notes are no longer relevant.
## Attendees
- Luca
- Matteo
## Discussion
- Explored potential partnership with a media company
- Decided not to pursue after evaluating the terms
- Follow-up was scheduled but never happened

6084
design/trash-notes.pen Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -25,6 +25,9 @@ pub struct VaultEntry {
pub owner: Option<String>,
pub cadence: Option<String>,
pub archived: bool,
pub trashed: bool,
#[serde(rename = "trashedAt")]
pub trashed_at: Option<u64>,
#[serde(rename = "modifiedAt")]
pub modified_at: Option<u64>,
#[serde(rename = "createdAt")]
@@ -60,6 +63,10 @@ struct Frontmatter {
cadence: Option<String>,
#[serde(rename = "Archived")]
archived: Option<bool>,
#[serde(rename = "Trashed")]
trashed: Option<bool>,
#[serde(rename = "Trashed at")]
trashed_at: Option<String>,
#[serde(rename = "Created at")]
created_at: Option<String>,
#[serde(rename = "Created time")]
@@ -213,8 +220,8 @@ fn parse_frontmatter(data: &HashMap<String, serde_json::Value>) -> Frontmatter {
/// Known non-relationship frontmatter keys to skip (case-insensitive comparison).
/// Only skip keys that can never contain wikilinks.
const SKIP_KEYS: &[&str] = &[
"is a", "aliases", "status", "cadence", "archived", "created at", "created time",
"icon", "color",
"is a", "aliases", "status", "cadence", "archived", "trashed", "trashed at",
"created at", "created time", "icon", "color",
];
/// Check if a string contains a wikilink pattern `[[...]]`.
@@ -364,6 +371,8 @@ pub fn parse_md_file(path: &Path) -> Result<VaultEntry, String> {
owner: frontmatter.owner,
cadence: frontmatter.cadence,
archived: frontmatter.archived.unwrap_or(false),
trashed: frontmatter.trashed.unwrap_or(false),
trashed_at: frontmatter.trashed_at.as_deref().and_then(parse_iso_date),
modified_at, created_at, file_size,
icon: frontmatter.icon,
color: frontmatter.color,

View File

@@ -112,10 +112,26 @@ function App() {
vault.updateEntry(typeEntry.path, { icon, color })
}, [vault, notes])
const handleTrashNote = useCallback(async (path: string) => {
const now = new Date().toISOString().slice(0, 10)
await notes.handleUpdateFrontmatter(path, 'trashed', true)
await notes.handleUpdateFrontmatter(path, 'trashed_at', now)
vault.updateEntry(path, { trashed: true, trashedAt: Date.now() / 1000 })
setToastMessage('Note moved to trash')
}, [notes, vault, setToastMessage])
const handleRestoreNote = useCallback(async (path: string) => {
await notes.handleUpdateFrontmatter(path, 'trashed', false)
await notes.handleDeleteProperty(path, 'trashed_at')
vault.updateEntry(path, { trashed: false, trashedAt: null })
setToastMessage('Note restored from trash')
}, [notes, vault, setToastMessage])
useAppKeyboard({
onQuickOpen: () => setShowQuickOpen(true),
onCreateNote: openCreateDialog,
onSave: () => setToastMessage('Saved'),
onTrashNote: handleTrashNote,
activeTabPathRef: notes.activeTabPathRef,
handleCloseTabRef: notes.handleCloseTabRef,
})
@@ -193,6 +209,8 @@ function App() {
showAIChat={showAIChat}
onToggleAIChat={() => setShowAIChat(c => !c)}
vaultPath={vaultPath}
onTrashNote={handleTrashNote}
onRestoreNote={handleRestoreNote}
/>
</div>
</div>

View File

@@ -0,0 +1,70 @@
import { render, screen, fireEvent } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
import { BreadcrumbBar } from './BreadcrumbBar'
import type { VaultEntry } from '../types'
const baseEntry: VaultEntry = {
path: '/vault/note/test.md',
filename: 'test.md',
title: 'Test Note',
isA: 'Note',
aliases: [],
belongsTo: [],
relatedTo: [],
status: null,
owner: null,
cadence: null,
archived: false,
trashed: false,
trashedAt: null,
modifiedAt: 1700000000,
createdAt: null,
fileSize: 100,
snippet: '',
relationships: {},
icon: null,
color: null,
}
const trashedEntry: VaultEntry = {
...baseEntry,
trashed: true,
trashedAt: Date.now() / 1000 - 86400 * 5,
}
const defaultProps = {
wordCount: 100,
isModified: false,
showDiffToggle: false,
diffMode: false,
diffLoading: false,
onToggleDiff: vi.fn(),
}
describe('BreadcrumbBar — trash/restore', () => {
it('shows trash button for non-trashed note', () => {
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} onTrash={vi.fn()} onRestore={vi.fn()} />)
expect(screen.getByTitle('Move to trash (Cmd+Delete)')).toBeInTheDocument()
expect(screen.queryByTitle('Restore from trash')).not.toBeInTheDocument()
})
it('shows restore button for trashed note', () => {
render(<BreadcrumbBar entry={trashedEntry} {...defaultProps} onTrash={vi.fn()} onRestore={vi.fn()} />)
expect(screen.getByTitle('Restore from trash')).toBeInTheDocument()
expect(screen.queryByTitle('Move to trash (Cmd+Delete)')).not.toBeInTheDocument()
})
it('calls onTrash when trash button is clicked', () => {
const onTrash = vi.fn()
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} onTrash={onTrash} onRestore={vi.fn()} />)
fireEvent.click(screen.getByTitle('Move to trash (Cmd+Delete)'))
expect(onTrash).toHaveBeenCalledOnce()
})
it('calls onRestore when restore button is clicked', () => {
const onRestore = vi.fn()
render(<BreadcrumbBar entry={trashedEntry} {...defaultProps} onTrash={vi.fn()} onRestore={onRestore} />)
fireEvent.click(screen.getByTitle('Restore from trash'))
expect(onRestore).toHaveBeenCalledOnce()
})
})

View File

@@ -8,6 +8,8 @@ import {
Sparkle,
SlidersHorizontal,
DotsThree,
Trash,
ArrowCounterClockwise,
} from '@phosphor-icons/react'
interface BreadcrumbBarProps {
@@ -22,6 +24,8 @@ interface BreadcrumbBarProps {
onToggleAIChat?: () => void
inspectorCollapsed?: boolean
onToggleInspector?: () => void
onTrash?: () => void
onRestore?: () => void
}
const DISABLED_ICON_STYLE = { opacity: 0.4, cursor: 'not-allowed' } as const
@@ -29,6 +33,7 @@ const DISABLED_ICON_STYLE = { opacity: 0.4, cursor: 'not-allowed' } as const
export const BreadcrumbBar = memo(function BreadcrumbBar({
entry, wordCount, isModified, showDiffToggle, diffMode, diffLoading,
onToggleDiff, showAIChat, onToggleAIChat, inspectorCollapsed, onToggleInspector,
onTrash, onRestore,
}: BreadcrumbBarProps) {
return (
<div
@@ -104,6 +109,23 @@ export const BreadcrumbBar = memo(function BreadcrumbBar({
>
<Sparkle size={16} weight={showAIChat ? 'fill' : 'regular'} />
</button>
{entry.trashed ? (
<button
className="flex items-center justify-center border-none bg-transparent p-0 cursor-pointer transition-colors text-muted-foreground hover:text-foreground"
onClick={onRestore}
title="Restore from trash"
>
<ArrowCounterClockwise size={16} />
</button>
) : (
<button
className="flex items-center justify-center border-none bg-transparent p-0 cursor-pointer transition-colors text-muted-foreground hover:text-destructive"
onClick={onTrash}
title="Move to trash (Cmd+Delete)"
>
<Trash size={16} />
</button>
)}
{inspectorCollapsed && (
<button
className="flex items-center justify-center border-none bg-transparent p-0 text-muted-foreground cursor-pointer hover:text-foreground transition-colors"

View File

@@ -53,6 +53,8 @@ const mockEntry: VaultEntry = {
owner: 'Luca',
cadence: null,
archived: false,
trashed: false,
trashedAt: null,
modifiedAt: 1700000000,
createdAt: null,
fileSize: 1024,

View File

@@ -48,6 +48,8 @@ interface EditorProps {
showAIChat?: boolean
onToggleAIChat?: () => void
vaultPath?: string
onTrashNote?: (path: string) => void
onRestoreNote?: (path: string) => void
}
// --- Custom Inline Content: WikiLink ---
@@ -151,6 +153,7 @@ export const Editor = memo(function Editor({
onUpdateFrontmatter, onDeleteProperty, onAddProperty,
showAIChat, onToggleAIChat,
vaultPath,
onTrashNote, onRestoreNote,
}: EditorProps) {
const [diffMode, setDiffMode] = useState(false)
const [diffContent, setDiffContent] = useState<string | null>(null)
@@ -360,6 +363,8 @@ export const Editor = memo(function Editor({
onToggleAIChat={onToggleAIChat}
inspectorCollapsed={inspectorCollapsed}
onToggleInspector={onToggleInspector}
onTrash={onTrashNote ? () => onTrashNote(activeTab.entry.path) : undefined}
onRestore={onRestoreNote ? () => onRestoreNote(activeTab.entry.path) : undefined}
/>
) : null

View File

@@ -15,6 +15,8 @@ const mockEntry: VaultEntry = {
owner: 'Luca Rossi',
cadence: null,
archived: false,
trashed: false,
trashedAt: null,
modifiedAt: 1707900000,
createdAt: null,
fileSize: 1024,
@@ -54,6 +56,8 @@ const referrerEntry: VaultEntry = {
owner: null,
cadence: null,
archived: false,
trashed: false,
trashedAt: null,
modifiedAt: 1707900000,
createdAt: null,
fileSize: 200,

View File

@@ -4,7 +4,7 @@ import type { VaultEntry, GitCommit } from '../types'
import { cn } from '@/lib/utils'
import {
SlidersHorizontal, X, Wrench, Flask, Target, ArrowsClockwise,
Users, CalendarBlank, Tag, FileText, StackSimple,
Users, CalendarBlank, Tag, FileText, StackSimple, Trash,
} from '@phosphor-icons/react'
import { parseFrontmatter, type ParsedFrontmatter } from '../utils/frontmatter'
import { DynamicPropertiesPanel, RELATIONSHIP_KEYS, containsWikilinks } from './DynamicPropertiesPanel'
@@ -84,6 +84,8 @@ function RelationshipGroup({ label, refs, entries, onNavigate }: { label: string
const resolved = resolveRef(ref, entries)
const refType = resolved?.isA ?? undefined
const isArchived = resolved?.archived ?? false
const isTrashed = resolved?.trashed ?? false
const isDimmed = isArchived || isTrashed
const color = refType ? getTypeColor(refType) : 'var(--accent-blue)'
const bgColor = refType ? getTypeLightColor(refType) : 'var(--accent-blue-light)'
const TypeIcon = getTypeIcon(refType)
@@ -92,19 +94,21 @@ function RelationshipGroup({ label, refs, entries, onNavigate }: { label: string
key={`${ref}-${idx}`}
className="flex items-center justify-between gap-2 border-none text-left cursor-pointer hover:opacity-80"
style={{
background: isArchived ? 'var(--muted)' : bgColor,
color: isArchived ? 'var(--muted-foreground)' : color,
background: isDimmed ? 'var(--muted)' : bgColor,
color: isDimmed ? 'var(--muted-foreground)' : color,
borderRadius: 6, padding: '6px 10px', fontSize: 12, fontWeight: 500,
opacity: isArchived ? 0.7 : 1,
opacity: isDimmed ? 0.7 : 1,
}}
onClick={() => onNavigate(wikilinkTarget(ref))}
title={isArchived ? 'Archived' : undefined}
title={isTrashed ? 'Trashed' : isArchived ? 'Archived' : undefined}
>
<span className="flex-1 truncate">
<span className="flex items-center gap-1 flex-1 truncate">
{isTrashed && <Trash size={12} className="shrink-0" />}
{wikilinkDisplay(ref)}
{isArchived && <span style={{ marginLeft: 4, fontSize: 10, opacity: 0.8 }}>(archived)</span>}
{isTrashed && <span style={{ fontSize: 10, opacity: 0.8 }}>(trashed)</span>}
{isArchived && !isTrashed && <span style={{ marginLeft: 4, fontSize: 10, opacity: 0.8 }}>(archived)</span>}
</span>
<TypeIcon width={14} height={14} className="shrink-0" style={{ color: isArchived ? 'var(--muted-foreground)' : color }} />
<TypeIcon width={14} height={14} className="shrink-0" style={{ color: isDimmed ? 'var(--muted-foreground)' : color }} />
</button>
)
})}
@@ -182,19 +186,22 @@ function BacklinksPanel({ backlinks, onNavigate }: { backlinks: VaultEntry[]; on
{backlinks.map((e) => {
const color = e.isA ? getTypeColor(e.isA) : 'var(--accent-blue)'
const TypeIcon = getTypeIcon(e.isA ?? undefined)
const isDimmed = e.archived || e.trashed
return (
<button
key={e.path}
className="flex items-center justify-between gap-2 border-none bg-transparent p-0 py-1 text-left text-[13px] cursor-pointer hover:opacity-80"
style={{ color: e.archived ? 'var(--muted-foreground)' : color, opacity: e.archived ? 0.7 : 1 }}
style={{ color: isDimmed ? 'var(--muted-foreground)' : color, opacity: isDimmed ? 0.7 : 1 }}
onClick={() => onNavigate(e.title)}
title={e.archived ? 'Archived' : undefined}
title={e.trashed ? 'Trashed' : e.archived ? 'Archived' : undefined}
>
<span className="flex-1 truncate">
<span className="flex items-center gap-1 flex-1 truncate">
{e.trashed && <Trash size={12} className="shrink-0" />}
{e.title}
{e.archived && <span style={{ marginLeft: 4, fontSize: 10, opacity: 0.8 }}>(archived)</span>}
{e.trashed && <span style={{ fontSize: 10, opacity: 0.8 }}>(trashed)</span>}
{e.archived && !e.trashed && <span style={{ marginLeft: 4, fontSize: 10, opacity: 0.8 }}>(archived)</span>}
</span>
{e.isA && <TypeIcon width={14} height={14} className="shrink-0" style={{ color: e.archived ? 'var(--muted-foreground)' : color }} />}
{e.isA && <TypeIcon width={14} height={14} className="shrink-0" style={{ color: isDimmed ? 'var(--muted-foreground)' : color }} />}
</button>
)
})}

View File

@@ -1,6 +1,6 @@
import { render, screen, fireEvent } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NoteList, getSortComparator } from './NoteList'
import { NoteList, getSortComparator, filterEntries } from './NoteList'
import type { VaultEntry, SidebarSelection } from '../types'
const allSelection: SidebarSelection = { kind: 'filter', filter: 'all' }
@@ -19,6 +19,8 @@ const mockEntries: VaultEntry[] = [
owner: 'Luca',
cadence: null,
archived: false,
trashed: false,
trashedAt: null,
modifiedAt: 1700000000,
createdAt: null,
fileSize: 1024,
@@ -41,6 +43,8 @@ const mockEntries: VaultEntry[] = [
owner: null,
cadence: null,
archived: false,
trashed: false,
trashedAt: null,
modifiedAt: 1700000000,
createdAt: null,
fileSize: 847,
@@ -64,6 +68,8 @@ const mockEntries: VaultEntry[] = [
owner: null,
cadence: null,
archived: false,
trashed: false,
trashedAt: null,
modifiedAt: 1700000000,
createdAt: null,
fileSize: 320,
@@ -84,6 +90,8 @@ const mockEntries: VaultEntry[] = [
owner: null,
cadence: null,
archived: false,
trashed: false,
trashedAt: null,
modifiedAt: 1700000000,
createdAt: null,
fileSize: 512,
@@ -104,6 +112,8 @@ const mockEntries: VaultEntry[] = [
owner: null,
cadence: null,
archived: false,
trashed: false,
trashedAt: null,
modifiedAt: 1700000000,
createdAt: null,
fileSize: 256,
@@ -445,3 +455,129 @@ describe('NoteList sort controls', () => {
expect(titles).toEqual(['Alpha Note', 'Zebra Note'])
})
})
// --- Trash feature tests ---
const trashedEntry: VaultEntry = {
path: '/vault/note/old-draft.md',
filename: 'old-draft.md',
title: 'Old Draft Notes',
isA: 'Note',
aliases: [],
belongsTo: [],
relatedTo: [],
status: null,
owner: null,
cadence: null,
archived: false,
trashed: true,
trashedAt: Date.now() / 1000 - 86400 * 5,
modifiedAt: 1700000000,
createdAt: null,
fileSize: 280,
snippet: 'Some draft content that is no longer needed.',
relationships: {},
icon: null,
color: null,
}
const expiredTrashedEntry: VaultEntry = {
path: '/vault/note/deprecated-api.md',
filename: 'deprecated-api.md',
title: 'Deprecated API Notes',
isA: 'Note',
aliases: [],
belongsTo: [],
relatedTo: [],
status: null,
owner: null,
cadence: null,
archived: false,
trashed: true,
trashedAt: Date.now() / 1000 - 86400 * 35,
modifiedAt: 1700000000,
createdAt: null,
fileSize: 190,
snippet: 'Old API docs replaced by v2.',
relationships: {},
icon: null,
color: null,
}
const entriesWithTrashed = [...mockEntries, trashedEntry, expiredTrashedEntry]
describe('filterEntries — trash', () => {
it('excludes trashed entries from "all" filter', () => {
const result = filterEntries(entriesWithTrashed, { kind: 'filter', filter: 'all' })
expect(result.find((e) => e.title === 'Old Draft Notes')).toBeUndefined()
expect(result.find((e) => e.title === 'Build Laputa App')).toBeDefined()
})
it('excludes trashed entries from section group', () => {
const result = filterEntries(entriesWithTrashed, { kind: 'sectionGroup', type: 'Note' })
expect(result.find((e) => e.title === 'Old Draft Notes')).toBeUndefined()
})
it('trash filter returns only trashed entries', () => {
const result = filterEntries(entriesWithTrashed, { kind: 'filter', filter: 'trash' })
expect(result).toHaveLength(2)
expect(result.every((e) => e.trashed)).toBe(true)
})
it('archived filter excludes trashed entries', () => {
const archivedAndTrashed = [
...mockEntries,
{ ...mockEntries[0], path: '/archived.md', archived: true, trashed: false, trashedAt: null, title: 'Archived Note' },
trashedEntry,
]
const result = filterEntries(archivedAndTrashed, { kind: 'filter', filter: 'archived' })
expect(result.find((e) => e.title === 'Archived Note')).toBeDefined()
expect(result.find((e) => e.title === 'Old Draft Notes')).toBeUndefined()
})
it('topic filter excludes trashed entries', () => {
const topicEntry: VaultEntry = { ...mockEntries[4] } // Software Development topic
const trashedWithTopic: VaultEntry = {
...trashedEntry,
relatedTo: ['[[topic/software-development]]'],
}
const all = [...mockEntries, trashedWithTopic]
const result = filterEntries(all, { kind: 'topic', entry: topicEntry })
expect(result.find((e) => e.title === 'Old Draft Notes')).toBeUndefined()
// Normal entry with that topic should still appear
expect(result.find((e) => e.title === 'Build Laputa App')).toBeDefined()
})
})
describe('NoteList — trash view', () => {
const trashSelection: SidebarSelection = { kind: 'filter', filter: 'trash' }
it('shows "Trash" header when trash filter is active', () => {
render(<NoteList entries={entriesWithTrashed} selection={trashSelection} selectedNote={null} onSelectNote={noopSelect} allContent={{}} onCreateNote={vi.fn()} />)
expect(screen.getByText('Trash')).toBeInTheDocument()
})
it('shows only trashed entries in trash view', () => {
render(<NoteList entries={entriesWithTrashed} selection={trashSelection} selectedNote={null} onSelectNote={noopSelect} allContent={{}} onCreateNote={vi.fn()} />)
expect(screen.getByText('Old Draft Notes')).toBeInTheDocument()
expect(screen.getByText('Deprecated API Notes')).toBeInTheDocument()
expect(screen.queryByText('Build Laputa App')).not.toBeInTheDocument()
})
it('shows TRASHED badge on trashed entries', () => {
render(<NoteList entries={entriesWithTrashed} selection={trashSelection} selectedNote={null} onSelectNote={noopSelect} allContent={{}} onCreateNote={vi.fn()} />)
const badges = screen.getAllByText('TRASHED')
expect(badges.length).toBeGreaterThanOrEqual(1)
})
it('shows 30-day warning banner when expired notes exist', () => {
render(<NoteList entries={entriesWithTrashed} selection={trashSelection} selectedNote={null} onSelectNote={noopSelect} allContent={{}} onCreateNote={vi.fn()} />)
expect(screen.getByText('Notes in trash for 30+ days will be permanently deleted')).toBeInTheDocument()
expect(screen.getByText(/1 note is past the 30-day retention period/)).toBeInTheDocument()
})
it('shows "Trash is empty" when no trashed entries', () => {
render(<NoteList entries={mockEntries} selection={trashSelection} selectedNote={null} onSelectNote={noopSelect} allContent={{}} onCreateNote={vi.fn()} />)
expect(screen.getByText('Trash is empty')).toBeInTheDocument()
})
})

View File

@@ -6,7 +6,7 @@ import { Input } from '@/components/ui/input'
import {
MagnifyingGlass, Plus, Wrench, Flask, Target, ArrowsClockwise,
Users, CalendarBlank, Tag, FileText, CaretDown, CaretRight, StackSimple,
ArrowsDownUp, Check,
ArrowsDownUp, Check, Warning,
} from '@phosphor-icons/react'
import type { ComponentType, SVGAttributes } from 'react'
import { getTypeColor, getTypeLightColor } from '../utils/typeColors'
@@ -255,20 +255,22 @@ export function filterEntries(entries: VaultEntry[], selection: SidebarSelection
case 'filter':
switch (selection.filter) {
case 'all':
return entries.filter((e) => !e.archived)
return entries.filter((e) => !e.archived && !e.trashed)
case 'favorites':
return []
case 'archived':
return entries.filter((e) => e.archived)
return entries.filter((e) => e.archived && !e.trashed)
case 'trash':
return entries.filter((e) => e.trashed)
}
break
case 'sectionGroup':
return entries.filter((e) => e.isA === selection.type && !e.archived)
return entries.filter((e) => e.isA === selection.type && !e.archived && !e.trashed)
case 'entity':
return []
case 'topic': {
const topic = selection.entry
return entries.filter((e) => refsMatch(e.relatedTo, topic) && !e.archived)
return entries.filter((e) => refsMatch(e.relatedTo, topic) && !e.archived && !e.trashed)
}
}
}
@@ -350,6 +352,7 @@ function NoteListInner({ entries, selection, selectedNote, allContent, modifiedF
const isEntityView = selection.kind === 'entity'
const isSectionGroup = selection.kind === 'sectionGroup'
const isTrashView = selection.kind === 'filter' && selection.filter === 'trash'
const handleSortChange = useCallback((groupLabel: string, option: SortOption) => {
setSortPrefs((prev) => {
@@ -421,6 +424,12 @@ function NoteListInner({ entries, selection, selectedNote, allContent, modifiedF
)
const expiredTrashCount = useMemo(() => {
if (!isTrashView) return 0
const now = Date.now() / 1000
return searched.filter((e) => e.trashedAt && (now - e.trashedAt) >= 86400 * 30).length
}, [isTrashView, searched])
const renderItem = useCallback((entry: VaultEntry, isPinned = false) => {
const isSelected = selectedNote?.path === entry.path && !isPinned
const te = typeEntryMap[entry.isA ?? '']
@@ -466,13 +475,23 @@ function NoteListInner({ entries, selection, selectedNote, allContent, modifiedF
ARCHIVED
</span>
)}
{entry.trashed && (
<span
className="ml-1.5 inline-block align-middle"
style={{ fontSize: 9, fontWeight: 500, background: 'var(--destructive-light, #ef44441a)', color: 'var(--destructive)', borderRadius: 4, padding: '1px 4px', verticalAlign: 'middle' }}
>
TRASHED
</span>
)}
</div>
</div>
<div className="mt-0.5 text-[12px] leading-[1.5] text-muted-foreground" style={{ display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
{entry.snippet}
</div>
<div className="mt-0.5 text-[10px] text-muted-foreground">
{relativeDate(getDisplayDate(entry))}
<div className="mt-0.5 text-[10px] text-muted-foreground" style={entry.trashed && entry.trashedAt && (Date.now() / 1000 - entry.trashedAt) >= 86400 * 30 ? { color: 'var(--destructive)', fontWeight: 500 } : undefined}>
{entry.trashed && entry.trashedAt
? `Trashed ${relativeDate(entry.trashedAt)}${(Date.now() / 1000 - entry.trashedAt) >= 86400 * 30 ? ' — will be permanently deleted' : ''}`
: relativeDate(getDisplayDate(entry))}
</div>
</div>
)
@@ -571,7 +590,9 @@ function NoteListInner({ entries, selection, selectedNote, allContent, modifiedF
? typeDocument.title
: selection.kind === 'filter' && (selection as { filter: string }).filter === 'archived'
? 'Archive'
: 'Notes'}
: selection.kind === 'filter' && (selection as { filter: string }).filter === 'trash'
? 'Trash'
: 'Notes'}
</h3>
<div className="flex items-center gap-3" style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}>
{!isEntityView && (
@@ -646,8 +667,27 @@ function NoteListInner({ entries, selection, selectedNote, allContent, modifiedF
</div>
)
})()}
{/* 30-day warning banner for trash view */}
{isTrashView && expiredTrashCount > 0 && (
<div
className="flex items-start gap-2 border-b border-[var(--border)]"
style={{ padding: '10px 12px', background: 'color-mix(in srgb, var(--destructive) 6%, transparent)' }}
>
<Warning size={16} className="shrink-0" style={{ color: 'var(--destructive)', marginTop: 1 }} />
<div>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--destructive)' }}>
Notes in trash for 30+ days will be permanently deleted
</div>
<div className="text-muted-foreground" style={{ fontSize: 11 }}>
{expiredTrashCount} {expiredTrashCount === 1 ? 'note is' : 'notes are'} past the 30-day retention period
</div>
</div>
</div>
)}
{searched.length === 0 ? (
<div className="px-4 py-8 text-center text-[13px] text-muted-foreground">No notes found</div>
<div className="px-4 py-8 text-center text-[13px] text-muted-foreground">
{isTrashView ? 'Trash is empty' : 'No notes found'}
</div>
) : (
searched.map((entry) => renderItem(entry))
)}

View File

@@ -28,6 +28,8 @@ const mockEntries: VaultEntry[] = [
owner: 'Luca',
cadence: null,
archived: false,
trashed: false,
trashedAt: null,
modifiedAt: 1700000000,
createdAt: null,
fileSize: 1024,
@@ -48,6 +50,8 @@ const mockEntries: VaultEntry[] = [
owner: 'Luca',
cadence: null,
archived: false,
trashed: false,
trashedAt: null,
modifiedAt: 1700000000,
createdAt: null,
fileSize: 512,
@@ -68,6 +72,8 @@ const mockEntries: VaultEntry[] = [
owner: 'Luca',
cadence: null,
archived: false,
trashed: false,
trashedAt: null,
modifiedAt: 1700000000,
createdAt: null,
fileSize: 256,
@@ -88,6 +94,8 @@ const mockEntries: VaultEntry[] = [
owner: 'Luca',
cadence: 'Weekly',
archived: false,
trashed: false,
trashedAt: null,
modifiedAt: 1700000000,
createdAt: null,
fileSize: 128,
@@ -108,6 +116,8 @@ const mockEntries: VaultEntry[] = [
owner: null,
cadence: null,
archived: false,
trashed: false,
trashedAt: null,
modifiedAt: 1700000000,
createdAt: null,
fileSize: 256,
@@ -128,6 +138,8 @@ const mockEntries: VaultEntry[] = [
owner: null,
cadence: null,
archived: false,
trashed: false,
trashedAt: null,
modifiedAt: 1700000000,
createdAt: null,
fileSize: 180,
@@ -148,6 +160,8 @@ const mockEntries: VaultEntry[] = [
owner: null,
cadence: null,
archived: false,
trashed: false,
trashedAt: null,
modifiedAt: 1700000000,
createdAt: null,
fileSize: 100,
@@ -168,6 +182,8 @@ const mockEntries: VaultEntry[] = [
owner: null,
cadence: null,
archived: false,
trashed: false,
trashedAt: null,
modifiedAt: 1700000000,
createdAt: null,
fileSize: 200,
@@ -315,6 +331,8 @@ describe('Sidebar', () => {
owner: null,
cadence: null,
archived: false,
trashed: false,
trashedAt: null,
modifiedAt: 1700000000,
createdAt: null,
fileSize: 200,
@@ -335,6 +353,8 @@ describe('Sidebar', () => {
owner: null,
cadence: null,
archived: false,
trashed: false,
trashedAt: null,
modifiedAt: 1700000000,
createdAt: null,
fileSize: 200,
@@ -355,6 +375,8 @@ describe('Sidebar', () => {
owner: null,
cadence: null,
archived: false,
trashed: false,
trashedAt: null,
modifiedAt: 1700000000,
createdAt: null,
fileSize: 300,
@@ -403,6 +425,8 @@ describe('Sidebar', () => {
owner: null,
cadence: null,
archived: false,
trashed: false,
trashedAt: null,
modifiedAt: 1700000000,
createdAt: null,
fileSize: 200,

View File

@@ -136,6 +136,7 @@ export const Sidebar = memo(function Sidebar({ entries, selection, onSelect, onS
)
const archivedCount = useMemo(() => entries.filter((e) => e.archived).length, [entries])
const trashedCount = useMemo(() => entries.filter((e) => e.trashed).length, [entries])
// Close context menu on outside click
useEffect(() => {
@@ -192,7 +193,7 @@ export const Sidebar = memo(function Sidebar({ entries, selection, onSelect, onS
}, [customizeTarget, typeEntryMap, onCustomizeType])
const renderSection = ({ label, type, Icon, customColor }: SectionGroup) => {
const items = entries.filter((e) => e.isA === type && !e.archived)
const items = entries.filter((e) => e.isA === type && !e.archived && !e.trashed)
const isCollapsed = collapsed[type] ?? true
const isTopic = type === 'Topic'
const isTypeSection = type === 'Type'
@@ -293,7 +294,7 @@ export const Sidebar = memo(function Sidebar({ entries, selection, onSelect, onS
{/* Top nav — All Notes + Favorites */}
<div className="border-b border-border" style={{ padding: '4px 6px' }}>
{TOP_NAV.map(({ label, filter, Icon }) => {
const count = filter === 'all' ? entries.filter((e) => !e.archived).length : 0
const count = filter === 'all' ? entries.filter((e) => !e.archived && !e.trashed).length : 0
return (
<div
key={filter}
@@ -350,13 +351,27 @@ export const Sidebar = memo(function Sidebar({ entries, selection, onSelect, onS
<TagSimple size={16} />
<span className="flex-1 text-[13px] font-medium">Untagged</span>
</div>
{/* Trash filter */}
<div
className="flex select-none items-center gap-2 rounded text-foreground"
style={{ padding: '6px 16px', borderRadius: 4, opacity: 0.4, cursor: 'not-allowed' }}
title="Coming soon"
className={cn(
"flex cursor-pointer select-none items-center gap-2 rounded transition-colors",
isActive({ kind: 'filter', filter: 'trash' })
? "bg-destructive/10 text-destructive"
: "text-foreground hover:bg-accent"
)}
style={{ padding: '6px 16px', borderRadius: 4 }}
onClick={() => onSelect({ kind: 'filter', filter: 'trash' })}
>
<Trash size={16} />
<span className="flex-1 text-[13px] font-medium">Trash</span>
{trashedCount > 0 && (
<span
className="flex items-center justify-center text-muted-foreground"
style={{ height: 20, borderRadius: 9999, padding: '0 6px', fontSize: 10, background: 'var(--muted)' }}
>
{trashedCount}
</span>
)}
</div>
</div>

View File

@@ -4,12 +4,13 @@ interface KeyboardActions {
onQuickOpen: () => void
onCreateNote: () => void
onSave: () => void
onTrashNote: (path: string) => void
activeTabPathRef: React.MutableRefObject<string | null>
handleCloseTabRef: React.MutableRefObject<(path: string) => void>
}
export function useAppKeyboard({
onQuickOpen, onCreateNote, onSave,
onQuickOpen, onCreateNote, onSave, onTrashNote,
activeTabPathRef, handleCloseTabRef,
}: KeyboardActions) {
useEffect(() => {
@@ -36,9 +37,16 @@ export function useAppKeyboard({
if (path) handleCloseTabRef.current(path)
break
}
case 'Backspace':
case 'Delete': {
e.preventDefault()
const path = activeTabPathRef.current
if (path) onTrashNote(path)
break
}
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [onQuickOpen, onCreateNote, onSave, activeTabPathRef, handleCloseTabRef])
}, [onQuickOpen, onCreateNote, onSave, onTrashNote, activeTabPathRef, handleCloseTabRef])
}

View File

@@ -213,7 +213,8 @@ export function useNoteActions(
path, filename: `${slug}.md`, title, isA: type,
aliases: [], belongsTo: [], relatedTo: [],
status: noStatusTypes.has(type) ? null : 'Active',
owner: null, cadence: null, archived: false, modifiedAt: now, createdAt: now, fileSize: 0,
owner: null, cadence: null, archived: false, trashed: false, trashedAt: null,
modifiedAt: now, createdAt: now, fileSize: 0,
snippet: '', relationships: {}, icon: null, color: null,
}
@@ -240,7 +241,7 @@ export function useNoteActions(
const newEntry: VaultEntry = {
path, filename: `${slug}.md`, title: typeName, isA: 'Type',
aliases: [], belongsTo: [], relatedTo: [],
status: null, owner: null, cadence: null, archived: false,
status: null, owner: null, cadence: null, archived: false, trashed: false, trashedAt: null,
modifiedAt: now, createdAt: now, fileSize: 0,
snippet: '', relationships: {}, icon: null, color: null,
}

View File

@@ -583,6 +583,45 @@ A **book** you're reading or have read. Track reading progress, notes, and key t
- **Author**: The book's author
- **Status**: Reading, Finished, Abandoned
- **Rating**: 1-5 stars
`,
// --- Trashed entries ---
'/Users/luca/Laputa/note/old-draft-notes.md': `---
title: Old Draft Notes
is_a: Note
trashed: true
trashed_at: ${new Date(Date.now() - 86400000 * 5).toISOString().slice(0, 10)}
belongs_to:
- "[[project/26q1-laputa-app]]"
---
# Old Draft Notes
Some rough draft content that is no longer relevant. Moving to trash.
`,
'/Users/luca/Laputa/note/deprecated-api-notes.md': `---
title: Deprecated API Notes
is_a: Note
trashed: true
trashed_at: ${new Date(Date.now() - 86400000 * 35).toISOString().slice(0, 10)}
---
# Deprecated API Notes
Old API documentation for the v1 endpoint. Replaced by v2 docs.
`,
'/Users/luca/Laputa/experiment/failed-seo-experiment.md': `---
title: Failed SEO Experiment
is_a: Experiment
status: Dropped
trashed: true
trashed_at: ${new Date(Date.now() - 86400000 * 10).toISOString().slice(0, 10)}
related_to:
- "[[responsibility/grow-newsletter]]"
---
# Failed SEO Experiment
Tried programmatic SEO pages. Results were negligible — trashing this.
`,
// --- Archived entries ---
'/Users/luca/Laputa/project/25q3-website-redesign.md': `---
@@ -672,6 +711,8 @@ const MOCK_ENTRIES: VaultEntry[] = [
owner: 'Luca Rossi',
cadence: null,
archived: false,
trashed: false,
trashedAt: null,
modifiedAt: Date.now() / 1000,
createdAt: Date.now() / 1000 - 86400 * 60,
fileSize: 2048,
@@ -696,6 +737,8 @@ const MOCK_ENTRIES: VaultEntry[] = [
owner: 'Luca Rossi',
cadence: null,
archived: false,
trashed: false,
trashedAt: null,
modifiedAt: Date.now() / 1000 - 3600,
createdAt: Date.now() / 1000 - 86400 * 180,
fileSize: 1024,
@@ -725,6 +768,8 @@ const MOCK_ENTRIES: VaultEntry[] = [
owner: 'Matteo Cellini',
cadence: null,
archived: false,
trashed: false,
trashedAt: null,
modifiedAt: Date.now() / 1000 - 7200,
createdAt: Date.now() / 1000 - 86400 * 150,
fileSize: 890,
@@ -748,6 +793,8 @@ const MOCK_ENTRIES: VaultEntry[] = [
owner: 'Luca Rossi',
cadence: 'Weekly',
archived: false,
trashed: false,
trashedAt: null,
modifiedAt: Date.now() / 1000 - 86400,
createdAt: Date.now() / 1000 - 86400 * 120,
fileSize: 512,
@@ -771,6 +818,8 @@ const MOCK_ENTRIES: VaultEntry[] = [
owner: 'Matteo Cellini',
cadence: 'Weekly',
archived: false,
trashed: false,
trashedAt: null,
modifiedAt: Date.now() / 1000 - 86400 * 2,
createdAt: Date.now() / 1000 - 86400 * 100,
fileSize: 640,
@@ -794,6 +843,8 @@ const MOCK_ENTRIES: VaultEntry[] = [
owner: 'Luca Rossi',
cadence: null,
archived: false,
trashed: false,
trashedAt: null,
modifiedAt: Date.now() / 1000 - 86400,
createdAt: Date.now() / 1000 - 86400 * 45,
fileSize: 3200,
@@ -818,6 +869,8 @@ const MOCK_ENTRIES: VaultEntry[] = [
owner: null,
cadence: null,
archived: false,
trashed: false,
trashedAt: null,
modifiedAt: Date.now() / 1000 - 3600 * 5,
createdAt: Date.now() / 1000 - 86400 * 30,
fileSize: 847,
@@ -842,6 +895,8 @@ const MOCK_ENTRIES: VaultEntry[] = [
owner: null,
cadence: null,
archived: false,
trashed: false,
trashedAt: null,
modifiedAt: Date.now() / 1000 - 86400,
createdAt: Date.now() / 1000 - 86400 * 20,
fileSize: 560,
@@ -865,6 +920,8 @@ const MOCK_ENTRIES: VaultEntry[] = [
owner: null,
cadence: null,
archived: false,
trashed: false,
trashedAt: null,
modifiedAt: Date.now() / 1000 - 86400 * 7,
createdAt: Date.now() / 1000 - 86400 * 200,
fileSize: 320,
@@ -887,6 +944,8 @@ const MOCK_ENTRIES: VaultEntry[] = [
owner: null,
cadence: null,
archived: false,
trashed: false,
trashedAt: null,
modifiedAt: Date.now() / 1000 - 3600 * 2,
createdAt: Date.now() / 1000 - 86400 * 7,
fileSize: 1200,
@@ -910,6 +969,8 @@ const MOCK_ENTRIES: VaultEntry[] = [
owner: null,
cadence: null,
archived: false,
trashed: false,
trashedAt: null,
modifiedAt: Date.now() / 1000 - 86400 * 30,
createdAt: Date.now() / 1000 - 86400 * 365,
fileSize: 256,
@@ -933,6 +994,8 @@ const MOCK_ENTRIES: VaultEntry[] = [
owner: null,
cadence: null,
archived: false,
trashed: false,
trashedAt: null,
modifiedAt: Date.now() / 1000 - 86400 * 14,
createdAt: Date.now() / 1000 - 86400 * 300,
fileSize: 180,
@@ -956,6 +1019,8 @@ const MOCK_ENTRIES: VaultEntry[] = [
owner: null,
cadence: null,
archived: false,
trashed: false,
trashedAt: null,
modifiedAt: Date.now() / 1000 - 86400 * 3,
createdAt: Date.now() / 1000 - 86400 * 14,
fileSize: 4200,
@@ -979,6 +1044,8 @@ const MOCK_ENTRIES: VaultEntry[] = [
owner: null,
cadence: null,
archived: false,
trashed: false,
trashedAt: null,
modifiedAt: Date.now() / 1000 - 86400 * 7,
createdAt: Date.now() / 1000 - 86400 * 30,
fileSize: 3800,
@@ -1003,6 +1070,8 @@ const MOCK_ENTRIES: VaultEntry[] = [
owner: null,
cadence: null,
archived: false,
trashed: false,
trashedAt: null,
modifiedAt: Date.now() / 1000 - 86400 * 10,
createdAt: Date.now() / 1000 - 86400 * 21,
fileSize: 5100,
@@ -1027,6 +1096,8 @@ const MOCK_ENTRIES: VaultEntry[] = [
owner: null,
cadence: null,
archived: false,
trashed: false,
trashedAt: null,
modifiedAt: Date.now() / 1000 - 86400 * 90,
createdAt: Date.now() / 1000 - 86400 * 365,
fileSize: 320,
@@ -1047,6 +1118,8 @@ const MOCK_ENTRIES: VaultEntry[] = [
owner: null,
cadence: null,
archived: false,
trashed: false,
trashedAt: null,
modifiedAt: Date.now() / 1000 - 86400 * 90,
createdAt: Date.now() / 1000 - 86400 * 365,
fileSize: 280,
@@ -1067,6 +1140,8 @@ const MOCK_ENTRIES: VaultEntry[] = [
owner: null,
cadence: null,
archived: false,
trashed: false,
trashedAt: null,
modifiedAt: Date.now() / 1000 - 86400 * 90,
createdAt: Date.now() / 1000 - 86400 * 365,
fileSize: 310,
@@ -1087,6 +1162,8 @@ const MOCK_ENTRIES: VaultEntry[] = [
owner: null,
cadence: null,
archived: false,
trashed: false,
trashedAt: null,
modifiedAt: Date.now() / 1000 - 86400 * 90,
createdAt: Date.now() / 1000 - 86400 * 365,
fileSize: 290,
@@ -1107,6 +1184,8 @@ const MOCK_ENTRIES: VaultEntry[] = [
owner: null,
cadence: null,
archived: false,
trashed: false,
trashedAt: null,
modifiedAt: Date.now() / 1000 - 86400 * 90,
createdAt: Date.now() / 1000 - 86400 * 365,
fileSize: 200,
@@ -1127,6 +1206,8 @@ const MOCK_ENTRIES: VaultEntry[] = [
owner: null,
cadence: null,
archived: false,
trashed: false,
trashedAt: null,
modifiedAt: Date.now() / 1000 - 86400 * 90,
createdAt: Date.now() / 1000 - 86400 * 365,
fileSize: 180,
@@ -1147,6 +1228,8 @@ const MOCK_ENTRIES: VaultEntry[] = [
owner: null,
cadence: null,
archived: false,
trashed: false,
trashedAt: null,
modifiedAt: Date.now() / 1000 - 86400 * 90,
createdAt: Date.now() / 1000 - 86400 * 365,
fileSize: 170,
@@ -1167,6 +1250,8 @@ const MOCK_ENTRIES: VaultEntry[] = [
owner: null,
cadence: null,
archived: false,
trashed: false,
trashedAt: null,
modifiedAt: Date.now() / 1000 - 86400 * 90,
createdAt: Date.now() / 1000 - 86400 * 365,
fileSize: 200,
@@ -1187,6 +1272,8 @@ const MOCK_ENTRIES: VaultEntry[] = [
owner: null,
cadence: null,
archived: false,
trashed: false,
trashedAt: null,
modifiedAt: Date.now() / 1000 - 86400 * 90,
createdAt: Date.now() / 1000 - 86400 * 365,
fileSize: 190,
@@ -1208,6 +1295,8 @@ const MOCK_ENTRIES: VaultEntry[] = [
owner: null,
cadence: null,
archived: false,
trashed: false,
trashedAt: null,
modifiedAt: Date.now() / 1000 - 86400 * 30,
createdAt: Date.now() / 1000 - 86400 * 365,
fileSize: 250,
@@ -1228,6 +1317,8 @@ const MOCK_ENTRIES: VaultEntry[] = [
owner: null,
cadence: null,
archived: false,
trashed: false,
trashedAt: null,
modifiedAt: Date.now() / 1000 - 86400 * 30,
createdAt: Date.now() / 1000 - 86400 * 365,
fileSize: 220,
@@ -1249,6 +1340,8 @@ const MOCK_ENTRIES: VaultEntry[] = [
owner: null,
cadence: null,
archived: false,
trashed: false,
trashedAt: null,
modifiedAt: Date.now() / 1000 - 86400 * 5,
createdAt: Date.now() / 1000 - 86400 * 10,
fileSize: 420,
@@ -1271,6 +1364,8 @@ const MOCK_ENTRIES: VaultEntry[] = [
owner: null,
cadence: null,
archived: false,
trashed: false,
trashedAt: null,
modifiedAt: Date.now() / 1000 - 86400 * 14,
createdAt: Date.now() / 1000 - 86400 * 60,
fileSize: 380,
@@ -1281,6 +1376,81 @@ const MOCK_ENTRIES: VaultEntry[] = [
icon: null,
color: null,
},
// --- Trashed entries ---
{
path: '/Users/luca/Laputa/note/old-draft-notes.md',
filename: 'old-draft-notes.md',
title: 'Old Draft Notes',
isA: 'Note',
aliases: [],
belongsTo: ['[[project/26q1-laputa-app]]'],
relatedTo: [],
status: null,
owner: null,
cadence: null,
archived: false,
trashed: true,
trashedAt: Date.now() / 1000 - 86400 * 5,
modifiedAt: Date.now() / 1000 - 86400 * 10,
createdAt: null,
fileSize: 280,
snippet: 'Some rough draft content that is no longer relevant. Moving to trash.',
relationships: {
'Belongs to': ['[[project/26q1-laputa-app]]'],
'Type': ['[[type/note]]'],
},
icon: null,
color: null,
},
{
path: '/Users/luca/Laputa/note/deprecated-api-notes.md',
filename: 'deprecated-api-notes.md',
title: 'Deprecated API Notes',
isA: 'Note',
aliases: [],
belongsTo: [],
relatedTo: [],
status: null,
owner: null,
cadence: null,
archived: false,
trashed: true,
trashedAt: Date.now() / 1000 - 86400 * 35,
modifiedAt: Date.now() / 1000 - 86400 * 40,
createdAt: null,
fileSize: 190,
snippet: 'Old API documentation for the v1 endpoint. Replaced by v2 docs.',
relationships: {
'Type': ['[[type/note]]'],
},
icon: null,
color: null,
},
{
path: '/Users/luca/Laputa/experiment/failed-seo-experiment.md',
filename: 'failed-seo-experiment.md',
title: 'Failed SEO Experiment',
isA: 'Experiment',
aliases: [],
belongsTo: [],
relatedTo: ['[[responsibility/grow-newsletter]]'],
status: 'Dropped',
owner: 'Luca Rossi',
cadence: null,
archived: false,
trashed: true,
trashedAt: Date.now() / 1000 - 86400 * 10,
modifiedAt: Date.now() / 1000 - 86400 * 15,
createdAt: null,
fileSize: 340,
snippet: 'Tried programmatic SEO pages. Results were negligible — trashing this.',
relationships: {
'Related to': ['[[responsibility/grow-newsletter]]'],
'Type': ['[[type/experiment]]'],
},
icon: null,
color: null,
},
// --- Archived entries ---
{
path: '/Users/luca/Laputa/project/25q3-website-redesign.md',
@@ -1294,6 +1464,8 @@ const MOCK_ENTRIES: VaultEntry[] = [
owner: 'Luca Rossi',
cadence: null,
archived: true,
trashed: false,
trashedAt: null,
icon: null,
color: null,
modifiedAt: Date.now() / 1000 - 86400 * 120,
@@ -1317,6 +1489,8 @@ const MOCK_ENTRIES: VaultEntry[] = [
owner: 'Luca Rossi',
cadence: null,
archived: true,
trashed: false,
trashedAt: null,
icon: null,
color: null,
modifiedAt: Date.now() / 1000 - 86400 * 90,

View File

@@ -10,6 +10,8 @@ export interface VaultEntry {
owner: string | null
cadence: string | null
archived: boolean
trashed: boolean
trashedAt: number | null
modifiedAt: number | null
createdAt: number | null
fileSize: number
@@ -37,7 +39,7 @@ export interface ModifiedFile {
}
export type SidebarSelection =
| { kind: 'filter'; filter: 'all' | 'favorites' | 'archived' }
| { kind: 'filter'; filter: 'all' | 'favorites' | 'archived' | 'trash' }
| { kind: 'sectionGroup'; type: string }
| { kind: 'entity'; entry: VaultEntry }
| { kind: 'topic'; entry: VaultEntry }