- BaseModal.vue (new): the backdrop + dialog-panel shell (dimmed fixed overlay, bordered rounded panel, role=dialog, close on Escape + backdrop mousedown). Caller sizes/pads/shadows the panel via `panelClass`, picks start/center `align`, and sets an `ariaLabel` for header-less panels. - LabelsModal, CommandPalette, and the AppShell keyboard-shortcuts overlay drop their hand-rolled backdrop+panel shells and slot their content into BaseModal (~12 lines of overlay boilerplate each → gone). - NoteEditor deliberately keeps its own shell: its backdrop mousedown is drag-guarded and its Esc/⌘-Enter handling is bespoke (unsaved-edit safety), so folding it in would risk regressing the app's core editing surface (rule 28). - AccountView's one device-name field now uses the shared BaseInput. SettingsView is intentionally NOT converted — its rows are a horizontal label+control pattern (checkbox/number/text, direct value mutation), a different shape than BaseInput's vertical form field. Frontend-only; CI vue-tsc is the type/template gate (no local typecheck, rule 10). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
158 lines
6.2 KiB
Vue
158 lines
6.2 KiB
Vue
<script setup lang="ts">
|
|
import { computed, ref } from "vue";
|
|
import { useLabelsStore, type Label } from "../stores/labels";
|
|
import { NOTE_COLOR_KEYS, NOTE_COLOR_LABELS, NOTE_SWATCH_CLASSES, type NoteColor } from "../notes/colors";
|
|
import BaseModal from "./BaseModal.vue";
|
|
import Icon from "./Icon.vue";
|
|
|
|
const emit = defineEmits<{ (e: "close"): void }>();
|
|
const labels = useLabelsStore();
|
|
const newName = ref("");
|
|
const pickerFor = ref<string | null>(null);
|
|
const mergeFor = ref<string | null>(null);
|
|
|
|
const canMerge = computed(() => labels.items.length > 1);
|
|
|
|
async function add() {
|
|
const name = newName.value.trim();
|
|
if (!name) return;
|
|
await labels.create(name);
|
|
newName.value = "";
|
|
}
|
|
|
|
async function rename(id: string, value: string) {
|
|
const name = value.trim();
|
|
if (name) await labels.rename(id, name);
|
|
}
|
|
|
|
function labelDot(color: string): string {
|
|
return NOTE_SWATCH_CLASSES[color as NoteColor] ?? NOTE_SWATCH_CLASSES.default;
|
|
}
|
|
|
|
function openColor(id: string) {
|
|
mergeFor.value = null;
|
|
pickerFor.value = pickerFor.value === id ? null : id;
|
|
}
|
|
|
|
async function pickColor(id: string, color: NoteColor) {
|
|
pickerFor.value = null;
|
|
await labels.setColor(id, color);
|
|
}
|
|
|
|
function openMerge(id: string) {
|
|
pickerFor.value = null;
|
|
mergeFor.value = mergeFor.value === id ? null : id;
|
|
}
|
|
|
|
function otherLabels(id: string): Label[] {
|
|
return labels.items.filter((lb) => lb.id !== id);
|
|
}
|
|
|
|
async function doMerge(sourceId: string, targetId: string) {
|
|
mergeFor.value = null;
|
|
await labels.mergeInto(sourceId, targetId);
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<BaseModal panel-class="w-full max-w-sm shadow-xl" @close="emit('close')">
|
|
<div class="flex items-center justify-between border-b border-neutral-100 px-4 py-3 dark:border-neutral-800">
|
|
<h2 class="text-sm font-semibold">Manage labels</h2>
|
|
<button type="button" class="icon-btn" aria-label="Close" @click="emit('close')"><Icon name="close" /></button>
|
|
</div>
|
|
<div class="flex flex-col gap-2 p-4">
|
|
<form class="flex items-center gap-2" @submit.prevent="add">
|
|
<Icon name="plus" />
|
|
<input
|
|
v-model="newName"
|
|
type="text"
|
|
placeholder="Create label"
|
|
class="flex-1 rounded-md border border-neutral-300 bg-white px-2 py-1.5 text-sm outline-none focus-visible:ring-2 focus-visible:ring-brand dark:border-neutral-700 dark:bg-neutral-800"
|
|
/>
|
|
</form>
|
|
<ul class="flex flex-col gap-1 pt-1">
|
|
<li v-for="lb in labels.items" :key="lb.id" class="relative flex items-center gap-1.5">
|
|
<button
|
|
type="button"
|
|
class="h-4 w-4 shrink-0 rounded-full border border-black/10 transition hover:scale-110 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand dark:border-white/15"
|
|
:class="labelDot(lb.color)"
|
|
:title="`Color: ${NOTE_COLOR_LABELS[(lb.color as NoteColor)] ?? lb.color}`"
|
|
aria-label="Change label color"
|
|
@click="openColor(lb.id)"
|
|
/>
|
|
<input
|
|
:value="lb.name"
|
|
class="min-w-0 flex-1 rounded-md bg-transparent px-2 py-1.5 text-sm outline-none focus-visible:ring-2 focus-visible:ring-brand"
|
|
@change="rename(lb.id, ($event.target as HTMLInputElement).value)"
|
|
/>
|
|
<span
|
|
class="shrink-0 tabular-nums text-xs text-neutral-400"
|
|
:title="`${lb.count ?? 0} note${(lb.count ?? 0) === 1 ? '' : 's'}`"
|
|
>{{ lb.count ?? 0 }}</span
|
|
>
|
|
<button
|
|
v-if="canMerge"
|
|
type="button"
|
|
class="icon-btn"
|
|
title="Merge into another label"
|
|
aria-label="Merge into another label"
|
|
@click="openMerge(lb.id)"
|
|
>
|
|
<Icon name="merge" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="icon-btn"
|
|
title="Delete label"
|
|
aria-label="Delete label"
|
|
@click="labels.remove(lb.id)"
|
|
>
|
|
<Icon name="trash" />
|
|
</button>
|
|
<div
|
|
v-if="pickerFor === lb.id"
|
|
class="absolute left-0 top-full z-10 mt-1 flex max-w-[13rem] flex-wrap gap-1.5 rounded-lg border border-neutral-200 bg-white p-2 shadow-lg dark:border-neutral-700 dark:bg-neutral-800"
|
|
>
|
|
<button
|
|
v-for="key in NOTE_COLOR_KEYS"
|
|
:key="key"
|
|
type="button"
|
|
:title="NOTE_COLOR_LABELS[key]"
|
|
:aria-label="NOTE_COLOR_LABELS[key]"
|
|
class="h-6 w-6 rounded-full border border-black/10 transition hover:scale-110 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand"
|
|
:class="[NOTE_SWATCH_CLASSES[key], lb.color === key ? 'ring-2 ring-brand' : '']"
|
|
@click="pickColor(lb.id, key)"
|
|
/>
|
|
</div>
|
|
<div
|
|
v-if="mergeFor === lb.id"
|
|
class="absolute right-0 top-full z-10 mt-1 w-52 rounded-lg border border-neutral-200 bg-white p-1 shadow-lg dark:border-neutral-700 dark:bg-neutral-800"
|
|
>
|
|
<p class="px-2 py-1 text-xs text-neutral-400">
|
|
Merge “{{ lb.name }}” into… <span class="text-neutral-300 dark:text-neutral-500">(keeps notes)</span>
|
|
</p>
|
|
<ul class="max-h-44 overflow-y-auto">
|
|
<li v-for="t in otherLabels(lb.id)" :key="t.id">
|
|
<button
|
|
type="button"
|
|
class="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm hover:bg-neutral-100 dark:hover:bg-neutral-700"
|
|
@click="doMerge(lb.id, t.id)"
|
|
>
|
|
<span
|
|
class="h-2.5 w-2.5 shrink-0 rounded-full border border-black/10 dark:border-white/15"
|
|
:class="labelDot(t.color)"
|
|
></span>
|
|
<span class="truncate">{{ t.name }}</span>
|
|
</button>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
</li>
|
|
</ul>
|
|
<p v-if="!labels.items.length" class="py-2 text-center text-xs text-neutral-400">
|
|
No labels yet — create one above.
|
|
</p>
|
|
</div>
|
|
</BaseModal>
|
|
</template>
|