CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 14s
CI & Build / integration (push) Successful in 19s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 2m28s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m52s
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Failing after 4m1s
Step 3 of M315, and the destructive half. Steps 1 and 2 stopped every read of this field: a card is one neutral surface per theme, and the only coloured thing on a board is a tag. What was left was a column written by a picker and read by nothing. Rule 22 — the old path comes out completely. No flag, no fallback, no "override if set". Server: the column, the `?color=` facet, the create/update/serialise paths, the sync assignment, the front-matter line, and Keep's colour map. Alembic 0029 drops it and sweeps `"color"` out of stored saved-filter params — a view that silently filtered on a field the app no longer has would return nothing and never say why. That sweep is Python, not `params::jsonb - 'color'`, because Postgres has no try-cast and one malformed blob would abort a migration that is running over somebody's saved views. `NOTE_COLORS` moves from `models/note.py` to `colors.py`. A palette defined on the model that lost one is an invitation to put the column back; labels still name a colour, so the vocabulary belongs where the normalizer already is. Core: the field, the facet, the `NoteCreateInput`, and every read and write in store/push/pull. Local schema v9 drops the column and does the same saved-filter sweep, guarded on `json_valid` so a corrupt blob loses a key rather than becoming NULL. The uniffi layer drops `NoteEdit::Color` and `NoteDraft.color` with it. Web: `ColorPicker.vue`, the per-card swatch popover and its stylesheet rule, the FilterBar colour row, the facet in the query round-trip, and the colour half of the editor's baseline-and-save. Android: the `ColorSheet`, the `Picker.COLOR` case, the toolbar's swatch dot, `EditorAction.SetColor`. ## The protocol: v4, and the floor deliberately stays at 3 Checked against `compat.rs` and the push handler rather than trusting the `#[serde(default)]` annotation, because the v2 precedent points the other way: v2 dropped `kind` and `title` and DID raise both floors, on the rule that dropping a field a client sends and expects back is breaking. `color` fails the second half of that test. A v3 client reading a v4 note gets `"default"` from its own serde default and draws the colour it derives locally — the board it drew yesterday. A v3 client pushing `color` has the key ignored, since `_assign_note_fields` reads its payload key by key and never validates the shape. Neither direction errors and neither shows anything wrong. `title` was the note's NAME; this is a field that no longer renders. So `SYNC_PROTOCOL_VERSION` and `CLIENT_PROTOCOL_VERSION` go to 4, and both floors stay at 3. `docs/sync.md` carries the reasoning and the per-version history, and its push example is brought back in line — it still listed `title`, `kind` and `items`, all gone before this. Import stays tolerant: a pre-M315 export or a Keep takeout carrying `color:` imports fine, the key simply read past. Old exports must still import. #3041 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
478 lines
22 KiB
Vue
478 lines
22 KiB
Vue
<script setup lang="ts">
|
|
import { computed, ref, watch } from "vue";
|
|
import { useNotesStore } from "../stores/notes";
|
|
import { NOTE_CARD_SURFACE, labelChipClasses } from "../notes/colors";
|
|
import type { Note } from "../stores/notes";
|
|
import Icon from "./Icon.vue";
|
|
import LinkPreview from "./LinkPreview.vue";
|
|
import MarkdownText from "./MarkdownText.vue";
|
|
import {
|
|
cardIdAt,
|
|
draggingId,
|
|
DRAG_THRESHOLD_PX,
|
|
endCardDrag,
|
|
overId,
|
|
} from "../composables/useCardDrag";
|
|
import { formatReminder, formatTrashCountdown, isOverdue, trashDaysLeft } from "../notes/datetime";
|
|
import { useConfigStore } from "../stores/config";
|
|
|
|
const props = defineProps<{ note: Note; reorderable?: boolean; active?: boolean }>();
|
|
const emit = defineEmits<{
|
|
(e: "open", note: Note): void;
|
|
(e: "dragstart", note: Note): void;
|
|
(e: "dragend", note: Note): void;
|
|
/** The card this one was dropped ON, by id — the dragged card hit-tests the DOM
|
|
* for it, so an id is all it can know without looking the note up again. */
|
|
(e: "drop", targetId: string): void;
|
|
/** A reminder was completed or snoozed. The store reconciles itself, so the board
|
|
* needs nothing — this is for hosts holding their OWN list (the reminders lens
|
|
* fetches separately) that must re-query to drop the note they just cleared. */
|
|
(e: "reminder-changed"): void;
|
|
}>();
|
|
const notes = useNotesStore();
|
|
const config = useConfigStore();
|
|
|
|
// --- Retention countdown. A note in Trash is on a clock, and the card is the only
|
|
// place someone browsing Trash would ever find that out in time to restore it.
|
|
// Null whenever nothing is going to happen: not trashed, or retention turned off. ---
|
|
const trashDays = computed(() =>
|
|
props.note.trashed ? trashDaysLeft(props.note.deleted_at, config.trashRetentionDays) : null,
|
|
);
|
|
const trashCountdown = computed(() => formatTrashCountdown(trashDays.value));
|
|
// Same red the overdue reminder uses — the last few days are worth noticing.
|
|
const trashUrgent = computed(() => trashDays.value !== null && trashDays.value <= 3);
|
|
|
|
// The card previews the first image inline; non-image files show as compact chips.
|
|
const firstImage = computed(() => props.note.attachments.find((a) => a.mime.startsWith("image/")));
|
|
const otherAttachments = computed(() => props.note.attachments.filter((a) => !a.mime.startsWith("image/")));
|
|
|
|
// How much of a note the CARD shows. Android has always clamped to 8
|
|
// (`MAX_PREVIEW_LINES`); the web rendered the whole body, so one long note could
|
|
// produce a card taller than the screen and push everything else off the board.
|
|
//
|
|
// It matters more now that the title is gone (M13 step 4). The first line used to be
|
|
// the thing your eye caught; with one weight throughout, an unbounded card is just a
|
|
// wall, and the note next to it is the one you were looking for.
|
|
//
|
|
// Clamped in the STRING rather than with CSS `line-clamp`, which needs a
|
|
// `-webkit-box` and behaves unreliably around the block elements MarkdownText emits
|
|
// (lists, quotes, fenced code). This is deterministic, matches Android's semantics
|
|
// exactly, and skips parsing a body the card was never going to show.
|
|
const PREVIEW_LINES = 8;
|
|
|
|
// --- Links ------------------------------------------------------------------
|
|
//
|
|
// A note that is NOTHING but a URL is a link, and its preview is the whole card —
|
|
// showing the raw URL underneath a card that already says where it goes is saying the
|
|
// same thing twice, badly. A URL mentioned *inside* a note is a footnote to it, and
|
|
// gets a compact strip at the bottom instead.
|
|
//
|
|
// Whitespace either side still counts as lone: someone pasting a link rarely trims it.
|
|
const LONE_URL_RE = /^\s*(https?:\/\/[^\s<>"'\])]+)\s*$/;
|
|
|
|
const isLoneUrl = computed(() => LONE_URL_RE.test(props.note.body) && !props.note.items.length);
|
|
|
|
/** The preview for a lone-URL note — null while it is still being fetched, or if it
|
|
* could never be fetched at all. */
|
|
const loneUrlPreview = computed(() => {
|
|
if (!isLoneUrl.value) return null;
|
|
const url = props.note.body.trim();
|
|
return props.note.previews.find((p) => p.url === url) ?? null;
|
|
});
|
|
|
|
const bodyPreview = computed(() => {
|
|
const lines = props.note.body.split("\n");
|
|
if (lines.length <= PREVIEW_LINES) return props.note.body;
|
|
return lines.slice(0, PREVIEW_LINES).join("\n") + "\n…";
|
|
});
|
|
|
|
/** Tick a box without opening the note — the common gesture on a board.
|
|
*
|
|
* The index is the item's ordinal in the WHOLE body, which survives the preview
|
|
* clamp above because that only ever drops lines from the end. `updateItem` takes
|
|
* it as the item id, which is exactly what an id is now (M304). */
|
|
function toggleTask(index: number, checked: boolean) {
|
|
void notes.updateItem(props.note.id, String(index), { checked });
|
|
}
|
|
|
|
const root = ref<HTMLElement | null>(null);
|
|
|
|
// --- Drag-to-reorder. Pointer Events, gated behind an explicit grip handle so a
|
|
// plain tap/click/select never starts a drag by accident. `dragging` dims the
|
|
// source card; `dragOver` shows where the drop will land.
|
|
//
|
|
// Pointer rather than native HTML5 drag-and-drop because that API never fires from
|
|
// touch — on a phone this did nothing at all. One code path now covers mouse, touch
|
|
// and stylus. See composables/useCardDrag.ts for why the state is shared. ---
|
|
const canDrag = () => !!props.reorderable && !props.note.trashed;
|
|
const dragging = computed(() => draggingId.value === props.note.id);
|
|
const dragOver = computed(() => overId.value === props.note.id);
|
|
|
|
/** The pointer that owns the current gesture; null when no press is in flight. */
|
|
let activePointer: number | null = null;
|
|
let startX = 0;
|
|
let startY = 0;
|
|
/** A press becomes a drag only after the threshold — below it, it's still a tap. */
|
|
let moved = false;
|
|
|
|
function onGripDown(e: PointerEvent) {
|
|
if (!canDrag()) return;
|
|
activePointer = e.pointerId;
|
|
startX = e.clientX;
|
|
startY = e.clientY;
|
|
moved = false;
|
|
// Capture so the gesture keeps reporting to this grip even once the finger has
|
|
// travelled onto another card — without it the stream stops at the first
|
|
// boundary crossing.
|
|
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
|
// Claims the gesture from the browser's own scroll/text-selection handling.
|
|
e.preventDefault();
|
|
}
|
|
|
|
function onGripMove(e: PointerEvent) {
|
|
if (activePointer !== e.pointerId) return;
|
|
if (!moved) {
|
|
if (Math.hypot(e.clientX - startX, e.clientY - startY) < DRAG_THRESHOLD_PX) return;
|
|
moved = true;
|
|
draggingId.value = props.note.id;
|
|
emit("dragstart", props.note);
|
|
}
|
|
overId.value = cardIdAt(e.clientX, e.clientY, props.note.id);
|
|
}
|
|
|
|
function onGripUp(e: PointerEvent) {
|
|
if (activePointer !== e.pointerId) return;
|
|
const target = moved ? overId.value : null;
|
|
const wasDragging = moved;
|
|
activePointer = null;
|
|
moved = false;
|
|
endCardDrag();
|
|
if (target) emit("drop", target);
|
|
// Always paired with dragstart, dropped or not, so the parent can clear its own
|
|
// tracked source rather than leaking it into the next gesture.
|
|
if (wasDragging) emit("dragend", props.note);
|
|
}
|
|
|
|
// Fires when the system takes the gesture away (an incoming call, a browser-level
|
|
// scroll taking over). Treated as a cancel: no reorder, no half-set state left.
|
|
function onGripCancel(e: PointerEvent) {
|
|
if (activePointer !== e.pointerId) return;
|
|
const wasDragging = moved;
|
|
activePointer = null;
|
|
moved = false;
|
|
endCardDrag();
|
|
if (wasDragging) emit("dragend", props.note);
|
|
}
|
|
|
|
// When this card becomes the keyboard-focused card, scroll it into view.
|
|
watch(
|
|
() => props.active,
|
|
(a) => {
|
|
if (a) root.value?.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
|
},
|
|
);
|
|
|
|
async function completeReminder(): Promise<void> {
|
|
await notes.completeReminder(props.note.id);
|
|
emit("reminder-changed");
|
|
}
|
|
|
|
async function snoozeReminder(minutes: number): Promise<void> {
|
|
await notes.snoozeReminder(props.note.id, minutes);
|
|
emit("reminder-changed");
|
|
}
|
|
|
|
// Only the tags the BODY is not already showing. `via_tag` means exactly "backed by
|
|
// text still in the note" since M311, so a chip for one printed the same tag twice —
|
|
// once where it was typed, once in this row — and the loud copy was the duplicate. A
|
|
// tag left in prose is tinted where it sits instead (MarkdownInline). What survives
|
|
// here is what the body cannot say: a tag lifted off its own line, and a label added
|
|
// through the picker.
|
|
const chipLabels = computed(() => props.note.labels.filter((lb) => !lb.via_tag));
|
|
|
|
// The colour the operator stored for each of this note's tags, keyed by lowercased
|
|
// name — what MarkdownInline needs to tint a `#tag` the same as its chip would be.
|
|
// Lowercased because tags dedupe case-insensitively, so `#Todo` and `#todo` are one.
|
|
const tagColors = computed<Record<string, string>>(() => {
|
|
const map: Record<string, string> = {};
|
|
for (const lb of props.note.labels) map[lb.name.toLowerCase()] = lb.color;
|
|
return map;
|
|
});
|
|
|
|
</script>
|
|
|
|
<template>
|
|
<div
|
|
ref="root"
|
|
class="group relative mb-4 break-inside-avoid rounded-xl border border-[#b8b8b8] p-3 shadow-sm transition hover:shadow-md dark:border-[#404040]"
|
|
:class="[
|
|
NOTE_CARD_SURFACE,
|
|
dragging ? 'opacity-40' : '',
|
|
dragOver
|
|
? 'scale-[1.02] shadow-lg ring-2 ring-brand ring-offset-2 ring-offset-white dark:ring-offset-neutral-950'
|
|
: '',
|
|
active ? 'ring-2 ring-brand' : '',
|
|
]"
|
|
:data-note-id="note.id"
|
|
>
|
|
<!-- THE EDGE IS THE CARD'S BOUNDARY, and since M315 it is the ONLY thing that
|
|
varies from the board: the fill is one neutral (NOTE_CARD_SURFACE) and no
|
|
longer says anything about the note. That makes this line load-bearing rather
|
|
than decorative — it is what a card IS.
|
|
|
|
It was already neutral before the fill was. The version that came from the
|
|
palette was a `{hue}-900` border and failed twice over: the line was the
|
|
loudest element on the card (1.56-2.09 against its own fill, where the fill
|
|
managed 1.03-1.05 against the board) AND carried the same information the fill
|
|
did, so a board of them read as a grid of outlines. A neutral line carries no
|
|
information at all, which is exactly what lets it be structure. The fill is
|
|
the same argument one size up, made two milestones later.
|
|
|
|
MEASURED AGAINST ONE FILL NOW, and deliberately left where it was. #b8b8b8 on
|
|
white is 1.98 and #404040 on #171717 is 1.73 — both inside the ranges these
|
|
values already shipped at across twenty fills (light 1.57-1.98, dark
|
|
1.58-1.73), but at the top of them rather than the ~1.6-1.7 the pair was
|
|
originally matched on. Softening the light edge to re-match would weaken the
|
|
only boundary a white card on a #fafafa board has, and the complaint that
|
|
started M315 was about fill, never about edge weight. If an operator pass
|
|
disagrees it is one constant, in two files.
|
|
|
|
NOT a translucent black/white edge, which is the tidier-looking way to do this
|
|
and was measured and rejected: a border composites over what is under it, so
|
|
`border-white/20` came out #56396d on a purple card and #a3c9c1 on a teal one.
|
|
With one fill that argument no longer bites — but an opaque grey is what the
|
|
Android side must also write, and two surfaces stating the same hex is how
|
|
they stay the same card.
|
|
|
|
`shadow-sm`, back down from `shadow`: the border is the boundary again, so the
|
|
shadow is only depth. -->
|
|
<!-- TAGS FIRST. They used to sit under everything else, which on a tall note put
|
|
the one thing that says what a note IS below the fold of a glance. A board is
|
|
scanned, not read, and the answer to "which of these is about the thing I am
|
|
looking for" should be the first thing the eye lands on rather than the last.
|
|
|
|
Above the image and the body rather than beside them, because the body's first
|
|
line is the note's NAME (M13 steps 3 and 4) and a chip floated next to it would
|
|
compete with the thing that identifies the note. -->
|
|
<div v-if="chipLabels.length" class="mb-2 flex flex-wrap gap-1">
|
|
<!-- Every chip carries the `#`, not just the ones derived from body text. That
|
|
branch used to distinguish a `#tag` from a picker label; it cannot any more,
|
|
because a tag whose text is still in the body no longer reaches this row at
|
|
all. What is left is all the same thing to the eye and to the vocabulary —
|
|
and the hash is what keeps a lifted chip reading as the `#todo` somebody
|
|
typed. Android's row says the same, which it did not before. -->
|
|
<span
|
|
v-for="lb in chipLabels"
|
|
:key="lb.id"
|
|
class="rounded-full px-2 py-0.5 text-xs"
|
|
:class="labelChipClasses(lb)"
|
|
>#{{ lb.name }}</span
|
|
>
|
|
</div>
|
|
|
|
<img
|
|
v-if="firstImage"
|
|
:src="firstImage.url"
|
|
alt=""
|
|
loading="lazy"
|
|
decoding="async"
|
|
class="mb-2 max-h-48 w-full cursor-pointer rounded-lg object-cover"
|
|
@click="emit('open', note)"
|
|
/>
|
|
<div v-if="otherAttachments.length" class="mb-2 flex flex-wrap gap-1">
|
|
<span
|
|
v-for="att in otherAttachments"
|
|
:key="att.id"
|
|
class="inline-flex max-w-full items-center gap-1 rounded-md bg-black/5 px-1.5 py-0.5 text-xs text-neutral-500 dark:bg-white/10 dark:text-neutral-400"
|
|
>
|
|
<Icon name="paperclip" />
|
|
<span class="max-w-[140px] truncate">{{ att.filename || "file" }}</span>
|
|
</span>
|
|
</div>
|
|
|
|
|
|
<!-- One render path: every note is a body plus, maybe, checkable items.
|
|
A focusable div rather than a <button>, because a checklist nests interactive
|
|
controls and those cannot live inside a button — and the card is the same
|
|
shape whether or not it happens to carry items today. -->
|
|
<div
|
|
role="button"
|
|
tabindex="0"
|
|
class="cursor-text rounded focus:outline-none focus-visible:ring-2 focus-visible:ring-brand"
|
|
@click="emit('open', note)"
|
|
@keydown.enter="emit('open', note)"
|
|
>
|
|
<!-- A lone URL renders as its preview and nothing else. Until the fetch lands
|
|
— or if it never does — the URL itself stands in, so the card is never
|
|
blank and the link is never unreachable. -->
|
|
<LinkPreview v-if="loneUrlPreview" :preview="loneUrlPreview" />
|
|
<div v-else-if="note.body" class="text-sm text-neutral-700 dark:text-neutral-300">
|
|
<MarkdownText :text="bodyPreview" :tag-colors="tagColors" toggleable @toggle="toggleTask" />
|
|
</div>
|
|
<p
|
|
v-if="!note.body && !note.items.length && !note.attachments.length"
|
|
class="text-sm italic text-neutral-400"
|
|
>
|
|
Empty note
|
|
</p>
|
|
</div>
|
|
<!-- Inline links: a compact strip at the FOOT of the card, under the note's own
|
|
words rather than stacked on top of them. They were above the body until
|
|
M13 — which put a stranger's headline where the note's first line should be. -->
|
|
<div v-if="!isLoneUrl && note.previews.length" class="mt-2 flex flex-col gap-1">
|
|
<LinkPreview v-for="p in note.previews" :key="p.id" :preview="p" compact />
|
|
</div>
|
|
|
|
<!-- No separate checklist block any more. A checklist is lines of the body (M304),
|
|
so MarkdownText above draws it in place — which is what lets a list sit between
|
|
two paragraphs instead of always after them. Rendering both would have shown
|
|
every list twice. -->
|
|
|
|
<div v-if="note.remind_at" class="mt-2 flex flex-wrap items-center gap-1.5">
|
|
<span
|
|
class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs"
|
|
:class="
|
|
isOverdue(note.remind_at)
|
|
? 'bg-red-100 text-red-700 dark:bg-red-950/50 dark:text-red-300'
|
|
: 'bg-black/5 text-neutral-600 dark:bg-white/10 dark:text-neutral-300'
|
|
"
|
|
>
|
|
<svg
|
|
class="h-3 w-3"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="2"
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
>
|
|
<circle cx="12" cy="12" r="10" />
|
|
<polyline points="12 6 12 12 16 14" />
|
|
</svg>
|
|
{{ formatReminder(note.remind_at) }}
|
|
<!-- Recurrence was only ever shown in the reminders list; carried onto the
|
|
card so unifying them didn't quietly drop it, and so a repeating note
|
|
reads as repeating on the board too. -->
|
|
<span v-if="note.recurrence" :title="`Repeats ${note.recurrence}`">· ↻ {{ note.recurrence }}</span>
|
|
</span>
|
|
|
|
<!-- Clearing a reminder is UPKEEP, and upkeep that costs three clicks is
|
|
upkeep people stop doing — so it happens right here rather than by
|
|
opening the note. Shown wherever a note carries a reminder, the board
|
|
included, not only in the reminders lens.
|
|
Always visible rather than hover-revealed: a finger cannot hover, and
|
|
these are the primary action for a due note (task 2697). -->
|
|
<button type="button" class="chip-btn" title="Mark this reminder done" @click.stop="completeReminder">
|
|
Done
|
|
</button>
|
|
<button type="button" class="chip-btn" title="Snooze 1 hour" @click.stop="snoozeReminder(60)">1h</button>
|
|
<button type="button" class="chip-btn" title="Snooze 1 day" @click.stop="snoozeReminder(1440)">1d</button>
|
|
</div>
|
|
|
|
<div v-if="trashCountdown" class="mt-2">
|
|
<span
|
|
class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs"
|
|
:class="
|
|
trashUrgent
|
|
? 'bg-red-100 text-red-700 dark:bg-red-950/50 dark:text-red-300'
|
|
: 'bg-black/5 text-neutral-600 dark:bg-white/10 dark:text-neutral-300'
|
|
"
|
|
:title="`Permanently deleted ${config.trashRetentionDays} days after it was trashed`"
|
|
>
|
|
<svg
|
|
class="h-3 w-3"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="2"
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
>
|
|
<circle cx="12" cy="12" r="10" />
|
|
<polyline points="12 6 12 12 16 14" />
|
|
</svg>
|
|
{{ trashCountdown }}
|
|
</span>
|
|
</div>
|
|
|
|
<!-- The card's controls: the drag grip, then the action set.
|
|
Placement is CSS's job, not the markup's — `.note-actions` in style.css puts
|
|
them where the device can actually use them. Where a pointer hovers they lift
|
|
out of flow into the floating top-corner pills they have always been; where
|
|
nothing can hover they stay here, in flow, as a footer row. The alternative
|
|
(a permanently visible overlay) sat on top of the note's own title, which is
|
|
what a phone was showing.
|
|
`hover-reveal` still handles the visibility half — see style.css. -->
|
|
<div class="note-actions">
|
|
<!-- Reorder is gated behind this grip so a normal click or tap never starts a
|
|
drag. Board views only (reorderable). A pointer-only affordance — there is
|
|
no keyboard equivalent, hence tabindex="-1". `touch-none` hands the gesture
|
|
to us instead of the browser's scrolling. -->
|
|
<button
|
|
v-if="canDrag()"
|
|
type="button"
|
|
tabindex="-1"
|
|
class="note-grip hover-reveal pointer-events-none flex touch-none cursor-grab items-center rounded-full bg-white/85 p-1 text-neutral-500 opacity-0 shadow-sm ring-1 ring-black/5 backdrop-blur-sm transition hover:text-neutral-800 active:cursor-grabbing group-hover:pointer-events-auto group-hover:opacity-100 dark:bg-neutral-900/85 dark:text-neutral-400 dark:ring-white/10 dark:hover:text-neutral-100"
|
|
title="Drag to reorder"
|
|
aria-label="Drag to reorder"
|
|
@pointerdown="onGripDown"
|
|
@pointermove="onGripMove"
|
|
@pointerup="onGripUp"
|
|
@pointercancel="onGripCancel"
|
|
>
|
|
<Icon name="grip" />
|
|
</button>
|
|
|
|
<div
|
|
class="note-actions-set hover-reveal pointer-events-none flex items-center gap-0.5 rounded-full bg-white/85 p-0.5 opacity-0 shadow-sm ring-1 ring-black/5 backdrop-blur-sm transition focus-within:pointer-events-auto focus-within:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100 dark:bg-neutral-900/85 dark:ring-white/10"
|
|
>
|
|
<template v-if="note.trashed">
|
|
<button type="button" class="icon-btn" title="Restore" aria-label="Restore" @click="notes.restore(note.id)">
|
|
<Icon name="restore" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="icon-btn"
|
|
title="Delete forever"
|
|
aria-label="Delete forever"
|
|
@click="notes.deleteForever(note.id)"
|
|
>
|
|
<Icon name="trash" />
|
|
</button>
|
|
</template>
|
|
<template v-else>
|
|
<button
|
|
type="button"
|
|
class="icon-btn"
|
|
:class="note.pinned ? 'text-brand-700 dark:text-brand' : ''"
|
|
:title="note.pinned ? 'Unpin' : 'Pin'"
|
|
:aria-label="note.pinned ? 'Unpin' : 'Pin'"
|
|
:aria-pressed="note.pinned"
|
|
@click="notes.setPinned(note.id, !note.pinned)"
|
|
>
|
|
<Icon name="pin" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="icon-btn"
|
|
:title="note.archived ? 'Unarchive' : 'Archive'"
|
|
:aria-label="note.archived ? 'Unarchive' : 'Archive'"
|
|
@click="notes.setArchived(note.id, !note.archived)"
|
|
>
|
|
<Icon name="archive" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="icon-btn"
|
|
title="Move to trash"
|
|
aria-label="Move to trash"
|
|
@click="notes.trash(note.id)"
|
|
>
|
|
<Icon name="trash" />
|
|
</button>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|