43ebb6ecebf7035ca73e81d9ddfd680b85272c78
45
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
d77a79859c |
server: hand out the Android client this server syncs with (2726)
CI & Build / Python tests (push) Successful in 11s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Build & push image (push) Successful in 50s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Failing after 3m8s
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 5m32s
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Successful in 8m8s
A self-hoster should not need an account on someone else's forge to get the app
for their own notes. The Fabled-Git instance is private — which is why
`install.sh` already cannot fetch for anyone but the operator — so a release page
is no use as a distribution point. The server holding the notes is something the
person already trusts and already reaches.
It also keeps the pair in step by construction. Client and server negotiate a
sync protocol version before linking, so a server that also serves the client
cannot hand out a phone it is unable to talk to.
**Two files, and both must be present**: `thoughtsync.apk` and a
`thoughtsync-android.json` sidecar carrying `{version_name, version_code, size,
sha256}`. The sidecar exists because an APK keeps its version in a binary AXML
manifest, which Python cannot read and which is not worth putting `aapt` on a
Quart server to reach. CI writes it beside the APK, where the values are already
known — including the digest, computed over the same bytes it uploads, so a
phone can tell a truncated download from a complete one before handing it to the
installer. Not a trust anchor; the signature is that.
**Under DATA_DIR, not baked into the image.** Baking charges ~55 MiB to every
self-hoster including everyone who never touches Android. `/var/thoughtsync` is
already the mounted volume that holds attachments, so a build dropped there
survives container recreation.
**Absence is an ordinary state, not an error.** No APK means the key is absent
from `/api/config` — absent rather than null, so a client testing for it cannot
confuse "this server has no client" with "this server predates the field" — the
web UI hides the card instead of offering a button that 404s, and the metadata
route answers 404. A server whose owner does not use Android is not misconfigured.
**A mismatched pair also counts as no client.** If the sidecar's recorded size
does not match the file on disk, the two did not arrive together; serving one
build while advertising another is worse than serving none, because the phone
would compare versions against a promise the bytes do not keep. That makes the
copy order in docs/android-distribution.md load-bearing, and it is written down
there: APK first, sidecar last.
**The version is public, the bytes are not.** An updater has to be able to ask
"is there something newer?" cheaply and before it has done anything; 55 MiB is
not for anyone who can reach the port. `login_required` already accepts either a
session cookie or a device bearer token, so the browser and a linked phone both
work with no second auth path.
The Android lane now publishes both files to the same rolling `dev` release the
desktop bundles use, reusing `publish-release.sh` — its nullglob asset list was
already built for several jobs in separate workspaces publishing to one release,
which is exactly this. Signed builds only: publishing an unsigned APK would offer
people something they cannot install over what they already have.
Nine tests, DB-free like the rest of the suite — this lane runs no Postgres, so
the advertisement is asserted through `advertisement()` rather than through
`/api/config`, whose other half needs a database. Both routes ARE exercised,
because neither opens a session.
|
||
|
|
641999de58 |
frontend: reminders becomes a lens, and cards can clear a reminder (task 1913)
CI & Build / Python lint (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 11s
CI & Build / Build & push image (push) Successful in 46s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m50s
Android (Tauri) / Android APK (debug) (push) Successful in 3m41s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m2s
Desktop (Tauri) / Update manifest (push) Successful in 6s
Reminders was the last surface still reading as its own page — a bespoke row list rather than the board's cards. It is now the same NoteGrid as every other lens. The reason it wasn't already is that the list was a TRIAGE surface: one tap for Done, 1h, 1d. Cards had none of that, so converting naively would have turned each of those into open-act-close. Reminder upkeep is exactly the "maintenance must stay dead simple or people stop coming back" case from the north star, so making it three times more work to look tidier would have been a bad trade. So the actions moved onto the card instead, shown wherever a note carries a reminder — the board included. That turns out to be the better place for them anyway: seeing something due while browsing and clearing it there is useful outside the reminders lens. Always visible rather than hover-revealed, because a finger cannot hover and these are the primary action on a due note; .chip-btn takes the same coarse-pointer sizing rule as .icon-btn. The card acts on the store directly, which the board picks up through reconcile. The reminders lens fetches its own list, so it needs telling — hence the reminder-changed event, which exists only for hosts that hold a list of their own. Also carried recurrence (↻) onto the card. It was shown only in the reminders list, so unifying would have silently dropped it; a repeating note now reads as repeating on the board too. And the container went max-w-2xl → max-w-6xl, since a narrower column would have reintroduced the different-page feeling the cards just removed. RemindersView is ~40 lines lighter for it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
c8c8ec4b4e |
frontend: the shell names the active lens (task 1913)
CI & Build / TypeScript typecheck (push) Successful in 8s
CI & Build / Python tests (push) Successful in 15s
CI & Build / Python lint (push) Successful in 4s
CI & Build / Build & push image (push) Successful in 44s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m57s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m51s
Android (Tauri) / Android APK (debug) (push) Successful in 3m37s
Which lens you're looking at is a property of the space, not of a page you navigated to — so the name now sits in the bar that never moves, beside the app name, and stays put while everything beneath it re-filters. It replaces three per-view <h1>s that each sat in a different place with slightly different markup (timeline, reminders, graph) and, more to the point, were absent entirely on the board and in search — the two lenses people spend the most time in had no name at all. A label lens is named by the label itself, because "Groceries" is what the user came looking for and "Label" tells them nothing. Shown at every width rather than hidden on small screens, which was my first cut and would have been a regression: deleting the per-view titles while hiding the shell one leaves a phone with no lens name anywhere, and Android is a peer surface now. Below `sm` the app name is already hidden, so the lens name simply takes the space it vacates — you know which app you're in; what you need is which lens. The h1s on Settings, Sync, Account, Login and Register are untouched: those routes render outside the shell entirely, so they have no chrome to be named by. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
67b9ea2938 |
frontend: one grid for every lens, and a cross-fade between surfaces (task 1913)
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Python tests (push) Successful in 11s
CI & Build / Build & push image (push) Successful in 59s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m33s
Android (Tauri) / Android APK (debug) (push) Successful in 4m24s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m32s
Desktop (Tauri) / Update manifest (push) Successful in 5s
"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, its non-board branch, search, and timeline — so a lens could drift from home by a single edit. One already had: the FLIP reflow from 1914 landed on the board's three grids and left search and timeline popping. NoteGrid is now the only file that knows how the masonry is laid out or how it moves, and search and timeline gained the motion by adopting it. It takes activeId rather than an index. The board splits its notes across two grids, so index-based focus made the call site do offset arithmetic (focusedIndex === pinnedNotes.length + i) against a list the grid didn't own. The lens cross-fade is deliberately UNKEYED, which is the whole trick. Board, archive, trash and label all render the same BoardView; keying the transition on the route would remount it, blanking the board and refetching — exactly the page-change feeling this is meant to remove. Unkeyed, Vue transitions only when the component TYPE changes (board to search to timeline to graph), and moving between the board's own lenses stays an in-place reflow that NoteGrid animates. The two behaviours fall out of one rule rather than needing to be special-cased. Out is quicker than in because mode="out-in" makes the durations additive. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
18a58fb5da |
frontend: the board glides and the editor grows from its card (task 1914)
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Python tests (push) Successful in 10s
CI & Build / Build & push image (push) Successful in 1m0s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m30s
Android (Tauri) / Android APK (debug) (push) Successful in 4m25s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 6m8s
Two of M7's motion targets. prefers-reduced-motion was already in place from the 1999 pass and gates both of these for free. FILTERED REFLOW. The three card grids become TransitionGroups sharing one transition name, so "how the board moves" is defined once in CSS rather than three times in markup. Vue's TransitionGroup does the FLIP itself — measure before, measure after, transition the difference away — so no animation dependency, which the task called for. Leavers are deliberately NOT pulled out of flow with position:absolute, the usual TransitionGroup trick. This masonry is CSS multi-column, and an absolutely positioned child escapes its column to the container's origin: a note would fly diagonally across the board on its way out. Keeping leavers in flow costs a small settle when the element is finally removed, so the leave is the shortest of the three durations. EDITOR CONTINUITY. useNoteEditor.open() is the one place that knows which card was clicked, so that is where the card's on-screen centre is captured; the editor panel then scales from that point. Deliberately not a true shared-element morph: scaling by the real card-to-panel ratio distorts the text on the way, and a card is often a third of the modal, so an honest ratio reads as a zoom rather than a transition. The task sanctioned a good-enough scale/position tween; this is that. A point rather than a rect, because nothing needs the card's size and a point survives the card being filtered away while the editor is open. Consumed on read, so a compose — which has no card — cannot inherit the origin of whatever was edited before it and grow from an arbitrary corner. The animation lives inside NoteEditor rather than in the five views that render it: the leave has to finish BEFORE the host unmounts, so the component owns its own visibility and tells the host when it is done. visible starts true with `appear`, because the panel lives inside that v-if and would not exist to measure otherwise. The origin is measured with offsetLeft/offsetTop rather than getBoundingClientRect — enter-from has already applied scale(0.94) by then, so the bounding rect is of the shrunken panel and the origin would land off by a few pixels. Offsets are layout geometry and ignore transforms. Durations are 140-220ms. The brief is continuity, so a card should read as having moved, not as having performed. NOT verified: motion is a visual property and there is no frontend test lane, no device, and no app run here. vue-tsc proves it compiles. Whether it FEELS right is an operator live pass, which is what M7's own verification section asks for. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
be0eb94225 |
frontend: reorder cards with Pointer Events so touch can do it at all (task 2697)
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 9s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Build & push image (push) Successful in 36s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m17s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m12s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Native HTML5 drag-and-drop never fires from touch — the API predates it and was never wired to it — so on a phone reordering did nothing whatsoever, and the grip that starts it was hover-gated on top of that. Pointer Events cover mouse, touch and stylus on one code path instead of two. The awkward part is hit-testing. Native DnD routed dragover/drop to whatever was under the cursor, so each card learned on its own that it was the target. A captured pointer sends every move to the element that captured it, so the dragged card has to hit-test for itself and publish the result where the other cards can see it — hence the shared refs in useCardDrag. It reads the DOM via elementFromPoint rather than tracking geometry because the board is a CSS masonry: visual order isn't derivable from model order, and cards reflow as the column count changes. Asking the browser what is actually under the finger is the only answer that stays true. Capture is what makes the gesture survive crossing a card boundary; touch-action: none claims it from the browser's scrolling; a 6px threshold keeps a tap from becoming a drag; and pointercancel is handled so a system interruption leaves no half-set state. The parent contract is unchanged apart from `drop` now carrying the target's ID rather than its note — the dragged card finds its target in the DOM, so an id is all it can know without a second lookup. BoardView keeps its own tracking of what was picked up; that it now duplicates the composable's draggingId is real, and noted for the DRY pass rather than expanded into here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
659237ccc6 |
desktop: the empty board explains where your notes live (task 1999)
A fresh desktop install has no login — auth_me returns a synthetic local user so the shared router's guard resolves — but nothing said so. You landed on a bare board with no way to tell whether the app was storing your thoughts on this machine, waiting for a credential, or quietly shipping them somewhere. The start state is the empty board itself, not a welcome modal or an onboarding gate. The product exists to take a thought in under a second; spending that second on a dialog taxes the one thing it is for. It also means there is no "seen it" flag to persist, migrate, or let drift out of step with reality — the message retires itself the moment a first note exists, which is exactly when it stops being true that you have nothing here. Shown only when the app is unlinked: offering to connect a server to someone who already has one is noise. The status read is best-effort and never awaited, so the board renders at full speed regardless; if it fails we keep showing the offline copy, which is the honest reading of "we know of no server". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
2cfe049f9c |
sync: unlinking a device now revokes its token on the server (issue 2110)
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 8s
CI & Build / Build & push image (push) Successful in 40s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m13s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m9s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Unlink was local-only. It cleared the server URL, token and cursor from the device, and left the bearer token valid on the server indefinitely — so someone who unlinked because the laptop was being sold or handed on believed they had revoked access when they hadn't. The blocker was identification, not intent: a token pasted from the web app never carried a device id, and /api/auth/me describes the user, not the device row, so DELETE /devices/<id> could only ever have worked for one of the two ways this app can be linked. DELETE /api/auth/devices/self keys off the token in the Authorization header instead, which the caller always holds — one route that works for both paths, owner-scoped like the rest, and no local schema change. Unlinking is never blocked on the network. Wanting to stop syncing is a local decision, so the revoke is attempted first, its outcome carried back, and the link cleared either way. When the token survives — server unreachable, or older than the route — the Sync screen says so in place, with where to revoke it. A toast would have been the wrong shape for that: it disappears, and this is exactly what someone returns to the screen to check. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
d6734cf7a0 |
desktop: in-app updates, two channels, signed, fed by fixed-tag releases
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 10s
CI & Build / Build & push image (push) Successful in 30s
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 1m59s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m23s
Desktop (Tauri) / Update manifest (push) Has been skipped
There was no in-place update anywhere. The app never checked, downloaded or applied anything, and the only published release predates the whole sync arc — so `install.sh` would hand out a build with no sync in it. Installing from per-run CI artifacts, which is what's been happening, is not something an updater can point at: ephemeral, auth-gated, no stable URL. Two channels, switchable in the app: `stable` follows tagged releases, `dev` follows every green push. The feed is a Fabled-Git release asset, not a ThoughtSync server route. This reverses the lean recorded in task 1998, and the reason matters — a server-hosted feed can only reach a desktop that has linked a server, and local-first-with-no-server is the whole premise. An unlinked install has to be able to update itself. Each channel reads a `latest.json` on a release whose TAG NEVER MOVES. That's forced, not stylistic: Forgejo has no /releases/latest/download/<asset> route (verified — it 404s with no redirect), so "newest" cannot be named in a URL. `dev` carries the rolling bundles; `stable` is a pointer release holding only the manifest, whose URLs aim at the versioned release's assets, so nothing is duplicated. The manifest is written by a third job that runs after both bundle jobs. They build in separate workspaces and neither can see the other's output, but one manifest has to describe both platforms — generating it inside either job would silently omit the other, and a missing platform reads to a user as "no update available" rather than as a broken feed. It reads what actually landed on the release, so it can never advertise a bundle that failed to upload. Signing is gated on the secret existing, in the script rather than an `if:` (the secrets context isn't reliably available to step conditions). No key means no updater artifacts and no publish: a feed the app would refuse to verify is worse than no feed, because it looks like it works. CI stays green until the key lands. On Linux the updater can only replace an AppImage — a deb or pacman install is owned by its package manager and must never be overwritten underneath it. The app detects that case up front and says so, instead of failing halfway through with a permissions error nobody can read. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SreJkbxB4gx8pPsu8QbLPi |
||
|
|
e64d67e904 |
Expire trash after 30 days, and make the deadline something you can see
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 12s
CI & Build / Build & push image (push) Successful in 44s
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 1m45s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m12s
Trash had no end. A note sat in /trash until someone emptied it by hand, and its attachment BYTES sat on disk the whole time — the pile-up the operator asked about. Nothing purged; there was no scheduler at all. Retention is server-owned: `trash_retention_days` (default 30, 0 = keep forever) in the settings registry, so it lands in admin Settings with no migration and takes effect without a restart. A background sweep started in before_serving does the work. Clients learn about a purge the way they learn about any deletion — as a tombstone on the delta feed. An auto-purge nobody can see coming is data loss on a timer, so the window is now visible: /api/config publishes it, notes carry `deleted_at`, Trash leads with the policy, and each card counts down. The countdown rounds DOWN — saying "1 day left" for a note with ten minutes on the clock is the one error here that actually costs someone a note. Three things this turned up on the way: - `DELETE /api/notes/<id>` hard-deleted the row, leaving no tombstone at all. A permanent delete in the web UI never reached a linked device, which would keep its copy forever and push it back on the next edit. It now purges through the same path as everything else. - The purge left `note_revisions` and `note_link_previews` behind. A revision holds the full body, so the text of a "permanently deleted" note was still sitting in the database. - `deleted_at` now SURVIVES a purge instead of being cleared. It's still true, and it means every query that says "not trashed" excludes tombstones for free — without it a content-less row reads as a perfectly normal active note and shows up on the board as a blank card. Desktop keeps its own clock only when there's nobody else to keep one: the sweep runs at startup on an UNLINKED device and refuses otherwise. A linked client that expired notes on its own schedule could destroy something the server was deliberately keeping, then push that delete upstream. Local policy must never outrank the server's — so it also adopts the server's window for the countdown rather than showing its offline default. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SreJkbxB4gx8pPsu8QbLPi |
||
|
|
ed623a7bef |
M10.7d: download attachment bytes into a content-addressed store (task 2107)
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 5s
CI & Build / Python tests (push) Successful in 8s
CI & Build / Build & push image (push) Successful in 35s
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 1m29s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 1m47s
The client half of task 1942's server work. Metadata already rides the delta feed; this fetches the payload so a synced image exists on the device. Blobs are filed under their own sha256, so the same image attached to five notes is stored once and re-downloading it is free — the dedupe the task asks for falls out of content addressing rather than needing bookkeeping. The hash is also the integrity check, applied on the way IN. Bytes that don't hash to what the server advertised are refused rather than filed under a name that lies about them — and because the blob then still counts as missing, the next sync simply tries again. SECURITY: the hash arrives in a server response and becomes a FILENAME, so it is validated as 64 hex characters before touching the filesystem. Without that, a hostile or buggy server could send "../../..." and steer a write outside the blob directory. Tested. A failed attachment never fails the sync. Notes are the primary data and have already landed; aborting here would let one unreachable file block every future sync. Counted, logged, surfaced in the UI as "they'll retry on the next sync", and retried because the blob is still absent. sha2 is pure Rust, so the Windows cross-compile lane pays nothing for it — the constraint recorded in ci-requirements.md. SPLIT, deliberately: this stores the bytes but does NOT yet render them in the webview. That half needs a custom URI scheme or the asset protocol, whose URL form differs by platform (Windows uses http://scheme.localhost/, others scheme://localhost/) — and CI cannot verify webview rendering at all, being headless with no webview. Guessing at it here would ship an unverifiable change on the most fragile lane. Follow-up filed; synced images will show as broken until it lands. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SreJkbxB4gx8pPsu8QbLPi |
||
|
|
fe683595df |
M10.7e: desktop Sync settings screen (task 2108)
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 11s
CI & Build / Build & push image (push) Successful in 33s
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 1m32s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m4s
The surface that turns the engine into a feature (rule 27). Desktop-only — the web build IS a server's UI, so a "connect a server" screen there would be nonsense; the route redirects to the board and the nav entry is hidden. UNLINKED IS THE RESTING STATE, not an incomplete setup. The empty case leads with "Working offline on this device — everything works without a server", because a screen that framed the default as a problem would push people into configuring something they may never need. The app is local-first; this is opt-in. Probe before credentials. "Check" shows who actually answered — site name, version, and the M10.6 verdict — before any password or token is typed. An incompatible server is shown in red and the sign-in fields never appear, so you cannot hand a credential to something that can't use it. `degraded` names the missing capabilities rather than staying quiet and letting a feature mysteriously do nothing. Both credential paths, matching the Rust side: email+password (a fresh install has no session to mint a token from) or a pasted device token (for anyone who'd rather not type a password into a desktop app). Secrets are cleared from component state the moment they're exchanged. Disconnect states plainly that the token stays valid server-side and points at Account -> Linked devices, rather than implying a remote revoke that didn't happen (issue 2110). Wording avoids "revoke" for exactly that reason. Push rejections are surfaced verbatim after a sync, never swallowed — a duplicate label name is the realistic case and only a person can resolve it. Adds schema v3: last_sync_at. The cursor can't answer "am I up to date?" — it's a revision watermark, not a time, and it doesn't move at all when a sync legitimately finds nothing new, so "synced a moment ago, nothing new" would be indistinguishable from "never synced". Stamped only after BOTH halves of the cycle succeed; a stamp after a partial cycle would claim currency the data doesn't have. Cleared on unlink so a new server can't inherit it. run_cycle now returns the post-cycle status, so the UI updates from one round-trip instead of chasing every sync with a status call. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SreJkbxB4gx8pPsu8QbLPi |
||
|
|
20cf15c99c |
desktop M10.3: frontend data-source adapter seam (repo interface + rest.ts)
Extract a typed repository interface (adapters/repo.ts) from the scattered store/view -> api.* calls, backed by adapters/rest.ts (verbatim HTTP mapping) and selected through adapters/index.ts. Every store and the notes-facing views now depend on `repo`, never the HTTP client directly -- the seam the offline local source (M10.5, over Tauri invoke) plugs into next. Behavior-preserving for web: rest.ts maps each semantic method to the exact endpoint the code called before; query-string and multipart building moved out of the stores/views into rest.ts (the one place that knows the URL shape). Client-side logic (reconcile/sort/optimistic reorder/toasts) stays in the stores. GraphView + admin SettingsView keep direct api calls -- out of the offline-core scope (M10.5 is board/editor/capture/search/filter/labels/ checklists/reminders). Task 1992. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm |
||
|
|
877a6a572f |
M10 (task 2013): integrated AppImage — app self-integration (OOBE + Account toggle)
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 9s
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 26s
CI & Build / Build & push image (push) Successful in 33s
The Linux AppImage can now install itself into the applications menu, so it behaves like an installed app instead of a loose file. - Rust (desktop/src-tauri/src/integration.rs): integration_status / integrate_desktop / unintegrate_desktop commands — detect $APPIMAGE, copy the AppImage to ~/Applications, write ~/.local/share/applications/thoughtsync.desktop + embedded icon, update-desktop-database. Registered in lib.rs. - Frontend: withGlobalTauri exposes window.__TAURI__.core.invoke; desktop/bridge.ts (isDesktop + typed invoke, NO @tauri-apps/api dep -> web bundle unaffected); DesktopIntegrationPrompt (first-run OOBE, remembered) mounted in App.vue; AccountView "Desktop app" add/remove control. All desktop-guarded -> no-ops on web. - desktop.yml: upload the .deb + .AppImage as a run artifact (continue-on-error) so the build is downloadable for hand-testing. Verified by CI: ci.yml (vue-tsc) for the frontend, desktop.yml (cargo + tauri build) for the Rust + AppImage. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm |
||
|
|
44a5466793 |
M9 S5 (frontend): shared BaseModal for the standard modals + BaseInput in Account
- BaseModal.vue (new): the backdrop + dialog-panel shell (dimmed fixed overlay, bordered rounded panel, role=dialog, close on Escape + backdrop mousedown). Caller sizes/pads/shadows the panel via `panelClass`, picks start/center `align`, and sets an `ariaLabel` for header-less panels. - LabelsModal, CommandPalette, and the AppShell keyboard-shortcuts overlay drop their hand-rolled backdrop+panel shells and slot their content into BaseModal (~12 lines of overlay boilerplate each → gone). - NoteEditor deliberately keeps its own shell: its backdrop mousedown is drag-guarded and its Esc/⌘-Enter handling is bespoke (unsaved-edit safety), so folding it in would risk regressing the app's core editing surface (rule 28). - AccountView's one device-name field now uses the shared BaseInput. SettingsView is intentionally NOT converted — its rows are a horizontal label+control pattern (checkbox/number/text, direct value mutation), a different shape than BaseInput's vertical form field. Frontend-only; CI vue-tsc is the type/template gate (no local typecheck, rule 10). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm |
||
|
|
9c679d0ab9 |
M9 S5: guard the login ?redirect= against open redirect
LoginView handed route.query.redirect straight to router.replace, so a crafted link like /login?redirect=//evil.com (or a backslash variant) could bounce a just-authenticated user off-site. safeRedirect() now only follows an in-app absolute path — a single leading slash, rejecting "//host" and "/\\host" (and anything without a leading slash, i.e. absolute/scheme URLs) → falls back to "/". Frontend-only; CI vue-tsc is the gate. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm |
||
|
|
e1cf63e875 |
M9 S3 (frontend): consolidate local-date helpers into notes/datetime.ts
The created-date range facets built local-day bounds by hand in two places (FilterBar's onTo/toInput, TimelineView's buildQuery) — parse a "YYYY-MM-DD", shift a day for the half-open upper bound, format back. - datetime.ts: parseLocalDate() / addLocalDays() (non-mutating) / formatLocalDay(). - TimelineView: drops its inline localDate() + the +1-day Date math. - FilterBar: drops its inline isoDay() + the setDate(±1) mutations. Behavior-preserving and deliberately NOT unifying output: Timeline still emits UTC (.toISOString()) bounds, FilterBar still emits naive-local "…T00:00:00" strings — only the shared primitives are extracted. (The naive-vs-UTC divergence is a separate backend-datetime-semantics question, flagged for later, not silently changed.) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm |
||
|
|
590d3ff2f6 |
M9 S3 (frontend): recall views adopt useNoteList + AsyncState/EmptyState
The Search / Timeline / Reminders views each hand-rolled the same items+loading+error scaffold, retry button, and editor-host glue. - useNoteList(fetcher, fallbackError) (new): the load-a-note-list scaffold (items/loading/error + a load() that never leaves a half-state). Views supply just the fetcher; the refs drive <AsyncState>. - SearchView / TimelineView / RemindersView: adopt useNoteList + useNoteEditor + <AsyncState>/<EmptyState>; drop the local list/loading/error refs, the duplicated retry blocks, and the notes.items-shadowing navigate glue. - GraphView: editor host now via useNoteEditor (openNode/closeEditor/onNavigate collapse to navigate); loading/error via <AsyncState>. Its two empty states keep inline markup/buttons, so they stay custom (not forced into EmptyState). - reminders store: fetchReminders() is the single owner of /api/notes/reminders; both the background poll (check) and RemindersView read through it. Frontend-only; CI vue-tsc is the type gate (no local typecheck, rule 10). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm |
||
|
|
18ca4d4db4 |
S1: frontend infra — AsyncState/EmptyState + useNoteEditor, adopted in BoardView
M9 section S1, commit 4 (frontend). Introduce the shared UI primitives the 7 data views + 5 editor hosts were hand-rolling: - components/AsyncState.vue: the loading / error(+Retry) wrapper (emits `retry`). - components/EmptyState.vue: the centered title/subtitle "nothing here" block. - composables/useNoteEditor.ts: the editing/open/close/navigate glue every editor host duplicated, as one controller (onClose hook + local-list resolution for [[wiki-link]] navigation). BoardView adopts all three: its three hand-rolled loading/error/empty blocks collapse into <AsyncState> + <EmptyState>, and its editor glue into useNoteEditor. The other views + editor hosts adopt these in the Organize (S3) and Auth (S5) sections. Behavior-preserving. Frontend has no local typecheck (rule 10); CI's vue-tsc is the gate. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm |
||
|
|
1df70c53bd |
Capture: Enter-to-start + on-board notice; dismiss discards a new note
Live-pass feedback: make the "waiting for input" state visible and starting a note more deliberate, and let dismiss cancel an accidental note. - New notes are now a confirm-to-keep dialog: Esc OR click-away DISCARD a brand-new, not-yet-persisted note (so an accidental keystroke / type-to- compose never litters); Ctrl/Cmd+Enter, the footer button, or Shift+Enter commit it. A compose already persisted by a rich action, and any existing note, still close-and-save on dismiss. The compose footer button is now a filled "Add note" so the save path is unmistakable. - Enter (board, nothing focused) starts a new note; a subtle dashed on-board notice — "Press Enter or start typing to add a note" — makes capture discoverable instead of silent (click it to compose too). Type-to-compose stays as the fast path. - Empty-state copy + shortcuts help updated (Enter/c for a new note). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm |
||
|
|
675b3aa248 |
Capture UX: "+ New" header button + type-to-compose (two-mode board keys)
Live-pass feedback: the wide "Take a note…" bar showed through behind the
compose modal and felt redundant. Per operator choice, remove the bar and
make capture header-button + keyboard driven.
- Removed the board's inline "Take a note…" trigger bar.
- AppShell gains a "+ New" header button (next to search) — navigates to the
board if needed, then opens the compose modal. The `c` shortcut now does
the same (unified with newNote()).
- Type-to-compose: on the board with no card focused, any other single
printable key opens a new note SEEDED with that key (AppShell onKeydown
fall-through, after the reserved / c ? g shortcuts). Seed travels via
ui.composeSeed → NoteEditor's new `initialBody` prop; caret placed at end.
- Two-mode board keyboard (BoardView): RESTING = arrows enter browse, letters
type-to-compose; BROWSING (a card focused) = j/k move, e/x/# act, Enter
opens, Esc exits to resting. ui.boardCardFocused tells the global handler
to stand down while browsing so it doesn't swallow card keys.
- Updated the shortcuts help + the empty-state copy ("Hit + New — or just
start typing").
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
|
||
|
|
f9f1b77d37 |
Unify compose + edit onto one surface (modal-only editor)
Live-pass feedback: compose and edit still felt like different surfaces. They already shared one component (task 1920), but rendered as two frames — an inline in-flow box (compose) vs a modal overlay (edit) — which read as two designs. Per operator choice, make BOTH the modal. NoteEditor is now modal-only (rule 22 — the inline frame is fully removed): dropped the `inline`/`autofocus` props, the collapsed "Take a note" frame, `expanded`, `open()`, `commitInline`, `autoGrow`, and the outside-click commit. Compose vs edit is purely note=null vs a note. Esc / Ctrl+Enter / backdrop / Done all commit-and-close (create in compose, save in edit); Shift+Enter still saves & starts a fresh note in compose (now gated on isCreate, not the frame). Edit-only sections gate on !isCreate. BoardView: the always-expanded inline composer becomes a slim "Take a note…" trigger bar that opens the SAME modal with an empty note; the `c` shortcut does likewise. One surface for capture and editing — and the seam the card→editor grow animation (1914) will hook into. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm |
||
|
|
d1bc91a54a |
M6 1908b: foreground reminder delivery + recurrence/snooze UI (frontend)
Completes 1908 without Web Push. While the app is open, due reminders now actually surface; recurring reminders + snooze/done are manageable. - reminders store (singleton): polls /api/notes/reminders every 45s while the app is open; each due reminder fires ONCE as a toast (with an "Open" action) and, if the user opts in, a page-context OS Notification — no service worker, no PWA. Silently primes a stale backlog on first load; only announces recently-due ones. AppShell starts/stops it. - Editor reminder section: a Repeat picker (Does not repeat / Daily / Weekly / Monthly / Yearly) + Done (advances a recurring reminder / clears a one-off) + Snooze 1h/1d, shown when a reminder is set. - RemindersView rebuilt as a chronological list: per row a due time + recurrence badge + Done / 1h / 1d, click to open; plus an "Enable notifications" opt-in and a note that background alerts come with native. - Note type gains `recurrence`; notes store setRecurrence / completeReminder / snoozeReminder. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm |
||
|
|
5c045aed63 |
M6 1902b: facet bar + saved views UI (frontend)
The dead-simple facet bar over the board + saved views in the sidebar,
completing task 1902. Filter state lives in the URL query, so a filtered
board is a shareable lens and a saved view is just a link ("one space,
many lenses").
- notes/facets.ts: facetsFromQuery / facetsToQuery / facetCount helpers.
- FilterBar.vue (board only): a "Filters (N)" toggle expanding to text
search + color swatches + label chips + has-reminder / has-attachment /
Lists / Notes toggles + a created-date range; Clear + "Save view".
Each control writes the URL query (router.replace).
- notes store: load(view, label, facets) builds the query; NoteFacets type
+ activeFacets; import reload preserves active facets.
- savedFilters store + sidebar "Views" section (each a query-link, delete
on hover); loaded on mount.
- BoardView derives facets from the query, reloads on facet change (ignores
?open=), and shows a "no notes match these filters" empty state.
- Backend: saved-filter param whitelist keys on `label` (matches the
repeatable ?label= query) so saved views keep their labels. New filter
icon.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
|
||
|
|
3c76b50a9c |
Sync 2: device-token bearer auth + linked-devices UI (M8)
Native clients (Tauri/Android) authenticate sync with a long-lived device bearer token, alongside the existing web session cookie. Backend: - security.py: generate_token() (secrets.token_urlsafe) + hash_token() (SHA-256 — device tokens are already high-entropy, so no slow KDF; keeps per-request bearer auth cheap). Only the hash is stored. - device_tokens table (migration 0016): id, user_id, token_hash (unique), name, created_at, last_used_at. - login_required now accepts `Authorization: Bearer <token>` OR the session cookie. Session path stays DB-free (fast); bearer path looks up the token hash, sets g.user_id, and stamps last_used_at. - Endpoints: POST /api/auth/device-login (public; email+password → token, the native first-link flow), POST /api/auth/devices (session/bearer → token, web "link a device"), GET /api/auth/devices (list), DELETE /api/auth/devices/<id> (revoke). All owner-scoped; token shown once. Frontend: - Per-user (not admin) /account view "Linked devices": create a token (one-time reveal + copy), list devices (name, linked/last-synced), revoke with confirm. Top-bar device icon for all users; devices Pinia store. Tests (DB-free): token hash determinism + uniqueness; device endpoints auth-guard (401 without auth, before DB); device-login input validation. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm |
||
|
|
1417479729 |
Graph: label hubs cluster notes; unlinked notes float by default
Add labels as first-class nodes in the graph so tags act as visual clustering hubs, and let unlinked notes float in the space by default — per operator request (the graph is a light auxiliary lens, not a focal surface). Backend (graph.py): - Emit a label-hub node (id "label:<uuid>", kind "label", #name, label color) for every label attached to a live note. - Emit note -> label membership edges (kind "label") alongside the existing wiki-link edges (now kind "link"). Frontend (GraphView.vue): - Render hubs as larger ringed nodes; membership edges dashed with a slightly longer spring rest so notes ring their hub. - Default showAll (unlinked notes float) to true; add a Show labels toggle (default on) that hides hubs + membership edges. - Click a label hub -> that label's board lens (/label/<id>); note clicks still open the editor. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm |
||
|
|
bb33d5c495 |
M7: unify compose + edit into one NoteEditor (DRY)
The 'Take a note' composer (QuickAdd) and the note editor were two components with duplicated field logic and unequal capabilities — composing lacked [[ links, labels, images, reminders. Merge them into a single NoteEditor with two frames: inline on the board (compose), modal on a card (edit). Same fields, styling, [[ autocomplete and toolbar in both (task 1920). Compose is now a draft that persists on first real action: on commit-with-content, or on the first label/image/reminder/checklist use (ensureDraft) — so no empty-note litter. Rich controls light up once there's content; note-lifecycle actions (pin/archive/trash, links/backlinks) stay in the modal. notes.create() now returns the created note (for the draft id). QuickAdd.vue deleted; BoardView renders <NoteEditor inline>. Pure frontend — no backend/DB/migration. Sets up the editor<->card morph (task 1914). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm |
||
|
|
95b0e30fc7 |
M6: browse notes by creation date (Timeline lens)
A temporal recall path — find a note by WHEN it was captured, not just what it contains (task 1903, first of the M6 recall items). Backend: list_notes gains an optional created_at range (created_after / created_before, half-open interval) + sort=created; also lays groundwork for the richer-search facets (task 1902). New _parse_iso_dt helper with a DB-free unit test. Frontend: a Timeline view (sidebar nav + 'g t' + command palette) grouping active notes newest-first into local-time buckets (Today / Yesterday / Earlier this week / this month / Month YYYY), plus an optional From/To date filter. Built as a lens on the same NoteCard masonry, consistent with the existing Reminders/Search views. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm |
||
|
|
5e225075f3 |
m4: drag-to-reorder polish — grip handle + drop affordances
Refine the masonry drag interaction (task 1869) so reordering reads as intentional instead of an accidental, feedbackless jump: - explicit grip handle (top-left, hover-revealed, board views only) gates dragging — a plain click or text-select no longer starts a drag - source card dims while dragging; the card under the pointer shows a brand ring + slight lift, so the drop position is clear before release - proper move cursor via dataTransfer effectAllowed/dropEffect - dragleave uses a relatedTarget guard so the target ring doesn't flicker over child elements - clear drag state on dragend (BoardView clears the tracked source even when a drag is cancelled off-target) - add a "grip" icon to the shared Icon set Native HTML5 DnD stays library-free; it remains a desktop/mouse affordance (touch reorder is the M5 Android client's domain). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm |
||
|
|
b7b37e5e1a |
m4: settings-UI polish (dirty tracking + load-error state)
Save is disabled unless something actually changed (dirty = current values vs. the last loaded/saved snapshot); "Saved." clears the moment you edit again, and a quiet "No unsaved changes" shows at rest. A failed load now renders an explicit error + Retry instead of a blank form. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm |
||
|
|
545f946754 |
m4: per-view error states (board / search / graph / reminders)
Each data view now catches a failed load and shows an explicit error message with a Retry button, instead of falling through to a misleading empty state. Pairs with the global error toast: the toast says something failed, the view says which and offers a retry. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm |
||
|
|
894dd4ac42 |
graph: interactive liveliness (drag/pan/zoom, unlinked toggle, label colors)
Backend /api/graph now returns ALL non-trashed notes as nodes, each with its first label's color; edges unchanged. GraphView reworked: drag a node to reposition it (pins to cursor + reheats the sim), pan the background, wheel- zoom toward the cursor, a "Show unlinked notes" toggle (connected-only by default), a Reset view button, and nodes filled by label color. Click (a press without a drag) still opens the note; re-fetches on view open. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm |
||
|
|
4c72dfea0b |
keyboard: board card navigation (j/k/arrows + actions)
Focused-card selection on the board: j/k or arrows move it, Enter opens, e archives, # pins, x trashes the focused card (actions gated off the Trash view). The selection is a brand ring on the card; it scrolls into view and clamps as the list/view changes. A window keydown listener on BoardView, ignored while typing, on a button/link, or while the editor modal is open. Added the card keys to the ? cheat-sheet. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm |
||
|
|
b24316843e |
keyboard: command palette (Cmd/Ctrl+K)
Fuzzy quick-open overlay: type to jump to any note by title (titles index) or run a command — New note, Go to Board/Graph/Reminders/Archive/Trash, and Open Settings (admins). Arrow keys move the selection, Enter activates, Esc closes; Cmd/Ctrl+K toggles it (works even while typing). Selecting a note navigates to the board with ?open=<id>; BoardView watches that query and opens the editor (fetching the note if it isn't loaded), then clears the param. Added to the ? cheat-sheet. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm |
||
|
|
4bd4ce5a1f |
keyboard: global shortcuts + ? cheat-sheet
App-shell keydown dispatcher (active only inside the authed shell): - c new note (jump to board / reopen composer via a ui-store signal) - / focus the search box - g b/g/r jump to Board / Graph / Reminders (two-key, 800ms window) - ? open a keyboard-shortcuts cheat-sheet overlay - Esc blur a focused field / close the cheat-sheet Ignored while typing in an input/textarea/contenteditable (except Esc). New stores/ui.ts carries the compose signal; QuickAdd exposes open(); the board reopens the composer on the signal when already on the board. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm |
||
|
|
c57982d910 |
M3 reminders: notes.remind_at + Reminders view
- Migration 0010: notes.remind_at (nullable tz). PATCH accepts remind_at (ISO set / null clear); GET /api/notes/reminders (soonest first, non-trashed); serialize includes remind_at. - Frontend: datetime util (local<->ISO, format, overdue); notes store setReminder; editor datetime-local picker + clear; card reminder chip (overdue = red); sidebar Reminders entry + /reminders view. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm |
||
|
|
ad006ccb58 |
M3 graph view: /api/graph + force-directed SVG
- graph blueprint: GET /api/graph resolves note_links to target notes by
normalized title (owner-scoped, non-trashed, self-excluded) → {nodes, edges}
of connected notes.
- GraphView: hand-rolled force simulation (repulsion + edge springs + centering,
cooling over ~400 frames), SVG nodes/edges, click a node to open it (reuses the
editor with link navigation). Sidebar Graph entry + route; empty state.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
|
||
|
|
2d72dcc7cb |
M3 wiki-links: [[links]] + backlinks
- Migration 0009: note_links (source_id, target_norm). Parse [[...]] from body on
create/update and rewrite the source's links. GET /api/notes/titles (owner
{id,title} index for client-side resolution); GET /api/notes/<id>/backlinks.
- Frontend: titles store; LinkedText renders [[Title]] styled on cards; editor
shows Links (outgoing, resolve/create-on-click) + Linked-from (backlinks),
clicking navigates the editor to the target note (board + search).
- notes store: fetchOne, createTitled. DB-free link-parser tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
|
||
|
|
339cc5c2d2 |
M2 drag-reorder: notes.position + reorder API + native DnD
- Migration 0008: notes.position (int). Board orders pinned -> position -> updated_at; new notes created at top (max position + 1). POST /api/notes/reorder assigns positions from the given order (owner-scoped). - notes store: position on Note, position-aware sort, optimistic reorder(). - NoteCard reorderable (native HTML5 draggable + dragstart/drop); BoardView moves the dragged note before the drop target and persists. Note: drag on a CSS-columns masonry has imperfect during-drag visuals (columns reflow); order persists correctly. Candidate for a polish pass / layout tweak. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm |
||
|
|
ffc008bf4d |
M2 search: Postgres FTS backend + top search bar + results
- Migration 0005: generated tsvector column (title A + body B) + GIN index on notes; GET /api/notes/search?q= (websearch_to_tsquery, ts_rank, ACL-scoped, excludes trash), labels merged into results. - Persistent AppShell layout (parent route + <RouterView> children) so the new top search box keeps focus across board/search/label navigation. - SearchView (debounced live search from the shell → /search?q=, results masonry, no-match empty state); BoardView/SearchView render inside the shared shell. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm |
||
|
|
2046600a95 |
M2 labels frontend: sidebar shell + label filter, chips, picker, manage
- AppShell: persistent left sidebar (Notes · labels · Archive · Trash) + top bar (site name, admin Settings, sign out); BoardView now renders inside it. - labels store (list/create/rename/delete); Note gains labels[]; notes store gains setLabels + label-aware reconcile + /api/notes?label= loading. - /label/:id route → label-filtered board. - LabelPicker (tag a note, create-on-the-fly) in the editor; label chips shown on cards and in the editor; LabelsModal to create/rename/delete labels. - api client PUT; new icons (note/tag/pencil/plus/check); nav-link styles. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm |
||
|
|
fdaf5c370c |
QoL: Shift+Enter save-and-continue + autofocus quick-add on landing
- Shift+Enter in the quick-add title/body saves the current note and immediately starts a fresh one, staying focused for rapid capture. Plain Enter is still a newline (body) / title→body (title). Placeholder hints the shortcut. - Landing on the board auto-opens + focuses the note input (QuickAdd autofocus prop, passed from BoardView) so you can start typing on arrival. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm |
||
|
|
68f7b260de |
M1.5 frontend: admin Settings UI + config store + register gating
- config store (public /api/config: site_name, allow_registration, version), loaded in the router guard; site name drives the board header. - session User gains is_admin. - /settings route with requiresAdmin guard + admin-only gear link in the header; SettingsView renders grouped typed fields (text/number/toggle), saves via PATCH /api/settings with saved/error feedback, refreshes public config. - Register link hidden + /register route blocked when registration is closed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm |
||
|
|
0f604f9a26 |
M1: masonry board UI — quick-add, note cards, editor, views
- notes Pinia store (load/create/pin/archive/color/trash/restore/delete) with view-aware reconcile; api client patch/del methods. - colors.ts palette (10 keys → light/dark card + swatch tints). - QuickAdd (collapsed → expand, title/body/color, click-outside/Esc to save), NoteCard (color tint, click-to-edit, hover action bar), NoteEditor modal, ColorPicker, inline Icon set (no icon dep). - BoardView rewrite: Notes/Archive/Trash routes, CSS-columns masonry, Pinned + Others sections, per-view empty states, sign-out. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm |
||
|
|
bf3d403648 |
M0: Vue 3 + TypeScript frontend — auth views + authed board shell
- Vite + Vue 3.5 + Pinia + vue-router + Tailwind (brand accent #F5C518), dark-mode aware, deterministic package-lock.json for `npm ci`. - Session store (fetchMe/login/register/logout) over a credentials:'include' fetch client; router guards (requiresAuth / guestOnly) with lazy /me resolve. - BaseButton + BaseInput primitives (focus rings, loading, error states). - LoginView, RegisterView, and an authed BoardView shell with an empty state for the M1 masonry board — all at v1 polish (rule 24). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm |