M12 — the Android client, end to end #2
@@ -428,7 +428,21 @@ async function signOut() {
|
||||
<!-- tabindex="-1" so the skip link above actually moves FOCUS here, not just
|
||||
the viewport — several browsers scroll to a plain anchor without focusing
|
||||
it, which leaves the next Tab back at the top of the page. -->
|
||||
<main id="main" tabindex="-1" class="min-w-0 flex-1 focus:outline-none"><RouterView /></main>
|
||||
<!-- Cross-fade between lenses so switching reads as the same space
|
||||
re-framing rather than a page swap.
|
||||
Deliberately UNKEYED: board / archive / trash / label all render the same
|
||||
BoardView, so keying on the route would remount it — blanking the board
|
||||
and refetching, which is precisely the page-change feeling this is meant
|
||||
to remove. Unkeyed, Vue only transitions when the component TYPE changes
|
||||
(board ↔ search ↔ timeline ↔ graph), and moving between the board's own
|
||||
lenses stays an in-place reflow that NoteGrid animates. -->
|
||||
<main id="main" tabindex="-1" class="min-w-0 flex-1 focus:outline-none">
|
||||
<RouterView v-slot="{ Component }">
|
||||
<Transition name="lens" mode="out-in">
|
||||
<component :is="Component" />
|
||||
</Transition>
|
||||
</RouterView>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<LabelsModal v-if="managing" @close="managing = false" />
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
<script setup lang="ts">
|
||||
import NoteCard from "./NoteCard.vue";
|
||||
import type { Note } from "../stores/notes";
|
||||
|
||||
/**
|
||||
* The masonry of note cards — the one place the board's LAYOUT and its MOTION are
|
||||
* defined.
|
||||
*
|
||||
* It exists because "the same board, re-filtered" has to be literally true to read
|
||||
* as true. The column classes were copy-pasted into five places (the board's pinned
|
||||
* and other sections, the non-board views, search, timeline), so a lens could drift
|
||||
* from home by a single edit — and one already had: the FLIP reflow landed on the
|
||||
* board's three grids and left search and timeline popping.
|
||||
*
|
||||
* `activeId` rather than an index, because the board splits its notes across two
|
||||
* grids and index-based focus made every call site do offset arithmetic against a
|
||||
* list it didn't own.
|
||||
*/
|
||||
defineProps<{
|
||||
notes: Note[];
|
||||
/** The keyboard-focused note, if any. */
|
||||
activeId?: string | null;
|
||||
/** Board views only — reorder is meaningless in a search result or a date group. */
|
||||
reorderable?: boolean;
|
||||
}>();
|
||||
|
||||
defineEmits<{
|
||||
(e: "open", note: Note): void;
|
||||
(e: "dragstart", note: Note): void;
|
||||
(e: "dragend", note: Note): void;
|
||||
(e: "drop", targetId: string): void;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- `board` is the transition name defined in style.css; `appear` covers first
|
||||
paint so a lens arrives eased rather than snapping in. -->
|
||||
<TransitionGroup
|
||||
tag="div"
|
||||
name="board"
|
||||
appear
|
||||
class="columns-1 gap-4 sm:columns-2 lg:columns-3 xl:columns-4"
|
||||
>
|
||||
<NoteCard
|
||||
v-for="n in notes"
|
||||
:key="n.id"
|
||||
:note="n"
|
||||
:active="activeId === n.id"
|
||||
:reorderable="reorderable"
|
||||
@open="$emit('open', $event)"
|
||||
@dragstart="$emit('dragstart', $event)"
|
||||
@dragend="$emit('dragend', $event)"
|
||||
@drop="$emit('drop', $event)"
|
||||
/>
|
||||
</TransitionGroup>
|
||||
</template>
|
||||
@@ -127,6 +127,21 @@ body {
|
||||
transform: scale(0.94);
|
||||
}
|
||||
|
||||
/* Lens cross-fade (M7). Only fires between genuinely different surfaces; the
|
||||
* board's own lenses share a component and reflow in place instead. Out is quicker
|
||||
* than in so the gap between them stays short — `mode="out-in"` means the two
|
||||
* durations are additive, and anything slower starts to feel like waiting. */
|
||||
.lens-enter-active {
|
||||
transition: opacity 150ms ease-out;
|
||||
}
|
||||
.lens-leave-active {
|
||||
transition: opacity 100ms ease-in;
|
||||
}
|
||||
.lens-enter-from,
|
||||
.lens-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.icon-btn {
|
||||
@apply rounded-md p-1.5 text-neutral-500 transition hover:bg-black/5 focus:outline-none
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useNoteEditor } from "../composables/useNoteEditor";
|
||||
import AsyncState from "../components/AsyncState.vue";
|
||||
import EmptyState from "../components/EmptyState.vue";
|
||||
import FilterBar from "../components/FilterBar.vue";
|
||||
import NoteCard from "../components/NoteCard.vue";
|
||||
import NoteGrid from "../components/NoteGrid.vue";
|
||||
import NoteEditor from "../components/NoteEditor.vue";
|
||||
import { isDesktop, sync as syncBridge } from "../desktop/bridge";
|
||||
|
||||
@@ -76,6 +76,9 @@ const orderedNotes = computed<Note[]>(() =>
|
||||
isMainBoard.value ? [...pinnedNotes.value, ...otherNotes.value] : notes.items,
|
||||
);
|
||||
const focusedIndex = ref(-1);
|
||||
/** The focused note's id — what the grids compare against, so neither has to know
|
||||
* its own offset within the combined keyboard-navigation order. */
|
||||
const focusedId = computed<string | null>(() => orderedNotes.value[focusedIndex.value]?.id ?? null);
|
||||
const inTrash = computed(() => currentView.value === "trash");
|
||||
|
||||
function moveFocus(delta: number) {
|
||||
@@ -300,59 +303,42 @@ async function onDrop(targetId: string) {
|
||||
<template v-if="isMainBoard">
|
||||
<section v-if="pinnedNotes.length">
|
||||
<h2 class="mb-2 text-xs font-semibold uppercase tracking-wide text-neutral-400">Pinned</h2>
|
||||
<TransitionGroup tag="div" name="board" appear class="columns-1 gap-4 sm:columns-2 lg:columns-3 xl:columns-4">
|
||||
<NoteCard
|
||||
v-for="(n, i) in pinnedNotes"
|
||||
:key="n.id"
|
||||
:note="n"
|
||||
:active="focusedIndex === i"
|
||||
reorderable
|
||||
@open="openEditor"
|
||||
@dragstart="onDragStart"
|
||||
@dragend="onDragEnd"
|
||||
@drop="onDrop"
|
||||
/>
|
||||
</TransitionGroup>
|
||||
<NoteGrid
|
||||
:notes="pinnedNotes"
|
||||
:active-id="focusedId"
|
||||
reorderable
|
||||
@open="openEditor"
|
||||
@dragstart="onDragStart"
|
||||
@dragend="onDragEnd"
|
||||
@drop="onDrop"
|
||||
/>
|
||||
</section>
|
||||
<section v-if="otherNotes.length" :class="pinnedNotes.length ? 'mt-8' : ''">
|
||||
<h2 v-if="pinnedNotes.length" class="mb-2 text-xs font-semibold uppercase tracking-wide text-neutral-400">
|
||||
Others
|
||||
</h2>
|
||||
<TransitionGroup tag="div" name="board" appear class="columns-1 gap-4 sm:columns-2 lg:columns-3 xl:columns-4">
|
||||
<NoteCard
|
||||
v-for="(n, i) in otherNotes"
|
||||
:key="n.id"
|
||||
:note="n"
|
||||
:active="focusedIndex === pinnedNotes.length + i"
|
||||
reorderable
|
||||
@open="openEditor"
|
||||
@dragstart="onDragStart"
|
||||
@dragend="onDragEnd"
|
||||
@drop="onDrop"
|
||||
/>
|
||||
</TransitionGroup>
|
||||
<NoteGrid
|
||||
:notes="otherNotes"
|
||||
:active-id="focusedId"
|
||||
reorderable
|
||||
@open="openEditor"
|
||||
@dragstart="onDragStart"
|
||||
@dragend="onDragEnd"
|
||||
@drop="onDrop"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<TransitionGroup
|
||||
<NoteGrid
|
||||
v-else
|
||||
tag="div"
|
||||
name="board"
|
||||
appear
|
||||
class="columns-1 gap-4 sm:columns-2 lg:columns-3 xl:columns-4"
|
||||
>
|
||||
<NoteCard
|
||||
v-for="(n, i) in notes.items"
|
||||
:key="n.id"
|
||||
:note="n"
|
||||
:active="focusedIndex === i"
|
||||
reorderable
|
||||
@open="openEditor"
|
||||
@dragstart="onDragStart"
|
||||
@dragend="onDragEnd"
|
||||
@drop="onDrop"
|
||||
/>
|
||||
</TransitionGroup>
|
||||
:notes="notes.items"
|
||||
:active-id="focusedId"
|
||||
reorderable
|
||||
@open="openEditor"
|
||||
@dragstart="onDragStart"
|
||||
@dragend="onDragEnd"
|
||||
@drop="onDrop"
|
||||
/>
|
||||
</template>
|
||||
</AsyncState>
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useNoteList } from "../composables/useNoteList";
|
||||
import { useNoteEditor } from "../composables/useNoteEditor";
|
||||
import AsyncState from "../components/AsyncState.vue";
|
||||
import EmptyState from "../components/EmptyState.vue";
|
||||
import NoteCard from "../components/NoteCard.vue";
|
||||
import NoteGrid from "../components/NoteGrid.vue";
|
||||
import NoteEditor from "../components/NoteEditor.vue";
|
||||
|
||||
const route = useRoute();
|
||||
@@ -38,9 +38,7 @@ watch(query, run, { immediate: true });
|
||||
|
||||
<AsyncState :loading="loading" :error="error || undefined" error-title="Couldn't search" @retry="run">
|
||||
<EmptyState v-if="query && results.length === 0" title="No matches" :subtitle="noMatchSubtitle" />
|
||||
<div v-else-if="results.length" class="columns-1 gap-4 sm:columns-2 lg:columns-3 xl:columns-4">
|
||||
<NoteCard v-for="n in results" :key="n.id" :note="n" @open="openEditor" />
|
||||
</div>
|
||||
<NoteGrid v-else-if="results.length" :notes="results" @open="openEditor" />
|
||||
</AsyncState>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useNoteList } from "../composables/useNoteList";
|
||||
import { useNoteEditor } from "../composables/useNoteEditor";
|
||||
import AsyncState from "../components/AsyncState.vue";
|
||||
import EmptyState from "../components/EmptyState.vue";
|
||||
import NoteCard from "../components/NoteCard.vue";
|
||||
import NoteGrid from "../components/NoteGrid.vue";
|
||||
import NoteEditor from "../components/NoteEditor.vue";
|
||||
|
||||
// The "find by WHEN" recall lens: your notes grouped by when you captured them,
|
||||
@@ -149,9 +149,7 @@ onMounted(load);
|
||||
<div v-else class="flex flex-col gap-8">
|
||||
<section v-for="group in groups" :key="group.key">
|
||||
<h2 class="mb-3 text-xs font-semibold uppercase tracking-wide text-neutral-400">{{ group.label }}</h2>
|
||||
<div class="columns-1 gap-4 sm:columns-2 lg:columns-3 xl:columns-4">
|
||||
<NoteCard v-for="n in group.notes" :key="n.id" :note="n" @open="openEditor" />
|
||||
</div>
|
||||
<NoteGrid :notes="group.notes" @open="openEditor" />
|
||||
</section>
|
||||
</div>
|
||||
</AsyncState>
|
||||
|
||||
Reference in New Issue
Block a user