Compare commits
2
Commits
05c82c2362
...
888c6410f0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
888c6410f0 | ||
|
|
e75e3d37d0 |
@@ -143,28 +143,18 @@ fun List<EditorBlock>.plusTask(): Pair<List<EditorBlock>, Long> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-read ONE prose block for `- [ ] ` lines somebody typed by hand.
|
||||
* Re-read ONE prose block for `- [ ] ` lines somebody typed by hand, so a marker
|
||||
* typed in the editor becomes an item without closing and reopening the note.
|
||||
*
|
||||
* [splitBlocks] runs once, when the editor opens. After that the blocks are the state
|
||||
* and nothing reads the body again — every edit travels the other way, through
|
||||
* [joinBlocks]. So a marker typed by hand stayed literal text on screen until the note
|
||||
* was closed and reopened, even though it was already a real item in storage and the
|
||||
* card was already drawing a checkbox for it. The editor was the only place that
|
||||
* disagreed.
|
||||
* **On blur, and only the block being left:** re-splitting on a keystroke would move
|
||||
* the caret, and converting the moment `- [ ]` is complete would catch an item with
|
||||
* no text yet.
|
||||
*
|
||||
* **On blur, and only the block being left.** There is no good moment to convert while
|
||||
* someone is typing: re-splitting on a keystroke moves the caret out of the word being
|
||||
* written, and converting the instant `- [ ]` is complete does it before the item has
|
||||
* any text. Blur is the one moment the person has demonstrably finished with the block,
|
||||
* so a re-split costs no caret and cannot catch a half-typed line.
|
||||
* Returns THIS LIST when there was nothing to promote; the caller relies on that to
|
||||
* leave the state alone, so a blur that changed nothing doesn't re-key every field.
|
||||
*
|
||||
* Returns THIS LIST, not an equal copy, when there was nothing to promote — the caller
|
||||
* leans on that to leave the state alone, and a blur that changed nothing must not
|
||||
* re-key every field below it.
|
||||
*
|
||||
* Non-canonical markers (`- [X]`, an odd bullet) come back canonical, exactly as they
|
||||
* would have on reopen. That is the only case where this changes the body rather than
|
||||
* only the way it is drawn.
|
||||
* Non-canonical markers (`- [X]`, an odd bullet) come back canonical, as they would on
|
||||
* reopen.
|
||||
*/
|
||||
internal fun List<EditorBlock>.promotingTasks(index: Int): List<EditorBlock> {
|
||||
val block = getOrNull(index)
|
||||
|
||||
@@ -101,22 +101,13 @@ fun NoteCard(
|
||||
.border(1.dp, if (dark) CARD_EDGE_DARK else CARD_EDGE_LIGHT, RoundedCornerShape(CARD_RADIUS))
|
||||
.padding(12.dp),
|
||||
) {
|
||||
// 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.
|
||||
// TAGS FIRST, on a row of their own above the body: a board is scanned, and
|
||||
// what a note is about should be the first thing the eye lands on. Not beside
|
||||
// the body, whose first line is the note's name.
|
||||
//
|
||||
// Above the body rather than beside it, 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. A row of its own costs one line and
|
||||
// only on notes that have tags at all.
|
||||
//
|
||||
// ONLY the labels whose text is not still in the note. `via_tag` means exactly
|
||||
// "backed by body text" since M311, so a chip for one printed the same tag
|
||||
// twice — once where it was typed, once up here — and the card was carrying
|
||||
// furniture for information it was already showing. A tag left in prose is
|
||||
// tinted in place instead; see [tintTags]. What reaches this row is what the
|
||||
// body cannot say: a tag lifted off its own line, and a label added by hand.
|
||||
// Only labels whose text is not still in the note (`via_tag` is false). A tag
|
||||
// left in prose is tinted in place instead (see [tintTags]), so this row holds
|
||||
// what the body cannot say: a tag lifted off its own line, or one added by hand.
|
||||
val chips = note.labels.filterNot { it.viaTag }
|
||||
if (chips.isNotEmpty()) {
|
||||
LabelChips(labels = chips)
|
||||
@@ -192,34 +183,16 @@ fun NoteCard(
|
||||
}
|
||||
|
||||
/**
|
||||
* What you can do to a note without opening it.
|
||||
* What you can do to a note without opening it, on a long press.
|
||||
*
|
||||
* The board used to have none of this, and the operator's read of that was not "the
|
||||
* actions are in the editor" — it was *"there are no long hold context menus in the
|
||||
* app I have no way to delete notes."* Trash was three interactions deep (open, ⋮,
|
||||
* Move to trash), and on a phone that is far enough from the gesture people reach
|
||||
* for that it may as well not exist.
|
||||
* The same items as the editor's overflow, in the same words from the same string
|
||||
* resources, dispatched through [EditorAction] so `BoardViewModel.onEditorAction`
|
||||
* stays the one place they are handled.
|
||||
*
|
||||
* **The same items as the editor's overflow, in the same words, from the same string
|
||||
* resources.** A note has one vocabulary of things that can be done to it, and two
|
||||
* surfaces that named them differently would be describing two different apps. It
|
||||
* dispatches [EditorAction] for the same reason — `BoardViewModel.onEditorAction` is
|
||||
* already the exhaustive dispatcher for every one of them, so the board reuses the
|
||||
* seam rather than growing a parallel one that could drift.
|
||||
* Gated on the NOTE (`note.trashed`), not on the view, because Reminders and search
|
||||
* mix piles: a note already in Trash must not offer "Move to trash".
|
||||
*
|
||||
* **Gated on the NOTE, not on the destination.** `note.trashed` is what the editor
|
||||
* gates its own read-only mode on, and it is the only reading that survives the views
|
||||
* that mix piles: Reminders cuts across archived and active alike, and a search hits
|
||||
* whatever matches. A menu that offered "Move to trash" on a note already in the
|
||||
* trash would be offering to do something twice.
|
||||
*
|
||||
* **Colour is absent**, though #2946 suggested it. It was left out because `note.color`
|
||||
* was already scheduled for removal; M315 removed it. There is no colour to set on a
|
||||
* note any more — a card is one neutral surface and the only coloured thing on a board
|
||||
* is a tag — so the row this menu never grew is a row that could not exist.
|
||||
*
|
||||
* Labels are absent too, for a duller reason: the picker they open is editor state,
|
||||
* and hoisting it to the board is a bigger change than the friction actually reported.
|
||||
* No Labels item: the picker it opens is editor state.
|
||||
*/
|
||||
@Composable
|
||||
private fun NoteMenu(
|
||||
@@ -555,31 +528,13 @@ fun noteCardSurface(dark: Boolean): Color = if (dark) CARD_SURFACE_DARK else CAR
|
||||
private val CARD_SURFACE_LIGHT = Color(0xFFFFFFFF)
|
||||
private val CARD_SURFACE_DARK = Color(0xFF171717)
|
||||
|
||||
// THE CARD'S EDGE — one grey, every card, both themes. Since M315 it is the only thing
|
||||
// that differs from the board by more than a hair, which makes it structure rather than
|
||||
// decoration: it is what a card IS.
|
||||
// THE CARD'S EDGE — one neutral grey, every card, both themes. The fill barely differs
|
||||
// from the board, so this line is what makes a card a card. Neutral on purpose: a
|
||||
// coloured edge would carry information and turn a board into a grid of outlines.
|
||||
//
|
||||
// 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 measured 1.56-2.09 against
|
||||
// its own fill while the fill managed only 1.03-1.05 against the board, so it was the
|
||||
// loudest thing on the card — and it carried the same information the fill did, so a
|
||||
// field of cards read as a grid of outlines however different the colours inside were.
|
||||
// A neutral line carries no information at all, which is exactly what lets it be
|
||||
// structure instead of content. The fill is that same argument one size up.
|
||||
//
|
||||
// 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 way to write this and was
|
||||
// measured and rejected: a border composites over what is under it, so `White` at 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 NoteCard.vue must also write,
|
||||
// and two surfaces stating the same hex is how they stay the same card.
|
||||
// Opaque rather than a translucent black/white, which would composite differently over
|
||||
// whatever is under it. NoteCard.vue writes the same two hex values, which is how the
|
||||
// surfaces stay the same card. Contrast: #B8B8B8 on white 1.98, #404040 on #171717 1.73.
|
||||
private val CARD_EDGE_LIGHT = Color(0xFFB8B8B8)
|
||||
private val CARD_EDGE_DARK = Color(0xFF404040)
|
||||
private val CHIP_RADIUS = 6.dp
|
||||
|
||||
@@ -44,22 +44,9 @@ data class NoteTint(
|
||||
val lightChipForeground: Color,
|
||||
val darkChipForeground: Color,
|
||||
/**
|
||||
* THE INK A TAG IS DRAWN IN — inline in the prose AND as a chip's text — see
|
||||
* [tagInk].
|
||||
*
|
||||
* These were two columns until M315, and the split was real while it lasted: a chip
|
||||
* brought its own `-100` fill and could afford `-700`, while inline text sat on
|
||||
* whatever the card was, which included a gray-tagged card at `neutral-200` where
|
||||
* `-700` measured 3.98 (green), 4.11 (orange) and 4.34 (teal), all under the 4.5
|
||||
* body text needs. One step deeper cleared every fill at once.
|
||||
*
|
||||
* The twenty card fills that split was solving for are gone, so both jobs take this
|
||||
* one value. The direction is deliberate: since M311 a tag whose text is in the body
|
||||
* is drawn where it was typed and NOT repeated as a chip, so the inline token is the
|
||||
* common case and collapsing onto ITS column leaves what is seen most exactly as it
|
||||
* was. The chip is strictly better for the move — on its own fill it goes from
|
||||
* 4.52-8.23 to 6.37-12.01 in light. Dark needed no decision: the two columns already
|
||||
* held the same value for all ten hues.
|
||||
* THE INK A TAG IS DRAWN IN — inline in the prose and as a chip's text — see
|
||||
* [tagInk]. One value serves both: `-800` light / `-300` dark clears body-text
|
||||
* contrast on the card and on a chip's own fill.
|
||||
*/
|
||||
val lightTagInk: Color,
|
||||
val darkTagInk: Color,
|
||||
|
||||
@@ -187,14 +187,9 @@ pub fn evaluate(info: &ServerInfo) -> Compatibility {
|
||||
|
||||
/// Who this client says it is, set once by the host application at startup.
|
||||
///
|
||||
/// THE CORE CANNOT KNOW THIS, and the value it used to invent was wrong twice. It
|
||||
/// was `inkwell-desktop/{CARGO_PKG_VERSION}`, and this crate is compiled into
|
||||
/// the desktop app AND the Android app — so every phone in the field announced
|
||||
/// itself as a desktop. The version was worse: `CARGO_PKG_VERSION` here is the
|
||||
/// version of the CORE crate, a number no build stamps and no user has ever seen,
|
||||
/// while the thing a reader of that header wants is the app's own build (note 3127
|
||||
/// §5 — with no version tags, the artifact's self-report is the only answer to
|
||||
/// "which build is this?").
|
||||
/// The core can't know this: it is compiled into both the desktop and the Android
|
||||
/// app, and its own crate version is a number no build stamps. The app's build is
|
||||
/// what a reader of the header wants (note 3127 §5).
|
||||
///
|
||||
/// So the host names itself. `OnceLock` because identity is fixed for the life of
|
||||
/// the process and a second caller should be ignored rather than race the first.
|
||||
|
||||
@@ -16,7 +16,7 @@ import type { Device } from "../stores/devices";
|
||||
import type { TitleEntry } from "../stores/titles";
|
||||
import type { User } from "../stores/session";
|
||||
import type { PublicConfig } from "../stores/config";
|
||||
import type { DeviceToken, Directory, ImportResult, NoteShare, Repo } from "./repo";
|
||||
import type { DeviceToken, Directory, ImportResult, NoteShare, Repo, ServerSetting } from "./repo";
|
||||
|
||||
const NEEDS_SERVER = "That's not available offline — connect a server to use it.";
|
||||
|
||||
@@ -91,6 +91,12 @@ export const local: Repo = {
|
||||
|
||||
// Sharing is between accounts on a server, so these go to the linked one (#5175).
|
||||
// Unlinked, each rejects with the core's explanation, which the dialog shows.
|
||||
settings: {
|
||||
list: () => Promise.reject<ServerSetting[]>(new Error(NEEDS_SERVER)),
|
||||
save: () => Promise.reject<ServerSetting[]>(new Error(NEEDS_SERVER)),
|
||||
sendTestEmail: () => Promise.reject<{ to: string }>(new Error(NEEDS_SERVER)),
|
||||
},
|
||||
|
||||
shares: {
|
||||
directory: () => invoke<Directory>("shares_directory"),
|
||||
list: (noteId) => invoke<NoteShare[]>("shares_list", { noteId }),
|
||||
|
||||
@@ -158,6 +158,38 @@ export interface SharesRepo {
|
||||
unshare(noteId: string, shareId: string): Promise<NoteShare[]>;
|
||||
}
|
||||
|
||||
/** One admin setting as the server describes it: its value, and enough about it to
|
||||
* draw the right control. */
|
||||
export interface ServerSetting {
|
||||
key: string;
|
||||
type: "string" | "bool" | "int";
|
||||
value: SettingValue;
|
||||
default: SettingValue;
|
||||
label: string;
|
||||
description: string;
|
||||
group: string;
|
||||
/** Ints only, and nullable: the registry's bounds, so the number input can refuse
|
||||
* an out-of-range value before the round trip. */
|
||||
minimum: number | null;
|
||||
maximum: number | null;
|
||||
/** Strings: the allowed values, drawn as a choice. */
|
||||
choices: string[] | null;
|
||||
/** A credential (the SMTP password). Its value never comes back; `is_set` says
|
||||
* whether one is saved, and leaving the field empty keeps it. */
|
||||
secret: boolean;
|
||||
is_set: boolean | null;
|
||||
}
|
||||
|
||||
export type SettingValue = string | boolean | number;
|
||||
|
||||
/** The server's admin settings. A server's, so the offline desktop has none. */
|
||||
export interface SettingsRepo {
|
||||
list(): Promise<ServerSetting[]>;
|
||||
save(values: Record<string, SettingValue>): Promise<ServerSetting[]>;
|
||||
/** Send a test email with the saved mail settings; answers who it went to. */
|
||||
sendTestEmail(): Promise<{ to: string }>;
|
||||
}
|
||||
|
||||
export interface SavedFiltersRepo {
|
||||
list(): Promise<SavedFilter[]>;
|
||||
create(name: string, params: NoteFacets): Promise<SavedFilter>;
|
||||
@@ -172,5 +204,6 @@ export interface Repo {
|
||||
labels: LabelsRepo;
|
||||
notes: NotesRepo;
|
||||
savedFilters: SavedFiltersRepo;
|
||||
settings: SettingsRepo;
|
||||
shares: SharesRepo;
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import type {
|
||||
NoteListQuery,
|
||||
ChecklistItemChanges,
|
||||
Repo,
|
||||
ServerSetting,
|
||||
} from "./repo";
|
||||
|
||||
// Render a board query to the GET /api/notes query string. Mirrors the param
|
||||
@@ -116,6 +117,13 @@ export const rest: Repo = {
|
||||
rename: (id, name) => api.patch<SavedFilter>(`/api/saved-filters/${id}`, { name }),
|
||||
},
|
||||
|
||||
settings: {
|
||||
list: async () => (await api.get<{ settings: ServerSetting[] }>("/api/settings")).settings,
|
||||
save: async (values) =>
|
||||
(await api.patch<{ settings: ServerSetting[] }>("/api/settings", { settings: values })).settings,
|
||||
sendTestEmail: () => api.post<{ to: string }>("/api/settings/test-email"),
|
||||
},
|
||||
|
||||
shares: {
|
||||
directory: () => api.get<Directory>("/api/users/directory"),
|
||||
list: async (noteId) => (await api.get<{ shares: NoteShare[] }>(`/api/notes/${noteId}/shares`)).shares,
|
||||
|
||||
@@ -17,7 +17,7 @@ import ShareDialog from "./ShareDialog.vue";
|
||||
import { isDesktop } from "../desktop/bridge";
|
||||
import { useLensName } from "../composables/useLensName";
|
||||
import { facetsToQuery } from "../notes/facets";
|
||||
import { NOTE_SWATCH_CLASSES, resolveLabelColor } from "../notes/colors";
|
||||
import { labelDotClasses } from "../notes/colors";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
@@ -191,14 +191,6 @@ onBeforeUnmount(() => {
|
||||
window.removeEventListener("keydown", onKeydown);
|
||||
});
|
||||
|
||||
const currentLabelId = computed(() => (route.name === "label" ? String(route.params.id) : null));
|
||||
|
||||
// The drawer's tag list. Same resolution as every other chip and dot — a tag that is
|
||||
// green on a card must be green here, or the sidebar stops being a way to find it.
|
||||
function labelDot(label: { name: string; color: string }): string {
|
||||
return NOTE_SWATCH_CLASSES[resolveLabelColor(label)] ?? NOTE_SWATCH_CLASSES.default;
|
||||
}
|
||||
|
||||
// The board lenses — the routes a search can happen *within*. Searching while looking
|
||||
// at Trash should search Trash, not silently move you.
|
||||
const BOARD_ROUTES = new Set(["board", "archive", "trash", "label"]);
|
||||
@@ -412,7 +404,7 @@ async function signOut() {
|
||||
:class="drawer ? 'translate-x-0' : ''"
|
||||
>
|
||||
<nav class="flex flex-col gap-0.5 text-sm" @click="drawer = false">
|
||||
<RouterLink to="/" class="nav-link" :class="route.name === 'board' ? 'nav-link-active' : ''">
|
||||
<RouterLink to="/" class="nav-link" exact-active-class="nav-link-active">
|
||||
<Icon name="note" /> Notes
|
||||
</RouterLink>
|
||||
|
||||
@@ -434,11 +426,11 @@ async function signOut() {
|
||||
:key="lb.id"
|
||||
:to="`/label/${lb.id}`"
|
||||
class="nav-link"
|
||||
:class="currentLabelId === lb.id ? 'nav-link-active' : ''"
|
||||
exact-active-class="nav-link-active"
|
||||
>
|
||||
<span
|
||||
class="h-2.5 w-2.5 shrink-0 rounded-full border border-black/10 dark:border-white/15"
|
||||
:class="labelDot(lb)"
|
||||
:class="labelDotClasses(lb)"
|
||||
></span>
|
||||
<span class="truncate">{{ lb.name }}</span>
|
||||
</RouterLink>
|
||||
@@ -466,23 +458,23 @@ async function signOut() {
|
||||
</RouterLink>
|
||||
</template>
|
||||
|
||||
<RouterLink to="/archive" class="nav-link mt-3" :class="route.name === 'archive' ? 'nav-link-active' : ''">
|
||||
<RouterLink to="/archive" class="nav-link mt-3" exact-active-class="nav-link-active">
|
||||
<Icon name="archive" /> Archive
|
||||
</RouterLink>
|
||||
<RouterLink to="/trash" class="nav-link" :class="route.name === 'trash' ? 'nav-link-active' : ''">
|
||||
<RouterLink to="/trash" class="nav-link" exact-active-class="nav-link-active">
|
||||
<Icon name="trash" /> Trash
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
to="/reminders"
|
||||
class="nav-link"
|
||||
:class="route.name === 'reminders' ? 'nav-link-active' : ''"
|
||||
exact-active-class="nav-link-active"
|
||||
>
|
||||
<Icon name="bell" /> Reminders
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
to="/timeline"
|
||||
class="nav-link"
|
||||
:class="route.name === 'timeline' ? 'nav-link-active' : ''"
|
||||
exact-active-class="nav-link-active"
|
||||
>
|
||||
<Icon name="calendar" /> Timeline
|
||||
</RouterLink>
|
||||
@@ -501,14 +493,14 @@ async function signOut() {
|
||||
class="mt-3 flex flex-col gap-0.5 border-t border-neutral-200 pt-3 sm:hidden dark:border-neutral-800"
|
||||
>
|
||||
<p class="truncate px-3 pb-1 text-xs text-neutral-400">{{ session.user?.display_name }}</p>
|
||||
<RouterLink to="/account" class="nav-link" :class="route.name === 'account' ? 'nav-link-active' : ''">
|
||||
<RouterLink to="/account" class="nav-link" exact-active-class="nav-link-active">
|
||||
<Icon name="device" /> Linked devices
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
v-if="session.user?.is_admin"
|
||||
to="/settings"
|
||||
class="nav-link"
|
||||
:class="route.name === 'settings' ? 'nav-link-active' : ''"
|
||||
exact-active-class="nav-link-active"
|
||||
>
|
||||
<Icon name="settings" /> Settings
|
||||
</RouterLink>
|
||||
|
||||
@@ -19,6 +19,7 @@ const paths: Record<string, string> = {
|
||||
grip: '<circle cx="9" cy="5" r="1" fill="currentColor"/><circle cx="9" cy="12" r="1" fill="currentColor"/><circle cx="9" cy="19" r="1" fill="currentColor"/><circle cx="15" cy="5" r="1" fill="currentColor"/><circle cx="15" cy="12" r="1" fill="currentColor"/><circle cx="15" cy="19" r="1" fill="currentColor"/>',
|
||||
calendar: '<path d="M8 2v4"/><path d="M16 2v4"/><rect width="18" height="18" x="3" y="4" rx="2"/><path d="M3 10h18"/>',
|
||||
close: '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>',
|
||||
back: '<path d="m15 18-6-6 6-6"/>',
|
||||
merge: '<circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><path d="M6 21V9a9 9 0 0 0 9 9"/>',
|
||||
history: '<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l4 2"/>',
|
||||
download: '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" x2="12" y1="15" y2="3"/>',
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
NOTE_COLOR_KEYS,
|
||||
NOTE_COLOR_LABELS,
|
||||
NOTE_SWATCH_CLASSES,
|
||||
labelDotClasses,
|
||||
resolveLabelColor,
|
||||
type NoteColor,
|
||||
} from "../notes/colors";
|
||||
@@ -31,14 +32,6 @@ async function rename(id: string, value: string) {
|
||||
if (name) await labels.rename(id, name);
|
||||
}
|
||||
|
||||
// Shows the colour the tag ACTUALLY wears, derived from its name when nobody has
|
||||
// picked one — so this screen agrees with the chips everywhere else. The ring in the
|
||||
// swatch grid below follows the same resolution, so opening the picker highlights
|
||||
// what you can already see rather than nothing at all.
|
||||
function labelDot(label: { name: string; color: string }): string {
|
||||
return NOTE_SWATCH_CLASSES[resolveLabelColor(label)] ?? NOTE_SWATCH_CLASSES.default;
|
||||
}
|
||||
|
||||
function openColor(id: string) {
|
||||
mergeFor.value = null;
|
||||
pickerFor.value = pickerFor.value === id ? null : id;
|
||||
@@ -85,7 +78,7 @@ async function doMerge(sourceId: string, targetId: string) {
|
||||
<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)"
|
||||
:class="labelDotClasses(lb)"
|
||||
:title="`Color: ${NOTE_COLOR_LABELS[resolveLabelColor(lb)]}`"
|
||||
aria-label="Change tag color"
|
||||
@click="openColor(lb.id)"
|
||||
@@ -150,7 +143,7 @@ async function doMerge(sourceId: string, targetId: string) {
|
||||
>
|
||||
<span
|
||||
class="h-2.5 w-2.5 shrink-0 rounded-full border border-black/10 dark:border-white/15"
|
||||
:class="labelDot(t)"
|
||||
:class="labelDotClasses(t)"
|
||||
></span>
|
||||
<span class="truncate">{{ t.name }}</span>
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
<script setup lang="ts">
|
||||
import { useNotesStore } from "../stores/notes";
|
||||
import { useUiStore } from "../stores/ui";
|
||||
import type { Note } from "../stores/notes";
|
||||
import Icon from "./Icon.vue";
|
||||
|
||||
// A note's own actions, as the card and the editor both offer them. Pin and archive
|
||||
// are everyone's own (#5176); share and trash are the owner's. A note shared with
|
||||
// you is never in your Trash, so restore and delete forever are the owner's too.
|
||||
// The default slot sits after Share, for the editor's history toggle.
|
||||
const props = defineProps<{ note: Note; own: boolean; canShare: boolean }>();
|
||||
const emit = defineEmits<{
|
||||
/** A pin, archive, trash, restore or delete landed. The editor closes on it. */
|
||||
(e: "acted"): void;
|
||||
}>();
|
||||
|
||||
const notes = useNotesStore();
|
||||
const ui = useUiStore();
|
||||
|
||||
async function act(fn: (id: string) => Promise<unknown>): Promise<void> {
|
||||
await fn(props.note.id);
|
||||
emit("acted");
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
v-if="canShare && !note.trashed"
|
||||
type="button"
|
||||
class="icon-btn"
|
||||
:class="note.shared ? 'text-brand-700 dark:text-brand' : ''"
|
||||
title="Share"
|
||||
aria-label="Share"
|
||||
@click="ui.openShare(note.id)"
|
||||
>
|
||||
<Icon name="users" />
|
||||
</button>
|
||||
<slot />
|
||||
<template v-if="note.trashed">
|
||||
<template v-if="own">
|
||||
<button type="button" class="icon-btn" title="Restore" aria-label="Restore" @click="act(notes.restore)">
|
||||
<Icon name="restore" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="icon-btn"
|
||||
title="Delete forever"
|
||||
aria-label="Delete forever"
|
||||
@click="act(notes.deleteForever)"
|
||||
>
|
||||
<Icon name="trash" />
|
||||
</button>
|
||||
</template>
|
||||
</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="act((id) => notes.setPinned(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="act((id) => notes.setArchived(id, !note.archived))"
|
||||
>
|
||||
<Icon name="archive" />
|
||||
</button>
|
||||
<button
|
||||
v-if="own"
|
||||
type="button"
|
||||
class="icon-btn"
|
||||
title="Move to trash"
|
||||
aria-label="Move to trash"
|
||||
@click="act(notes.trash)"
|
||||
>
|
||||
<Icon name="trash" />
|
||||
</button>
|
||||
</template>
|
||||
</template>
|
||||
@@ -7,6 +7,8 @@ import type { Note } from "../stores/notes";
|
||||
import Icon from "./Icon.vue";
|
||||
import LinkPreview from "./LinkPreview.vue";
|
||||
import MarkdownText from "./MarkdownText.vue";
|
||||
import NoteActions from "./NoteActions.vue";
|
||||
import ReminderActions from "./ReminderActions.vue";
|
||||
import {
|
||||
cardIdAt,
|
||||
draggingId,
|
||||
@@ -14,9 +16,8 @@ import {
|
||||
endCardDrag,
|
||||
overId,
|
||||
} from "../composables/useCardDrag";
|
||||
import { formatReminder, formatTrashCountdown, isOverdue, trashDaysLeft } from "../notes/datetime";
|
||||
import { formatShortDateTime, formatTrashCountdown, isOverdue, trashDaysLeft } from "../notes/datetime";
|
||||
import { useConfigStore } from "../stores/config";
|
||||
import { useUiStore } from "../stores/ui";
|
||||
import { canEditText, isOwnNote } from "../notes/sharing";
|
||||
|
||||
const props = defineProps<{ note: Note; reorderable?: boolean; active?: boolean }>();
|
||||
@@ -34,7 +35,6 @@ const emit = defineEmits<{
|
||||
}>();
|
||||
const notes = useNotesStore();
|
||||
const config = useConfigStore();
|
||||
const ui = useUiStore();
|
||||
|
||||
// --- Sharing (#5174). Someone else's note shows who shared it and offers none of
|
||||
// the owner's controls; at `view` its checkboxes are inert too. The desktop offers
|
||||
@@ -193,15 +193,7 @@ watch(
|
||||
},
|
||||
);
|
||||
|
||||
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 —
|
||||
@@ -385,24 +377,14 @@ const tagColors = computed<Record<string, string>>(() => {
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<polyline points="12 6 12 12 16 14" />
|
||||
</svg>
|
||||
{{ formatReminder(note.remind_at) }}
|
||||
{{ formatShortDateTime(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>
|
||||
<ReminderActions :note-id="note.id" @changed="emit('reminder-changed')" />
|
||||
</div>
|
||||
|
||||
<div v-if="trashCountdown" class="mt-2">
|
||||
@@ -459,68 +441,11 @@ const tagColors = computed<Record<string, string>>(() => {
|
||||
<Icon name="grip" />
|
||||
</button>
|
||||
|
||||
<!-- Pin and archive are everyone's own (#5176); share and trash are the owner's.
|
||||
A note shared with you is never in your Trash, so the trashed set is too. -->
|
||||
<div
|
||||
v-if="own || !note.trashed"
|
||||
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
|
||||
v-if="canShare"
|
||||
type="button"
|
||||
class="icon-btn"
|
||||
title="Share"
|
||||
aria-label="Share"
|
||||
@click="ui.openShare(note.id)"
|
||||
>
|
||||
<Icon name="users" />
|
||||
</button>
|
||||
<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
|
||||
v-if="own"
|
||||
type="button"
|
||||
class="icon-btn"
|
||||
title="Move to trash"
|
||||
aria-label="Move to trash"
|
||||
@click="notes.trash(note.id)"
|
||||
>
|
||||
<Icon name="trash" />
|
||||
</button>
|
||||
</template>
|
||||
<NoteActions :note="note" :own="own" :can-share="canShare" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,15 +4,16 @@ import { useNotesStore } from "../stores/notes";
|
||||
import { errorMessage } from "../api/errors";
|
||||
import { rendersInline } from "../notes/attachments";
|
||||
import Icon from "./Icon.vue";
|
||||
import NoteActions from "./NoteActions.vue";
|
||||
import ReminderActions from "./ReminderActions.vue";
|
||||
import LabelPicker from "./LabelPicker.vue";
|
||||
import LinkPreview from "./LinkPreview.vue";
|
||||
import { fromLocalInput, toLocalInput } from "../notes/datetime";
|
||||
import { formatShortDateTime, fromLocalInput, toLocalInput } from "../notes/datetime";
|
||||
import { takeMorphOrigin } from "../composables/useEditorMorph";
|
||||
import { prefersReducedMotion } from "../composables/useReducedMotion";
|
||||
import type { Note, NoteLabel, NoteRevision } from "../stores/notes";
|
||||
import { labelChipClasses } from "../notes/colors";
|
||||
import { canEditText, isOwnNote } from "../notes/sharing";
|
||||
import { useUiStore } from "../stores/ui";
|
||||
import {
|
||||
afterEnter,
|
||||
type EditorBlock,
|
||||
@@ -119,7 +120,6 @@ const bodyPlaceholder = "Take a note…";
|
||||
// Sharing (#5174). Someone else's note: at `edit` its text and checklist are theirs to
|
||||
// change and nothing else is; at `view` it is read-only. The server enforces both —
|
||||
// this only stops the editor offering what would be refused.
|
||||
const ui = useUiStore();
|
||||
const own = computed(() => isOwnNote(liveNote.value));
|
||||
const editable = computed(() => canEditText(liveNote.value));
|
||||
// On the desktop too: linked, it shares through the server; unlinked, the dialog
|
||||
@@ -427,12 +427,6 @@ async function onRecurrenceChange(e: Event) {
|
||||
if (!id) return;
|
||||
await notes.setRecurrence(id, (e.target as HTMLSelectElement).value || null);
|
||||
}
|
||||
async function completeReminder() {
|
||||
if (noteId.value) await notes.completeReminder(noteId.value);
|
||||
}
|
||||
async function snoozeReminder(minutes: number) {
|
||||
if (noteId.value) await notes.snoozeReminder(noteId.value, minutes);
|
||||
}
|
||||
|
||||
// ---- labels ----
|
||||
async function onLabelsChange(next: NoteLabel[]) {
|
||||
@@ -516,12 +510,6 @@ async function onPaste(e: ClipboardEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- edit-mode lifecycle actions (pin/archive/trash/restore/delete) ----
|
||||
async function act(fn: () => Promise<void>) {
|
||||
await fn();
|
||||
await finish();
|
||||
}
|
||||
|
||||
// ---- version history (modal edit only) ----
|
||||
const showHistory = ref(false);
|
||||
const revisions = ref<NoteRevision[]>([]);
|
||||
@@ -549,10 +537,6 @@ async function restoreRevisionAt(revId: string) {
|
||||
baseline.value = { body: updated.body };
|
||||
void loadRevisions(); // the pre-restore state became a new revision
|
||||
}
|
||||
function revLabel(iso: string | null): string {
|
||||
if (!iso) return "";
|
||||
return new Date(iso).toLocaleString(undefined, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" });
|
||||
}
|
||||
function revPreview(rev: NoteRevision): string {
|
||||
const s = rev.body.trim().replace(/\s+/g, " ");
|
||||
if (!s) return "(empty)";
|
||||
@@ -770,19 +754,7 @@ function revPreview(rev: NoteRevision): string {
|
||||
<option value="monthly">Monthly</option>
|
||||
<option value="yearly">Yearly</option>
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md border border-neutral-300 px-2 py-1 text-neutral-600 hover:bg-neutral-100 dark:border-neutral-700 dark:text-neutral-300 dark:hover:bg-neutral-800"
|
||||
@click="completeReminder"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
<button type="button" class="text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-200" @click="snoozeReminder(60)">
|
||||
Snooze 1h
|
||||
</button>
|
||||
<button type="button" class="text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-200" @click="snoozeReminder(1440)">
|
||||
1d
|
||||
</button>
|
||||
<ReminderActions :note-id="liveNote.id" />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -797,7 +769,7 @@ function revPreview(rev: NoteRevision): string {
|
||||
</p>
|
||||
<ul v-else class="flex max-h-40 flex-col gap-0.5 overflow-y-auto">
|
||||
<li v-for="rev in revisions" :key="rev.id" class="flex items-center gap-2 rounded-md px-1 py-1 text-xs">
|
||||
<span class="shrink-0 tabular-nums text-neutral-400">{{ revLabel(rev.created_at) }}</span>
|
||||
<span class="shrink-0 tabular-nums text-neutral-400">{{ formatShortDateTime(rev.created_at) }}</span>
|
||||
<span class="min-w-0 flex-1 truncate text-neutral-600 dark:text-neutral-300">{{ revPreview(rev) }}</span>
|
||||
<button
|
||||
type="button"
|
||||
@@ -842,71 +814,20 @@ function revPreview(rev: NoteRevision): string {
|
||||
:model-value="labelList"
|
||||
@update:model-value="onLabelsChange"
|
||||
/>
|
||||
<button
|
||||
v-if="canShare && !liveNote.trashed"
|
||||
type="button"
|
||||
class="icon-btn"
|
||||
:class="liveNote.shared ? 'text-brand-700 dark:text-brand' : ''"
|
||||
title="Share"
|
||||
aria-label="Share"
|
||||
@click="ui.openShare(liveNote.id)"
|
||||
>
|
||||
<Icon name="users" />
|
||||
</button>
|
||||
<button
|
||||
v-if="!isCreate && own"
|
||||
type="button"
|
||||
class="icon-btn"
|
||||
:class="showHistory ? 'text-brand-700 dark:text-brand' : ''"
|
||||
title="Version history"
|
||||
aria-label="Version history"
|
||||
:aria-pressed="showHistory"
|
||||
@click="toggleHistory"
|
||||
>
|
||||
<Icon name="history" />
|
||||
</button>
|
||||
<!-- Pin and archive are everyone's own on a shared note (#5176); trash is the owner's. -->
|
||||
<template v-if="!isCreate && !liveNote.trashed">
|
||||
<button
|
||||
type="button"
|
||||
class="icon-btn"
|
||||
:class="liveNote.pinned ? 'text-brand-700 dark:text-brand' : ''"
|
||||
:title="liveNote.pinned ? 'Unpin' : 'Pin'"
|
||||
@click="act(() => notes.setPinned(liveNote.id, !liveNote.pinned))"
|
||||
>
|
||||
<Icon name="pin" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="icon-btn"
|
||||
:title="liveNote.archived ? 'Unarchive' : 'Archive'"
|
||||
@click="act(() => notes.setArchived(liveNote.id, !liveNote.archived))"
|
||||
>
|
||||
<Icon name="archive" />
|
||||
</button>
|
||||
<NoteActions v-if="!isCreate" :note="liveNote" :own="own" :can-share="canShare" @acted="finish">
|
||||
<button
|
||||
v-if="own"
|
||||
type="button"
|
||||
class="icon-btn"
|
||||
title="Move to trash"
|
||||
@click="act(() => notes.trash(liveNote.id))"
|
||||
:class="showHistory ? 'text-brand-700 dark:text-brand' : ''"
|
||||
title="Version history"
|
||||
aria-label="Version history"
|
||||
:aria-pressed="showHistory"
|
||||
@click="toggleHistory"
|
||||
>
|
||||
<Icon name="trash" />
|
||||
<Icon name="history" />
|
||||
</button>
|
||||
</template>
|
||||
<template v-else-if="!isCreate && liveNote.trashed">
|
||||
<button type="button" class="icon-btn" title="Restore" @click="act(() => notes.restore(liveNote.id))">
|
||||
<Icon name="restore" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="icon-btn"
|
||||
title="Delete forever"
|
||||
@click="act(() => notes.deleteForever(liveNote.id))"
|
||||
>
|
||||
<Icon name="trash" />
|
||||
</button>
|
||||
</template>
|
||||
</NoteActions>
|
||||
<button
|
||||
type="button"
|
||||
:class="
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import Icon from "./Icon.vue";
|
||||
|
||||
// The top of a page that sits outside the board (Settings, Sync, Linked devices):
|
||||
// a way back to the board, and the page's name.
|
||||
defineProps<{ title: string }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="mb-8 flex items-center gap-3">
|
||||
<RouterLink to="/" class="icon-btn" title="Back to board" aria-label="Back to board">
|
||||
<Icon name="back" />
|
||||
</RouterLink>
|
||||
<h1 class="text-xl font-bold tracking-tight">{{ title }}</h1>
|
||||
</header>
|
||||
</template>
|
||||
@@ -0,0 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
import { useNotesStore } from "../stores/notes";
|
||||
|
||||
// Done, snooze an hour, snooze a day: the upkeep a due reminder asks for, one tap
|
||||
// each wherever the reminder shows (card and editor). Always visible rather than
|
||||
// hover-revealed: a finger cannot hover, and these are the point of a due note.
|
||||
const props = defineProps<{ noteId: string }>();
|
||||
const emit = defineEmits<{
|
||||
/** Completed or snoozed. For a host holding its own list (the reminders lens). */
|
||||
(e: "changed"): void;
|
||||
}>();
|
||||
|
||||
const notes = useNotesStore();
|
||||
|
||||
async function complete(): Promise<void> {
|
||||
await notes.completeReminder(props.noteId);
|
||||
emit("changed");
|
||||
}
|
||||
|
||||
async function snooze(minutes: number): Promise<void> {
|
||||
await notes.snoozeReminder(props.noteId, minutes);
|
||||
emit("changed");
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button type="button" class="chip-btn" title="Mark this reminder done" @click.stop="complete">Done</button>
|
||||
<button type="button" class="chip-btn" title="Snooze 1 hour" @click.stop="snooze(60)">1h</button>
|
||||
<button type="button" class="chip-btn" title="Snooze 1 day" @click.stop="snooze(1440)">1d</button>
|
||||
</template>
|
||||
@@ -110,28 +110,18 @@ export function plusTask(blocks: EditorBlock[]): { blocks: EditorBlock[]; focus:
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-read ONE prose block for `- [ ] ` lines somebody typed by hand.
|
||||
* Re-read ONE prose block for `- [ ] ` lines somebody typed by hand, so a marker
|
||||
* typed in the editor becomes an item without closing and reopening the note.
|
||||
*
|
||||
* `splitBlocks` runs once, when the editor opens. After that the blocks are the state
|
||||
* and nothing reads the body again — every edit travels the other way, through
|
||||
* `joinBlocks`. So a marker typed by hand stayed literal text on screen until the note
|
||||
* was closed and reopened, even though it was already a real item in storage and the
|
||||
* card was already drawing a checkbox for it. The editor was the only place that
|
||||
* disagreed.
|
||||
* On blur, and only the block being left: re-splitting on a keystroke would move the
|
||||
* caret, and converting the moment `- [ ]` is complete would catch an item with no
|
||||
* text yet.
|
||||
*
|
||||
* ON BLUR, and only the block being left. There is no good moment to convert while
|
||||
* someone is typing: re-splitting on a keystroke moves the caret out of the word being
|
||||
* written, and converting the instant `- [ ]` is complete does it before the item has
|
||||
* any text. Blur is the one moment the person has demonstrably finished with the block,
|
||||
* so a re-split costs no caret and cannot catch a half-typed line.
|
||||
* Returns THE SAME ARRAY when there was nothing to promote; the caller relies on that
|
||||
* to leave the ref alone, so a blur that changed nothing doesn't re-key every field.
|
||||
*
|
||||
* Returns THE SAME ARRAY, not an equal copy, when there was nothing to promote — the
|
||||
* caller leans on that to leave the ref alone, and a blur that changed nothing must not
|
||||
* re-key every field below it.
|
||||
*
|
||||
* Non-canonical markers (`- [X]`, an odd bullet) come back canonical, exactly as they
|
||||
* would have on reopen. That is the only case where this changes the body rather than
|
||||
* only the way it is drawn.
|
||||
* Non-canonical markers (`- [X]`, an odd bullet) come back canonical, as they would on
|
||||
* reopen.
|
||||
*/
|
||||
export function promoteTasks(blocks: EditorBlock[], index: number): EditorBlock[] {
|
||||
const block = blocks[index];
|
||||
|
||||
@@ -36,29 +36,12 @@ export const NOTE_SWATCH_CLASSES: Record<NoteColor, string> = {
|
||||
};
|
||||
|
||||
// A chip's SHELL: its fill and its hairline edge, keyed by the colour vocabulary. The
|
||||
// INK is not here — see TAG_TEXT_CLASSES, which one table now serves both a chip's text
|
||||
// and a `#tag` left in the prose. Compose the two with `labelChipClasses`.
|
||||
// ink is TAG_TEXT_CLASSES; `labelChipClasses` composes the two.
|
||||
//
|
||||
// THE RING IS NOT DECORATION. A chip's fill measures 1.02-1.26 against the card in
|
||||
// light and 1.02-1.73 in dark — that is to say, very nearly nothing. The pill's shape
|
||||
// is the edge; the fill only tints it. (Dark red is the extreme at 1.02, which is
|
||||
// invisible: without the ring that chip would be loose text.) An edge holds the shape
|
||||
// against any background, where shifting the fill only moves which card it collides
|
||||
// with.
|
||||
//
|
||||
// AT 65%, NOT 60%, AND SOLVED FOR RATHER THAN GUESSED. 0.60 was chosen against a worst
|
||||
// case that no longer exists — a chip sitting on a card of its own colour, back when a
|
||||
// note took its first tag's fill. Against the one card surface (M315) the ring is the
|
||||
// ink at alpha over a known fill, so the alpha that clears the 3:1 of WCAG 1.4.11 for
|
||||
// all ten hues can simply be solved: 0.60 gives 2.75-3.82 in light and misses for six
|
||||
// of them, 0.65 gives 3.03-4.36 and misses for none. Dark is 4.52-5.76. 0.80 was the
|
||||
// number the old comment named as the fallback and it is more than is needed — it
|
||||
// draws a hard outline where a hairline does the job.
|
||||
//
|
||||
// `default`'s ring was `black/10 dark:white/15` here and the ink at alpha on the phone —
|
||||
// 1.36 against its own fill in light against Compose's 3.21, so the two surfaces were
|
||||
// drawing visibly different pills for the same chip. It is the ink at 65% on both now,
|
||||
// like every other key: one rule for ten hues, not nine and an exception.
|
||||
// The ring is what gives the pill its shape: a chip's fill measures only 1.02-1.73
|
||||
// against the card. It is the ink at 65%, the lowest alpha that clears WCAG 1.4.11's
|
||||
// 3:1 for all ten hues on the one card surface (light 3.03-4.36, dark 4.52-5.76).
|
||||
// `default` follows the same rule as every other key.
|
||||
//
|
||||
// Mirrored in NoteTint.kt as `chipBackground` and `chipBorder` / CHIP_EDGE_ALPHA.
|
||||
export const LABEL_CHIP_SHELL: Record<NoteColor, string> = {
|
||||
@@ -74,26 +57,13 @@ export const LABEL_CHIP_SHELL: Record<NoteColor, string> = {
|
||||
gray: "bg-neutral-200 dark:bg-neutral-700 ring-1 ring-inset ring-neutral-800/65 dark:ring-neutral-200/65",
|
||||
};
|
||||
|
||||
// THE INK A TAG IS DRAWN IN — one table, for a `#tag` left in the prose AND for a
|
||||
// chip's text. It was two, and the split was real while it lasted: a chip carried its
|
||||
// own `-100` fill and could afford `-700`, while inline text sat on whatever the card
|
||||
// was, which included a gray-tagged card at `neutral-200` where `-700` measured 3.98
|
||||
// (green), 4.11 (orange) and 4.34 (teal) — all under the 4.5 body text needs. One step
|
||||
// deeper cleared every fill at once, so inline got `-800` and the chip kept `-700`.
|
||||
//
|
||||
// M315 removed the twenty card fills the split was solving for, and this is the payoff:
|
||||
// against ONE card surface both jobs can take the same value. `-800`/`-300` is the one
|
||||
// they take, and the direction is deliberate — the inline token is the common case
|
||||
// (since M311 a tag whose text is in the body is drawn where it was typed and NOT
|
||||
// repeated as a chip), so collapsing onto the inline column leaves what is seen most
|
||||
// exactly as it was, and moves only the chip. The chip is strictly better for it:
|
||||
// THE INK A TAG IS DRAWN IN — one table, for a `#tag` left in the prose and for a
|
||||
// chip's text. `-800` in light and `-300` in dark clear body-text contrast in both
|
||||
// places:
|
||||
//
|
||||
// inline, on the card as a chip, on its own fill
|
||||
// light `-800` 7.09 - 15.13 6.37 - 12.01 (was 4.52 - 8.23)
|
||||
// dark `-300` 9.45 - 14.23 8.23 - 11.88 (unchanged)
|
||||
//
|
||||
// Dark needed no decision at all: the two tables were already the same value for all
|
||||
// ten hues, which is on its own most of the argument that one table was always enough.
|
||||
// light `-800` 7.09 - 15.13 6.37 - 12.01
|
||||
// dark `-300` 9.45 - 14.23 8.23 - 11.88
|
||||
//
|
||||
// Mirrored in NoteTint.kt as `lightTagInk` / `darkTagInk`.
|
||||
export const TAG_TEXT_CLASSES: Record<NoteColor, string> = {
|
||||
@@ -137,6 +107,12 @@ export function labelChipClasses(label: { name: string; color?: string | null })
|
||||
return `${LABEL_CHIP_SHELL[color]} ${TAG_TEXT_CLASSES[color]}`;
|
||||
}
|
||||
|
||||
/** The small round swatch for a tag (sidebar, tag manager): the colour it actually
|
||||
* wears, derived from its name when nobody picked one, as on every chip. */
|
||||
export function labelDotClasses(label: { name: string; color?: string | null }): string {
|
||||
return NOTE_SWATCH_CLASSES[resolveLabelColor(label)] ?? NOTE_SWATCH_CLASSES.default;
|
||||
}
|
||||
|
||||
export const NOTE_COLOR_LABELS: Record<NoteColor, string> = {
|
||||
default: "Default",
|
||||
red: "Red",
|
||||
@@ -243,33 +219,9 @@ export function resolveLabelColor(label: { name: string; color?: string | null }
|
||||
// chips of one colour are the same tag. The chip's text is what says which tag it is.
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// THE CARD SURFACE — one neutral, no hue, no note involved (M315).
|
||||
//
|
||||
// This used to be a function of the note: a palette class for a tagged one, a fill
|
||||
// generated from the id for the rest. Both are gone. The operator's verdict after
|
||||
// four passes at it — "my coloring attempt has failed and nothing looks right… we've
|
||||
// tried a lot to make the color work and somehow it never seems to land" — and the
|
||||
// diagnosis underneath it is that a card's fill was being asked to carry meaning it
|
||||
// could not carry. Nine keys is too few to identify anything on a board of any size,
|
||||
// and a generated fill identifies nothing by construction, so a coloured board taught
|
||||
// the eye to read hue as significant and then handed it noise.
|
||||
//
|
||||
// COLOUR NOW LIVES ONLY ON THE TAG — the chip and the inline `#tag`, both of which sit
|
||||
// on this one known background from here on. That is a smaller job done properly
|
||||
// instead of a larger one done four times.
|
||||
//
|
||||
// The codebase had already made this argument about the card's EDGE, one level down:
|
||||
// the hue-coded border came out as "a neutral line carries no information at all,
|
||||
// which is exactly what lets it be structure instead of content". The fill is the same
|
||||
// argument at the next size up.
|
||||
//
|
||||
// THE VALUES. `bg-white dark:bg-neutral-900` — which is exactly what `default` always
|
||||
// was, and exactly what the note EDITOR panel has always been (NoteEditor.vue), so
|
||||
// this is a collapse onto a surface both other surfaces already used rather than a
|
||||
// new colour anybody has to like.
|
||||
//
|
||||
// Measured against the operator's constraint, "not the same color as their background
|
||||
// but close to it":
|
||||
// THE CARD SURFACE — one neutral, the same for every note. Colour lives only on tags
|
||||
// (the chip and the inline `#tag`); a card's fill can't carry meaning, because nine
|
||||
// keys identify nothing on a board of any size. It is the editor panel's surface too.
|
||||
//
|
||||
// card vs board light #ffffff on #fafafa 1.04
|
||||
// dark #171717 on #0a0a0a 1.10
|
||||
@@ -277,11 +229,7 @@ export function resolveLabelColor(label: { name: string; color?: string | null }
|
||||
// dark #404040 on #171717 1.73
|
||||
// body vs card light #171717 on #ffffff 17.93 (needs 4.5)
|
||||
// dark #fafafa on #171717 17.17
|
||||
// muted vs card light #404040 on #ffffff 10.37
|
||||
// dark #e5e5e5 on #171717 14.23
|
||||
//
|
||||
// The fill is deliberately the WEAKEST of those numbers. A card is not separated from
|
||||
// the board by its fill and never was — the edge and the shadow do that, which is why
|
||||
// 1.04 is enough and why it has to stay near 1: a fill that separated on its own would
|
||||
// be a panel, and a board of panels is the wall this whole line of work started from.
|
||||
// The fill is deliberately the weakest number: the edge and the shadow separate a card
|
||||
// from the board, and a fill strong enough to do it alone would make every card a panel.
|
||||
export const NOTE_CARD_SURFACE = "bg-white dark:bg-neutral-900";
|
||||
|
||||
@@ -13,7 +13,8 @@ export function fromLocalInput(local: string): string | null {
|
||||
return new Date(local).toISOString();
|
||||
}
|
||||
|
||||
export function formatReminder(iso: string | null): string {
|
||||
// A moment, short: "Oct 7, 4:05 PM". A reminder on a card, a revision in history.
|
||||
export function formatShortDateTime(iso: string | null): string {
|
||||
if (!iso) return "";
|
||||
return new Date(iso).toLocaleString(undefined, {
|
||||
month: "short",
|
||||
@@ -23,6 +24,11 @@ export function formatReminder(iso: string | null): string {
|
||||
});
|
||||
}
|
||||
|
||||
// A moment, in full, or `empty` when there is none ("last synced never").
|
||||
export function formatDateTime(iso: string | null, empty = "never"): string {
|
||||
return iso ? new Date(iso).toLocaleString() : empty;
|
||||
}
|
||||
|
||||
export function isOverdue(iso: string | null): boolean {
|
||||
return !!iso && new Date(iso).getTime() < Date.now();
|
||||
}
|
||||
|
||||
@@ -7,7 +7,9 @@ import BaseButton from "../components/BaseButton.vue";
|
||||
import BaseInput from "../components/BaseInput.vue";
|
||||
import ClientDownloads from "../components/ClientDownloads.vue";
|
||||
import Icon from "../components/Icon.vue";
|
||||
import PageHeader from "../components/PageHeader.vue";
|
||||
import { errorMessage } from "../api/errors";
|
||||
import { formatDateTime } from "../notes/datetime";
|
||||
|
||||
// Per-user (not admin) management of linked native clients — the Tauri desktop and
|
||||
// Android apps authenticate sync with a device bearer token issued here.
|
||||
@@ -66,11 +68,6 @@ async function revoke(id: string, name: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function fmt(iso: string | null): string {
|
||||
if (!iso) return "never";
|
||||
return new Date(iso).toLocaleString();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void load();
|
||||
});
|
||||
@@ -78,23 +75,7 @@ onMounted(() => {
|
||||
|
||||
<template>
|
||||
<div class="mx-auto min-h-full max-w-2xl px-4 py-8">
|
||||
<header class="mb-8 flex items-center gap-3">
|
||||
<RouterLink to="/" class="icon-btn" title="Back to board" aria-label="Back to board">
|
||||
<svg
|
||||
class="h-[18px] w-[18px]"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="m15 18-6-6 6-6" />
|
||||
</svg>
|
||||
</RouterLink>
|
||||
<h1 class="text-xl font-bold tracking-tight">Linked devices</h1>
|
||||
</header>
|
||||
<PageHeader title="Linked devices" />
|
||||
|
||||
<p class="mb-6 max-w-xl text-sm text-neutral-500 dark:text-neutral-400">
|
||||
Link the Inkwell desktop or mobile app to sync your notes. Create a device token here, then
|
||||
@@ -170,7 +151,7 @@ onMounted(() => {
|
||||
<div class="min-w-0">
|
||||
<p class="truncate text-sm font-medium text-neutral-800 dark:text-neutral-100">{{ d.name }}</p>
|
||||
<p class="text-xs text-neutral-400">
|
||||
Linked {{ fmt(d.created_at) }} · last synced {{ fmt(d.last_used_at) }}
|
||||
Linked {{ formatDateTime(d.created_at) }} · last synced {{ formatDateTime(d.last_used_at) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -281,6 +281,17 @@ async function onDrop(targetId: string) {
|
||||
order.splice(toIdx, 0, from);
|
||||
await notes.reorder(order);
|
||||
}
|
||||
|
||||
/** What every grid on this board takes besides its notes: the keyboard focus, and
|
||||
* opening and reordering cards. */
|
||||
const gridBinds = computed(() => ({
|
||||
activeId: focusedId.value,
|
||||
reorderable: true,
|
||||
onOpen: openEditor,
|
||||
onDragstart: onDragStart,
|
||||
onDragend: onDragEnd,
|
||||
onDrop,
|
||||
}));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -340,42 +351,17 @@ 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>
|
||||
<NoteGrid
|
||||
:notes="pinnedNotes"
|
||||
:active-id="focusedId"
|
||||
reorderable
|
||||
@open="openEditor"
|
||||
@dragstart="onDragStart"
|
||||
@dragend="onDragEnd"
|
||||
@drop="onDrop"
|
||||
/>
|
||||
<NoteGrid :notes="pinnedNotes" v-bind="gridBinds" />
|
||||
</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>
|
||||
<NoteGrid
|
||||
:notes="otherNotes"
|
||||
:active-id="focusedId"
|
||||
reorderable
|
||||
@open="openEditor"
|
||||
@dragstart="onDragStart"
|
||||
@dragend="onDragEnd"
|
||||
@drop="onDrop"
|
||||
/>
|
||||
<NoteGrid :notes="otherNotes" v-bind="gridBinds" />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<NoteGrid
|
||||
v-else
|
||||
:notes="notes.items"
|
||||
:active-id="focusedId"
|
||||
reorderable
|
||||
@open="openEditor"
|
||||
@dragstart="onDragStart"
|
||||
@dragend="onDragEnd"
|
||||
@drop="onDrop"
|
||||
/>
|
||||
<NoteGrid v-else :notes="notes.items" v-bind="gridBinds" />
|
||||
</template>
|
||||
</AsyncState>
|
||||
</div>
|
||||
|
||||
@@ -1,40 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
import { api } from "../api/client";
|
||||
import { repo } from "../adapters";
|
||||
import type { ServerSetting, SettingValue } from "../adapters/repo";
|
||||
import { useConfigStore } from "../stores/config";
|
||||
import AsyncState from "../components/AsyncState.vue";
|
||||
import BaseButton from "../components/BaseButton.vue";
|
||||
import PageHeader from "../components/PageHeader.vue";
|
||||
import InviteList from "../components/InviteList.vue";
|
||||
import AccountList from "../components/AccountList.vue";
|
||||
import GroupList from "../components/GroupList.vue";
|
||||
import { errorMessage } from "../api/errors";
|
||||
import { useUiStore } from "../stores/ui";
|
||||
|
||||
interface SettingItem {
|
||||
key: string;
|
||||
type: "string" | "bool" | "int";
|
||||
value: string | boolean | number;
|
||||
default: string | boolean | number;
|
||||
label: string;
|
||||
description: string;
|
||||
group: string;
|
||||
// Ints only, and nullable: the server sends the registry's bounds so the number
|
||||
// input can refuse an out-of-range value before the round trip.
|
||||
minimum: number | null;
|
||||
maximum: number | null;
|
||||
// Strings: the allowed values, drawn as a choice.
|
||||
choices: string[] | null;
|
||||
// A credential (the SMTP password). Its value never comes back; `is_set` says
|
||||
// whether one is saved, and leaving the field empty keeps it.
|
||||
secret: boolean;
|
||||
is_set: boolean | null;
|
||||
}
|
||||
|
||||
const config = useConfigStore();
|
||||
const ui = useUiStore();
|
||||
|
||||
const items = ref<SettingItem[]>([]);
|
||||
const original = ref<Record<string, string | boolean | number>>({});
|
||||
const items = ref<ServerSetting[]>([]);
|
||||
const original = ref<Record<string, SettingValue>>({});
|
||||
const loading = ref(true);
|
||||
const loadError = ref("");
|
||||
const saving = ref(false);
|
||||
const error = ref("");
|
||||
const saved = ref(false);
|
||||
@@ -46,7 +30,7 @@ watch(dirty, (d) => {
|
||||
});
|
||||
|
||||
const groups = computed(() => {
|
||||
const map = new Map<string, SettingItem[]>();
|
||||
const map = new Map<string, ServerSetting[]>();
|
||||
for (const it of items.value) {
|
||||
const arr = map.get(it.group) ?? [];
|
||||
arr.push(it);
|
||||
@@ -55,19 +39,18 @@ const groups = computed(() => {
|
||||
return Array.from(map, ([name, entries]) => ({ name, entries }));
|
||||
});
|
||||
|
||||
function snapshot(list: SettingItem[]) {
|
||||
function snapshot(list: ServerSetting[]) {
|
||||
original.value = Object.fromEntries(list.map((it) => [it.key, it.value]));
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
loadError.value = "";
|
||||
try {
|
||||
const res = await api.get<{ settings: SettingItem[] }>("/api/settings");
|
||||
items.value = res.settings;
|
||||
snapshot(res.settings);
|
||||
} catch {
|
||||
error.value = "Could not load settings.";
|
||||
items.value = await repo.settings.list();
|
||||
snapshot(items.value);
|
||||
} catch (e) {
|
||||
loadError.value = errorMessage(e, "Could not load settings.");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
@@ -77,12 +60,11 @@ async function save() {
|
||||
error.value = "";
|
||||
saved.value = false;
|
||||
saving.value = true;
|
||||
const payload: Record<string, string | boolean | number> = {};
|
||||
const payload: Record<string, SettingValue> = {};
|
||||
for (const it of items.value) payload[it.key] = it.value;
|
||||
try {
|
||||
const res = await api.patch<{ settings: SettingItem[] }>("/api/settings", { settings: payload });
|
||||
items.value = res.settings;
|
||||
snapshot(res.settings);
|
||||
items.value = await repo.settings.save(payload);
|
||||
snapshot(items.value);
|
||||
saved.value = true;
|
||||
await config.reload(); // header/site name reflects changes immediately
|
||||
} catch (e) {
|
||||
@@ -98,7 +80,7 @@ const testing = ref(false);
|
||||
async function sendTestEmail() {
|
||||
testing.value = true;
|
||||
try {
|
||||
const res = await api.post<{ to: string }>("/api/settings/test-email");
|
||||
const res = await repo.settings.sendTestEmail();
|
||||
ui.showToast(`Test email sent to ${res.to}.`);
|
||||
} catch (e) {
|
||||
ui.showToast(errorMessage(e, "Couldn't send a test email."));
|
||||
@@ -112,121 +94,93 @@ onMounted(load);
|
||||
|
||||
<template>
|
||||
<div class="mx-auto min-h-full max-w-2xl px-4 py-8">
|
||||
<header class="mb-8 flex items-center gap-3">
|
||||
<RouterLink to="/" class="icon-btn" title="Back to board" aria-label="Back to board">
|
||||
<svg
|
||||
class="h-[18px] w-[18px]"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="m15 18-6-6 6-6" />
|
||||
</svg>
|
||||
</RouterLink>
|
||||
<h1 class="text-xl font-bold tracking-tight">Settings</h1>
|
||||
</header>
|
||||
<PageHeader title="Settings" />
|
||||
|
||||
<div v-if="loading" class="py-20 text-center text-sm text-neutral-400">Loading…</div>
|
||||
<AsyncState :loading="loading" :error="loadError" error-title="Couldn't load settings" @retry="load">
|
||||
<form class="flex flex-col gap-8" @submit.prevent="save">
|
||||
<section v-for="group in groups" :key="group.name" class="flex flex-col gap-5">
|
||||
<h2 class="text-xs font-semibold uppercase tracking-wide text-neutral-400">{{ group.name }}</h2>
|
||||
|
||||
<div v-else-if="error && !items.length" class="py-20 text-center">
|
||||
<h2 class="text-lg font-semibold text-neutral-700 dark:text-neutral-200">Couldn't load settings</h2>
|
||||
<p class="mt-1 text-sm text-neutral-400">{{ error }}</p>
|
||||
<button
|
||||
type="button"
|
||||
class="mt-3 rounded-md border border-neutral-300 px-3 py-1.5 text-sm hover:bg-neutral-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand dark:border-neutral-700 dark:hover:bg-neutral-800"
|
||||
@click="load"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
<div v-for="it in group.entries" :key="it.key" class="flex flex-col gap-1">
|
||||
<div class="flex items-center justify-between gap-6">
|
||||
<label :for="it.key" class="text-sm font-medium text-neutral-800 dark:text-neutral-200">{{
|
||||
it.label
|
||||
}}</label>
|
||||
|
||||
<form v-else class="flex flex-col gap-8" @submit.prevent="save">
|
||||
<section v-for="group in groups" :key="group.name" class="flex flex-col gap-5">
|
||||
<h2 class="text-xs font-semibold uppercase tracking-wide text-neutral-400">{{ group.name }}</h2>
|
||||
|
||||
<div v-for="it in group.entries" :key="it.key" class="flex flex-col gap-1">
|
||||
<div class="flex items-center justify-between gap-6">
|
||||
<label :for="it.key" class="text-sm font-medium text-neutral-800 dark:text-neutral-200">{{
|
||||
it.label
|
||||
}}</label>
|
||||
|
||||
<input
|
||||
v-if="it.type === 'bool'"
|
||||
:id="it.key"
|
||||
type="checkbox"
|
||||
class="h-4 w-4 accent-brand focus:outline-none focus-visible:ring-2 focus-visible:ring-brand"
|
||||
:checked="Boolean(it.value)"
|
||||
@change="it.value = ($event.target as HTMLInputElement).checked"
|
||||
/>
|
||||
<!-- min/max come from the registry. The server rejects out-of-range
|
||||
values regardless — this is so the browser says so first, rather than
|
||||
letting someone type a hop count that would disable a protection and
|
||||
only learn about it from an error banner. -->
|
||||
<input
|
||||
v-else-if="it.type === 'int'"
|
||||
:id="it.key"
|
||||
type="number"
|
||||
:min="it.minimum ?? undefined"
|
||||
:max="it.maximum ?? undefined"
|
||||
class="w-28 rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm text-neutral-900 shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-brand dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100"
|
||||
:value="Number(it.value)"
|
||||
@input="it.value = Number(($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
<select
|
||||
v-else-if="it.choices"
|
||||
:id="it.key"
|
||||
class="w-56 rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm text-neutral-900 shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-brand dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100"
|
||||
:value="String(it.value)"
|
||||
@change="it.value = ($event.target as HTMLSelectElement).value"
|
||||
>
|
||||
<option v-for="c in it.choices" :key="c" :value="c">{{ c }}</option>
|
||||
</select>
|
||||
<input
|
||||
v-else-if="it.secret"
|
||||
:id="it.key"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
:placeholder="it.is_set ? 'Saved — type to replace' : ''"
|
||||
class="w-56 rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm text-neutral-900 shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-brand dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100"
|
||||
:value="String(it.value)"
|
||||
@input="it.value = ($event.target as HTMLInputElement).value"
|
||||
/>
|
||||
<input
|
||||
v-else
|
||||
:id="it.key"
|
||||
type="text"
|
||||
class="w-56 rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm text-neutral-900 shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-brand dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100"
|
||||
:value="String(it.value)"
|
||||
@input="it.value = ($event.target as HTMLInputElement).value"
|
||||
/>
|
||||
<input
|
||||
v-if="it.type === 'bool'"
|
||||
:id="it.key"
|
||||
type="checkbox"
|
||||
class="h-4 w-4 accent-brand focus:outline-none focus-visible:ring-2 focus-visible:ring-brand"
|
||||
:checked="Boolean(it.value)"
|
||||
@change="it.value = ($event.target as HTMLInputElement).checked"
|
||||
/>
|
||||
<!-- min/max come from the registry. The server rejects out-of-range
|
||||
values regardless — this is so the browser says so first, rather than
|
||||
letting someone type a hop count that would disable a protection and
|
||||
only learn about it from an error banner. -->
|
||||
<input
|
||||
v-else-if="it.type === 'int'"
|
||||
:id="it.key"
|
||||
type="number"
|
||||
:min="it.minimum ?? undefined"
|
||||
:max="it.maximum ?? undefined"
|
||||
class="w-28 rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm text-neutral-900 shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-brand dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100"
|
||||
:value="Number(it.value)"
|
||||
@input="it.value = Number(($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
<select
|
||||
v-else-if="it.choices"
|
||||
:id="it.key"
|
||||
class="w-56 rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm text-neutral-900 shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-brand dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100"
|
||||
:value="String(it.value)"
|
||||
@change="it.value = ($event.target as HTMLSelectElement).value"
|
||||
>
|
||||
<option v-for="c in it.choices" :key="c" :value="c">{{ c }}</option>
|
||||
</select>
|
||||
<input
|
||||
v-else-if="it.secret"
|
||||
:id="it.key"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
:placeholder="it.is_set ? 'Saved — type to replace' : ''"
|
||||
class="w-56 rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm text-neutral-900 shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-brand dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100"
|
||||
:value="String(it.value)"
|
||||
@input="it.value = ($event.target as HTMLInputElement).value"
|
||||
/>
|
||||
<input
|
||||
v-else
|
||||
:id="it.key"
|
||||
type="text"
|
||||
class="w-56 rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm text-neutral-900 shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-brand dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100"
|
||||
:value="String(it.value)"
|
||||
@input="it.value = ($event.target as HTMLInputElement).value"
|
||||
/>
|
||||
</div>
|
||||
<p class="max-w-md text-xs text-neutral-400">{{ it.description }}</p>
|
||||
</div>
|
||||
<p class="max-w-md text-xs text-neutral-400">{{ it.description }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="group.name === 'Email'" class="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md border border-neutral-300 px-3 py-1.5 text-sm hover:bg-neutral-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand disabled:opacity-50 dark:border-neutral-700 dark:hover:bg-neutral-800"
|
||||
:disabled="testing || dirty"
|
||||
@click="sendTestEmail"
|
||||
>
|
||||
Send test email
|
||||
</button>
|
||||
<span v-if="dirty" class="text-xs text-neutral-400">Save first</span>
|
||||
</div>
|
||||
</section>
|
||||
<div v-if="group.name === 'Email'" class="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md border border-neutral-300 px-3 py-1.5 text-sm hover:bg-neutral-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand disabled:opacity-50 dark:border-neutral-700 dark:hover:bg-neutral-800"
|
||||
:disabled="testing || dirty"
|
||||
@click="sendTestEmail"
|
||||
>
|
||||
Send test email
|
||||
</button>
|
||||
<span v-if="dirty" class="text-xs text-neutral-400">Save first</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<BaseButton type="submit" :loading="saving" :disabled="!dirty">Save changes</BaseButton>
|
||||
<span v-if="saved && !dirty" class="text-sm text-green-600 dark:text-green-400">Saved.</span>
|
||||
<span v-else-if="!dirty && !error" class="text-sm text-neutral-400">No unsaved changes</span>
|
||||
<span v-if="error" class="text-sm text-red-600 dark:text-red-400">{{ error }}</span>
|
||||
</div>
|
||||
</form>
|
||||
<div class="flex items-center gap-3">
|
||||
<BaseButton type="submit" :loading="saving" :disabled="!dirty">Save changes</BaseButton>
|
||||
<span v-if="saved && !dirty" class="text-sm text-green-600 dark:text-green-400">Saved.</span>
|
||||
<span v-else-if="!dirty && !error" class="text-sm text-neutral-400">No unsaved changes</span>
|
||||
<span v-if="error" class="text-sm text-red-600 dark:text-red-400">{{ error }}</span>
|
||||
</div>
|
||||
</form>
|
||||
</AsyncState>
|
||||
|
||||
<!-- Outside the settings form: each invite, account and group action saves on its own,
|
||||
and the form's Save button has nothing to do with them. -->
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useUiStore } from "../stores/ui";
|
||||
import BaseButton from "../components/BaseButton.vue";
|
||||
import BaseInput from "../components/BaseInput.vue";
|
||||
import Icon from "../components/Icon.vue";
|
||||
import PageHeader from "../components/PageHeader.vue";
|
||||
import DesktopMenuEntry from "../components/DesktopMenuEntry.vue";
|
||||
import {
|
||||
SUGGESTED_CAPTURE_SHORTCUT,
|
||||
@@ -22,6 +23,7 @@ import {
|
||||
type UpdateStatus,
|
||||
} from "../desktop/bridge";
|
||||
import { errorMessage } from "../api/errors";
|
||||
import { formatDateTime } from "../notes/datetime";
|
||||
|
||||
// Opt-in server sync for the desktop app. Being UNLINKED is the normal resting
|
||||
// state, not an incomplete setup — the app is local-first and fully usable having
|
||||
@@ -309,11 +311,6 @@ async function disconnect() {
|
||||
}
|
||||
}
|
||||
|
||||
function fmt(iso: string | null): string {
|
||||
if (!iso) return "never";
|
||||
return new Date(iso).toLocaleString();
|
||||
}
|
||||
|
||||
// A background cycle finished — redraw from it, so this screen is never staler
|
||||
// than the board behind it.
|
||||
let stopSyncListener: (() => void) | null = null;
|
||||
@@ -335,23 +332,7 @@ onBeforeUnmount(() => stopSyncListener?.());
|
||||
|
||||
<template>
|
||||
<div class="mx-auto min-h-full max-w-2xl px-4 py-8">
|
||||
<header class="mb-8 flex items-center gap-3">
|
||||
<RouterLink to="/" class="icon-btn" title="Back to board" aria-label="Back to board">
|
||||
<svg
|
||||
class="h-[18px] w-[18px]"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="m15 18-6-6 6-6" />
|
||||
</svg>
|
||||
</RouterLink>
|
||||
<h1 class="text-xl font-bold tracking-tight">Sync</h1>
|
||||
</header>
|
||||
<PageHeader title="Sync" />
|
||||
|
||||
<div v-if="loading" class="py-10 text-center text-sm text-neutral-400">Loading…</div>
|
||||
|
||||
@@ -366,7 +347,7 @@ onBeforeUnmount(() => stopSyncListener?.());
|
||||
</p>
|
||||
<p v-if="linkedAs" class="mt-0.5 text-xs text-neutral-400">as {{ linkedAs }}</p>
|
||||
<p class="mt-1 text-xs text-neutral-400">
|
||||
Last synced {{ fmt(status?.last_sync_at ?? null) }}
|
||||
Last synced {{ formatDateTime(status?.last_sync_at ?? null) }}
|
||||
<span v-if="pending"> · unsent changes on this device</span>
|
||||
</p>
|
||||
<p class="mt-0.5 text-xs text-neutral-400">
|
||||
|
||||
+7
-33
@@ -51,45 +51,19 @@ DEFAULT_LIMIT = 500
|
||||
MAX_LIMIT = 1000
|
||||
MAX_PUSH = 1000 # per-batch change cap
|
||||
|
||||
# --- protocol versioning (M10.6) --------------------------------------------
|
||||
# --- protocol versioning ------------------------------------------------------
|
||||
#
|
||||
# The client<->server compatibility contract. These integers version the WIRE
|
||||
# PROTOCOL, deliberately separate from the app's release version, so a client and
|
||||
# server on different releases can still work out whether they can talk. Without
|
||||
# that separation every protocol change would force app<->server lockstep.
|
||||
# PROTOCOL, separately from the app's release version, so a client and server on
|
||||
# different releases can still work out whether they can talk.
|
||||
#
|
||||
# SYNC_PROTOCOL_VERSION what this server speaks.
|
||||
# MIN_CLIENT_PROTOCOL_VERSION the oldest client protocol it still accepts.
|
||||
#
|
||||
# Bump SYNC_PROTOCOL_VERSION for ANY wire change. Raise
|
||||
# MIN_CLIENT_PROTOCOL_VERSION only for a genuinely BREAKING one: it is the switch
|
||||
# that hard-blocks older clients, so additive changes must leave it alone.
|
||||
# v2 (M13): `kind` and `title` both left the wire. Dropping a field a v1 client sends
|
||||
# and expects back is breaking, so the FLOOR moves too — a v1 client would keep pushing
|
||||
# both and would read back notes carrying neither.
|
||||
#
|
||||
# One bump for the pair: they landed in the same protocol generation, and nothing ever
|
||||
# ran against a half-applied v2.
|
||||
# v4 (M315): `color` left the note. The FLOOR DELIBERATELY DOES NOT MOVE, and v2 is
|
||||
# the precedent that makes saying so worthwhile — it dropped `kind` and `title` and did
|
||||
# raise the floor, on the rule that dropping a field a client sends and expects back is
|
||||
# breaking. `color` fails the second half of that: a v3 client reading a v4 note gets
|
||||
# `"default"` from its own serde default and draws the colour it derives locally, which
|
||||
# is 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 loses anything visible. `title` was the note's
|
||||
# NAME; this is a field that no longer renders anywhere.
|
||||
# v5 (#5168): attachments became a sync entity — `PUT /attachments/<id>` uploads a file
|
||||
# attached offline, and push takes `attachment` and `preview` deletes. Additive, so the
|
||||
# floor stays: a v4 client never sends either, and gets the same notes it always did.
|
||||
# v6 (#5175): shared notes. A client asking `changes?shares=1` gets the notes shared
|
||||
# with it, each saying how it is held (`permission`, `shared`, `shared_by`), and a
|
||||
# `revoked` list of notes that have left it; push takes body edits to notes shared at
|
||||
# `edit`. Additive and opt-in, so the floor stays: a v5 client never asks, and gets
|
||||
# its own notes exactly as before.
|
||||
# v7 (#5176): a recipient's own pin, archive and place. On a shared note the feed
|
||||
# carries the caller's own, and push takes them, stamped `state_at`, for a note shared
|
||||
# at any level. Additive, so the floor stays: a v6 client pushes only text, as before.
|
||||
# Bump SYNC_PROTOCOL_VERSION for ANY wire change. Raise the floor only for a
|
||||
# BREAKING one: it hard-blocks older clients, so additive changes leave it alone.
|
||||
# What each version changed, and why the floor moved or didn't, is in docs/sync.md
|
||||
# ("The policy").
|
||||
SYNC_PROTOCOL_VERSION = 7
|
||||
MIN_CLIENT_PROTOCOL_VERSION = 3
|
||||
|
||||
|
||||
Reference in New Issue
Block a user