feat: add notes app

This commit is contained in:
2026-10-01 19:39:20 +02:00
parent ccb8dd3d15
commit ddbb6f37b5
35 changed files with 706 additions and 52 deletions
@@ -0,0 +1,20 @@
<script setup lang="ts">
// "New", bottom right.
// The list opens with NotesDrawer (mobile) and NotesSidebarHandle (desktop).
const { creating, createAndOpen } = useNewNote()
</script>
<template>
<div class="pointer-events-none fixed inset-x-0 bottom-0 z-40 flex justify-end px-4 pb-[max(1rem,env(safe-area-inset-bottom))] md:px-6 md:pb-6">
<UTooltip text="New note" :kbds="['meta', 'alt', 'n']">
<UButton
icon="i-lucide-square-pen"
label="New"
size="lg"
class="pointer-events-auto rounded-full px-5 shadow-lg"
:loading="creating"
@click="createAndOpen"
/>
</UTooltip>
</div>
</template>
@@ -0,0 +1,42 @@
<script setup lang="ts">
// Mobile only: the note list, pulled down from the top. Tap or pull the bar at the top edge to open it;
// push it back up, tap outside or pick a note to close it.
const { drawerOpen } = useNotesSidebar()
const PULL_DISTANCE_PX = 24
let startY: number | null = null
function onTouchStart(event: TouchEvent) {
startY = event.touches[0]?.clientY ?? null
}
function onTouchMove(event: TouchEvent) {
const y = event.touches[0]?.clientY
if (startY === null || y === undefined) return
if (y - startY > PULL_DISTANCE_PX) {
drawerOpen.value = true
startY = null
}
}
</script>
<template>
<div class="md:hidden">
<button
type="button"
aria-label="Show notes"
class="fixed inset-x-0 top-0 z-30 flex h-7 touch-none items-start justify-center pt-2"
@click="drawerOpen = true"
@touchstart.passive="onTouchStart"
@touchmove.passive="onTouchMove"
>
<span class="h-1.5 w-12 rounded-full bg-accented" />
</button>
<UDrawer v-model:open="drawerOpen" direction="top" title="Notes" :ui="{ content: 'max-h-[85dvh]', body: 'overflow-y-auto p-0 sm:p-0' }">
<template #body>
<NotesList />
</template>
</UDrawer>
</div>
</template>
+59
View File
@@ -0,0 +1,59 @@
<script setup lang="ts">
// The note list, in the desktop sidebar and in the mobile drawer
const { docs: allDocs, error } = useNotes()
const { query, matches, searching } = useNotesSearch()
const docs = computed(() => matches.value ? allDocs.value.filter(doc => matches.value!.includes(doc.id)) : allDocs.value)
const { closeOnMobile } = useNotesSidebar()
const deleteDoc = useDeleteNote()
const route = useRoute()
</script>
<template>
<div>
<div class="px-2 pb-2">
<UInput
v-model="query"
icon="i-lucide-search"
placeholder="Search"
:loading="searching"
class="w-full"
@keydown.esc="query = ''"
>
<template v-if="query" #trailing>
<UButton icon="i-lucide-x" color="neutral" variant="link" size="sm" aria-label="Clear search" @click="query = ''" />
</template>
</UInput>
</div>
<p v-if="error" class="px-4 text-sm text-error">Couldn't load the notes: {{ errorMessage(error) }}</p>
<nav class="px-2 pb-4">
<!-- The delete button shows on hover on desktop (instead of the date), always on mobile -->
<div v-for="doc in docs" :key="doc.id" class="group relative">
<NuxtLink
:to="`/notes/${doc.id}`"
class="flex items-baseline gap-2 rounded-md py-2.5 ps-2 pe-11 text-base md:py-1.5 md:pe-2 md:text-sm"
:class="String(doc.id) === route.params.id ? 'bg-elevated text-highlighted' : 'text-default hover:bg-elevated/50'"
@click="closeOnMobile"
>
<span class="flex-1 truncate" :class="{ 'text-muted': !doc.title }">{{ notesDocName(doc) }}</span>
<span class="hidden shrink-0 text-xs text-dimmed tabular-nums md:inline md:group-hover:invisible">{{ notesDocDay(doc.created) }}</span>
</NuxtLink>
<UButton
icon="i-lucide-trash-2"
color="neutral"
variant="ghost"
size="sm"
:aria-label="`Delete ${notesDocName(doc)}`"
title="Delete"
class="absolute inset-y-0 end-1 my-auto h-fit text-dimmed hover:text-error md:opacity-0 md:group-hover:opacity-100 md:focus-visible:opacity-100"
@click="deleteDoc(doc)"
/>
</div>
<p v-if="!docs.length && !error && !searching" class="px-2 py-4 text-sm text-muted">
{{ query.trim() ? 'No notes match.' : 'No notes yet.' }}
</p>
</nav>
</div>
</template>
@@ -0,0 +1,11 @@
<script setup lang="ts">
// Desktop only; on mobile the list is pulled down from the top (NotesDrawer)
const { open } = useNotesSidebar()
</script>
<template>
<aside v-show="open" class="hidden w-72 shrink-0 flex-col border-e border-default md:flex">
<h1 class="px-4 py-4 text-lg font-semibold text-highlighted">Notes</h1>
<NotesList class="flex-1 overflow-y-auto" />
</aside>
</template>
@@ -0,0 +1,25 @@
<script setup lang="ts">
// Desktop only: a chevron on the sidebar's right border (or the window's left edge when the
// sidebar is hidden) that appears on hover and shows/hides the note list
const { open, toggle } = useNotesSidebar()
</script>
<template>
<!-- The whole hover area is the button. It only reaches to the right of the line (never over the note list's
delete buttons), as wide as the note's side padding (md:px-10), so it never covers the text either -->
<button
type="button"
:title="`${open ? 'Hide' : 'Show'} notes (⌘E)`"
:aria-label="open ? 'Hide notes' : 'Show notes'"
class="group fixed inset-y-0 z-20 hidden w-10 cursor-pointer items-center focus:outline-none md:flex"
:class="open ? 'left-72' : 'left-0'"
@click="toggle"
>
<span
class="flex size-6 items-center justify-center rounded-full bg-default text-muted opacity-0 shadow-sm ring-1 ring-accented transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100 group-focus-visible:ring-2 group-focus-visible:ring-primary"
:class="open ? '-translate-x-1/2' : 'ms-2'"
>
<UIcon :name="open ? 'i-lucide-chevron-left' : 'i-lucide-chevron-right'" class="size-4" />
</span>
</button>
</template>