feat: add standalone memos
This commit is contained in:
+16
-6
@@ -3,7 +3,7 @@ import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import {
|
||||
ArchiveRestore, Bold, CalendarDays, CalendarHeart, Check, ChevronDown, ChevronRight, Code, Folder,
|
||||
Ellipsis, GripVertical, Heading2, Inbox, Italic, Link, List, ListChecks, ListOrdered, ListTodo, Menu, Pencil, Plus, Quote, Search,
|
||||
Settings, Trash2, X, Repeat2, RefreshCw,
|
||||
Settings, Trash2, X, Repeat2, RefreshCw, StickyNote,
|
||||
} from 'lucide-vue-next'
|
||||
import { applyMarkdownFormat, buildTaskRecurrencePayload, defaultTaskDueAt, filterTasks, fromDateTimeLocal, groupTaskTree, isSameTaskSortTier, moveItemWithinScope, parseTaskRecurrence, parseTaskRrule, renderMarkdown, toDateTimeLocal, type MarkdownFormat, type TaskRepeatConfig, type TaskRepeatOption } from './lib/task-utils'
|
||||
import { beginLatestRequest, createMutationReconciler, formatApiErrorDetail, isLatestRequest, isTaskView, loadCountdownCache, nextTotalAfterLocalTaskAdd, nextTotalAfterLocalTaskRemoval, normalizeRequiredName, performTrashMutation, readStoredBoolean, readStoredNavigation, runLatestRequest, shouldToggleRowSwipe, startPrimaryWithBackground, writeStoredBoolean, writeStoredNavigation } from './lib/mvp-utils'
|
||||
@@ -15,6 +15,7 @@ import { nextDialogFocusIndex } from './lib/list-purge'
|
||||
import { positionArchivedMenu, resolveArchivedMenuFocusTarget } from './lib/archived-list-menu'
|
||||
import MvpPanel from './MvpPanel.vue'
|
||||
import CountdownPanel from './CountdownPanel.vue'
|
||||
import MemoPanel from './MemoPanel.vue'
|
||||
import FloatingAddButton from './components/FloatingAddButton.vue'
|
||||
import CompletedFilterPill from './components/CompletedFilterPill.vue'
|
||||
import CalendarPicker from './components/CalendarPicker.vue'
|
||||
@@ -26,7 +27,7 @@ type TaskList = { id: string; folder_id: string | null; name: string; is_inbox:
|
||||
type Task = { id: string; list_id: string; parent_id: string | null; title: string; description: string; priority: number; completed: boolean; version: number; due_at: string | null; due_has_time: boolean; subtasks?: Task[] }
|
||||
type RepeatOption = TaskRepeatOption
|
||||
type Recurrence = { id: string; task_id: string; rrule: string | null; trigger_mode: 'scheduled' | 'after_completion'; after_completion_days: number | null }
|
||||
type View = 'tasks' | 'today' | 'upcoming' | 'trash' | 'habits' | 'countdowns' | 'settings'
|
||||
type View = 'tasks' | 'today' | 'upcoming' | 'trash' | 'habits' | 'countdowns' | 'memos' | 'settings'
|
||||
|
||||
const initialized = ref<boolean | null>(null)
|
||||
const authReady = ref(false)
|
||||
@@ -152,6 +153,8 @@ let recurrenceLoadToken = 0
|
||||
let todaySummaryLoadToken = 0
|
||||
const habitComposer = ref<InstanceType<typeof MvpPanel> | null>(null)
|
||||
const countdownComposer = ref<InstanceType<typeof CountdownPanel> | null>(null)
|
||||
const memoPanel = ref<InstanceType<typeof MemoPanel> | null>(null)
|
||||
const memoTrash = ref(false)
|
||||
const composeOrigin = ref({ x: window.innerWidth - 43, y: window.innerHeight - 104 })
|
||||
let suppressTaskClickId = ''
|
||||
|
||||
@@ -199,6 +202,7 @@ function activateFloatingAdd(origin: { x: number; y: number }) {
|
||||
composeOrigin.value = origin
|
||||
if (activeView.value === 'habits') habitComposer.value?.openHabitComposer(origin)
|
||||
else if (activeView.value === 'countdowns') countdownComposer.value?.openCountdownComposer(origin)
|
||||
else if (activeView.value === 'memos') void memoPanel.value?.createMemo()
|
||||
else if (['tasks', 'today', 'upcoming'].includes(activeView.value)) openTaskCompose()
|
||||
}
|
||||
async function saveRepeat(task: Task, value: RepeatOption, config: TaskRepeatConfig, afterCompletionDays: string, recurrence: Recurrence | null) {
|
||||
@@ -316,6 +320,7 @@ const activeName = computed(() => {
|
||||
if (activeView.value === 'upcoming') return '最近 7 天'
|
||||
if (activeView.value === 'habits') return '习惯'
|
||||
if (activeView.value === 'countdowns') return '倒数日'
|
||||
if (activeView.value === 'memos') return '备忘录'
|
||||
if (activeView.value === 'settings') return '设置与数据'
|
||||
return lists.value.find((item) => item.id === activeList.value)?.name || '收集箱'
|
||||
})
|
||||
@@ -335,7 +340,7 @@ const visibleTasks = computed(() => {
|
||||
const now = new Date()
|
||||
const end = new Date(now); end.setDate(end.getDate() + 7)
|
||||
let result = sourceTasks.value
|
||||
if (['habits','settings','countdowns'].includes(activeView.value)) return []
|
||||
if (['habits','settings','countdowns','memos'].includes(activeView.value)) return []
|
||||
if (activeView.value === 'today') result = result.filter((task) => task.due_at && new Date(task.due_at).toDateString() === now.toDateString())
|
||||
if (activeView.value === 'upcoming') result = result.filter((task) => task.due_at && new Date(task.due_at) >= now && new Date(task.due_at) <= end)
|
||||
return query.value.trim() ? filterTasks(result, query.value) : result
|
||||
@@ -374,7 +379,9 @@ async function api(path: string, options: RequestInit = {}) {
|
||||
if (!response.ok) {
|
||||
let message = '请求失败'
|
||||
try { const body = await response.json(); message = formatApiErrorDetail(body.detail) } catch { /* noop */ }
|
||||
throw new Error(message)
|
||||
const requestError = new Error(message) as Error & { status: number }
|
||||
requestError.status = response.status
|
||||
throw requestError
|
||||
}
|
||||
return response.status === 204 ? null : response.json()
|
||||
}
|
||||
@@ -596,6 +603,7 @@ async function loadTrash() {
|
||||
})
|
||||
}
|
||||
async function switchView(view: View, listId?: string) {
|
||||
if (activeView.value === 'memos' && view !== 'memos' && memoPanel.value?.dirty && !window.confirm('有未保存的更改,确定离开吗?')) return
|
||||
taskMutationNavigation.value += 1
|
||||
activeView.value = view
|
||||
if (!query.value) mobileSearchOpen.value = false
|
||||
@@ -1336,6 +1344,7 @@ onUnmounted(() => {
|
||||
<button :class="{ active: activeView==='upcoming' }" @click="switchView('upcoming')"><CalendarDays />最近 7 天</button>
|
||||
<button :class="{ active: activeView==='habits' }" @click="switchView('habits')"><Repeat2 />习惯</button>
|
||||
<button :class="{ active: activeView==='countdowns' }" @click="switchView('countdowns')"><CalendarHeart />倒数日</button>
|
||||
<button :class="{ active: activeView==='memos' }" @click="switchView('memos')"><StickyNote />备忘录</button>
|
||||
</nav>
|
||||
<div class="section-title list-root-drop" :class="{'list-drop-target':listDrag&&listDropFolderId===null&&!listReorderTarget}" @pointermove="listDrag&&resolveListDrop($event)"><span>我的清单</span><span class="sidebar-create-wrap"><button class="mini-icon list-create-trigger" aria-label="新建清单或文件夹" :aria-expanded="sidebarCreateOpen" @click="toggleSidebarCreate"><Plus /></button><span v-if="sidebarCreateOpen" class="sidebar-popover sidebar-create-menu"><button @click="runSidebarCreate('list')"><ListTodo/>新建清单</button><button @click="runSidebarCreate('folder')"><Folder/>新建文件夹</button></span></span></div>
|
||||
<div class="folders">
|
||||
@@ -1413,6 +1422,7 @@ onUnmounted(() => {
|
||||
<template v-if="['habits','settings'].includes(activeView)">
|
||||
<MvpPanel ref="habitComposer" :key="activeView" :view="activeView as 'habits'|'settings'" :show-completed="showCompleted" @changed="refreshAll" @notice="toast" />
|
||||
</template>
|
||||
<MemoPanel v-else-if="activeView==='memos'" ref="memoPanel" :request="api" @scope="memoTrash=$event==='trash'" @notice="toast" />
|
||||
<CountdownPanel ref="countdownComposer" v-else-if="activeView==='countdowns'" @notice="toast" />
|
||||
<template v-else>
|
||||
<section v-if="activeView==='today'" class="today-board" aria-label="今日进度">
|
||||
@@ -1468,7 +1478,7 @@ onUnmounted(() => {
|
||||
<section v-if="selectedTaskRepeat==='custom'" class="repeat-custom-fields"><div><span>每隔</span><input v-model.number="selectedRepeatConfig.interval" type="number" min="1"><select v-model="selectedRepeatConfig.frequency"><option value="daily">天</option><option value="weekly">周</option><option value="monthly">月</option><option value="yearly">年</option></select></div><label v-if="selectedRepeatConfig.frequency==='weekly'">重复日期<span class="weekday-picker"><label v-for="day in weekdayOptions" :key="day.value"><input v-model="selectedRepeatConfig.weekdays" type="checkbox" :value="day.value">{{day.label}}</label></span></label><label v-if="selectedRepeatConfig.frequency==='monthly'">每月日期<input v-model="selectedRepeatConfig.monthDays" placeholder="例如 1,15,31" @change="selectedRepeatConfig.monthDays=String(($event.target as HTMLInputElement).value).split(',').map(Number).filter(Boolean)"></label><label>结束方式<select v-model="selectedRepeatConfig.endMode"><option value="never">永不结束</option><option value="date">指定日期</option><option value="count">重复次数</option></select></label><label v-if="selectedRepeatConfig.endMode==='date'">结束日期<input v-model="selectedRepeatConfig.until" type="date"></label><label v-if="selectedRepeatConfig.endMode==='count'">重复次数<input v-model.number="selectedRepeatConfig.count" type="number" min="1"></label></section>
|
||||
<small v-if="selectedRepeatError" role="alert" class="field-error">{{selectedRepeatError}}</small>
|
||||
<div class="field markdown">
|
||||
<div class="field-label"><span>备注</span><span class="markdown-mode-switch"><button type="button" :aria-pressed="!markdownPreview" :class="{active:!markdownPreview}" @click="markdownPreview=false">编辑</button><button type="button" :aria-pressed="markdownPreview" :class="{active:markdownPreview}" @click="markdownPreview=true">预览</button></span></div>
|
||||
<div class="field-label"><span>任务备注</span><span class="markdown-mode-switch"><button type="button" :aria-pressed="!markdownPreview" :class="{active:!markdownPreview}" @click="markdownPreview=false">编辑</button><button type="button" :aria-pressed="markdownPreview" :class="{active:markdownPreview}" @click="markdownPreview=true">预览</button></span></div>
|
||||
<div v-if="!markdownPreview" class="markdown-editor-shell">
|
||||
<div class="markdown-toolbar" role="toolbar" aria-label="Markdown 格式">
|
||||
<button type="button" aria-label="标题" title="标题" @click="formatTaskNote('heading')"><Heading2/></button>
|
||||
@@ -1496,7 +1506,7 @@ onUnmounted(() => {
|
||||
|
||||
<div v-if="mobileMore" class="more-mask app-sheet-mask" @click.self="mobileMore=false;switchView('settings')"><section id="mobile-more-menu" class="more-sheet app-sheet app-sheet--actions" role="dialog" aria-modal="true" aria-label="更多导航" @click.stop><div class="more-sheet-head app-sheet__header"><b>更多</b><button class="icon" aria-label="关闭更多菜单" @click="mobileMore=false"><X/></button></div><div class="app-sheet__body"><button @click="switchView('settings')"><Settings/>设置与数据</button></div></section></div>
|
||||
<nav class="bottom" aria-label="主要导航"><button :class="{active:activeView==='today'}" :aria-current="activeView==='today' ? 'page' : undefined" @click="switchView('today')"><ListTodo/><span>今天</span></button><button :class="{active:activeView==='habits'}" :aria-current="activeView==='habits' ? 'page' : undefined" @click="switchView('habits')"><Repeat2/><span>习惯</span></button><button :class="{active:activeView==='countdowns'}" :aria-current="activeView==='countdowns' ? 'page' : undefined" @click="switchView('countdowns')"><CalendarHeart/><span>倒数日</span></button><button :class="{active:activeView==='settings'}" :aria-current="activeView==='settings' ? 'page' : undefined" @click="switchView('settings')"><Settings/><span>设置</span></button></nav>
|
||||
<FloatingAddButton v-if="['tasks','today','upcoming','habits','countdowns'].includes(activeView)" :label="activeView==='habits' ? '添加习惯' : activeView==='countdowns' ? '添加倒数日' : '添加任务'" @activate="activateFloatingAdd" />
|
||||
<FloatingAddButton v-if="['tasks','today','upcoming','habits','countdowns','memos'].includes(activeView)" :show="activeView!=='memos' || !memoTrash" :label="activeView==='habits' ? '添加习惯' : activeView==='countdowns' ? '添加倒数日' : activeView==='memos' ? '添加备忘录' : '添加任务'" @activate="activateFloatingAdd" />
|
||||
<Transition name="task-compose">
|
||||
<div v-if="taskComposeOpen" class="task-compose-mask app-sheet-mask" @click.self="closeTaskCompose">
|
||||
<form class="task-compose-sheet app-sheet app-sheet--create" :style="taskComposeStyle" role="dialog" aria-modal="true" aria-labelledby="task-compose-title" @submit.prevent="submitTaskCompose">
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const app = readFileSync('src/App.vue', 'utf8')
|
||||
const css = readFileSync('src/memo.css', 'utf8')
|
||||
const panel = readFileSync('src/MemoPanel.vue', 'utf8')
|
||||
const editor = readFileSync('src/components/MemoEditor.vue', 'utf8')
|
||||
|
||||
describe('memo shell integration', () => {
|
||||
it('places Memos immediately after Countdowns in desktop navigation and keeps mobile tabs unchanged', () => {
|
||||
const nav = app.slice(app.indexOf('<nav class="primary-nav">'), app.indexOf('</nav>', app.indexOf('<nav class="primary-nav">')))
|
||||
expect(nav.indexOf("switchView('memos')")).toBeGreaterThan(nav.indexOf("switchView('countdowns')"))
|
||||
expect(nav.match(/switchView\('memos'\)/g)).toHaveLength(1)
|
||||
const bottom = app.slice(app.indexOf('<nav class="bottom"'), app.indexOf('</nav>', app.indexOf('<nav class="bottom"')))
|
||||
expect(bottom).not.toContain("switchView('memos')")
|
||||
expect(bottom.match(/aria-current=/g)).toHaveLength(4)
|
||||
})
|
||||
|
||||
it('routes the shared cat FAB to memo creation and hides it in memo trash', () => {
|
||||
expect(app).toContain("activeView.value === 'memos'")
|
||||
expect(app).toContain('memoPanel.value?.createMemo()')
|
||||
expect(app).toContain(':show="activeView!==\'memos\' || !memoTrash"')
|
||||
expect(panel).toContain("scope.value === 'trash'")
|
||||
})
|
||||
|
||||
it('keeps memo UI componentized and task detail copy explicit', () => {
|
||||
expect(app).toContain("import MemoPanel from './MemoPanel.vue'")
|
||||
expect(panel).toContain("import MemoRow")
|
||||
expect(panel).toContain("import MemoEditor")
|
||||
expect(app).toContain('<span>任务详情</span>')
|
||||
expect(app).toContain('<div class="field-label"><span>任务备注</span>')
|
||||
})
|
||||
|
||||
it('uses a 350px desktop detail, near-full mobile sheet, continuous rows and reduced motion', () => {
|
||||
expect(css).toContain('.memo-editor{width:350px')
|
||||
expect(css).toContain('.memo-list{display:grid;gap:0')
|
||||
expect(css).toContain('.memo-row+.memo-row{border-top:1px solid var(--border-cream)}')
|
||||
expect(css).toContain('@media(max-width:930px){.memo-panel{')
|
||||
expect(css).toContain('.memo-editor{position:fixed;z-index:42;left:0;right:0;top:auto')
|
||||
expect(css).toContain('height:min(92dvh,820px)')
|
||||
expect(css).toContain('@media(prefers-reduced-motion:reduce){.memo-editor')
|
||||
expect(editor).toContain("window.addEventListener('beforeunload'")
|
||||
expect(editor).toContain("event.key !== 'Tab'")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,73 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createApp, h, nextTick } from 'vue'
|
||||
import MemoPanel from './MemoPanel.vue'
|
||||
|
||||
const cleanups: Array<() => void> = []
|
||||
const item = { id: 'm1', title: '第一条', excerpt: '摘要', version: 1, created_at: '2026-09-12T01:00:00Z', updated_at: '2026-09-12T02:00:00Z', deleted_at: null }
|
||||
|
||||
type RequestMock = ReturnType<typeof vi.fn<(path: string, options?: RequestInit) => Promise<unknown>>>
|
||||
|
||||
async function mount(request: RequestMock) {
|
||||
const host = document.createElement('div'); document.body.append(host)
|
||||
let panel: InstanceType<typeof MemoPanel> | null = null
|
||||
const app = createApp(() => h(MemoPanel, { ref: (value: unknown) => { panel = value as InstanceType<typeof MemoPanel> }, request }))
|
||||
app.mount(host)
|
||||
cleanups.push(() => { app.unmount(); host.remove() })
|
||||
await Promise.resolve(); await Promise.resolve(); await nextTick()
|
||||
return { host, vm: panel! }
|
||||
}
|
||||
afterEach(() => { vi.useRealTimers(); cleanups.splice(0).forEach((cleanup) => cleanup()) })
|
||||
|
||||
describe('MemoPanel', () => {
|
||||
it('loads active memos in server order and appends the next 50', async () => {
|
||||
const request = vi.fn(async (path: string) => path.includes('page=2')
|
||||
? { items: [{ ...item, id: 'm2', title: '第二页' }], total: 51 }
|
||||
: { items: [item], total: 51 })
|
||||
const { host } = await mount(request)
|
||||
expect(request.mock.calls[0][0]).toContain('/memos?scope=active')
|
||||
expect(request.mock.calls[0][0]).toContain('page_size=50')
|
||||
expect(host.querySelectorAll('.memo-row')).toHaveLength(1)
|
||||
host.querySelector<HTMLButtonElement>('.memo-load-more')!.click(); await new Promise((resolve) => setTimeout(resolve, 0)); await nextTick()
|
||||
expect([...host.querySelectorAll('.memo-row strong')].map((node) => node.textContent)).toEqual(['第一条', '第二页'])
|
||||
})
|
||||
|
||||
it('searches title and body through the server and has a search empty state', async () => {
|
||||
vi.useFakeTimers()
|
||||
const request = vi.fn(async (_path: string, _options?: RequestInit): Promise<unknown> => ({ items: [], total: 0 }))
|
||||
const { host } = await mount(request)
|
||||
const input = host.querySelector<HTMLInputElement>('[aria-label="搜索备忘录"]')!
|
||||
input.value = '咖啡'; input.dispatchEvent(new Event('input'))
|
||||
await vi.advanceTimersByTimeAsync(260); await nextTick()
|
||||
expect(request.mock.calls.at(-1)?.[0]).toContain('q=%E5%92%96%E5%95%A1')
|
||||
expect(host.textContent).toContain('没有匹配的备忘录')
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('only makes the memo list inert while the detail is a mobile sheet', async () => {
|
||||
const originalWidth = window.innerWidth
|
||||
const request = vi.fn(async (path: string) => path.startsWith('/memos/m1') ? { ...item, content: '正文' } : { items: [item], total: 1 })
|
||||
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1200 })
|
||||
const desktop = await mount(request)
|
||||
desktop.host.querySelector<HTMLButtonElement>('.memo-row')!.click(); await Promise.resolve(); await nextTick()
|
||||
expect(desktop.host.querySelector('.memo-panel__main')?.hasAttribute('inert')).toBe(false)
|
||||
expect(desktop.host.querySelector('.memo-editor')?.hasAttribute('aria-modal')).toBe(false)
|
||||
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 390 })
|
||||
window.dispatchEvent(new Event('resize')); await nextTick()
|
||||
expect(desktop.host.querySelector('.memo-panel__main')?.hasAttribute('inert')).toBe(true)
|
||||
expect(desktop.host.querySelector('.memo-editor')?.getAttribute('aria-modal')).toBe('true')
|
||||
Object.defineProperty(window, 'innerWidth', { configurable: true, value: originalWidth })
|
||||
})
|
||||
|
||||
it('switches between active and trash and creates through the exposed FAB action', async () => {
|
||||
const request = vi.fn(async (path: string, options?: RequestInit) => options?.method === 'POST'
|
||||
? { ...item, id: 'new', title: '未命名备忘录', content: '', excerpt: '' }
|
||||
: { items: [], total: 0 })
|
||||
const { host, vm } = await mount(request)
|
||||
host.querySelector<HTMLButtonElement>('[data-scope="trash"]')!.click(); await Promise.resolve(); await Promise.resolve(); await nextTick()
|
||||
expect(request.mock.calls.at(-1)?.[0]).toContain('scope=trash')
|
||||
host.querySelector<HTMLButtonElement>('[data-scope="active"]')!.click(); await Promise.resolve(); await Promise.resolve(); await nextTick()
|
||||
await vm.createMemo()
|
||||
expect(request).toHaveBeenCalledWith('/memos', { method: 'POST', body: JSON.stringify({ title: '未命名备忘录', content: '' }) })
|
||||
expect(host.querySelector('.memo-editor')).not.toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,90 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { Archive, FileText, Search } from 'lucide-vue-next'
|
||||
import MemoRow, { type MemoListItem } from './components/MemoRow.vue'
|
||||
import MemoEditor, { type Memo } from './components/MemoEditor.vue'
|
||||
|
||||
type RequestFn = (path: string, options?: RequestInit) => Promise<unknown>
|
||||
const props = defineProps<{ request: RequestFn }>()
|
||||
const emit = defineEmits<{ notice: [message: string]; scope: [scope: 'active' | 'trash'] }>()
|
||||
const scope = ref<'active' | 'trash'>('active')
|
||||
const query = ref('')
|
||||
const items = ref<MemoListItem[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const loading = ref(false)
|
||||
const refreshing = ref(false)
|
||||
const error = ref('')
|
||||
const selected = ref<Memo | null>(null)
|
||||
const editor = ref<InstanceType<typeof MemoEditor> | null>(null)
|
||||
const mobileDetail = ref(window.innerWidth <= 930)
|
||||
let timer: number | undefined
|
||||
let generation = 0
|
||||
const hasMore = computed(() => items.value.length < total.value)
|
||||
const emptyCopy = computed(() => query.value.trim() ? '没有匹配的备忘录' : scope.value === 'trash' ? '回收站是空的' : '还没有备忘录')
|
||||
|
||||
async function load(reset = true) {
|
||||
const token = ++generation
|
||||
if (reset) { page.value = 1; loading.value = true } else refreshing.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const params = new URLSearchParams({ scope: scope.value, q: query.value.trim(), page: String(page.value), page_size: '50' })
|
||||
const data = await props.request(`/memos?${params}`) as { items: MemoListItem[]; total: number }
|
||||
if (token !== generation) return
|
||||
items.value = reset ? data.items : [...items.value, ...data.items]
|
||||
total.value = data.total
|
||||
} catch (reason) { if (token === generation) error.value = reason instanceof Error ? reason.message : '加载失败' }
|
||||
finally { if (token === generation) { loading.value = false; refreshing.value = false } }
|
||||
}
|
||||
async function loadMore() { if (!hasMore.value || refreshing.value) return; page.value += 1; await load(false) }
|
||||
async function setScope(next: 'active' | 'trash') {
|
||||
if (next === scope.value) return
|
||||
if (editor.value?.dirty && !window.confirm('有未保存的更改,确定切换吗?')) return
|
||||
selected.value = null; scope.value = next; emit('scope', next); await load()
|
||||
}
|
||||
async function selectMemo(id: string) {
|
||||
if (editor.value?.dirty && !window.confirm('有未保存的更改,确定切换吗?')) return
|
||||
try { selected.value = await props.request(`/memos/${id}`) as Memo } catch (reason) { error.value = reason instanceof Error ? reason.message : '读取失败' }
|
||||
}
|
||||
async function createMemo() {
|
||||
if (scope.value === 'trash') return
|
||||
if (editor.value?.dirty && !window.confirm('有未保存的更改,确定新建吗?')) return
|
||||
try {
|
||||
const created = await props.request('/memos', { method: 'POST', body: JSON.stringify({ title: '未命名备忘录', content: '' }) }) as Memo
|
||||
items.value = [{ ...created, excerpt: '' }, ...items.value]; total.value += 1; selected.value = created
|
||||
emit('notice', '备忘录已创建')
|
||||
} catch (reason) { error.value = reason instanceof Error ? reason.message : '创建失败' }
|
||||
}
|
||||
function updateItem(memo: Memo) {
|
||||
selected.value = memo
|
||||
const index = items.value.findIndex((item) => item.id === memo.id)
|
||||
if (index >= 0) items.value[index] = { ...memo, excerpt: memo.content.replace(/\s+/g, ' ').trim().slice(0, 120) }
|
||||
}
|
||||
function removeItem(id: string) { items.value = items.value.filter((item) => item.id !== id); total.value = Math.max(0, total.value - 1); selected.value = null }
|
||||
function removeRestoredItem(memo: Memo) { removeItem(memo.id) }
|
||||
function updateLayout() { mobileDetail.value = window.innerWidth <= 930 }
|
||||
watch(query, () => { if (timer) clearTimeout(timer); timer = window.setTimeout(() => void load(), 250) })
|
||||
onMounted(() => { window.addEventListener('resize', updateLayout); void load() })
|
||||
onUnmounted(() => { window.removeEventListener('resize', updateLayout); if (timer) clearTimeout(timer) })
|
||||
defineExpose({ createMemo, requestClose: () => editor.value?.requestClose(), dirty: computed(() => Boolean(editor.value?.dirty)) })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="memo-panel" :class="{'memo-panel--detail':selected}">
|
||||
<div class="memo-panel__main" :inert="selected && mobileDetail ? true : undefined">
|
||||
<div class="memo-toolbar">
|
||||
<div class="memo-scope" role="tablist" aria-label="备忘录范围"><button role="tab" data-scope="active" :aria-selected="scope==='active'" @click="setScope('active')">活动</button><button role="tab" data-scope="trash" :aria-selected="scope==='trash'" @click="setScope('trash')"><Archive/>回收站</button></div>
|
||||
<label class="memo-search"><Search/><input v-model="query" aria-label="搜索备忘录" placeholder="搜索标题或正文…"></label>
|
||||
</div>
|
||||
<p v-if="error" class="memo-error" role="alert">{{error}} <button class="link" @click="load()">重试</button></p>
|
||||
<div v-if="loading && !items.length" class="memo-state"><span class="loader"/>正在载入备忘录…</div>
|
||||
<div v-else-if="!items.length" class="memo-state"><FileText/><b>{{emptyCopy}}</b><span>{{query ? '换个关键词试试' : scope==='trash' ? '删除的备忘录会显示在这里' : '点击右下角团子猫新建一条'}}</span></div>
|
||||
<div v-else class="memo-list" :class="{refreshing}" aria-live="polite">
|
||||
<MemoRow v-for="memo in items" :key="memo.id" :memo="memo" :active="selected?.id===memo.id" @select="selectMemo"/>
|
||||
</div>
|
||||
<button v-if="hasMore" class="secondary memo-load-more" :disabled="refreshing" @click="loadMore">{{refreshing?'正在加载…':'加载更多'}}</button>
|
||||
</div>
|
||||
<div v-if="selected" class="memo-editor-scrim" @click="editor?.requestClose()"/>
|
||||
<MemoEditor v-if="selected" ref="editor" :memo="selected" :request="request" :mobile="mobileDetail" @saved="updateItem" @close="selected=null" @deleted="removeItem" @restored="removeRestoredItem" @purged="removeItem" @notice="emit('notice',$event)"/>
|
||||
</section>
|
||||
</template>
|
||||
@@ -2,7 +2,7 @@
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { clampFabPosition, isFabDrag, snapFabPosition } from '../lib/mvp-utils'
|
||||
|
||||
const props = withDefaults(defineProps<{ label?: string }>(), { label: '添加' })
|
||||
const props = withDefaults(defineProps<{ label?: string; show?: boolean }>(), { label: '添加', show: true })
|
||||
const emit = defineEmits<{ activate: [origin: { x: number; y: number }] }>()
|
||||
const dragging = ref(false)
|
||||
const snapping = ref(false)
|
||||
@@ -115,7 +115,7 @@ onUnmounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button class="unified-fab" :class="{ dragging, snapping, 'edge-left': edge === 'left', 'edge-right': edge === 'right' }" :style="buttonStyle" :aria-label="props.label" @pointerdown="startDrag" @pointermove="moveDrag" @pointerup="finishDrag" @pointercancel="finishDrag" @click="activate">
|
||||
<button v-if="props.show" class="unified-fab" :class="{ dragging, snapping, 'edge-left': edge === 'left', 'edge-right': edge === 'right' }" :style="buttonStyle" :aria-label="props.label" @pointerdown="startDrag" @pointermove="moveDrag" @pointerup="finishDrag" @pointercancel="finishDrag" @click="activate">
|
||||
<svg class="fab-cat" viewBox="0 0 56 56" aria-hidden="true">
|
||||
<g class="fab-cat__tail"><path d="M40.5 36.8 C46.2 38.4 47 43.2 43 46"/></g>
|
||||
<g class="fab-cat__character">
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createApp, h, nextTick } from 'vue'
|
||||
import MemoEditor, { type Memo } from './MemoEditor.vue'
|
||||
|
||||
const cleanups: Array<() => void> = []
|
||||
const memo: Memo = { id: 'm1', title: '旧标题', content: '正文', version: 3, created_at: '2026-09-12T01:00:00Z', updated_at: '2026-09-12T02:00:00Z', deleted_at: null }
|
||||
|
||||
async function mount(overrides: Record<string, unknown> = {}) {
|
||||
const host = document.createElement('div'); document.body.append(host)
|
||||
const events: Record<string, unknown[]> = { saved: [], close: [] }
|
||||
const defaultRequest = vi.fn(async (_path: string, _options?: RequestInit): Promise<unknown> => ({ ...memo, title: '新标题', version: 4 }))
|
||||
const request = (overrides.request ?? defaultRequest) as (path: string, options?: RequestInit) => Promise<unknown>
|
||||
const app = createApp(() => h(MemoEditor, { memo, request, onSaved: (v: unknown) => events.saved.push(v), onClose: () => events.close.push(true), ...overrides }))
|
||||
app.mount(host); cleanups.push(() => { app.unmount(); host.remove() }); await nextTick()
|
||||
return { host, request: request as ReturnType<typeof vi.fn>, events }
|
||||
}
|
||||
afterEach(() => { vi.restoreAllMocks(); cleanups.splice(0).forEach((cleanup) => cleanup()) })
|
||||
|
||||
describe('MemoEditor', () => {
|
||||
it('requires a 1-200 character title and saves explicitly with its version', async () => {
|
||||
const { host, request, events } = await mount()
|
||||
const title = host.querySelector<HTMLInputElement>('[aria-label="备忘录标题"]')!
|
||||
title.value = ' 新标题 '; title.dispatchEvent(new Event('input')); await nextTick()
|
||||
host.querySelector<HTMLButtonElement>('.memo-save')!.click(); await nextTick(); await nextTick()
|
||||
expect(request).toHaveBeenCalledWith('/memos/m1', expect.objectContaining({ method: 'PATCH', body: JSON.stringify({ title: '新标题', content: '正文', version: 3 }) }))
|
||||
expect(events.saved).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('supports Cmd/Ctrl+S and prevents browser save', async () => {
|
||||
const { host, request } = await mount()
|
||||
const title = host.querySelector<HTMLInputElement>('[aria-label="备忘录标题"]')!
|
||||
title.value = '新标题'; title.dispatchEvent(new Event('input'))
|
||||
const event = new KeyboardEvent('keydown', { key: 's', metaKey: true, bubbles: true, cancelable: true })
|
||||
host.querySelector('.memo-editor')!.dispatchEvent(event); await nextTick(); await nextTick()
|
||||
expect(event.defaultPrevented).toBe(true)
|
||||
expect(request).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('keeps the draft and offers reload after a 409 conflict', async () => {
|
||||
const request = vi.fn().mockRejectedValue(Object.assign(new Error('备忘录版本冲突'), { status: 409 }))
|
||||
const { host } = await mount({ request })
|
||||
const title = host.querySelector<HTMLInputElement>('[aria-label="备忘录标题"]')!
|
||||
title.value = '我的草稿'; title.dispatchEvent(new Event('input')); await nextTick()
|
||||
host.querySelector<HTMLButtonElement>('.memo-save')!.click(); await new Promise((resolve) => setTimeout(resolve, 0)); await nextTick()
|
||||
expect(title.value).toBe('我的草稿')
|
||||
expect(host.textContent).toContain('版本冲突')
|
||||
expect(host.querySelector('.memo-reload')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('keeps the editor open and reports destructive action failures', async () => {
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true)
|
||||
const request = vi.fn(async (_path: string, options?: RequestInit): Promise<unknown> => {
|
||||
if (options?.method === 'DELETE') throw new Error('删除失败')
|
||||
return memo
|
||||
})
|
||||
const { host, events } = await mount({ request })
|
||||
host.querySelector<HTMLButtonElement>('.danger-text')!.click()
|
||||
await new Promise((resolve) => setTimeout(resolve, 0)); await nextTick()
|
||||
expect(host.textContent).toContain('删除失败')
|
||||
expect(events.close).toEqual([])
|
||||
})
|
||||
|
||||
it('traps mobile focus, closes on Escape, and restores opener focus', async () => {
|
||||
const opener = document.createElement('button'); document.body.append(opener); opener.focus()
|
||||
const { host, events } = await mount({ mobile: true })
|
||||
const dialog = host.querySelector<HTMLElement>('.memo-editor')!
|
||||
expect(dialog.getAttribute('aria-modal')).toBe('true')
|
||||
const last = [...dialog.querySelectorAll<HTMLElement>('button:not(:disabled),input:not(:disabled),textarea:not(:disabled)')].at(-1)!
|
||||
last.focus()
|
||||
const tab = new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true })
|
||||
dialog.dispatchEvent(tab)
|
||||
expect(tab.defaultPrevented).toBe(true)
|
||||
expect(document.activeElement).toBe(dialog.querySelector('button'))
|
||||
dialog.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }))
|
||||
await nextTick()
|
||||
expect(events.close).toEqual([true])
|
||||
expect(document.activeElement).toBe(opener)
|
||||
opener.remove()
|
||||
})
|
||||
|
||||
it('guards dirty close and allows clean close', async () => {
|
||||
const confirm = vi.spyOn(window, 'confirm').mockReturnValue(false)
|
||||
const { host, events } = await mount()
|
||||
const body = host.querySelector<HTMLTextAreaElement>('[aria-label="备忘录正文"]')!
|
||||
body.value = '改过'; body.dispatchEvent(new Event('input')); await nextTick()
|
||||
host.querySelector<HTMLButtonElement>('[aria-label="关闭备忘录"]')!.click()
|
||||
expect(confirm).toHaveBeenCalled()
|
||||
expect(events.close).toEqual([])
|
||||
confirm.mockReturnValue(true)
|
||||
host.querySelector<HTMLButtonElement>('[aria-label="关闭备忘录"]')!.click()
|
||||
expect(events.close).toEqual([true])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,102 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { ArchiveRestore, Trash2, X } from 'lucide-vue-next'
|
||||
|
||||
export type Memo = { id: string; title: string; content: string; version: number; created_at: string; updated_at: string; deleted_at: string | null }
|
||||
type RequestFn = (path: string, options?: RequestInit) => Promise<unknown>
|
||||
const props = defineProps<{ memo: Memo; request: RequestFn; mobile?: boolean }>()
|
||||
const emit = defineEmits<{ saved: [memo: Memo]; close: []; deleted: [id: string]; restored: [memo: Memo]; purged: [id: string]; notice: [message: string] }>()
|
||||
const title = ref('')
|
||||
const content = ref('')
|
||||
const saving = ref(false)
|
||||
const error = ref('')
|
||||
const conflict = ref(false)
|
||||
const titleInput = ref<HTMLInputElement | null>(null)
|
||||
const root = ref<HTMLElement | null>(null)
|
||||
let initial = { title: '', content: '' }
|
||||
let opener: HTMLElement | null = null
|
||||
const dirty = computed(() => title.value !== initial.title || content.value !== initial.content)
|
||||
|
||||
function loadDraft(memo: Memo) {
|
||||
title.value = memo.title; content.value = memo.content
|
||||
initial = { title: memo.title, content: memo.content }
|
||||
error.value = ''; conflict.value = false
|
||||
}
|
||||
watch(() => props.memo, loadDraft, { immediate: true })
|
||||
function close() {
|
||||
if (dirty.value && !window.confirm('有未保存的更改,确定离开吗?')) return
|
||||
emit('close')
|
||||
nextTick(() => opener?.focus())
|
||||
}
|
||||
function validate() {
|
||||
const value = title.value.trim()
|
||||
if (!value) return '请输入标题'
|
||||
if (value.length > 200) return '标题最多 200 个字符'
|
||||
return ''
|
||||
}
|
||||
async function save() {
|
||||
error.value = validate(); if (error.value || saving.value || props.memo.deleted_at) return
|
||||
saving.value = true; conflict.value = false
|
||||
try {
|
||||
const updated = await props.request(`/memos/${props.memo.id}`, { method: 'PATCH', body: JSON.stringify({ title: title.value.trim(), content: content.value, version: props.memo.version }) }) as Memo
|
||||
loadDraft(updated); emit('saved', updated); emit('notice', '备忘录已保存')
|
||||
} catch (reason) {
|
||||
const status = (reason as { status?: number }).status
|
||||
conflict.value = status === 409
|
||||
error.value = conflict.value ? '版本冲突:草稿已保留,请重新载入后再保存。' : reason instanceof Error ? reason.message : '保存失败'
|
||||
} finally { saving.value = false }
|
||||
}
|
||||
async function reload() {
|
||||
try { const fresh = await props.request(`/memos/${props.memo.id}`) as Memo; loadDraft(fresh); emit('saved', fresh) } catch (reason) { error.value = reason instanceof Error ? reason.message : '重新载入失败' }
|
||||
}
|
||||
async function remove() {
|
||||
if (!window.confirm(`把“${props.memo.title}”移到回收站?`)) return
|
||||
error.value = ''
|
||||
try {
|
||||
await props.request(`/memos/${props.memo.id}`, { method: 'DELETE' })
|
||||
emit('deleted', props.memo.id); emit('notice', '备忘录已移到回收站')
|
||||
} catch (reason) { error.value = reason instanceof Error ? reason.message : '删除失败' }
|
||||
}
|
||||
async function restore() {
|
||||
error.value = ''
|
||||
try {
|
||||
const restored = await props.request(`/memos/${props.memo.id}/restore`, { method: 'POST' }) as Memo
|
||||
emit('restored', restored); emit('notice', '备忘录已恢复')
|
||||
} catch (reason) { error.value = reason instanceof Error ? reason.message : '恢复失败' }
|
||||
}
|
||||
async function purge() {
|
||||
if (!window.confirm(`永久删除“${props.memo.title}”?此操作无法撤销。`)) return
|
||||
error.value = ''
|
||||
try {
|
||||
await props.request(`/memos/${props.memo.id}/purge`, { method: 'DELETE' })
|
||||
emit('purged', props.memo.id); emit('notice', '备忘录已永久删除')
|
||||
} catch (reason) { error.value = reason instanceof Error ? reason.message : '永久删除失败' }
|
||||
}
|
||||
function keydown(event: KeyboardEvent) {
|
||||
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 's') { event.preventDefault(); void save(); return }
|
||||
if (event.key === 'Escape') { event.preventDefault(); close(); return }
|
||||
if (!props.mobile || event.key !== 'Tab' || !root.value) return
|
||||
const controls = [...root.value.querySelectorAll<HTMLElement>('button:not(:disabled),input:not(:disabled),textarea:not(:disabled)')]
|
||||
if (!controls.length) return
|
||||
const first = controls[0], last = controls.at(-1)!
|
||||
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus() }
|
||||
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus() }
|
||||
}
|
||||
function beforeUnload(event: BeforeUnloadEvent) { if (dirty.value) event.preventDefault() }
|
||||
onMounted(() => { opener = document.activeElement as HTMLElement; window.addEventListener('beforeunload', beforeUnload); nextTick(() => titleInput.value?.focus()) })
|
||||
onUnmounted(() => window.removeEventListener('beforeunload', beforeUnload))
|
||||
defineExpose({ dirty, requestClose: close })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside ref="root" class="memo-editor" role="dialog" :aria-modal="mobile ? 'true' : undefined" aria-labelledby="memo-editor-title" @keydown="keydown">
|
||||
<header><span id="memo-editor-title">备忘录详情</span><button type="button" aria-label="关闭备忘录" @click="close"><X/></button></header>
|
||||
<div class="memo-editor__fields">
|
||||
<label>标题<input ref="titleInput" v-model="title" maxlength="200" aria-label="备忘录标题" :disabled="Boolean(memo.deleted_at)"></label>
|
||||
<label>正文<textarea v-model="content" rows="16" aria-label="备忘录正文" placeholder="写点什么…" :disabled="Boolean(memo.deleted_at)"></textarea></label>
|
||||
<p v-if="error" role="alert" class="memo-editor__error">{{ error }} <button v-if="conflict" class="memo-reload" type="button" @click="reload">重新载入</button></p>
|
||||
</div>
|
||||
<footer v-if="!memo.deleted_at"><button type="button" class="danger-text" @click="remove"><Trash2/>移到回收站</button><button type="button" class="primary-small memo-save" :disabled="saving || !dirty" @click="save">{{ saving ? '正在保存…' : '保存' }}</button></footer>
|
||||
<footer v-else><button type="button" class="secondary" @click="restore"><ArchiveRestore/>恢复</button><button type="button" class="danger-button" @click="purge"><Trash2/>永久删除</button></footer>
|
||||
</aside>
|
||||
</template>
|
||||
@@ -0,0 +1,42 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { createApp, h, nextTick, ref } from 'vue'
|
||||
import MemoRow from './MemoRow.vue'
|
||||
|
||||
const cleanups: Array<() => void> = []
|
||||
const memo = { id: 'm1', title: '旅行清单', excerpt: '护照\n 充电器 相机', version: 1, created_at: '2026-09-12T01:00:00Z', updated_at: '2026-09-12T02:00:00Z', deleted_at: null }
|
||||
|
||||
afterEach(() => cleanups.splice(0).forEach((cleanup) => cleanup()))
|
||||
|
||||
describe('MemoRow', () => {
|
||||
it('renders a compact selectable row with plain excerpt and semantic update time', async () => {
|
||||
const host = document.createElement('div'); document.body.append(host)
|
||||
const selected: string[] = []
|
||||
const app = createApp(() => h(MemoRow, { memo, active: true, onSelect: (id: string) => selected.push(id) }))
|
||||
app.mount(host); cleanups.push(() => { app.unmount(); host.remove() }); await nextTick()
|
||||
const button = host.querySelector('button')!
|
||||
expect(button.classList.contains('active')).toBe(true)
|
||||
expect(button.getAttribute('aria-current')).toBe('true')
|
||||
expect(host.querySelector('.memo-row__excerpt')?.textContent).toBe('护照 充电器 相机')
|
||||
expect(host.querySelector('time')?.getAttribute('datetime')).toBe(memo.updated_at)
|
||||
button.click()
|
||||
expect(selected).toEqual(['m1'])
|
||||
})
|
||||
|
||||
it('updates the excerpt when the memo prop changes and preserves empty and whitespace formatting', async () => {
|
||||
const host = document.createElement('div'); document.body.append(host)
|
||||
const current = ref({ ...memo, excerpt: '' })
|
||||
const app = createApp(() => h(MemoRow, { memo: current.value }))
|
||||
app.mount(host); cleanups.push(() => { app.unmount(); host.remove() }); await nextTick()
|
||||
|
||||
const excerpt = () => host.querySelector('.memo-row__excerpt')?.textContent
|
||||
expect(excerpt()).toBe('暂无正文')
|
||||
|
||||
current.value = { ...current.value, excerpt: '保存后\n 新摘要 正常' }
|
||||
await nextTick()
|
||||
expect(excerpt()).toBe('保存后 新摘要 正常')
|
||||
|
||||
current.value = { ...current.value, excerpt: ' \n\t ' }
|
||||
await nextTick()
|
||||
expect(excerpt()).toBe('暂无正文')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
export type MemoListItem = { id: string; title: string; excerpt: string; version: number; created_at: string; updated_at: string; deleted_at: string | null }
|
||||
const props = defineProps<{ memo: MemoListItem; active?: boolean }>()
|
||||
const emit = defineEmits<{ select: [id: string] }>()
|
||||
const excerpt = computed(() => props.memo.excerpt.replace(/\s+/g, ' ').trim())
|
||||
const updated = new Intl.DateTimeFormat('zh-CN', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' }).format(new Date(props.memo.updated_at))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button class="memo-row" :class="{ active }" :aria-current="active ? 'true' : undefined" @click="emit('select', memo.id)">
|
||||
<strong>{{ memo.title }}</strong>
|
||||
<span class="memo-row__excerpt">{{ excerpt || '暂无正文' }}</span>
|
||||
<time :datetime="memo.updated_at">{{ updated }}</time>
|
||||
</button>
|
||||
</template>
|
||||
@@ -65,6 +65,8 @@ describe('MVP view utilities', () => {
|
||||
expect(readStoredNavigation(fakeStorage, 'dodo.navigation')).toEqual({ view: 'today', listId: '' })
|
||||
writeStoredNavigation(fakeStorage, 'dodo.navigation', 'tasks', 'list-2')
|
||||
expect(readStoredNavigation(fakeStorage, 'dodo.navigation')).toEqual({ view: 'tasks', listId: 'list-2' })
|
||||
writeStoredNavigation(fakeStorage, 'dodo.navigation', 'memos', 'list-2')
|
||||
expect(readStoredNavigation(fakeStorage, 'dodo.navigation')).toEqual({ view: 'memos', listId: 'list-2' })
|
||||
storage.set('dodo.navigation', JSON.stringify({ view: 'invalid', listId: 'list-2' }))
|
||||
expect(readStoredNavigation(fakeStorage, 'dodo.navigation')).toEqual({ view: 'today', listId: '' })
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
type BooleanStorage = Pick<Storage, 'getItem' | 'setItem'>
|
||||
type NavigationView = 'tasks' | 'today' | 'upcoming' | 'trash' | 'habits' | 'countdowns' | 'settings'
|
||||
type NavigationView = 'tasks' | 'today' | 'upcoming' | 'trash' | 'habits' | 'countdowns' | 'memos' | 'settings'
|
||||
type StoredNavigation = { view: NavigationView; listId: string }
|
||||
const NAVIGATION_VIEWS = new Set<NavigationView>(['tasks', 'today', 'upcoming', 'trash', 'habits', 'countdowns', 'settings'])
|
||||
const NAVIGATION_VIEWS = new Set<NavigationView>(['tasks', 'today', 'upcoming', 'trash', 'habits', 'countdowns', 'memos', 'settings'])
|
||||
|
||||
export function readStoredNavigation(storage: BooleanStorage, key: string): StoredNavigation {
|
||||
try {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import './style.css'
|
||||
import './memo.css'
|
||||
|
||||
createApp(App).mount('#app')
|
||||
if ('serviceWorker' in navigator && import.meta.env.PROD) {
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
.memo-panel{position:relative;min-height:calc(100vh - 130px)}.memo-panel__main{display:grid;gap:14px}.memo-toolbar{display:flex;align-items:center;justify-content:space-between;gap:12px}.memo-scope{display:flex;gap:4px;padding:3px;border:1px solid var(--border-cream);border-radius:12px;background:var(--surface-raised)}.memo-scope button{min-height:44px;display:inline-flex;align-items:center;gap:6px;border:0;border-radius:9px;background:transparent;padding:0 13px}.memo-scope button[aria-selected="true"]{background:var(--accent-soft);color:#b7421e;font-weight:700}.memo-search{height:44px;min-width:min(320px,45%);display:flex;align-items:center;gap:8px;border:1px solid var(--border-cream);border-radius:11px;background:var(--surface-raised);padding:0 12px}.memo-search input{min-width:0;width:100%;border:0;outline:0;background:transparent;box-shadow:none}.memo-list{display:grid;gap:0;border:1px solid var(--border-cream);border-radius:var(--radius-list);background:var(--surface-raised);overflow:hidden}.memo-list.refreshing{opacity:.62}.memo-row{width:100%;min-height:76px;display:grid;grid-template-columns:minmax(0,1fr) auto;gap:4px 12px;border:0;background:var(--surface-raised);padding:12px 15px;text-align:left}.memo-row+.memo-row{border-top:1px solid var(--border-cream)}.memo-row:hover,.memo-row.active{background:#fff7eb}.memo-row strong{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.memo-row__excerpt{grid-column:1;display:-webkit-box;overflow:hidden;-webkit-line-clamp:2;-webkit-box-orient:vertical;color:var(--text-secondary);font-size:12px;line-height:1.45;white-space:normal}.memo-row time{grid-column:2;grid-row:1/3;align-self:center;color:var(--muted);font-size:11px}.memo-state{min-height:240px;display:grid;place-items:center;align-content:center;gap:9px;color:var(--muted);text-align:center}.memo-state svg{width:30px;height:30px;color:var(--accent)}.memo-load-more{justify-self:center;min-width:132px;min-height:44px}.memo-error,.memo-editor__error{color:var(--danger);background:#fff0ec;border-radius:10px;padding:10px 12px}.memo-editor{width:350px;position:fixed;z-index:42;right:0;top:0;bottom:0;display:flex;flex-direction:column;border-left:1px solid var(--border-cream);background:var(--surface-raised);box-shadow:var(--shadow-raised)}.memo-editor>header,.memo-editor>footer{min-height:64px;display:flex;align-items:center;justify-content:space-between;gap:10px;padding:10px 16px;border-bottom:1px solid var(--border-cream)}.memo-editor>header span{font-size:12px;font-weight:750;letter-spacing:.06em;color:var(--muted)}.memo-editor>header button{width:44px;height:44px;display:grid;place-items:center;border:0;border-radius:10px;background:transparent}.memo-editor__fields{flex:1;min-height:0;overflow:auto;display:grid;align-content:start;gap:14px;padding:18px}.memo-editor__fields label{display:grid;gap:7px;color:var(--text-secondary);font-size:12px;font-weight:700}.memo-editor__fields input,.memo-editor__fields textarea{width:100%;border:1px solid var(--border-cream);border-radius:11px;background:#fff;padding:12px;outline:0}.memo-editor__fields textarea{resize:vertical;line-height:1.65}.memo-editor__fields input:focus,.memo-editor__fields textarea:focus{border-color:var(--accent);box-shadow:0 0 0 3px var(--focus-ring)}.memo-editor>footer{border-top:1px solid var(--border-cream);border-bottom:0}.memo-editor>footer button{min-height:44px}.memo-editor-scrim{display:none}
|
||||
@media(max-width:930px){.memo-panel{min-height:calc(100dvh - 150px)}.memo-toolbar{align-items:stretch;flex-direction:column}.memo-search{width:100%;min-width:0}.memo-row{min-height:76px}.memo-editor-scrim{display:block;position:fixed;z-index:41;inset:0;background:var(--scrim)}.memo-editor{position:fixed;z-index:42;left:0;right:0;top:auto;bottom:0;width:100%;height:min(92dvh,820px);border:1px solid var(--border-cream);border-bottom:0;border-radius:22px 22px 0 0;transition:transform .22s ease}.memo-editor__fields{padding:16px}.memo-editor>footer{padding-bottom:max(10px,env(safe-area-inset-bottom))}}
|
||||
@media(prefers-reduced-motion:reduce){.memo-editor,.memo-row,.memo-list{transition:none!important}}
|
||||
Reference in New Issue
Block a user