mirror of
https://github.com/bretzel-app/crumbs.git
synced 2026-10-12 20:06:57 +02:00
feat: URL-based routing for archive, trash, and tags
Add /archive, /trash, /tag/[name] routes with proper <a> links in the sidebar. Tags visible regardless of current filter. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
4d5ea35204
commit
9d67e7b9d1
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { currentFilter, loadNotes, allTags, selectedTag } from '$lib/stores/notes.js';
|
||||
import { page } from '$app/stores';
|
||||
import { allTags } from '$lib/stores/notes.js';
|
||||
import { StickyNote, Archive, Trash2, Tag, Settings } from 'lucide-svelte';
|
||||
import type { NoteFilter } from '$lib/types/index.js';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
@@ -17,17 +17,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
function setFilter(filter: NoteFilter) {
|
||||
selectedTag.set(null);
|
||||
loadNotes(filter);
|
||||
closeMobile();
|
||||
}
|
||||
|
||||
function selectTag(tag: string | null) {
|
||||
selectedTag.set(tag);
|
||||
loadNotes('all');
|
||||
closeMobile();
|
||||
}
|
||||
const navItems = [
|
||||
{ href: '/', label: 'Crumbs', icon: StickyNote, match: (p: string) => p === '/' },
|
||||
{ href: '/archive', label: 'Archive', icon: Archive, match: (p: string) => p === '/archive' },
|
||||
{ href: '/trash', label: 'Trash', icon: Trash2, match: (p: string) => p === '/trash' }
|
||||
];
|
||||
</script>
|
||||
|
||||
{#if open}
|
||||
@@ -44,33 +38,18 @@
|
||||
>
|
||||
<nav class="p-2">
|
||||
<ul class="space-y-1">
|
||||
<li>
|
||||
<button
|
||||
onclick={() => setFilter('all')}
|
||||
class="flex w-full items-center gap-3 rounded-sm px-6 py-3 text-left text-sm transition-colors {$currentFilter === 'all' && !$selectedTag ? 'bg-[var(--primary)]/15 text-[var(--primary)]' : 'text-[var(--text)] hover:bg-[var(--bg-base)]'}"
|
||||
>
|
||||
<StickyNote size={20} />
|
||||
Crumbs
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button
|
||||
onclick={() => setFilter('archived')}
|
||||
class="flex w-full items-center gap-3 rounded-sm px-6 py-3 text-left text-sm transition-colors {$currentFilter === 'archived' ? 'bg-[var(--primary)]/15 text-[var(--primary)]' : 'text-[var(--text)] hover:bg-[var(--bg-base)]'}"
|
||||
>
|
||||
<Archive size={20} />
|
||||
Archive
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button
|
||||
onclick={() => setFilter('trashed')}
|
||||
class="flex w-full items-center gap-3 rounded-sm px-6 py-3 text-left text-sm transition-colors {$currentFilter === 'trashed' ? 'bg-[var(--primary)]/15 text-[var(--primary)]' : 'text-[var(--text)] hover:bg-[var(--bg-base)]'}"
|
||||
>
|
||||
<Trash2 size={20} />
|
||||
Trash
|
||||
</button>
|
||||
</li>
|
||||
{#each navItems as item}
|
||||
<li>
|
||||
<a
|
||||
href={item.href}
|
||||
onclick={closeMobile}
|
||||
class="flex w-full items-center gap-3 rounded-sm px-6 py-3 text-left text-sm transition-colors {item.match($page.url.pathname) ? 'bg-[var(--primary)]/15 text-[var(--primary)]' : 'text-[var(--text)] hover:bg-[var(--bg-base)]'}"
|
||||
>
|
||||
<item.icon size={20} />
|
||||
{item.label}
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
{#if $allTags.length > 0}
|
||||
@@ -79,13 +58,14 @@
|
||||
<ul class="mt-2 space-y-1">
|
||||
{#each $allTags as tag}
|
||||
<li>
|
||||
<button
|
||||
onclick={() => selectTag($selectedTag === tag ? null : tag)}
|
||||
class="flex w-full items-center gap-3 rounded-sm px-6 py-2 text-left text-sm transition-colors {$selectedTag === tag ? 'bg-[var(--primary)]/15 text-[var(--primary)]' : 'text-[var(--text)] hover:bg-[var(--bg-base)]'}"
|
||||
<a
|
||||
href="/tag/{tag}"
|
||||
onclick={closeMobile}
|
||||
class="flex w-full items-center gap-3 rounded-sm px-6 py-2 text-left text-sm transition-colors {$page.url.pathname === `/tag/${tag}` ? 'bg-[var(--primary)]/15 text-[var(--primary)]' : 'text-[var(--text)] hover:bg-[var(--bg-base)]'}"
|
||||
>
|
||||
<Tag size={16} />
|
||||
#{tag}
|
||||
</button>
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
<script lang="ts">
|
||||
import NoteGrid from '$lib/components/NoteGrid.svelte';
|
||||
import NoteEditor from '$lib/components/NoteEditor.svelte';
|
||||
import TagFilter from '$lib/components/TagFilter.svelte';
|
||||
import SortSelector from '$lib/components/SortSelector.svelte';
|
||||
import { pinnedNotes, unpinnedNotes, selectedTag, currentFilter, notes, notesLoaded, loadNotes, updateSortOrders } from '$lib/stores/notes.js';
|
||||
import { sortMode } from '$lib/stores/sort.js';
|
||||
import { onMount } from 'svelte';
|
||||
import type { Note, NoteFilter } from '$lib/types/index.js';
|
||||
import Plus from 'lucide-svelte/icons/plus';
|
||||
|
||||
interface Props {
|
||||
filter: NoteFilter;
|
||||
tag?: string | null;
|
||||
}
|
||||
|
||||
let { filter, tag = null }: Props = $props();
|
||||
|
||||
let editingNote: Note | null = $state(null);
|
||||
let showNewNote = $state(false);
|
||||
|
||||
// Sync store with route props
|
||||
$effect(() => {
|
||||
currentFilter.set(filter);
|
||||
selectedTag.set(tag);
|
||||
});
|
||||
|
||||
function openEditor(note: Note) {
|
||||
editingNote = note;
|
||||
history.replaceState(null, '', `#${note.id}`);
|
||||
}
|
||||
|
||||
function closeEditor() {
|
||||
editingNote = null;
|
||||
showNewNote = false;
|
||||
history.replaceState(null, '', location.pathname);
|
||||
}
|
||||
|
||||
function handleReorder(noteIds: string[]) {
|
||||
const orders = noteIds.map((id, index) => ({ id, sortOrder: index }));
|
||||
updateSortOrders(orders);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
const hash = location.hash.slice(1);
|
||||
if (!hash) return;
|
||||
|
||||
const unsubscribe = notes.subscribe(($notes) => {
|
||||
if ($notes.length === 0) return;
|
||||
const note = $notes.find((n) => n.id === hash);
|
||||
if (note) editingNote = note;
|
||||
unsubscribe();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if filter === 'all' && !tag}
|
||||
<div class="mx-auto mb-6 max-w-xl">
|
||||
<button
|
||||
onclick={() => (showNewNote = true)}
|
||||
class="flex w-full items-center gap-3 rounded-sm border border-dashed border-[var(--border-subtle)] bg-[var(--bg-surface)] px-4 py-3 text-left text-sm text-[var(--text-muted)] transition-colors hover:border-[var(--primary)] hover:text-[var(--primary)]"
|
||||
data-testid="new-note-btn"
|
||||
>
|
||||
<Plus class="h-4 w-4" />
|
||||
Add a crumb...
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if filter === 'all'}
|
||||
<div class="mb-4 flex flex-wrap items-start justify-between gap-3">
|
||||
<TagFilter />
|
||||
<SortSelector />
|
||||
</div>
|
||||
{:else}
|
||||
<div class="mb-4">
|
||||
<TagFilter />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if filter === 'archived'}
|
||||
<h2 class="mb-4 text-lg font-medium text-[var(--text-muted)]">Archive</h2>
|
||||
{:else if filter === 'trashed'}
|
||||
<h2 class="mb-4 text-lg font-medium text-[var(--text-muted)]">Trash</h2>
|
||||
{:else if tag}
|
||||
<h2 class="mb-4 text-lg font-medium text-[var(--text-muted)]">#{tag}</h2>
|
||||
{/if}
|
||||
|
||||
{#if $pinnedNotes.length > 0}
|
||||
<div class="mb-6">
|
||||
<NoteGrid notes={$pinnedNotes} label="Pinned" onEdit={openEditor} draggable={$sortMode === 'custom'} dndType="pinned-notes" onReorder={handleReorder} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<NoteGrid
|
||||
notes={$unpinnedNotes}
|
||||
label={$pinnedNotes.length > 0 ? 'Others' : ''}
|
||||
onEdit={openEditor}
|
||||
draggable={$sortMode === 'custom'}
|
||||
dndType="unpinned-notes"
|
||||
onReorder={handleReorder}
|
||||
/>
|
||||
|
||||
{#if $notesLoaded && $pinnedNotes.length === 0 && $unpinnedNotes.length === 0}
|
||||
<div class="flex flex-col items-center justify-center py-20 text-[var(--text-muted)]">
|
||||
<img src="/favicon.svg" alt="" class="mb-4 h-18 w-18" />
|
||||
<p class="font-['Press_Start_2P'] text-xs">
|
||||
{#if filter === 'trashed'}
|
||||
No crumbs in trash
|
||||
{:else if filter === 'archived'}
|
||||
No archived crumbs
|
||||
{:else if tag}
|
||||
No crumbs with tag #{tag}
|
||||
{:else}
|
||||
No crumbs yet
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if showNewNote}
|
||||
<NoteEditor note={null} isNew={true} onClose={closeEditor} />
|
||||
{/if}
|
||||
|
||||
{#if editingNote}
|
||||
<NoteEditor note={editingNote} onClose={closeEditor} />
|
||||
{/if}
|
||||
@@ -1,16 +1,23 @@
|
||||
<script lang="ts">
|
||||
import { allTags, selectedTag } from '$lib/stores/notes.js';
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { allTags } from '$lib/stores/notes.js';
|
||||
import TagChip from './TagChip.svelte';
|
||||
|
||||
function toggleTag(tag: string) {
|
||||
selectedTag.update((current) => (current === tag ? null : tag));
|
||||
const isActive = $page.url.pathname === `/tag/${tag}`;
|
||||
goto(isActive ? '/' : `/tag/${tag}`);
|
||||
}
|
||||
|
||||
function isTagActive(tag: string, pathname: string): boolean {
|
||||
return pathname === `/tag/${tag}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if $allTags.length > 0}
|
||||
<div class="flex flex-wrap gap-2" data-testid="tag-filter">
|
||||
{#each $allTags as tag}
|
||||
<TagChip {tag} active={$selectedTag === tag} onclick={() => toggleTag(tag)} />
|
||||
<TagChip {tag} active={isTagActive(tag, $page.url.pathname)} onclick={() => toggleTag(tag)} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -51,9 +51,7 @@ export const unpinnedNotes = derived([filteredNotes, sortMode], ([$notes, $sortM
|
||||
|
||||
export const allTags = derived(notes, ($notes) => {
|
||||
const tagSet = new Set<string>();
|
||||
$notes
|
||||
.filter((n) => !n.archived && !n.trashed)
|
||||
.forEach((n) => n.tags?.forEach((t) => tagSet.add(t)));
|
||||
$notes.forEach((n) => n.tags?.forEach((t) => tagSet.add(t)));
|
||||
return [...tagSet].sort();
|
||||
});
|
||||
|
||||
|
||||
@@ -1,118 +1,9 @@
|
||||
<script lang="ts">
|
||||
import NoteGrid from '$lib/components/NoteGrid.svelte';
|
||||
import NoteEditor from '$lib/components/NoteEditor.svelte';
|
||||
import TagFilter from '$lib/components/TagFilter.svelte';
|
||||
import SortSelector from '$lib/components/SortSelector.svelte';
|
||||
import { pinnedNotes, unpinnedNotes, selectedTag, currentFilter, notes, notesLoaded, updateSortOrders } from '$lib/stores/notes.js';
|
||||
import { sortMode } from '$lib/stores/sort.js';
|
||||
import { onMount } from 'svelte';
|
||||
import { get } from 'svelte/store';
|
||||
import type { Note } from '$lib/types/index.js';
|
||||
import Plus from 'lucide-svelte/icons/plus';
|
||||
|
||||
let editingNote: Note | null = $state(null);
|
||||
let showNewNote = $state(false);
|
||||
|
||||
function openEditor(note: Note) {
|
||||
editingNote = note;
|
||||
history.replaceState(null, '', `#${note.id}`);
|
||||
}
|
||||
|
||||
function closeEditor() {
|
||||
editingNote = null;
|
||||
showNewNote = false;
|
||||
history.replaceState(null, '', location.pathname);
|
||||
}
|
||||
|
||||
function handleReorder(noteIds: string[]) {
|
||||
const orders = noteIds.map((id, index) => ({ id, sortOrder: index }));
|
||||
updateSortOrders(orders);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
const hash = location.hash.slice(1);
|
||||
if (!hash) return;
|
||||
|
||||
// Notes may not be loaded yet — wait for them
|
||||
const unsubscribe = notes.subscribe(($notes) => {
|
||||
if ($notes.length === 0) return;
|
||||
const note = $notes.find((n) => n.id === hash);
|
||||
if (note) editingNote = note;
|
||||
unsubscribe();
|
||||
});
|
||||
});
|
||||
import NotesView from '$lib/components/NotesView.svelte';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Crumbs</title>
|
||||
</svelte:head>
|
||||
|
||||
{#if $currentFilter === 'all'}
|
||||
<div class="mx-auto mb-6 max-w-xl">
|
||||
<button
|
||||
onclick={() => (showNewNote = true)}
|
||||
class="flex w-full items-center gap-3 rounded-sm border border-dashed border-[var(--border-subtle)] bg-[var(--bg-surface)] px-4 py-3 text-left text-sm text-[var(--text-muted)] transition-colors hover:border-[var(--primary)] hover:text-[var(--primary)]"
|
||||
data-testid="new-note-btn"
|
||||
>
|
||||
<Plus class="h-4 w-4" />
|
||||
Add a crumb...
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if $currentFilter === 'all'}
|
||||
<div class="mb-4 flex flex-wrap items-start justify-between gap-3">
|
||||
<TagFilter />
|
||||
<SortSelector />
|
||||
</div>
|
||||
{:else}
|
||||
<div class="mb-4">
|
||||
<TagFilter />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if $currentFilter === 'archived'}
|
||||
<h2 class="mb-4 text-lg font-medium text-[var(--text-muted)]">Archive</h2>
|
||||
{:else if $currentFilter === 'trashed'}
|
||||
<h2 class="mb-4 text-lg font-medium text-[var(--text-muted)]">Trash</h2>
|
||||
{/if}
|
||||
|
||||
{#if $pinnedNotes.length > 0}
|
||||
<div class="mb-6">
|
||||
<NoteGrid notes={$pinnedNotes} label="Pinned" onEdit={openEditor} draggable={$sortMode === 'custom'} dndType="pinned-notes" onReorder={handleReorder} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<NoteGrid
|
||||
notes={$unpinnedNotes}
|
||||
label={$pinnedNotes.length > 0 ? 'Others' : ''}
|
||||
onEdit={openEditor}
|
||||
draggable={$sortMode === 'custom'}
|
||||
dndType="unpinned-notes"
|
||||
onReorder={handleReorder}
|
||||
/>
|
||||
|
||||
{#if $notesLoaded && $pinnedNotes.length === 0 && $unpinnedNotes.length === 0}
|
||||
<div class="flex flex-col items-center justify-center py-20 text-[var(--text-muted)]">
|
||||
<img src="/favicon.svg" alt="" class="mb-4 h-18 w-18" />
|
||||
<p class="font-['Press_Start_2P'] text-xs">
|
||||
{#if $currentFilter === 'trashed'}
|
||||
No crumbs in trash
|
||||
{:else if $currentFilter === 'archived'}
|
||||
No archived crumbs
|
||||
{:else if $selectedTag}
|
||||
No crumbs with tag #{$selectedTag}
|
||||
{:else}
|
||||
No crumbs yet
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if showNewNote}
|
||||
<NoteEditor note={null} isNew={true} onClose={closeEditor} />
|
||||
{/if}
|
||||
|
||||
{#if editingNote}
|
||||
<NoteEditor note={editingNote} onClose={closeEditor} />
|
||||
{/if}
|
||||
<NotesView filter="all" />
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<script lang="ts">
|
||||
import NotesView from '$lib/components/NotesView.svelte';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Archive - Crumbs</title>
|
||||
</svelte:head>
|
||||
|
||||
<NotesView filter="archived" />
|
||||
@@ -0,0 +1,10 @@
|
||||
<script lang="ts">
|
||||
import NotesView from '$lib/components/NotesView.svelte';
|
||||
import { page } from '$app/stores';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>#{$page.params.name} - Crumbs</title>
|
||||
</svelte:head>
|
||||
|
||||
<NotesView filter="all" tag={$page.params.name} />
|
||||
@@ -0,0 +1,9 @@
|
||||
<script lang="ts">
|
||||
import NotesView from '$lib/components/NotesView.svelte';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Trash - Crumbs</title>
|
||||
</svelte:head>
|
||||
|
||||
<NotesView filter="trashed" />
|
||||
Reference in New Issue
Block a user