Compare commits

173 Commits

Author SHA1 Message Date
Julien Calixte
816c3687d8 fix(auth): clear stale credential error after github re-auth
The 401 flag and cached repo list were module-level and only reset
after a 20-min stale window, so re-authenticating left the
"credentials are invalid or expired" message pinned on. Watch the
access token: reset state and refetch on change. Also await
saveCredentials before redirecting so refs are settled.
2026-05-14 13:04:44 +02:00
Julien Calixte
f2f2a3114b style: no more underline for header tag 2026-05-14 01:22:46 +02:00
Julien Calixte
2f71566083 style(modal): keep conflict modal actions stacked on all sizes 2026-05-14 01:10:40 +02:00
Julien Calixte
80ae544a28 style(notes): drop hover cursor on rotated stacked-note header 2026-05-14 01:10:38 +02:00
Julien Calixte
bfd981de13 fix(ci): restore allowBuilds map in pnpm-workspace.yaml
pnpm 11.x reads the per-package allowBuilds boolean map, not the
pnpm 10 onlyBuiltDependencies arrays. The array form is silently
ignored, so every build script falls through as 'ignored' and
strict CI mode fails. Confirmed by reproducing locally and by
inspecting what 'pnpm approve-builds --all' writes back.
2026-05-13 18:54:38 +02:00
Julien Calixte
453332513a fix(docker): copy pnpm-workspace.yaml into deps stage
The build-allow config lives in pnpm-workspace.yaml, but the deps
stage only copied package.json and pnpm-lock.yaml — so the
container saw no allowlist and pnpm install failed on ignored
build scripts.
2026-05-13 18:49:23 +02:00
Julien Calixte
abc0113c8e chore(docker): defer pnpm version to packageManager field
Drop the explicit pnpm@latest prepare step and let corepack pick
up the pinned version from package.json on first invocation, so
the Docker build can't drift away from the local toolchain.
2026-05-13 18:48:09 +02:00
Julien Calixte
52deb5feb4 fix(ci): use portable pnpm build-allow config
The allowBuilds map syntax only works in pnpm 11.x, but the
Dockerfile resolves pnpm@latest to a 10.x that doesn't recognize
it, so install fails on unapproved build scripts. Switch to the
onlyBuiltDependencies/ignoredBuiltDependencies arrays and pin
packageManager so CI and local stay in sync.
2026-05-13 18:46:06 +02:00
Julien Calixte
9e07204430 design: change light theme to light 2026-05-13 18:38:54 +02:00
Julien Calixte
cd60429145 chore: pnpm to latest version 2026-05-09 15:00:43 +02:00
Julien Calixte
aad07184fd fix(freshness): surface silent failures when pulling latest
queryFileContent threw on octokit errors (stale SHA 404, expired token,
network blip) and the rejection bubbled up unhandled through pullLatest
and onBadgeClick, leaving the badge stuck on "Outdated" with no log or
toast. Wrap the octokit call, log on failure, clear the cached SHA so
the next click re-resolves it, and show an error toast.

Also fix a dead `if (!user || !repo) { null }` that did nothing.
2026-05-06 22:02:50 +02:00
Julien Calixte
76829afba2 design: change light theme to caramellatte 2026-05-06 20:37:55 +02:00
Julien Calixte
05f59a568d design: change light theme to lemonade 2026-05-06 20:31:57 +02:00
Julien Calixte
559bfccd08 design: change dark theme to dracula 2026-05-06 20:26:01 +02:00
Julien Calixte
f8ae4351d6 design: change light theme to cupcake 2026-05-06 20:25:13 +02:00
Julien Calixte
30f200df30 fix(flux-note): stop showing sign-in prompt while readme is loading
Cache miss wrote null into store.readme before getMainReadme finished,
collapsing isLoading and surfacing the not-accessible UI mid-fetch.
Also branch that UI on auth state so signed-in users aren't told to
sign in when access fails.
2026-05-06 09:54:25 +02:00
Julien Calixte
58568e2245 fix(pwa): use alpha mask for monochrome icon
Per W3C spec, purpose: "monochrome" icons use only the alpha channel
as the silhouette; RGB is ignored and replaced with the platform
theme color. The previous monochrome-icon.png was a black-on-white
RGB image with no alpha, so Safari (macOS PWAs) and Chrome (Android
themed icons) treated every pixel as opaque and painted the whole
1024x1024 canvas with theme_color (#ffa4c0) - a solid pink tile.

Regenerate as RGBA with the silhouette in alpha (derived from the
favicon's alpha channel via a sharp-based helper script). Rename to
monochromeicon.png to bust Safari's stuck PWA icon cache from prior
broken installs.
2026-05-05 17:40:40 +02:00
Julien Calixte
fd7d06ce69 design: change light theme to retro 2026-05-05 16:11:45 +02:00
Julien Calixte
5a9c0a3704 lint 2026-05-04 23:54:26 +02:00
Julien Calixte
e425be5c96 refactor(freshness): drop time-based stale-known status
The 2-minute timer + tick ref decayed verified to stale-known and rendered
a clock icon, but the user can always click the badge to re-check. Removing
the timer simplifies the hook and the badge has one fewer visual state.
2026-05-04 23:53:48 +02:00
Julien Calixte
84803c45dd refactor(scroll): clean up debug overlay and pass anchor by param
Removes the temporary on-screen scroll diagnosis panel and the global
window.__scrollAtClick stash. The anchor scrollTop is now captured
synchronously at addStackedNote entry and threaded through
scrollToFocusedNote and scrollToNoteElement to scrollToElement, so no
state survives across calls — nothing to reset on repo or page change.
2026-05-04 23:02:12 +02:00
Julien Calixte
a526a9f6af fix(scroll): snap to click-time scrollTop before smooth scroll
Capture mainApp.scrollTop synchronously when addStackedNote runs and
snap the scroll back to that value before scrollIntoView fires, so
the smooth scroll begins from where the user actually tapped rather
than from a position drifted by momentum or async work.
2026-05-04 19:57:00 +02:00
Julien Calixte
08e01d8484 revert: restore mobile body scroll for pull-to-reload
Reverts 550b3cf — removing the override broke pull-to-reload, and
single-scroll-container did not fix the offset glitch anyway.
2026-05-04 19:04:46 +02:00
Julien Calixte
c88340d5f1 chore(debug): add temporary scroll overlay for mobile diagnosis 2026-05-04 19:02:35 +02:00
Julien Calixte
550b3cf019 fix(layout): remove mobile body scroll to keep one scroll container
Both html/body and #main-app being scrollable on mobile made
scrollIntoView animate two ancestors at once, shifting the start
frame of the smooth scroll. With body locked, #main-app is the only
scroller and the animation matches the user's actual position.
2026-05-04 18:58:04 +02:00
Julien Calixte
2f05b93f51 fix(stacked-note): size mobile notes with svh to stabilize scroll target
Dynamic viewport units rescale every note when the mobile address bar
grows or shrinks, shifting the scroll target by the address-bar height
mid-flight. Small viewport units stay constant across address-bar
transitions so the smooth scroll lands where it was aimed.
2026-05-04 18:45:45 +02:00
Julien Calixte
cc266eac7c refactor(scroll): delegate note scroll to scrollIntoView
Native scrollIntoView reads the element position at scroll time and
picks the right scrollable ancestor itself, sidestepping iOS Safari
quirks with scrollTo on overflow containers and visual-viewport shifts.
2026-05-04 18:29:05 +02:00
Julien Calixte
be006f08b4 fix(stacked-note): align mobile scroll target to element rect
Replace the (index + 1) * clientHeight math and 80ms setTimeout with a
scrollToElement helper that reads getBoundingClientRect inside rAF, so
the smooth scroll starts from the user's actual position even when the
note is freshly mounted.
2026-05-04 18:15:10 +02:00
Julien Calixte
55ee3bddeb fix(router): skip view transition on query-only navigation
The root fade overlapped smooth scrolls triggered when stackedNotes
mutated, making the scroll appear to start from the snapshot's frame
instead of the user's actual position.
2026-05-04 18:15:04 +02:00
Julien Calixte
1f324208d2 design(stacked-notes): action buttons in vertical bar 2026-05-04 10:54:50 +02:00
Julien Calixte
002cf9a4b1 fix(stacked-note): act on outdated badge clicks
Clicking the badge while it shows outdated now pulls the latest version
from GitHub when there are no unsaved edits, or opens the conflict
modal when edits are in flight. Previously the click only re-ran the
same freshness check, so the badge appeared dead.
2026-05-03 23:37:28 +02:00
Julien Calixte
efe9c01e63 chore(github-content): pin api version on fetchLatestSha request
Silences the @octokit/request deprecation warning that prints whenever
the unversioned /repos/{owner}/{repo}/contents/{path} call fires.
2026-05-03 23:37:24 +02:00
Julien Calixte
d31c774ace feat(stacked-note): surface note freshness and guard saves on conflict
Adds a Tabler-icon badge in the stacked-note action bar showing whether
the loaded copy still matches GitHub HEAD (verified / outdated / offline
/ checking / unknown / stale-known). The save flow now re-checks before
the PUT and opens a conflict modal when GitHub has moved on, with three
explicit choices: discard local edits and pull, overwrite anyway, or
cancel. Race-condition 409s from the PUT itself are routed through the
same modal.
2026-05-03 23:32:54 +02:00
Julien Calixte
d8a59467a0 refactor(github-content): expose conflict info and add latest-sha lookup
updateFile/createFile now return { sha, conflict } so 409/422 from GitHub
can drive a UI flow instead of being swallowed as a generic save error.
Also adds fetchLatestSha(path) for cheap freshness checks against HEAD.
2026-05-03 23:32:37 +02:00
Julien Calixte
dffee40776 2026-05-02 22:48:10 2026-05-02 22:48:10 +02:00
Julien Calixte
4328411d88 chore(zed): disable deno LSP for TS/JS in this project 2026-05-02 22:43:49 +02:00
Julien Calixte
3339e28d41 style(notes): distinguish scrollable column from canvas
Tint the surrounding viewport and add a soft right-edge shadow on
the leftmost note so users can see where scrolling actually applies.
2026-05-02 09:51:24 +02:00
Julien Calixte
c8e5fd26a0 chore: drop disabled husky pre-push hook and dependency
The .husky/_pre-push script was renamed from pre-push, which
disables it under husky v9. With no remaining active hooks, husky
is dead weight, so remove the dependency and prepare script too.
2026-05-02 09:26:52 +02:00
Julien Calixte
f562ca48b1 docs(welcome): link footer atproto entry to atproto.com 2026-05-02 09:16:14 +02:00
Julien Calixte
7c40feeae0 style(welcome): drop network card hover left border 2026-05-02 09:08:09 +02:00
Julien Calixte
4d7b7d01f6 fix(welcome): keep network strip from widening the hero grid
Grid items default to min-width: auto, so the 5×220px scroll strip
forced the 1.2fr column to its intrinsic width and pushed the right
column out. min-width: 0 lets the track shrink and overflow-x scroll.
2026-05-02 08:31:04 +02:00
Julien Calixte
c78ce38845 docs(welcome): rephrase public-notes label to fit pool metaphor 2026-05-01 23:54:24 +02:00
Julien Calixte
b572380c37 feat(welcome): show live public-notes preview inline
Replaces the static "From the open network" CTA and sidebar button with a
horizontal strip and compact list of recent public notes fetched from the
public api.remanso.space/notes endpoint, so visitors can taste the network
before clicking through. Includes shimmer skeletons and a quiet fallback
when the endpoint is unreachable.
2026-05-01 23:27:55 +02:00
Julien Calixte
43c5e65077 docs(welcome): rephrase zettelkasten closing line for clarity 2026-05-01 12:40:56 +02:00
Julien Calixte
7b5af57941 fix(welcome): make zettelkasten link example read grammatically 2026-05-01 12:18:17 +02:00
Julien Calixte
abda5264a8 feat(welcome): use tabler svg icons for feature row 2026-05-01 12:16:36 +02:00
Julien Calixte
e715fb02d3 fix(markdown): cache Shikiji init promise to avoid race on parallel callers
The boolean guard flipped synchronously before the async plugin load
resolved, so concurrent callers (e.g. multiple stacked non-markdown
notes mounting on reload) returned early and rendered before
markdown-it-shikiji was attached to the shared md instance. Cache the
in-flight promise instead so all callers await the same resolution.
2026-04-30 11:03:29 +02:00
Julien Calixte
4c7c688688 2026-04-30 10:34:00 2026-04-30 10:34:00 +02:00
Julien Calixte
7b4c7947aa fix: remove bottom padding 2026-04-29 22:06:58 +02:00
Julien Calixte
68022971cd refactor(notes): restore fixed mobile heights for scroll math
Re-pin .note and .stacked-note to 100dvh on mobile and bring back the
container height in useResizeContainer so (index + 1) * height has a
reachable scroll target. Switch the polled scroll helper to that same
formula instead of offsetTop.
2026-04-29 11:32:23 +02:00
Julien Calixte
f529832eee refactor(notes): scope stacked-note sticky to desktop
Move position: sticky from the global .note rule into the desktop
@media block of the scoped stacked-note components, so mobile no longer
inherits sticky positioning (and no top is set there).
2026-04-29 11:32:13 +02:00
Julien Calixte
3e9418285f refactor(notes): let mobile notes size to content 2026-04-29 11:09:31 +02:00
Julien Calixte
17f015b686 fix(notes): wait for stacked note element before scrolling on mobile
A single nextTick is not enough for a freshly added stacked note to be
in the DOM, so the mobile scroll target was computed against a null
element. Poll with requestAnimationFrame (mirroring scrollToHashInNote)
and use offsetTop, with an (index + 1) * height fallback.
2026-04-29 10:52:07 +02:00
Julien Calixte
adb1bd5945 fix: fix height on mobile 2026-04-29 10:34:46 +02:00
Julien Calixte
86866e7d77 feat(welcome): wire demo note links with stack reveal and flash 2026-04-27 23:10:29 +02:00
Julien Calixte
cf5567de7c refactor(notes): use options object for renderCodeFile params 2026-04-27 20:36:46 +02:00
Julien Calixte
9d6f70546e feat(notes): render code files with Shikiji syntax highlighting
Non-markdown files opened as stacked notes are now highlighted using
the existing markdown-it-shikiji pipeline (4-backtick fence wrapping)
with a h1 filename heading. Edit controls are hidden for code files.
Adds alloy language grammar and a fileLanguage utility mapping
extensions to Shikiji language IDs.
2026-04-27 19:57:15 +02:00
Julien Calixte
812f393283 design: reduce padding for pre in tabs 2026-04-27 18:22:48 +02:00
Julien Calixte
37b39a6d96 design: box for tabs instead 2026-04-27 15:34:12 +02:00
Julien Calixte
df8bda0130 feat(markdown): render tabs as DaisyUI radio input pattern
Use @mdit/plugin-tab custom renderers to emit DaisyUI tabs-lift
structure (radio inputs + tab-content divs) instead of unstyled
default output. CSS-driven, no JS required.
2026-04-27 15:28:10 +02:00
Julien Calixte
74491a45a9 fix(repoList): prevent duplicate entries from concurrent loadMore calls
Add isLoading guard so concurrent fetches are rejected, and include
isLoading in canLoadMore so vInfiniteScroll waits before firing again.
2026-04-27 10:33:31 +02:00
Julien Calixte
da4fada8a1 fix(repoList): handle Bad credentials error from GitHub API
Catch 401 responses in useRepos loadMore and expose hasCredentialError,
then show a sign-in prompt in RepoList instead of an unhandled rejection.
2026-04-27 10:32:37 +02:00
Julien Calixte
df3e217d01 fix(userRepo): unwrap reactive proxies before postMessage to worker
Vue reactive Proxies cannot be serialized by the Structured Clone
Algorithm used by postMessage/Comlink. Use toRaw() on this.files and
this.userSettings before passing them to data.update() to avoid the
DataCloneError.
2026-04-27 10:22:20 +02:00
Julien Calixte
d50adc72e9 refactor(downloadFont): handle generic families and multi-family strings
Strips generic CSS families (serif, monospace, etc.) before building
the font API URL, and correctly parses comma-separated font stacks.
2026-04-27 10:12:12 +02:00
Julien Calixte
78de5e280f feat: show GitHub sign-in when repo is not accessible
Adds a message + sign-in button in FluxNote when the readme resolves
to null (private/unauthorized repo), and on the SpaceCowboy 404 page.
2026-04-27 10:12:09 +02:00
Julien Calixte
28ca9a17a9 fix(FluxNote): stop skeleton showing when repo is inaccessible
The skeleton was conditioned on `isLoading || !hasContent`, so it
persisted forever when readme resolved to null (e.g. private repo
visited while logged out). Skeleton now only shows while loading.
2026-04-27 10:07:08 +02:00
Julien Calixte
836b480ea6 fix(navigation): resolve clicked anchor when target is a nested element
A click on a child of an <a> (e.g. nested <strong>, <em>, <code>, icon)
made event.target a non-anchor, so getAttribute('href') returned null
and the handler bailed without preventDefault. The browser then
performed the native navigation, which for relative links like
'../note.md' resolved against the current /:user/:repo URL and the SPA
re-routed treating the destination as a new repo.
2026-04-26 13:58:48 +02:00
Julien Calixte
9f75e7971d fix(layout): cache pageWidth in localStorage to avoid render glitch
The page width from .remanso.json was only applied after an async
PouchDB + network fetch, so notes briefly rendered at the default
500px before snapping to the configured value. Persist pageWidth
alongside the existing font cache (key renamed to remanso:layout:*),
so it is read synchronously during setUserRepo and applied before
the first render. Also always reset --note-width with a default
fallback to prevent stale values leaking across repo navigation.
2026-04-26 13:44:10 +02:00
Julien Calixte
181ffd1e5c feat(navigation): smooth scroll for in-note anchor links
Pure-fragment links (#heading) used to fall through to the browser's
default jump. Handle them in the click listener and scope the lookup
to the same stacked note so identical heading ids in other notes
don't win, with smooth scroll behavior to match cross-note anchors
into already-stacked notes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 09:59:12 +02:00
Julien Calixte
c00065ce4a refactor(navigation): scrollToFocusedNote takes an options object
Smooth-scroll for the anchor jump when the target note is already
stacked, instant otherwise. While threading the new flag, the four
positional params got hard to read, so collapse them into
{ noteId, notes, hash, smoothHash } and update all call sites.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 09:56:32 +02:00
Julien Calixte
4ce8c30649 fix(navigation): support anchor fragments in note links
Links like `path/to/note.md#heading` previously errored with "Note not
found" because the full href (including `#hash`) was matched against
file paths. Split the fragment off in the link handler, plumb it through
the event bus, and scroll the matching heading into view once the
target note is in place. Headings now get GitHub-style ids via
markdown-it-anchor + github-slugger so the anchors actually exist.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 09:40:30 +02:00
Julien Calixte
d098b3b404 fix: no more clip overflow y 2026-04-25 00:02:12 +02:00
Julien Calixte
e03ff49764 fix(mobile): restore overflow-y and unstick readme on vertical scroll
- Restore explicit overflow-y:auto on #main-app for mobile (removed in
  63f5d64) — implicit coercion from overflow-x:auto is not reliable in
  all Safari/WebKit versions.
- Override position:sticky on .readme to position:relative on mobile.
  The desktop sticky (left:0) is correct for horizontal scroll, but on
  mobile vertical scroll it pinned the 100dvh-tall readme across the
  entire viewport, hiding all stacked notes behind it.
2026-04-24 23:42:22 +02:00
Julien Calixte
19495ddf0c feat(scroll): use smooth scrollTo instead of direct property assignment 2026-04-23 18:07:44 +02:00
Julien Calixte
63f5d644eb fix: remove overflow y 2026-04-23 18:03:51 +02:00
Julien Calixte
63bc3f4d5d fix(mobile): scroll #main-app instead of body on mobile
body/html have overflow:hidden so scrollTop is a no-op on them.
#main-app is the actual scroll container; use overflow-y:auto on
mobile and target it directly in scrollToNote and the scroll listener.
2026-04-23 18:01:30 +02:00
Julien Calixte
ded770aff1 fix(mobile): restore body scroll and prevent spurious section scroll
Three layered fixes for mobile note scrolling:

1. app.css / App.vue: on mobile, override overflow:hidden on html/body
   and overflow:visible on #main-app so content from useResizeContainer
   (which sets the note-container height to (n+1)*100vh) propagates to
   the document and document.body.scrollTop works again.

2. FluxNote.vue: give each .note an explicit height:100dvh on mobile so
   the percentage-based height:100% does not resolve against the
   inflated container height set by useResizeContainer.

3. StackedNote / StackedPublicNote: replace overflow-y:hidden with
   overflow-y:clip on the section. Unlike hidden, clip does not create a
   scroll container, so touch events fall through to the page scroll and
   the section never feels "draggable" when content fits within the note.
2026-04-23 17:58:33 +02:00
Julien Calixte
d12d7b660b Revert "fix(layout): make stacked notes stick horizontally when scrolling"
This reverts commit 86c9feaf55.
2026-04-22 23:59:36 +02:00
Julien Calixte
86c9feaf55 fix(layout): make stacked notes stick horizontally when scrolling
Add left offset to each stacked note so position: sticky activates
during horizontal scroll, pinning notes progressively to the right
of the readme column at calc((index + 1) * var(--note-width)).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 23:56:43 +02:00
Julien Calixte
449a16f791 fix(layout): prevent document-level scroll-y when stacked notes overflow
Contain horizontal overflow within #main-app instead of leaking to the
document, which caused a horizontal scrollbar to consume viewport height
and trigger an unwanted vertical scrollbar. Also fix note pane height
to use 100% instead of 100vh, and switch useResizeContainer to minWidth
so the flex container can grow when the window is wider than the notes.
Add a window resize listener to keep the value accurate on resize.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 23:52:03 +02:00
Julien Calixte
ee8bbd4a37 feat(config): add pageWidth setting to .remanso.json
Allows repo owners to configure note column width via `"pageWidth": "700px"` in .remanso.json. Applies the value to the --note-width CSS variable and invalidates the cached width so resize/overlay hooks pick it up.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 23:34:26 +02:00
Julien Calixte
1f272bc3e2 fix: no more after for repos 2026-04-22 23:01:40 +02:00
Julien Calixte
29c22a9b0f copy(home): reframe manifesto sentence without negative framing 2026-04-20 18:23:47 +02:00
Julien Calixte
5c76170645 style(home): adjust CTA card width from w-2xl to w-xl 2026-04-20 18:19:42 +02:00
Julien Calixte
ceb800b6ac refactor(home): replace gh-form pill with DaisyUI input + joined button
Wrap inputs in <label class="input"> (DaisyUI v5 compound input pattern).
Form uses flex fill so inputs auto-size and the button stays on the right
on a single line regardless of container width.
2026-04-20 18:15:12 +02:00
Julien Calixte
f809a1f5f8 style(home): enlarge GitHub repo and open network CTA cards 2026-04-20 18:00:42 +02:00
Julien Calixte
5cda110a98 style(home): sharpen hero headline and lede copy
"settles into a pool" → "comes to rest" (clearer double meaning)
"margin enough to think" → "where your thinking finally runs clear"
2026-04-20 17:58:45 +02:00
Julien Calixte
ce690b6767 fix(home): remove z-index from footer/main so PWA toast is not obscured 2026-04-20 17:17:24 +02:00
Julien Calixte
73253c9ad2 style(home): replace "Link, don't file" with "Link, don't nest" 2026-04-20 16:44:17 +02:00
Julien Calixte
369d730f70 feat: highlight on link clicked 2026-04-20 16:41:09 +02:00
Julien Calixte
668f73b546 feat(home): highlight linked note card on "durable enough" click 2026-04-20 16:38:00 +02:00
Julien Calixte
b1be42b5bf feat(home): redesign homepage with editorial and launchpad layouts
Replace the minimal centered layout with a full literary/academic
homepage: logged-out users see an editorial hero, manifesto, demo
notes, and ZK primer; logged-in users see a personal launchpad
(greeting, repo tiles, last visited, review queue) followed by the
same editorial content below.

Uses DaisyUI CSS variables throughout (color-mix) so it adapts to
any theme change without hardcoded overrides.
2026-04-20 14:32:48 +02:00
Julien Calixte
70b679b204 Merge branch 'main' of ssh://git.apoena.dev:22222/remanso-space/remanso 2026-04-20 11:10:48 +02:00
Julien Calixte
36dc1293f9 docs: fix ATProto session storage split between SecureStore and SQLite 2026-04-20 10:56:03 +02:00
Julien Calixte
801b7cb94a docs: add React Native migration design spec 2026-04-20 10:55:44 +02:00
Julien Calixte
1fa66d8594 fix: prevent spurious y-scrollbar when section has overflow-x: auto on mobile
Setting overflow-x: auto forces overflow-y off 'visible' per CSS spec,
which caused an unwanted vertical scrollbar in stacked note sections.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 14:28:25 +02:00
Julien Calixte
b827f31cf0 fix: current color for svg in buttons 2026-04-19 10:49:37 +02:00
Julien Calixte
cf02569c75 design: change light theme to emerald 2026-04-19 10:39:49 +02:00
Julien Calixte
0a4f8dbf41 fix: make BackButton and LinkedNotes keyboard accessible
Replace <a> (no href) with <button> so both elements receive tab focus.
BackButton gets text-base-content to preserve icon color; LinkedNotes
uses btn class="link" to keep the inline text-link appearance.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 01:08:34 +02:00
Julien Calixte
b6f6759af5 fix: restore icon color on button elements in header
<button> gets color:ButtonText from the browser UA stylesheet, making
SVG stroke="currentColor" render black. Add text-base-content to
inherit the DaisyUI theme color like the <a>-based router-links do.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 01:07:25 +02:00
Julien Calixte
c42c26a407 fix: restore icon color on FontChange trigger button
<button> defaults to color: ButtonText (black) in browsers, unlike <a>
which inherits. Adding color: inherit restores the theme color for the
SVG stroke (which uses currentColor).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 01:06:25 +02:00
Julien Calixte
cfe5ef8fcd fix: make HomeButton keyboard accessible
Replace <a> with <button> so the home logo receives tab focus.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 01:04:50 +02:00
Julien Calixte
4c5116bc89 fix: make FontChange modal trigger keyboard accessible
Replace <a> with <button> for the typography icon in HeaderNote so it
receives tab focus — <a> without href is excluded from the tab order.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 01:02:12 +02:00
Julien Calixte
8581baafb7 design: change dark theme to forest 2026-04-08 19:07:03 +02:00
Julien Calixte
29c092e0a0 design: change dark theme to abyss 2026-04-08 19:04:28 +02:00
Julien Calixte
410c0cec7c design: change dark theme to sunset 2026-04-08 19:03:07 +02:00
Julien Calixte
66a1bcbaa9 design: change dark theme to black 2026-04-08 19:01:48 +02:00
Julien Calixte
541e058d12 fix: restore dark theme and fix theme script regex
Dark theme was set to "dim" in theme.config.ts while app.css registered
"sunset" as the prefersdark theme. The script's regex required a trailing
comma that didn't exist on the last property, causing silent failures.
2026-04-08 19:01:29 +02:00
Julien Calixte
a05ff9f238 design: change dark theme to sunset 2026-04-08 18:57:36 +02:00
Julien Calixte
6558de8df5 design: change dark theme to black 2026-04-08 18:51:30 +02:00
Julien Calixte
b48c1bd0d5 prune: remove obsolete agent 2026-04-06 23:41:19 +02:00
Julien Calixte
e369541dc0 refactor: scope PouchDB writes to repo config, not user font prefs
chosen* fields are per-browser preferences — localStorage is the correct
and sufficient store for them. Removing data.update from font setters and
stripping chosen* from the GitHub fetch PouchDB write prevents stale PouchDB
data from conflicting with localStorage on reload.
2026-04-06 23:26:50 +02:00
Julien Calixte
73a6014750 fix: persist font selections across navigation and page reloads
- Use v-model with writable computeds instead of :value+@change so selects
  re-sync when the options list changes asynchronously
- Always include currently chosen fonts in sortedFontFamilies so a selected
  font not present in .remanso.json fontFamilies still shows in the select
- Initialize userSettings instead of returning early in font setters so
  changes made before async GitHub fetch completes are not silently dropped
- Back font choices with localStorage so they survive hard reloads even when
  PouchDB/IndexedDB fails silently in the web worker
2026-04-06 18:51:27 +02:00
Julien Calixte
c197b80095 feat: smaller modal 2026-04-06 17:44:43 +02:00
Julien Calixte
f3e74aed34 fix: resolve all TypeScript type errors
- Install missing comlink (was in lockfile but not node_modules)
- Add @ts-rest/core and @ts-rest/vue-query (imported but not declared as deps)
- Add declare module '*.vue' shim to shims-vue.d.ts
- Replace arktype validators in ts-rest contract with contract.type<T>() since @ts-rest expects Zod schemas
2026-04-06 15:05:57 +02:00
Julien Calixte
8d9134a062 perf: cache repo list with 20-minute stale time
Hoist useRepos state to module scope so all callers share one instance, and skip re-fetching until data is older than 20 minutes.
2026-04-06 14:59:12 +02:00
Julien Calixte
006cd63388 feat: paginate repo list with infinite scroll
Load 30 repos at a time instead of 100 at once, showing data sooner.
Adds v-infinite-scroll to RepoList.vue to fetch subsequent pages on scroll.
2026-04-05 11:56:36 +02:00
Julien Calixte
3de9eb35f6 feat: show font family selectors with default fonts when no .remanso.json 2026-04-05 10:49:01 +02:00
Julien Calixte
99c349f6df fix: preserve font settings when repo has no .remanso.json
When no config file exists, userSettings was set to null which destroyed
cached user preferences and silently blocked all setFont* actions.
2026-04-04 14:39:34 +02:00
Julien Calixte
64b29bcdef fix: remove favicon.png from PWA manifest icons to fix dock icon on macOS 2026-04-04 14:22:14 +02:00
Julien Calixte
9e26e231cb fix: show theme and font size controls before font families load
Move the v-if guard from the outer FontChange wrapper to only the font-family
selects, so ThemeSwap and the font-size select are always visible in the modal
even before userSettings.fontFamilies resolves asynchronously.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 11:35:05 +02:00
Julien Calixte
b003a3e008 perf: move PouchDB/IndexedDB operations to a Web Worker
All database reads and writes now run off the main thread via a
dedicated worker, eliminating IndexedDB overhead from the frame budget.

- Create data.worker.ts exposing the Data class via Comlink
- Refactor data.ts to export a Comlink-wrapped proxy and a standalone
  generateId() pure function (workers can't expose sync methods cleanly)
- Update all 10 call sites to import generateId directly instead of
  calling data.generateId()

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 11:27:45 +02:00
Julien Calixte
1b5e23e3d4 fix: keep font settings visible during repo navigation
- resetFiles() no longer clears userSettings so FontChange stays visible
  while navigating between repos (old fonts show until new ones load)
- Add _requestId counter to setUserRepo() to discard stale async callbacks
  from previous navigations, preventing state corruption on quick nav
- Load savedRepo and userSettings caches in parallel with Promise.all,
  reducing yield points so cache hits apply before first render

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 11:21:56 +02:00
Julien Calixte
52d7c84bd0 perf: prevent FPS drops during navigation in FluxNoteView
- Narrow backlinks watcher from entire store to store.files only,
  reducing trigger count from ~8 to 2 per navigation
- Defer computation start by 300ms so it runs after the 250ms view
  transition animation completes
- Yield to the browser between each file iteration using
  scheduler.yield() (with setTimeout fallback) to avoid blocking frames

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 11:14:02 +02:00
Julien Calixte
d76182b2c2 Merge branch 'main' of ssh://git.apoena.dev:22222/remanso-space/remanso 2026-04-03 15:02:46 +02:00
Julien Calixte
ed1a6b7fba fix: add the right margin to the right components 2026-03-29 22:09:01 +02:00
Julien Calixte
d5b251c4a0 fix: remove overflow because it's causing too much trouble 2026-03-29 22:00:22 +02:00
Julien Calixte
19b77810ec chore: remove healthcheck in docker to be faster 2026-03-29 21:55:32 +02:00
Julien Calixte
c8b0a78973 fix: add nginx SPA fallback to serve index.html for all routes
Prevents 404 errors when navigating directly to client-side routes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 21:50:50 +02:00
Julien Calixte
087d1a355e revert: remove justify-content center from welcome content
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 22:37:19 +01:00
Julien Calixte
5d90da8ab5 feat: center welcome content vertically
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 22:36:40 +01:00
Julien Calixte
72d065975d fix: lock html/body to 100dvh overflow hidden on all screen sizes
All views that need scroll use their own overflow-y: auto containers.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 22:35:11 +01:00
Julien Calixte
8b3df48791 fix: clip app at 100dvh to prevent body scroll on mobile
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 22:29:50 +01:00
Julien Calixte
cd8e173e05 fix: use 100dvh for body and #app to match dynamic viewport
Prevents white space below the app on Android Chrome where the
system nav bar makes 100vh > 100dvh.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 22:25:19 +01:00
Julien Calixte
8767f7c430 fix: give .home explicit height so flex children resolve correctly
On Chrome Android, cross-axis stretch doesn't always produce a
definite height for inner flex items. Adding height: 100dvh to
.home ensures flex: 1 on .welcome-world resolves to full viewport.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 22:22:54 +01:00
Julien Calixte
369a200a42 fix: wrap content in flex:1 div so footer doesn't overflow
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 22:07:08 +01:00
Julien Calixte
06eaa3c9a7 fix: ensure footer stays at bottom with align-self stretch
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 21:56:09 +01:00
Julien Calixte
4cbcf42e3d feat: replace BackButton and logo with HomeButton in PublicNoteView
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 21:32:19 +01:00
Julien Calixte
a0be25c0dd fix: prevent layout shift on first load in PWA mode
Replace space-between with flex-start + margin-top:auto on footer and
add gap to avoid wide spacing while async components are loading.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 21:28:47 +01:00
Julien Calixte
dcee26100f fix: use 100dvh to prevent scroll on mobile first load
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 21:05:34 +01:00
Julien Calixte
ac68c68f8a feat: reorganize FontChange layout and resize header icons
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 20:46:44 +01:00
Julien Calixte
982f3070a1 fix: use <a> for font modal trigger to match icon color
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 20:35:52 +01:00
Julien Calixte
20e9538983 feat: mv profile to footer 2026-03-28 20:24:08 +01:00
Julien Calixte
10c3e1ca60 feat: replace back button with HomeButton and fix view transition
- Use HomeButton component in HeaderNote for logo, hover, and view-transition-name
- Eagerly import HeaderNote in FluxNote so the logo exists in the DOM when the transition snapshot is taken
- Wait for afterEach + nextTick in the view transition hook to handle lazy-loaded routes
- Add cursor: pointer to font change button

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 20:19:59 +01:00
6dc98c80ca Merge pull request 'chore/migrate-to-oxc' (#1) from chore/migrate-to-oxc into main
Reviewed-on: #1
2026-03-28 09:00:30 +00:00
Julien Calixte
c06253e509 chore: migrate oxlint disable comments 2026-03-28 09:52:25 +01:00
Julien Calixte
767093c008 chore: install migrate oxlint skill 2026-03-28 09:49:50 +01:00
Julien Calixte
3a32cb5948 feat: change place for atproto 2026-03-28 09:45:39 +01:00
Julien Calixte
5f48aa5690 chore: lint and fmt 2026-03-28 09:38:55 +01:00
Julien Calixte
8e8706e258 chore: init oxc 2026-03-28 09:34:04 +01:00
Julien Calixte
d457fd4064 docs: rolldown and oxc 2026-03-22 02:44:11 +01:00
Julien Calixte
1aef212a36 docs: rolldown and oxc 2026-03-22 01:23:13 +01:00
Julien Calixte
d1bb9fa182 chore: minify with esbuild 2026-03-22 00:50:54 +01:00
Julien Calixte
80170b9f62 chore: try with regular minifier 2026-03-22 00:49:05 +01:00
Julien Calixte
ed5da7844a chore: try fixing Safari 2026-03-22 00:32:16 +01:00
Julien Calixte
12676135f6 prune nixpacks 2026-03-21 23:03:36 +01:00
Julien Calixte
32f79785a8 fix: prevent stacking a duplicate of the main note when clicking a self-link 2026-03-21 22:59:10 +01:00
Julien Calixte
c0b1a33c69 deps: upgrade vite 2026-03-21 22:46:46 +01:00
Julien Calixte
1fc66289a4 test: init analytics 2026-03-21 21:15:04 +01:00
Julien Calixte
db27b03f21 fix: set width so it doesn't take to much space with larger images 2026-03-21 12:06:22 +01:00
Julien Calixte
72b704a54d feat: small change for deployment 2026-03-21 11:15:17 +01:00
Julien Calixte
b6d5ad5d4b fix: add workbox-window and workbox-build as direct deps for pnpm v10 strict resolution 2026-03-21 11:11:44 +01:00
Julien Calixte
694c2fcae9 chore: replace nixpacks with Dockerfile for faster cached builds 2026-03-21 11:06:58 +01:00
Julien Calixte
dfdd646eb1 deps: migrate to api.remanso.space for everything now 2026-03-21 10:49:39 +01:00
Julien Calixte
3c736124e8 chore: approve build 2026-03-21 09:04:58 +01:00
Julien Calixte
53c444ed72 fix: use smoother ease-out-expo curve for logo view transition
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 21:05:39 +01:00
Julien Calixte
29e56304c4 feat: add View Transitions API hero animation for favicon between pages
The favicon animates from its large position in the WelcomeWorld title
to the smaller header position in PublicNoteListView and PublicNoteListByDidView.
2026-03-19 18:43:26 +01:00
Julien Calixte
ddabe5082d feat: show skeleton loaders while ATProto identity resolves
- Show skeleton in PublicNoteView and StackedPublicNote while note
  content is pending author resolution
- Show skeleton h1 in PublicNoteListByDidView while author loads
- Show skeleton in SignInAtproto until auth state is known
- Load cached session from IndexedDB before OAuth restore so the
  homepage resolves immediately without waiting for network
2026-03-19 18:12:52 +01:00
Julien Calixte
52561496b4 fix: change the edit button 2026-03-19 17:40:50 +01:00
Julien Calixte
0ed2906782 fix: use correct capture group index for tabler icon name 2026-03-17 23:50:21 +01:00
Julien Calixte
944b128894 feat: add icons, better suited than emojis 2026-03-17 23:43:29 +01:00
Julien Calixte
514d08946d Merge branch 'main' of ssh://git.apoena.dev:22222/julien/remanso 2026-03-17 22:03:33 +01:00
Julien Calixte
16efd8c637 design: better header and subheader 2026-03-16 23:22:25 +01:00
146 changed files with 7022 additions and 2056 deletions

13
.claude/settings.json Normal file
View File

@@ -0,0 +1,13 @@
{
"extraKnownMarketplaces": {
"remanso-local": {
"source": {
"source": "directory",
"path": "."
}
}
},
"enabledPlugins": {
"remanso-skills@remanso-local": true
}
}

View File

@@ -5,10 +5,6 @@
"label": "Vue i18n", "label": "Vue i18n",
"uri": "https://vue-i18n.intlify.dev/guide/introduction.html" "uri": "https://vue-i18n.intlify.dev/guide/introduction.html"
} }
], ]
"vite.config.ts": {
"label": "Remanso GitHub app",
"uri": "https://github.com/organizations/remanso-spance/settings/apps/lite-note"
}
} }
} }

6
.dockerignore Normal file
View File

@@ -0,0 +1,6 @@
node_modules
dist
.git
.gitignore
*.md
.env*

View File

@@ -1,53 +0,0 @@
require("@rushstack/eslint-patch/modern-module-resolution")
const DEV_TOOL_ACTIVATED =
process.env.NODE_ENV === "production" ? "warn" : "off"
module.exports = {
root: true,
env: {
node: true,
es2022: true,
},
extends: ["plugin:vue/vue3-essential", "@vue/eslint-config-typescript"],
plugins: ["simple-import-sort", "unused-imports"],
rules: {
"no-console": DEV_TOOL_ACTIVATED,
"no-debugger": DEV_TOOL_ACTIVATED,
"@typescript-eslint/explicit-module-boundary-types": "off",
"@typescript-eslint/camelcase": "off",
"prettier-vue/prettier": [
"error",
{
semi: false,
singleQuote: true,
trailingComma: "none",
arrowParens: "always",
},
],
"vue/no-v-html": "off",
"no-restricted-imports": [
"error",
{
paths: [
{
name: "vue-demi",
importNames: ["computed"],
message: "Please use computed from vue instead.",
},
],
},
],
"simple-import-sort/imports": "error",
"simple-import-sort/exports": "error",
"unused-imports/no-unused-imports": "error",
},
overrides: [
{
files: [
"**/__tests__/*.{j,t}s?(x)",
"**/tests/unit/**/*.spec.{j,t}s?(x)",
],
},
],
}

1
.gitignore vendored
View File

@@ -2,7 +2,6 @@
node_modules node_modules
/dist /dist
# local env files # local env files
.env.local .env.local
.env.*.local .env.*.local

View File

@@ -1,37 +0,0 @@
# Define the checksum file
CHECKSUM_FILE=".env.checksum"
# Calculate the current checksum of the .env file
CURRENT_CHECKSUM=$(shasum -a 256 .env | awk '{ print $1 }')
# Check if checksum file exists
if [ -f "$CHECKSUM_FILE" ]; then
# Read the previous checksum
PREVIOUS_CHECKSUM=$(cat "$CHECKSUM_FILE")
# Compare the current checksum with the previous checksum
if [ "$CURRENT_CHECKSUM" = "$PREVIOUS_CHECKSUM" ]; then
echo ".env file has not changed. Skipping Netlify environment import."
exit 0
fi
fi
# If the checksum is different or the file doesn't exist, import the variables
echo "Importing environment variables to Netlify..."
netlify env:import .env
if [ $? -ne 0 ]; then
echo "Failed to import environment variables to Netlify. Aborting push."
exit 1
fi
# Save the new checksum
echo "$CURRENT_CHECKSUM" > "$CHECKSUM_FILE"
# Stage the checksum file
git add "$CHECKSUM_FILE"
# Amend the last commit with the updated checksum
git commit -m "Update .env checksum"
echo "Environment variables imported successfully."

9
.oxfmtrc.json Normal file
View File

@@ -0,0 +1,9 @@
{
"$schema": "./node_modules/oxfmt/configuration_schema.json",
"semi": false,
"singleQuote": false,
"trailingComma": "none",
"printWidth": 80,
"sortPackageJson": false,
"ignorePatterns": []
}

40
.oxlintrc.json Normal file
View File

@@ -0,0 +1,40 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["typescript"],
"jsPlugins": [
"eslint-plugin-prettier-vue",
"eslint-plugin-simple-import-sort",
"eslint-plugin-unused-imports"
],
"categories": {
"correctness": "off"
},
"env": {
"builtin": true
},
"rules": {
"no-restricted-imports": [
"error",
{
"paths": [
{
"name": "vue-demi",
"importNames": ["computed"],
"message": "Please use computed from vue instead."
}
]
}
],
"simple-import-sort/imports": "error",
"simple-import-sort/exports": "error",
"unused-imports/no-unused-imports": "error"
},
"overrides": [
{
"files": ["**/*.vue"],
"rules": {
"unused-imports/no-unused-imports": "off"
}
}
]
}

View File

@@ -1,3 +0,0 @@
{
"semi": false
}

23
.zed/settings.json Normal file
View File

@@ -0,0 +1,23 @@
{
"format_on_save": "on",
"formatter": {
"external": {
"command": "node_modules/.bin/oxfmt",
"arguments": ["--stdin-filepath", "{buffer_path}"]
}
},
"languages": {
"TypeScript": {
"language_servers": ["!deno", "..."]
},
"TSX": {
"language_servers": ["!deno", "..."]
},
"JavaScript": {
"language_servers": ["!deno", "..."]
},
"JSX": {
"language_servers": ["!deno", "..."]
}
}
}

View File

@@ -51,7 +51,7 @@ src/
│ ├── card/ # Spaced repetition │ ├── card/ # Spaced repetition
│ ├── history/ # Edit history tracking │ ├── history/ # Edit history tracking
│ ├── atproto/ # ATProto/Bluesky integration (DID resolution, blob URLs) │ ├── atproto/ # ATProto/Bluesky integration (DID resolution, blob URLs)
│ └── post/ # ts-rest API client for public note publishing (api.litenote.li212.fr) │ └── post/ # ts-rest API client for public note publishing (api.remanso.space)
├── hooks/ # Composition hooks (useMarkdown, useBacklinks, useGitHubContent, etc.) ├── hooks/ # Composition hooks (useMarkdown, useBacklinks, useGitHubContent, etc.)
├── data/ # PouchDB wrapper and data models ├── data/ # PouchDB wrapper and data models
├── utils/ # Utilities including custom markdown-it plugins ├── utils/ # Utilities including custom markdown-it plugins

33
Dockerfile Normal file
View File

@@ -0,0 +1,33 @@
# ---- Stage 1: deps (only invalidated when lockfile changes) ----
FROM node:22-alpine AS deps
RUN corepack enable
WORKDIR /app
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
RUN pnpm install --frozen-lockfile
# ---- Stage 2: build (invalidated on any source change) ----
FROM node:22-alpine AS builder
RUN corepack enable
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN pnpm run build
# ---- Stage 3: serve ----
FROM nginx:alpine AS runner
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80

View File

@@ -0,0 +1,40 @@
import path from "path"
import sharp from "sharp"
// PWA spec: `purpose: "monochrome"` icons are *masks*. The user agent ignores
// RGB and uses only the alpha channel as the silhouette, then paints it with
// the platform theme color. So the source PNG must be RGBA with the silhouette
// in alpha, NOT a black-on-white RGB image.
const SRC = path.resolve(__dirname, "../public/favicon.png")
const OUT = path.resolve(__dirname, "../public/monochromeicon.png")
const SIZE = 1024
async function main() {
const { data, info } = await sharp(SRC)
.resize(SIZE, SIZE, { fit: "contain", background: { r: 0, g: 0, b: 0, alpha: 0 } })
.ensureAlpha()
.raw()
.toBuffer({ resolveWithObject: true })
if (info.channels !== 4) throw new Error(`expected RGBA, got ${info.channels} channels`)
const out = Buffer.alloc(data.length)
for (let i = 0; i < data.length; i += 4) {
out[i] = 0
out[i + 1] = 0
out[i + 2] = 0
out[i + 3] = data[i + 3]
}
await sharp(out, { raw: { width: SIZE, height: SIZE, channels: 4 } })
.png({ compressionLevel: 9 })
.toFile(OUT)
console.log(`Wrote ${OUT} (${SIZE}x${SIZE} RGBA)`)
}
main().catch((e) => {
console.error(e)
process.exit(1)
})

View File

@@ -5,6 +5,7 @@
import { readFileSync, writeFileSync } from "fs" import { readFileSync, writeFileSync } from "fs"
import { join } from "path" import { join } from "path"
import { commitTheme } from "./change-theme" import { commitTheme } from "./change-theme"
// Chemins vers les fichiers // Chemins vers les fichiers
@@ -27,8 +28,8 @@ let themeConfigContent = readFileSync(themeConfigPath, "utf8")
// Remplacer la valeur du thème sombre // Remplacer la valeur du thème sombre
themeConfigContent = themeConfigContent.replace( themeConfigContent = themeConfigContent.replace(
/dark:\s*['"][^'"]*['"],/, /dark:\s*['"][^'"]*['"](,?)/,
`dark: '${newTheme}',`, `dark: '${newTheme}'$1`
) )
// Écrire le contenu mis à jour dans le fichier // Écrire le contenu mis à jour dans le fichier
@@ -38,7 +39,7 @@ writeFileSync(themeConfigPath, themeConfigContent)
let appCssContent = readFileSync(appCssPath, "utf8") let appCssContent = readFileSync(appCssPath, "utf8")
appCssContent = appCssContent.replace( appCssContent = appCssContent.replace(
/(\s+)([a-zA-Z0-9-]+)(\s+--prefersdark;)/, /(\s+)([a-zA-Z0-9-]+)(\s+--prefersdark;)/,
`$1${newTheme}$3`, `$1${newTheme}$3`
) )
writeFileSync(appCssPath, appCssContent) writeFileSync(appCssPath, appCssContent)

View File

@@ -5,6 +5,7 @@
import { readFileSync, writeFileSync } from "fs" import { readFileSync, writeFileSync } from "fs"
import { join } from "path" import { join } from "path"
import { commitTheme } from "./change-theme" import { commitTheme } from "./change-theme"
// Chemins vers les fichiers // Chemins vers les fichiers
@@ -29,7 +30,7 @@ let themeConfigContent = readFileSync(themeConfigPath, "utf8")
// Remplacer la valeur du thème clair // Remplacer la valeur du thème clair
themeConfigContent = themeConfigContent.replace( themeConfigContent = themeConfigContent.replace(
/light:\s*['"][^'"]*['"],/, /light:\s*['"][^'"]*['"],/,
`light: '${newTheme}',`, `light: '${newTheme}',`
) )
// Écrire le contenu mis à jour dans le fichier // Écrire le contenu mis à jour dans le fichier
@@ -39,7 +40,7 @@ writeFileSync(themeConfigPath, themeConfigContent)
let indexContent = readFileSync(indexPath, "utf8") let indexContent = readFileSync(indexPath, "utf8")
indexContent = indexContent.replace( indexContent = indexContent.replace(
/data-theme="[^"]*"/, /data-theme="[^"]*"/,
`data-theme="${newTheme}"`, `data-theme="${newTheme}"`
) )
writeFileSync(indexPath, indexContent) writeFileSync(indexPath, indexContent)
@@ -47,7 +48,7 @@ writeFileSync(indexPath, indexContent)
let appCssContent = readFileSync(appCssPath, "utf8") let appCssContent = readFileSync(appCssPath, "utf8")
appCssContent = appCssContent.replace( appCssContent = appCssContent.replace(
/(\s+)([a-zA-Z0-9-]+)(\s+--default,)/, /(\s+)([a-zA-Z0-9-]+)(\s+--default,)/,
`$1${newTheme}$3`, `$1${newTheme}$3`
) )
writeFileSync(appCssPath, appCssContent) writeFileSync(appCssPath, appCssContent)

View File

@@ -1,3 +1,3 @@
module.exports = { module.exports = {
presets: ['@vue/cli-plugin-babel/preset'] presets: ["@vue/cli-plugin-babel/preset"]
} }

View File

@@ -0,0 +1,78 @@
# Rolldown minifier drops `while(` keyword — invalid syntax in Safari
**Status:** To be filed
**Workaround applied:** `build: { minify: "esbuild" }` in `vite.config.mts`
## Summary
Rolldown's minifier drops the `while(`/`for(;` keyword when a `while (x in globalThis)` loop is bundled alongside other module-level variable declarations. The resulting output is a syntax error that crashes Safari.
## Target repositories
- <https://github.com/rolldown/rolldown/issues>
- <https://github.com/oxc-project/oxc/issues> (underlying minifier)
## Environment
| Package | Version |
| -------------------- | --------------------------------------- |
| `vite` | 8.0.1 |
| `rolldown` | 1.0.0-rc.10 |
| `@oxc-project/types` | 0.120.0 |
| Triggered by | `@ark/schema` 0.56.0 / `arktype` 2.1.29 |
## Source
File: `node_modules/@ark/schema/out/shared/registry.js`
```js
let _registryName = "$ark"
let suffix = 2
while (_registryName in globalThis) _registryName = `$ark${suffix++}`
export const registryName = _registryName
```
## Actual minified output
```js
un=Pe(`implementedTraits`),dn=`$ark`,fn=2;dn in globalThis;)dn=`$ark${fn++}`;var pn=dn;
```
The `while(` keyword is missing. The orphaned `)` is a syntax error.
## Expected output
```js
var dn = `$ark`,
fn = 2
for (; dn in globalThis; ) dn = `$ark${fn++}`
var pn = dn
```
## Impact
Safari throws: `SyntaxError: Unexpected keyword 'in'. Expected a ';' following a return statement.`
Chrome and Firefox appear to tolerate the malformed output.
## Reproduction attempts
Direct Rolldown calls (even with the full `arktype` + `@better-fetch/fetch` bundle) do **not** reproduce the bug. It only manifests through Vite's full production build pipeline. This strongly suggests the issue is in the interaction between **Vite's module preprocessing** (dependency pre-bundling, plugin transforms, scope-flattening of ESM modules) and Rolldown's minifier `sequences` optimization pass.
Hypothesis: Vite's pipeline produces a flattened scope where multiple `var` declarations from different original modules are consecutive. When the `sequences` optimizer then tries to fold those `var` declarations into the `for` loop init, it incorrectly drops the `for(var` prefix when the loop condition contains the `in` operator.
## Notes
- Related closed issue: [rolldown/rolldown#8146](https://github.com/rolldown/rolldown/issues/8146) — "Minifier incorrectly merges statements into for-in expression via comma operator"
- To reproduce: run `pnpm build` in this project **without** `build.minify: "esbuild"` — the broken chunk is `getAuthor-*.js`
## Workaround
Add to `vite.config.mts`:
```ts
build: {
minify: "esbuild",
},
```
esbuild correctly outputs `for(;Dt in globalThis;)Dt=\`$ark${za++}\``.

View File

@@ -0,0 +1,236 @@
# Remanso React Native Migration — Design Spec
**Date:** 2026-04-20
**Status:** Approved
## Overview
Migrate Remanso from a Vue 3 web app to a fully native iOS + Android app built with Expo (React Native). The primary motivation is native feel: fluid stack animations, swipe-back gestures, and native navigation chrome. The scope is a full replacement of the web app.
## Tech Stack
| Concern | Current (Vue) | React Native |
| ---------------- | ---------------------- | ---------------------------------------------------------- |
| Framework | Vue 3 + Vite | Expo SDK (managed workflow) |
| Routing | Vue Router | Expo Router (file-system routing over React Navigation v7) |
| State | Pinia | Zustand |
| Server state | TanStack Vue Query | TanStack Query (same library) |
| Styling | DaisyUI + Tailwind CSS | NativeWind v4 |
| Local DB | PouchDB (IndexedDB) | Expo SQLite |
| Simple KV store | localStorage | MMKV |
| Auth | OAuth redirects | expo-auth-session |
| GitHub API | @octokit/rest | @octokit/rest (unchanged) |
| i18n | vue-i18n | react-i18next |
| Date utils | date-fns | date-fns (unchanged) |
| Markdown content | markdown-it (DOM) | react-native-webview |
| Fonts | CSS custom properties | expo-font |
## Navigation Structure
Expo Router generates a React Navigation v7 tree from the file system. The stacked note pattern — the core native feel win — maps to a nested Stack navigator where each backlink push adds a note with a native slide animation and swipe-left pops it.
```
Root Stack
├── Auth screens (unauthenticated, no tab bar)
│ ├── Home / Welcome
│ ├── GitHub OAuth callback
│ └── ATProto OAuth callback
└── Authenticated App — Bottom Tabs
├── Tab: Feed (Stack)
│ ├── Repo picker
│ └── Note Stack (nested Stack)
│ ├── Note (root)
│ ├── Note (pushed via backlink) ← swipe-back to pop
│ └── Note (pushed via backlink) ← swipe-back to pop
├── Tab: Inbox / Drafts / Todos (Stack)
│ └── Note list → Note detail
├── Tab: Public Notes (Stack)
│ ├── Public note list
│ └── Public note detail
└── Tab: Settings (Stack)
├── Settings root
└── Font picker (presented as modal)
```
History and Spaced Repetition are accessible as sections within the Feed tab or as additional tabs — to be decided during implementation.
## Data Layer
PouchDB is replaced by **Expo SQLite** for structured data and **MMKV** for simple key-value preferences.
### Expo SQLite schema
```sql
CREATE TABLE IF NOT EXISTS github_tokens (
id TEXT PRIMARY KEY,
access_token TEXT NOT NULL,
refresh_token TEXT,
expires_at INTEGER
);
CREATE TABLE IF NOT EXISTS atproto_sessions (
id TEXT PRIMARY KEY,
did TEXT NOT NULL
-- full session_json stored in Expo SecureStore keyed by id
);
CREATE TABLE IF NOT EXISTS saved_repos (
id TEXT PRIMARY KEY,
user TEXT NOT NULL,
repo TEXT NOT NULL,
files_json TEXT NOT NULL,
cached_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS history (
id TEXT PRIMARY KEY,
user TEXT NOT NULL,
repo TEXT NOT NULL,
note_path TEXT NOT NULL,
content TEXT NOT NULL,
created_at INTEGER NOT NULL
);
```
The existing `DataApi` interface (`add`, `update`, `remove`, `get`, `getAll`) is re-implemented as a thin wrapper over Expo SQLite. No ORM — plain async SQL with `CREATE TABLE IF NOT EXISTS` on first open handles schema initialization at this scale.
Sensitive data (access tokens, refresh tokens, ATProto sessions) is stored in **Expo SecureStore** rather than plain SQLite. SQLite holds only metadata and content cache.
### MMKV
Replaces localStorage for user settings: font family, font size, theme preference. Synchronous reads, no async overhead.
### No Web Worker
Database calls move to the main thread via Expo SQLite's async API. The performance concern that justified the Web Worker on web does not apply on mobile.
## Auth Flows
### GitHub OAuth
The existing auth server (`api.remanso.space/auth/github`) handles code exchange — the client flow is unchanged:
1. `expo-auth-session` opens an in-app browser tab with the GitHub authorize URL
2. OAuth redirect captured by Expo's deep link handler
3. Code sent to `api.remanso.space/auth/github?code=...` for token exchange
4. Access token + refresh token stored in Expo SecureStore
5. Token refresh logic (15-minute pre-expiry check) stays the same; HTTP calls use `fetch`
### ATProto / Bluesky OAuth
`@atproto/oauth-client-browser` is browser-only (IndexedDB, `window.crypto`, browser redirects). There is no official React Native client. A custom client (~200300 lines) is implemented using:
- `expo-auth-session` for the OAuth redirect flow (PKCE)
- `expo-crypto` for PKCE code verifier/challenge generation
- Expo SecureStore for session persistence
- The same Bluesky API endpoints as the browser client
This keeps ATProto auth fully client-side, consistent with the app's current architecture.
## State Management
Zustand replaces Pinia. The store shape is identical to `userRepo.store.ts`:
```ts
const useRepoStore = create<RepoState>((set, get) => ({
user: "",
repo: "",
files: [],
userSettings: null,
needToLogin: false,
setRepo: (user, repo) => set({ user, repo }),
loadFiles: async () => {
/* Octokit call */
},
loadSettings: async () => {
/* MMKV read */
}
}))
```
TanStack Query handles all GitHub API server state (file fetching, README, repo listing) — same library, same patterns as today.
## Styling
NativeWind v4 provides Tailwind utility classes in React Native. DaisyUI is web-only and has no React Native equivalent — all component styling (buttons, cards, modals) is hand-written using NativeWind utilities.
The two DaisyUI themes (`retro` light, `coffee` dark) are translated into a custom NativeWind theme in `tailwind.config.ts` with the same color tokens. System appearance (`useColorScheme`) drives theme selection.
Font customization uses `expo-font` for loading custom fonts and React Native's `fontFamily` style prop, replacing the CSS custom property approach.
## Markdown Rendering
The markdown-it pipeline (KaTeX, Mermaid, shiki, tabler icons, html5-media, GitHub alerts, checkboxes) runs in the React Native JS context unchanged — same code, same output. The resulting HTML string is passed to a `NoteWebView` component built on `react-native-webview`.
`NoteWebView` is a native UIView/View wrapper around a WebView engine. It is a React Native component — not a web app. The surrounding app (navigation chrome, tab bar, headers, settings, auth screens) is 100% native. Only the note content pane renders HTML. This is the standard pattern for rich content in React Native (used by GitHub Mobile, Linear, and others).
The WebView communicates back to the native layer via `postMessage` for:
- Internal note link taps (trigger React Navigation push)
- External URL taps (open in system browser)
- Backlink detection events
## Project Structure
```
src/
├── app/ # Expo Router — file-system screens
│ ├── _layout.tsx # Root Stack navigator
│ ├── index.tsx # Home / Welcome
│ └── (tabs)/ # Authenticated tab navigator
│ ├── _layout.tsx
│ ├── feed/ # Feed + Note Stack screens
│ ├── inbox/
│ ├── public/
│ └── settings/
├── modules/ # Feature domains (mirrors current structure)
│ ├── note/ # Note models, hooks, caching
│ ├── repo/ # Zustand store, Octokit service
│ ├── user/
│ │ ├── auth/ # GitHub + ATProto OAuth hooks
│ │ └── fonts.ts # Font downloading (was utils/downloadFont.ts)
│ ├── card/ # Spaced repetition
│ ├── history/ # Edit history
│ ├── atproto/ # Custom ATProto OAuth client, DID resolution
│ └── post/ # ts-rest API client (unchanged)
├── components/ # Shared UI components
├── rendering/ # Markdown pipeline — first-class module
│ ├── pipeline.ts # markdown-it setup + plugin registration
│ ├── plugins/ # Custom plugins
│ │ ├── html5-media.ts
│ │ ├── regexp.ts
│ │ └── tabler-icons.ts
│ └── NoteWebView.tsx # react-native-webview wrapper
├── lib/ # Shared low-level, stateless helpers
│ ├── text.ts # slugify, noteTitle, displayLanguage
│ ├── links.ts # link.ts + youtube.ts
│ ├── encoding.ts # decodeBase64ToUTF8
│ └── notifications.ts # notif.ts
├── hooks/ # React hooks
├── data/ # Expo SQLite wrapper (same DataApi interface)
├── locales/ # i18n strings (unchanged)
└── constants/ # Theme tokens, note width constants
```
## Key Risks
1. **ATProto custom OAuth client** — no official SDK; requires careful PKCE implementation and session lifecycle management.
2. **DaisyUI → NativeWind component styling** — no 1:1 mapping; all themed components need to be rebuilt. Most labor-intensive non-feature work.
3. **NoteWebView ↔ native bridge** — link tap handling and scroll coordination between the WebView and the native Stack navigator require careful implementation to feel seamless.
4. **Mermaid in WebView** — Mermaid is JS-heavy; initial render may be slow on lower-end Android. May need lazy rendering or a timeout fallback.
## Out of Scope
- PWA / service worker (not applicable to native)
- Web Worker (replaced by async SQLite)
- Comlink (not applicable)
- Server-side rendering

View File

@@ -1,5 +1,5 @@
<!doctype html> <!doctype html>
<html lang="en" data-theme="garden"> <html lang="en" data-theme="light">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" /> <meta http-equiv="X-UA-Compatible" content="IE=edge" />
@@ -25,6 +25,11 @@
rel="stylesheet" rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/github-markdown-css/4.0.0/github-markdown.min.css" href="https://cdnjs.cloudflare.com/ajax/libs/github-markdown-css/4.0.0/github-markdown.min.css"
/> />
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@latest/dist/tabler-icons.min.css"
fetchpriority="low"
/>
</head> </head>
<body> <body>
<noscript> <noscript>

9
nginx.conf Normal file
View File

@@ -0,0 +1,9 @@
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
}

View File

@@ -1,2 +0,0 @@
[phases.setup]
nixPkgs = ["nodejs_24", "pnpm"]

View File

@@ -2,39 +2,48 @@
"name": "remanso", "name": "remanso",
"version": "1.0.0", "version": "1.0.0",
"private": true, "private": true,
"packageManager": "pnpm@11.0.9",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"serve": "vite preview", "serve": "vite preview",
"test": "vitest", "test": "vitest",
"types": "tsc --noEmit", "types": "tsc --noEmit",
"lint": "eslint --ext .ts,.js,.vue --ignore-path .gitignore --fix src", "lint": "oxlint",
"prepare": "husky", "lint:fix": "oxlint --fix",
"fmt": "oxfmt",
"fmt:check": "oxfmt --check",
"theme:light": "esno _scripts/change-theme-light.ts", "theme:light": "esno _scripts/change-theme-light.ts",
"theme:dark": "esno _scripts/change-theme-dark.ts", "theme:dark": "esno _scripts/change-theme-dark.ts"
"generate-pwa-assets": "pwa-assets-generator"
}, },
"dependencies": { "dependencies": {
"@atproto/oauth-client-browser": "^0.3.41", "@atproto/oauth-client-browser": "^0.3.41",
"@better-fetch/fetch": "^1.1.21", "@better-fetch/fetch": "^1.1.21",
"@better-fetch/logger": "^1.1.21", "@better-fetch/logger": "^1.1.21",
"@intlify/unplugin-vue-i18n": "^6.0.8", "@intlify/unplugin-vue-i18n": "^6.0.8",
"@mdit/plugin-tab": "^0.24.2",
"@octokit/core": "^7.0.6", "@octokit/core": "^7.0.6",
"@octokit/rest": "^22.0.1", "@octokit/rest": "^22.0.1",
"@openpanel/web": "^1.3.0",
"@tailwindcss/postcss": "^4.1.16", "@tailwindcss/postcss": "^4.1.16",
"@tanstack/vue-query": "^5.92.9", "@tanstack/vue-query": "^5.92.9",
"@toycode/markdown-it-class": "^1.2.4", "@toycode/markdown-it-class": "^1.2.4",
"@ts-rest/core": "^3.52.1",
"@ts-rest/vue-query": "^3.52.1",
"@vscode/markdown-it-katex": "^1.1.2", "@vscode/markdown-it-katex": "^1.1.2",
"@vueuse/components": "^14.2.1", "@vueuse/components": "^14.2.1",
"@vueuse/core": "^13.6.0", "@vueuse/core": "^13.6.0",
"@vueuse/router": "^13.6.0", "@vueuse/router": "^13.6.0",
"arktype": "^2.1.29", "arktype": "^2.1.29",
"comlink": "^4.4.2",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"events": "^3.3.0", "events": "^3.3.0",
"font-color-contrast": "^11.1.0", "font-color-contrast": "^11.1.0",
"fontfaceobserver": "^2.3.0", "fontfaceobserver": "^2.3.0",
"github-slugger": "^2.0.0",
"isomorphic-fetch": "^3.0.0", "isomorphic-fetch": "^3.0.0",
"markdown-it": "^14.1.0", "markdown-it": "^14.1.0",
"markdown-it-anchor": "^9.2.0",
"markdown-it-block-embed": "^0.0.3", "markdown-it-block-embed": "^0.0.3",
"markdown-it-checkbox": "^1.1.0", "markdown-it-checkbox": "^1.1.0",
"markdown-it-github-alerts": "^1.0.0", "markdown-it-github-alerts": "^1.0.0",
@@ -53,42 +62,37 @@
"sanitize-html": "^2.17.0", "sanitize-html": "^2.17.0",
"vue": "^3.5.18", "vue": "^3.5.18",
"vue-i18n": "^11.1.11", "vue-i18n": "^11.1.11",
"vue-router": "^4.5.1" "vue-router": "^4.5.1",
"workbox-build": "^7.4.0",
"workbox-window": "^7.4.0"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.28.5", "@babel/core": "^7.28.5",
"@rushstack/eslint-patch": "^1.14.1",
"@tailwindcss/typography": "^0.5.19", "@tailwindcss/typography": "^0.5.19",
"@types/fontfaceobserver": "^2.1.3", "@types/fontfaceobserver": "^2.1.3",
"@types/markdown-it": "^14.1.2", "@types/markdown-it": "^14.1.2",
"@types/node": "^22.15.24", "@types/node": "^22.15.24",
"@types/pouchdb-browser": "^6.1.5", "@types/pouchdb-browser": "^6.1.5",
"@types/sanitize-html": "^2.16.0", "@types/sanitize-html": "^2.16.0",
"@typescript-eslint/eslint-plugin": "^8.46.2",
"@typescript-eslint/parser": "^8.46.2",
"@vite-pwa/assets-generator": "^1.0.2", "@vite-pwa/assets-generator": "^1.0.2",
"@vitejs/plugin-vue": "^5.2.4", "@vitejs/plugin-vue": "^5.2.4",
"@vue/compiler-sfc": "^3.5.28", "@vue/compiler-sfc": "^3.5.28",
"@vue/eslint-config-prettier": "^10.2.0",
"@vue/eslint-config-typescript": "^14.6.0",
"autoprefixer": "^10.4.24", "autoprefixer": "^10.4.24",
"daisyui": "^5.5.18", "daisyui": "^5.5.18",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"eslint": "^8.57.1",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier-vue": "^5.0.0", "eslint-plugin-prettier-vue": "^5.0.0",
"eslint-plugin-simple-import-sort": "^12.1.1", "eslint-plugin-simple-import-sort": "^12.1.1",
"eslint-plugin-unused-imports": "^4.4.1", "eslint-plugin-unused-imports": "^4.4.1",
"eslint-plugin-vue": "^10.8.0",
"esno": "^4.8.0", "esno": "^4.8.0",
"husky": "^9.1.7", "oxfmt": "^0.42.0",
"oxlint": "^1.57.0",
"prettier": "^3.8.1", "prettier": "^3.8.1",
"prettier-vue": "^1.1.2", "prettier-vue": "^1.1.2",
"sass": "^1.93.3", "sass": "^1.98.0",
"tailwindcss": "^4.1.16", "tailwindcss": "^4.2.2",
"typescript": "~5.9.3", "typescript": "~5.9.3",
"vite": "^7.1.12", "vite": "^8.0.1",
"vite-plugin-pwa": "^1.1.0", "vite-plugin-pwa": "^1.2.0",
"vitest": "^3.2.4" "vitest": "^3.2.4"
} }
} }

1935
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

7
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,7 @@
allowBuilds:
'@parcel/watcher': true
core-js: true
esbuild: true
fsevents: true
sharp: true
vue-demi: true

View File

@@ -1,3 +1,3 @@
module.exports = { module.exports = {
plugins: { "@tailwindcss/postcss": {}, autoprefixer: {} }, plugins: { "@tailwindcss/postcss": {}, autoprefixer: {} }
} }

View File

@@ -2,9 +2,7 @@
"client_id": "https://remanso.space/client-metadata.json", "client_id": "https://remanso.space/client-metadata.json",
"client_name": "Remanso", "client_name": "Remanso",
"client_uri": "https://remanso.space", "client_uri": "https://remanso.space",
"redirect_uris": [ "redirect_uris": ["https://remanso.space/"],
"https://remanso.space/"
],
"scope": "atproto transition:generic", "scope": "atproto transition:generic",
"grant_types": ["authorization_code", "refresh_token"], "grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"], "response_types": ["code"],

Binary file not shown.

Before

Width:  |  Height:  |  Size: 181 KiB

BIN
public/monochromeicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

View File

@@ -1,9 +1,9 @@
import { import {
defineConfig, defineConfig,
minimal2023Preset as preset, minimal2023Preset as preset
} from "@vite-pwa/assets-generator/config" } from "@vite-pwa/assets-generator/config"
export default defineConfig({ export default defineConfig({
preset, preset,
images: ["public/favicon.png"], images: ["public/favicon.png"]
}) })

10
skills-lock.json Normal file
View File

@@ -0,0 +1,10 @@
{
"version": 1,
"skills": {
"migrate-oxlint": {
"source": "oxc-project/oxc",
"sourceType": "github",
"computedHash": "80ce5201b1ef52d6cabe553a4cacfd6e1db97bad99618216b9cf9318d11d7e64"
}
}
}

View File

@@ -1,7 +1,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import NewVersion from '@/components/NewVersion.vue' import NewVersion from "@/components/NewVersion.vue"
import { useATProtoLogin } from '@/hooks/useATProtoLogin.hook' import { useATProtoLogin } from "@/hooks/useATProtoLogin.hook"
import { useGitHubLogin } from '@/hooks/useGitHubLogin.hook' import { useGitHubLogin } from "@/hooks/useGitHubLogin.hook"
const { isReady } = useGitHubLogin() const { isReady } = useGitHubLogin()
const { isATProtoReady } = useATProtoLogin() const { isATProtoReady } = useATProtoLogin()
@@ -17,8 +17,32 @@ const { isATProtoReady } = useATProtoLogin()
<style lang="scss"> <style lang="scss">
#main-app { #main-app {
height: 100vh; height: 100dvh;
width: 100%;
max-width: none;
display: flex; display: flex;
flex: 1; flex: 1;
overflow-x: auto;
}
@media screen and (max-width: 768px) {
#main-app {
overflow-y: auto;
}
}
::view-transition-old(root),
::view-transition-new(root) {
animation-duration: 0.25s;
}
::view-transition-group(remanso-logo) {
animation-duration: 0.4s;
animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1);
}
::view-transition-old(remanso-logo),
::view-transition-new(remanso-logo) {
object-fit: contain;
} }
</style> </style>

View File

@@ -0,0 +1,9 @@
import { OpenPanel } from "@openpanel/web"
export const op = new OpenPanel({
apiUrl: "https://api.panel.apoena.dev",
clientId: "038a6aac-19bb-4a7f-9aae-2d0201fead5b",
trackScreenViews: true,
trackOutgoingLinks: true,
trackAttributes: true
})

View File

@@ -1,4 +1,4 @@
import { createEventBus } from 'retrobus' import { createEventBus } from "retrobus"
interface EventBusParams { interface EventBusParams {
fileSha: string fileSha: string

View File

@@ -1,9 +1,10 @@
import { createEventBus } from 'retrobus' import { createEventBus } from "retrobus"
interface EventBusParams { interface EventBusParams {
user: string user: string
repo: string repo: string
path: string path: string
hash?: string
currentNoteSHA?: string currentNoteSHA?: string
} }

View File

@@ -1,9 +1,9 @@
<script lang="ts" setup> <script lang="ts" setup>
import { onBeforeMount, ref } from 'vue' import { onBeforeMount, ref } from "vue"
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from "vue-router"
import { useGitHubLogin } from '@/hooks/useGitHubLogin.hook' import { useGitHubLogin } from "@/hooks/useGitHubLogin.hook"
import { signIn } from '@/modules/user/service/signIn' import { signIn } from "@/modules/user/service/signIn"
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
@@ -16,14 +16,13 @@ onBeforeMount(async () => {
if (code) { if (code) {
const token = await signIn(code.toString()) const token = await signIn(code.toString())
if ('error' in token) { if ("error" in token) {
hasError.value = true hasError.value = true
} else { } else {
token.access_token await saveCredentials(token)
saveCredentials(token)
} }
router.replace({ name: 'Home' }) router.replace({ name: "Home" })
} }
}) })
</script> </script>

View File

@@ -1,9 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { useRouter, type RouteLocationRaw } from "vue-router" import { type RouteLocationRaw, useRouter } from "vue-router"
const props = withDefaults( const props = withDefaults(
defineProps<{ fallback?: RouteLocationRaw; preferFallback?: boolean }>(), defineProps<{ fallback?: RouteLocationRaw; preferFallback?: boolean }>(),
{ preferFallback: true }, { preferFallback: true }
) )
const router = useRouter() const router = useRouter()
@@ -24,7 +24,7 @@ const goBack = () => {
</script> </script>
<template> <template>
<a class="btn btn-sm back-button" @click="goBack"> <button class="btn btn-sm back-button text-base-content" @click="goBack">
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
class="icon icon-tabler icon-tabler-arrow-narrow-left" class="icon icon-tabler icon-tabler-arrow-narrow-left"
@@ -41,5 +41,5 @@ const goBack = () => {
<line x1="5" y1="12" x2="9" y2="16" /> <line x1="5" y1="12" x2="9" y2="16" />
<line x1="5" y1="12" x2="9" y2="8" /> <line x1="5" y1="12" x2="9" y2="8" />
</svg> </svg>
</a> </button>
</template> </template>

View File

@@ -1,15 +1,11 @@
<script lang="ts" setup> <script lang="ts" setup>
import { import { computed, nextTick, onMounted, onUnmounted, toRefs, watch } from "vue"
computed,
defineAsyncComponent,
nextTick,
onMounted,
onUnmounted,
toRefs,
watch,
} from "vue"
import HeaderNote from "@/components/HeaderNote.vue"
import SignInGithub from "@/components/SignInGithub.vue"
import SkeletonLoader from "@/components/SkeletonLoader.vue"
import StackedNote from "@/components/StackedNote.vue" import StackedNote from "@/components/StackedNote.vue"
import { useGitHubLogin } from "@/hooks/useGitHubLogin.hook"
import { useLinks } from "@/hooks/useLinks.hook" import { useLinks } from "@/hooks/useLinks.hook"
import { markdownBuilder } from "@/hooks/useMarkdown.hook" import { markdownBuilder } from "@/hooks/useMarkdown.hook"
import { useNoteView } from "@/hooks/useNoteView.hook" import { useNoteView } from "@/hooks/useNoteView.hook"
@@ -19,11 +15,6 @@ import { useVisitRepo } from "@/modules/history/hooks/useVisitRepo.hook"
import CacheAllNotes from "@/modules/note/components/CacheAllNote.vue" import CacheAllNotes from "@/modules/note/components/CacheAllNote.vue"
import { useUserRepoStore } from "@/modules/repo/store/userRepo.store" import { useUserRepoStore } from "@/modules/repo/store/userRepo.store"
import { useUserSettings } from "@/modules/user/hooks/useUserSettings.hook" import { useUserSettings } from "@/modules/user/hooks/useUserSettings.hook"
import SkeletonLoader from "@/components/SkeletonLoader.vue"
const HeaderNote = defineAsyncComponent(
() => import("@/components/HeaderNote.vue"),
)
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
@@ -38,8 +29,8 @@ const props = withDefaults(
content: null, content: null,
parseContent: true, parseContent: true,
withContent: true, withContent: true,
withHeader: true, withHeader: true
}, }
) )
const user = computed(() => props.user) const user = computed(() => props.user)
@@ -54,6 +45,7 @@ const { listenToClick } = useLinks("note-display")
const { stackedNotes, scrollToFocusedNote } = useRouteQueryStackedNotes() const { stackedNotes, scrollToFocusedNote } = useRouteQueryStackedNotes()
const { titles } = useNoteView() const { titles } = useNoteView()
const { isLogged } = useGitHubLogin()
useResizeContainer("note-container", stackedNotes) useResizeContainer("note-container", stackedNotes)
const renderedContent = computed(() => const renderedContent = computed(() =>
@@ -61,7 +53,7 @@ const renderedContent = computed(() =>
? props.parseContent ? props.parseContent
? toHTML(props.content) ? toHTML(props.content)
: props.content : props.content
: store.readme, : store.readme
) )
const isLoading = computed(() => renderedContent.value === undefined) const isLoading = computed(() => renderedContent.value === undefined)
@@ -73,7 +65,7 @@ watch(
await nextTick() await nextTick()
listenToClick() listenToClick()
}, },
{ immediate: true }, { immediate: true }
) )
watch( watch(
@@ -81,7 +73,7 @@ watch(
() => { () => {
store.setUserRepo(props.user, props.repo) store.setUserRepo(props.user, props.repo)
}, },
{ immediate: true }, { immediate: true }
) )
onMounted(() => visitRepo()) onMounted(() => visitRepo())
@@ -112,9 +104,18 @@ onUnmounted(() => {
<cache-all-notes /> <cache-all-notes />
</div> </div>
<slot /> <slot />
<skeleton-loader v-if="isLoading || !hasContent" /> <skeleton-loader v-if="isLoading" />
<div v-else-if="withContent && !hasContent" class="repo-not-found">
<template v-if="isLogged">
<p>This repository is not accessible.</p>
</template>
<template v-else>
<p>This repository is private. Sign in to view it.</p>
<sign-in-github />
</template>
</div>
<p <p
v-else-if="withContent" v-else-if="withContent && hasContent"
class="note-display" class="note-display"
v-html="renderedContent" v-html="renderedContent"
/> />
@@ -200,6 +201,15 @@ $header-height: 40px;
} }
} }
.repo-not-found {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
padding: 2rem 0;
color: var(--color-base-content);
}
.note-display { .note-display {
padding-bottom: 2rem; padding-bottom: 2rem;
} }
@@ -208,12 +218,6 @@ $header-height: 40px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow-y: auto; overflow-y: auto;
height: 100vh;
position: sticky;
&:not(:first-child) {
border-top: 1px solid rgba(18, 19, 58, 0.2);
}
.title { .title {
text-align: left; text-align: left;
@@ -221,6 +225,8 @@ $header-height: 40px;
} }
@media screen and (min-width: 769px) { @media screen and (min-width: 769px) {
background-color: var(--note-canvas-bg);
.repo-title-breadcrumb { .repo-title-breadcrumb {
padding: 0.5rem 1rem 0; padding: 0.5rem 1rem 0;
transform-origin: 0 0; transform-origin: 0 0;
@@ -237,6 +243,11 @@ $header-height: 40px;
.note { .note {
min-width: var(--note-width); min-width: var(--note-width);
max-width: var(--note-width); max-width: var(--note-width);
background-color: var(--color-base-100);
}
.readme {
box-shadow: var(--note-sheet-shadow);
} }
} }
} }
@@ -245,6 +256,7 @@ $header-height: 40px;
.flux-note { .flux-note {
.readme { .readme {
padding: 0 0.75rem; padding: 0 0.75rem;
position: relative;
} }
} }
@@ -254,6 +266,7 @@ $header-height: 40px;
.note { .note {
width: 100vw; width: 100vw;
height: 100svh;
overflow-y: visible; overflow-y: visible;
} }

View File

@@ -7,50 +7,94 @@ import { useUserRepoStore } from "../modules/repo/store/userRepo.store"
const store = useUserRepoStore() const store = useUserRepoStore()
const fontFamilies = computed(() => store.userSettings?.fontFamilies ?? []) const DEFAULT_FONT_FAMILIES = [
const sortedFontFamilies = computed(() => "EB Garamond",
[...fontFamilies.value].sort((a, b) => a.localeCompare(b)), "Inter",
"Lato",
"Libertinus Serif",
"Lora",
"Merriweather",
"Playfair Display",
"Roboto",
"Source Serif 4"
]
const fontFamilies = computed(
() => store.userSettings?.fontFamilies ?? DEFAULT_FONT_FAMILIES
) )
const sortedFontFamilies = computed(() => {
const base = fontFamilies.value
const extras = [
store.userSettings?.chosenTitleFont,
store.userSettings?.chosenBodyFont
].filter((f): f is string => !!f && !base.includes(f))
return [...base, ...extras].sort((a, b) => a.localeCompare(b))
})
const fontSizes = Array.from({ length: 7 }, (_, i) => `${9 + i * 2}pt`) const fontSizes = Array.from({ length: 7 }, (_, i) => `${9 + i * 2}pt`)
const titleFont = computed({
get: () => store.userSettings?.chosenTitleFont,
set: (value) => store.setTitleFont(value!)
})
const bodyFont = computed({
get: () => store.userSettings?.chosenBodyFont,
set: (value) => store.setBodyFont(value!)
})
const fontSize = computed({
get: () => store.userSettings?.chosenFontSize,
set: (value) => store.setFontSize(value!)
})
</script> </script>
<template> <template>
<div class="font-change" v-if="sortedFontFamilies.length > 0"> <div class="font-change">
<theme-swap /> <div>
<label for="title-font" class="font-label">t</label>
<select <select id="title-font" class="select" v-model="titleFont">
class="select"
:value="store.userSettings?.chosenFontFamily"
@change="store.setFontFamily(($event.target as HTMLSelectElement).value)"
>
<option v-for="font in sortedFontFamilies" :key="font" :value="font"> <option v-for="font in sortedFontFamilies" :key="font" :value="font">
{{ font }} {{ font }}
</option> </option>
</select> </select>
<select <label for="body-font" class="font-label">p</label>
class="select" <select id="body-font" class="select" v-model="bodyFont">
:value="store.userSettings?.chosenFontSize" <option v-for="font in sortedFontFamilies" :key="font" :value="font">
@change="store.setFontSize(($event.target as HTMLSelectElement).value)" {{ font }}
> </option>
</select>
</div>
<div>
<theme-swap />
<label for="font-size" class="font-label">s</label>
<select id="font-size" class="select" v-model="fontSize">
<option v-for="size in fontSizes" :key="size" :value="size"> <option v-for="size in fontSizes" :key="size" :value="size">
{{ size }} {{ size }}
</option> </option>
</select> </select>
</div> </div>
</div>
</template> </template>
<style lang="scss" scoped> <style lang="scss" scoped>
.font-change { .font-change {
flex: 1;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 1rem;
select { select {
flex: 1; flex: 1;
display: flex; display: flex;
} }
div {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 1rem;
margin: 1rem;
}
}
.font-label {
font-weight: bold;
font-size: 0.75rem;
opacity: 0.6;
} }
</style> </style>

View File

@@ -1,11 +1,11 @@
<script lang="ts" setup> <script lang="ts" setup>
import { useRouter } from 'vue-router' import { useRouter } from "vue-router"
const { push } = useRouter() const { push } = useRouter()
const back = () => const back = () =>
push({ push({
name: 'Home' name: "Home"
}) })
</script> </script>

View File

@@ -1,32 +1,13 @@
<script lang="ts" setup> <script lang="ts" setup>
import FontChange from "@/components/FontChange.vue" import FontChange from "@/components/FontChange.vue"
import HomeButton from "@/components/HomeButton.vue"
defineProps<{ user: string; repo: string }>() defineProps<{ user: string; repo: string }>()
</script> </script>
<template> <template>
<header class="header-note"> <header class="header-note">
<router-link <home-button />
:to="{ name: 'Home' }"
class="button is-small is-white back-button"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="icon icon-tabler icon-tabler-arrow-narrow-left"
width="28"
height="28"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="5" y1="12" x2="19" y2="12" />
<line x1="5" y1="12" x2="9" y2="16" />
<line x1="5" y1="12" x2="9" y2="8" />
</svg>
</router-link>
<!-- <router-link <!-- <router-link
:to="{ name: 'SpacedRepetitionCard', params: { user, repo } }" :to="{ name: 'SpacedRepetitionCard', params: { user, repo } }"
> >
@@ -51,12 +32,15 @@ defineProps<{ user: string; repo: string }>()
</svg> </svg>
</router-link> --> </router-link> -->
<button onclick="font_modal.showModal()"> <button
class="btn btn-ghost btn-circle text-base-content"
onclick="font_modal.showModal()"
>
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
class="icon icon-tabler icons-tabler-outline icon-tabler-typography" class="icon icon-tabler icons-tabler-outline icon-tabler-typography"
width="36" width="30"
height="36" height="30"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke-width="1.5" stroke-width="1.5"
stroke="currentColor" stroke="currentColor"
@@ -71,11 +55,14 @@ defineProps<{ user: string; repo: string }>()
<path d="M5 20l6 -16l2 0l7 16" /> <path d="M5 20l6 -16l2 0l7 16" />
</svg> </svg>
</button> </button>
<router-link :to="{ name: 'FluxNoteView', params: { user, repo } }"> <router-link
class="btn btn-ghost btn-circle"
:to="{ name: 'FluxNoteView', params: { user, repo } }"
>
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
width="36" width="30"
height="36" height="30"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke="currentColor" stroke="currentColor"
fill="none" fill="none"
@@ -88,12 +75,15 @@ defineProps<{ user: string; repo: string }>()
<path d="M9 21v-6a2 2 0 0 1 2 -2h2a2 2 0 0 1 2 2v6" /> <path d="M9 21v-6a2 2 0 0 1 2 -2h2a2 2 0 0 1 2 2v6" />
</svg> </svg>
</router-link> </router-link>
<router-link :to="{ name: 'DraftNotes', params: { user, repo } }"> <router-link
class="btn btn-ghost btn-circle"
:to="{ name: 'DraftNotes', params: { user, repo } }"
>
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
class="icon icon-tabler icon-tabler-notes" class="icon icon-tabler icon-tabler-notes"
width="36" width="30"
height="36" height="30"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke-width="1.5" stroke-width="1.5"
stroke="currentColor" stroke="currentColor"
@@ -107,11 +97,14 @@ defineProps<{ user: string; repo: string }>()
<line x1="9" y1="15" x2="13" y2="15" /> <line x1="9" y1="15" x2="13" y2="15" />
</svg> </svg>
</router-link> </router-link>
<router-link :to="{ name: 'TodoNotes', params: { user, repo } }"> <router-link
class="btn btn-ghost btn-circle"
:to="{ name: 'TodoNotes', params: { user, repo } }"
>
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
width="36" width="30"
height="36" height="30"
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
@@ -129,12 +122,15 @@ defineProps<{ user: string; repo: string }>()
<path d="M11 18l9 0" /> <path d="M11 18l9 0" />
</svg> </svg>
</router-link> </router-link>
<router-link :to="{ name: 'FleetingNotes', params: { user, repo } }"> <router-link
class="btn btn-ghost btn-circle"
:to="{ name: 'FleetingNotes', params: { user, repo } }"
>
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
class="icon icon-tabler icon-tabler-mailbox" class="icon icon-tabler icon-tabler-mailbox"
width="36" width="30"
height="36" height="30"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke-width="1.5" stroke-width="1.5"
stroke="currentColor" stroke="currentColor"
@@ -150,7 +146,7 @@ defineProps<{ user: string; repo: string }>()
</svg> </svg>
</router-link> </router-link>
<dialog id="font_modal" class="modal"> <dialog id="font_modal" class="modal">
<div class="modal-box"> <div class="modal-box w-10/12 max-w-2xl">
<h3 class="text-lg font-bold">Style settings</h3> <h3 class="text-lg font-bold">Style settings</h3>
<font-change /> <font-change />
</div> </div>
@@ -168,12 +164,6 @@ defineProps<{ user: string; repo: string }>()
justify-content: space-between; justify-content: space-between;
margin-top: 10px; margin-top: 10px;
img {
&:hover {
cursor: pointer;
}
}
button { button {
color: var(--color-accent); color: var(--color-accent);
} }

View File

@@ -6,13 +6,22 @@ const goHome = () => router.push({ name: "Home" })
</script> </script>
<template> <template>
<a class="btn btn-ghost btn-circle btn-lg" @click="goHome"> <button
<img src="/favicon.png" alt="Remanso icon" /> class="btn btn-ghost btn-circle btn-lg text-base-content"
</a> @click="goHome"
>
<img src="/favicon.png" alt="Remanso icon" class="remanso-logo" />
</button>
</template> </template>
<style> <style>
img { img {
box-shadow: none; box-shadow: none;
} }
.remanso-logo {
width: 32px;
height: 32px;
view-transition-name: remanso-logo;
}
</style> </style>

View File

@@ -24,9 +24,9 @@ const emitNote = (sha: string) => {
<h5 class="subtitle is-5">🔗</h5> <h5 class="subtitle is-5">🔗</h5>
<ul class="links"> <ul class="links">
<li v-for="link in backlink?.links" :key="link.sha"> <li v-for="link in backlink?.links" :key="link.sha">
<a @click.prevent="emitNote(link.sha)"> <button class="link" @click="emitNote(link.sha)">
{{ link.title }} {{ link.title }}
</a> </button>
</li> </li>
</ul> </ul>
</div> </div>

View File

@@ -1,8 +1,8 @@
<script lang="ts" setup> <script lang="ts" setup>
const url = new URL('https://github.com/login/oauth/authorize') const url = new URL("https://github.com/login/oauth/authorize")
url.searchParams.append('client_id', 'Iv1.87be14adcc912fa0') url.searchParams.append("client_id", "Iv1.87be14adcc912fa0")
url.searchParams.append('redirect_uri', location.href) url.searchParams.append("redirect_uri", location.href)
url.searchParams.append('scope', 'repo') url.searchParams.append("scope", "repo")
</script> </script>
<template> <template>

View File

@@ -0,0 +1,82 @@
<script lang="ts" setup>
import { onMounted, ref, watch } from "vue"
const props = defineProps<{ open: boolean }>()
const emit = defineEmits<{
(e: "discard"): void
(e: "overwrite"): void
(e: "cancel"): void
(e: "update:open", value: boolean): void
}>()
const dialogRef = ref<HTMLDialogElement | null>(null)
const close = () => {
if (dialogRef.value?.open) dialogRef.value.close()
emit("update:open", false)
}
const choose = (action: "discard" | "overwrite" | "cancel") => {
emit(action)
close()
}
watch(
() => props.open,
(open) => {
const el = dialogRef.value
if (!el) return
if (open && !el.open) el.showModal()
else if (!open && el.open) el.close()
}
)
onMounted(() => {
if (props.open) dialogRef.value?.showModal()
})
</script>
<template>
<dialog
ref="dialogRef"
class="modal"
@close="emit('update:open', false)"
@cancel.prevent="choose('cancel')"
>
<div class="modal-box">
<h3 class="text-lg font-bold">GitHub has a newer version of this note</h3>
<p class="py-3 text-sm">
Someone (or another device) updated this note on GitHub since you
started editing. If you save now, their changes will be overwritten.
</p>
<div class="modal-action flex-col gap-2">
<button
type="button"
class="btn btn-ghost"
@click="choose('cancel')"
>
Cancel
</button>
<button
type="button"
class="btn btn-warning"
@click="choose('overwrite')"
>
Save anyway (overwrite)
</button>
<button
type="button"
class="btn btn-primary"
@click="choose('discard')"
>
Discard my edits, pull latest
</button>
</div>
</div>
<form method="dialog" class="modal-backdrop">
<button type="submit" @click="choose('cancel')">close</button>
</form>
</dialog>
</template>

View File

@@ -0,0 +1,215 @@
<script lang="ts" setup>
import { computed } from "vue"
import type { FreshnessStatus } from "@/hooks/useNoteFreshness.hook"
const props = defineProps<{
status: FreshnessStatus
lastCheckedAt: Date | null
}>()
defineEmits<{ (e: "click"): void }>()
const formatTime = (d: Date) =>
d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" })
const label = computed(() => {
switch (props.status) {
case "verified":
return "Up to date"
case "checking":
return "Checking…"
case "outdated":
return "Outdated"
case "offline":
return "Cant reach GitHub"
case "unknown":
default:
return "Not checked"
}
})
const tooltip = computed(() => {
switch (props.status) {
case "verified":
return props.lastCheckedAt
? `Verified at ${formatTime(props.lastCheckedAt)}. Click to re-check.`
: "Click to re-check."
case "outdated":
return "GitHub has a newer version. Click to pull latest."
case "offline":
return "Could not reach GitHub. Click to retry."
case "checking":
return "Checking against GitHub…"
case "unknown":
default:
return "Click to check against GitHub."
}
})
const stateClass = computed(() => `state-${props.status}`)
const isBusy = computed(() => props.status === "checking")
</script>
<template>
<button
class="freshness button is-text is-light"
:class="stateClass"
:title="tooltip"
:aria-label="tooltip"
:disabled="isBusy"
@click="$emit('click')"
>
<svg
v-if="status === 'verified'"
xmlns="http://www.w3.org/2000/svg"
class="icon icon-tabler icon-tabler-cloud-check"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
stroke-linejoin="round"
>
<path
d="M11 18.004h-4.343c-2.572 -.004 -4.657 -2.011 -4.657 -4.487c0 -2.475 2.085 -4.482 4.657 -4.482c.393 -1.762 1.794 -3.2 3.675 -3.773c1.88 -.572 3.956 -.193 5.444 1c1.488 1.19 2.162 3.007 1.77 4.769h.99c1.388 0 2.585 .82 3.138 2.007"
/>
<path d="M15 19l2 2l4 -4" />
</svg>
<svg
v-else-if="status === 'unknown'"
xmlns="http://www.w3.org/2000/svg"
class="icon icon-tabler icon-tabler-cloud-question"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
stroke-linejoin="round"
>
<path
d="M14.5 18.004h-7.843c-2.572 -.004 -4.657 -2.011 -4.657 -4.487c0 -2.475 2.085 -4.482 4.657 -4.482c.393 -1.762 1.794 -3.2 3.675 -3.773c1.88 -.572 3.956 -.193 5.444 1c1.488 1.19 2.162 3.007 1.77 4.769h.99"
/>
<path d="M19 22v.01" />
<path
d="M19 19a2.003 2.003 0 0 0 .914 -3.782a1.98 1.98 0 0 0 -2.414 .483"
/>
</svg>
<svg
v-else-if="status === 'outdated'"
xmlns="http://www.w3.org/2000/svg"
class="icon icon-tabler icon-tabler-cloud-download"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
stroke-linejoin="round"
>
<path
d="M19 18a3.5 3.5 0 0 0 0 -7h-1a5 4.5 0 0 0 -11 -2a4.6 4.4 0 0 0 -2.1 8.4"
/>
<path d="M12 13l0 9" />
<path d="M9 19l3 3l3 -3" />
</svg>
<svg
v-else-if="status === 'checking'"
xmlns="http://www.w3.org/2000/svg"
class="icon icon-tabler icon-tabler-loader-2 spin"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M12 3a9 9 0 1 0 9 9" />
</svg>
<svg
v-else
xmlns="http://www.w3.org/2000/svg"
class="icon icon-tabler icon-tabler-cloud-off"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
stroke-linejoin="round"
>
<path
d="M9.58 5.548c.24 -.11 .492 -.207 .752 -.286c1.88 -.572 3.956 -.193 5.444 1c1.488 1.19 2.162 3.007 1.77 4.769h.99c1.913 0 3.464 1.56 3.464 3.486c0 .957 -.383 1.824 -1.003 2.454m-2.997 1.033h-11.343c-2.572 -.004 -4.657 -2.011 -4.657 -4.487c0 -2.475 2.085 -4.482 4.657 -4.482c.13 -.582 .37 -1.128 .7 -1.62"
/>
<path d="M3 3l18 18" />
</svg>
</button>
</template>
<style lang="scss" scoped>
.freshness {
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-size: 0.75rem;
padding: 0.15rem 0.5rem;
background: transparent;
border: 0;
cursor: pointer;
vertical-align: middle;
&[disabled] {
cursor: progress;
}
.icon {
flex-shrink: 0;
}
.freshness-label {
line-height: 1;
}
}
.state-verified {
color: var(--color-success, hsl(140, 60%, 35%));
}
.state-outdated {
color: var(--color-warning, hsl(35, 90%, 45%));
}
.state-offline {
color: var(--color-error, hsl(0, 70%, 45%));
}
.state-unknown,
.state-checking {
color: var(--color-base-content);
opacity: 0.6;
}
.spin {
animation: freshness-spin 1s linear infinite;
}
@keyframes freshness-spin {
to {
transform: rotate(360deg);
}
}
@media screen and (max-width: 768px) {
.freshness-label {
display: none;
}
}
</style>

View File

@@ -1,9 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { PublicNoteListItem } from "@/modules/note/models/Note"
import { toShortDid } from "@/modules/atproto/shortDid"
import { slugify } from "@/utils/slugify"
import { vInfiniteScroll } from "@vueuse/components" import { vInfiniteScroll } from "@vueuse/components"
import { toShortDid } from "@/modules/atproto/shortDid"
import { PublicNoteListItem } from "@/modules/note/models/Note"
import { slugify } from "@/utils/slugify"
defineProps<{ defineProps<{
notes: PublicNoteListItem[] notes: PublicNoteListItem[]
canLoadMore: boolean canLoadMore: boolean
@@ -28,8 +29,8 @@ defineSlots<{
params: { params: {
shortDid: toShortDid(note.did), shortDid: toShortDid(note.did),
rkey: note.rkey, rkey: note.rkey,
slug: slugify(note.title), slug: slugify(note.title)
}, }
}" }"
class="btn btn-link" class="btn btn-link"
>{{ note.title }}</router-link >{{ note.title }}</router-link

View File

@@ -26,8 +26,8 @@ const getStyle = (seed: string) => {
name: 'FluxNoteView', name: 'FluxNoteView',
params: { params: {
user: username, user: username,
repo: favoriteRepo.name, repo: favoriteRepo.name
}, }
}" }"
class="btn" class="btn"
:style="getStyle(`${favoriteRepo.name}-${username}`)" :style="getStyle(`${favoriteRepo.name}-${username}`)"
@@ -40,6 +40,7 @@ const getStyle = (seed: string) => {
<style scoped lang="scss"> <style scoped lang="scss">
.repo-list { .repo-list {
display: flex; display: flex;
justify-content: space-evenly;
gap: 1rem; gap: 1rem;
flex-wrap: wrap; flex-wrap: wrap;

View File

@@ -3,15 +3,16 @@ import { ref } from "vue"
import { useATProtoLogin } from "@/hooks/useATProtoLogin.hook" import { useATProtoLogin } from "@/hooks/useATProtoLogin.hook"
const { handle, isLoggedIn, signIn, signOut } = useATProtoLogin() const { handle, isLoggedIn, isATProtoReady, signIn, signOut } =
useATProtoLogin()
withDefaults( withDefaults(
defineProps<{ defineProps<{
withSignOut?: boolean withSignOut?: boolean
}>(), }>(),
{ {
withSignOut: true, withSignOut: true
}, }
) )
const inputHandle = ref("") const inputHandle = ref("")
@@ -24,13 +25,14 @@ const onSignIn = () => {
</script> </script>
<template> <template>
<div v-if="isLoggedIn" class="sign-in-atproto is-signed-in"> <div v-if="!isATProtoReady" class="skeleton h-8 w-40"></div>
<div v-else-if="isLoggedIn" class="sign-in-atproto is-signed-in">
<span>{{ handle }}</span> <span>{{ handle }}</span>
<button class="btn btn-sm" @click="signOut" v-if="withSignOut"> <button class="btn btn-sm" @click="signOut" v-if="withSignOut">
Sign out Sign out
</button> </button>
</div> </div>
<div v-else class="sign-in-atproto join"> <div v-else-if="!isLoggedIn" class="sign-in-atproto join">
<input <input
v-model="inputHandle" v-model="inputHandle"
class="input input-sm join-item" class="input input-sm join-item"

View File

@@ -5,7 +5,7 @@ import {
nextTick, nextTick,
onMounted, onMounted,
ref, ref,
watch, watch
} from "vue" } from "vue"
import { useEditionMode } from "@/hooks/useEditionMode" import { useEditionMode } from "@/hooks/useEditionMode"
@@ -13,20 +13,35 @@ import { useFile } from "@/hooks/useFile.hook"
import { useGitHubContent } from "@/hooks/useGitHubContent.hook" import { useGitHubContent } from "@/hooks/useGitHubContent.hook"
import { useImages } from "@/hooks/useImages.hook" import { useImages } from "@/hooks/useImages.hook"
import { useLinks } from "@/hooks/useLinks.hook" import { useLinks } from "@/hooks/useLinks.hook"
import {
renderCodeFile,
runMermaid,
useShikiji
} from "@/hooks/useMarkdown.hook"
import { useNoteFreshness } from "@/hooks/useNoteFreshness.hook"
import { useNoteOverlay } from "@/hooks/useNoteOverlay.hook" import { useNoteOverlay } from "@/hooks/useNoteOverlay.hook"
import { useRouteQueryStackedNotes } from "@/hooks/useRouteQueryStackedNotes.hook" import { useRouteQueryStackedNotes } from "@/hooks/useRouteQueryStackedNotes.hook"
import { useTitleNotes } from "@/hooks/useTitleNotes.hook" import { useTitleNotes } from "@/hooks/useTitleNotes.hook"
import { useUserRepoStore } from "@/modules/repo/store/userRepo.store" import { useUserRepoStore } from "@/modules/repo/store/userRepo.store"
import { encodeUTF8ToBase64 } from "@/utils/decodeBase64ToUTF8" import { encodeUTF8ToBase64 } from "@/utils/decodeBase64ToUTF8"
import { getFileLanguage, isMarkdownPath } from "@/utils/fileLanguage"
import { filenameToNoteTitle } from "@/utils/noteTitle" import { filenameToNoteTitle } from "@/utils/noteTitle"
import { runMermaid, useShikiji } from "@/hooks/useMarkdown.hook" import { errorMessage } from "@/utils/notif"
const LinkedNotes = defineAsyncComponent( const LinkedNotes = defineAsyncComponent(
() => import("@/components/LinkedNotes.vue"), () => import("@/components/LinkedNotes.vue")
)
const NoteFreshnessBadge = defineAsyncComponent(
() => import("@/components/NoteFreshnessBadge.vue")
)
const NoteConflictModal = defineAsyncComponent(
() => import("@/components/NoteConflictModal.vue")
) )
const EditNote = defineAsyncComponent( const EditNote = defineAsyncComponent(
() => import("@/modules/note/components/EditNote.vue"), () => import("@/modules/note/components/EditNote.vue")
) )
const props = defineProps<{ const props = defineProps<{
@@ -50,9 +65,38 @@ const {
rawContent, rawContent,
getRawContent, getRawContent,
saveCacheNote, saveCacheNote,
getEditedSha, getEditedSha
} = useFile(sha) } = useFile(sha)
const initialRawContent = ref<string | null>(null) const initialRawContent = ref<string | null>(null)
const isMarkdown = computed(() =>
path.value ? isMarkdownPath(path.value) : true
)
const displayedContent = ref("")
watch(
[rawContent, isMarkdown, path],
async ([raw, isMd, p]) => {
if (!raw) {
displayedContent.value = ""
return
}
if (isMd) {
displayedContent.value = content.value
return
}
const lang = p ? getFileLanguage(p) : null
const filename = p?.split("/").pop()
const result = await renderCodeFile({ rawContent: raw, lang, filename })
if (rawContent.value === raw) {
displayedContent.value = result
}
},
{ immediate: true }
)
watch(content, (c) => {
if (isMarkdown.value) displayedContent.value = c
})
const className = computed(() => `stacked-note-${props.index}`) const className = computed(() => `stacked-note-${props.index}`)
const { listenToClick } = useLinks(className.value, sha) const { listenToClick } = useLinks(className.value, sha)
const titleClassName = computed(() => `title-${className.value}`) const titleClassName = computed(() => `title-${className.value}`)
@@ -67,13 +111,37 @@ const breadcrumbs = computed(() => displayedTitle.value.split(" / "))
const { updateFile } = useGitHubContent({ const { updateFile } = useGitHubContent({
user: user.value, user: user.value,
repo: repo.value, repo: repo.value
}) })
const {
status: freshnessStatus,
lastCheckedAt,
latestSha,
check: checkFreshness,
pullLatest
} = useNoteFreshness({
user: user.value,
repo: repo.value,
sha,
path,
getEditedSha
})
const conflictOpen = ref(false)
onMounted(async () => { onMounted(async () => {
initialRawContent.value = await getRawContent() initialRawContent.value = await getRawContent()
}) })
watch(
path,
(p) => {
if (p) void checkFreshness()
},
{ immediate: true }
)
const { mode, toggleMode } = useEditionMode() const { mode, toggleMode } = useEditionMode()
watch([content, mode], () => { watch([content, mode], () => {
@@ -92,13 +160,49 @@ watch([content, mode], () => {
runMermaid(`.note-${sha.value} .mermaid`) runMermaid(`.note-${sha.value} .mermaid`)
} }
if (rawContent.value.includes("```")) { if (isMarkdown.value && rawContent.value.includes("```")) {
useShikiji() useShikiji()
} }
}) })
}) })
const performSave = async (overrideSha?: string) => {
if (!path.value) {
console.warn("no path found for this file")
return
}
const editedSha = overrideSha ?? (await getEditedSha()) ?? sha.value
const { sha: newSha, conflict } = await updateFile({
content: rawContent.value,
path: path.value,
sha: editedSha
})
if (conflict) {
await checkFreshness()
conflictOpen.value = true
if (mode.value === "read") toggleMode()
return
}
if (!newSha) {
console.warn("no new SHA found for this file")
return
}
await saveCacheNote(encodeUTF8ToBase64(rawContent.value), {
editedSha: newSha
})
initialRawContent.value = rawContent.value
}
watch(mode, async (newMode) => { watch(mode, async (newMode) => {
if (newMode === "edit") {
void checkFreshness()
return
}
const hasUserFinishedToEdit = const hasUserFinishedToEdit =
newMode === "read" && rawContent.value !== initialRawContent.value newMode === "read" && rawContent.value !== initialRawContent.value
@@ -107,28 +211,59 @@ watch(mode, async (newMode) => {
} }
if (!path.value) { if (!path.value) {
console.warn("no path found for this file") console.warn("no path found for this file")
return return
} }
const editedSha = (await getEditedSha()) ?? sha.value await checkFreshness()
const newSha = await updateFile({ if (freshnessStatus.value === "outdated") {
content: rawContent.value, conflictOpen.value = true
path: path.value,
sha: editedSha,
})
if (!newSha) {
console.warn("no new SHA found for this file")
return return
} }
await saveCacheNote(encodeUTF8ToBase64(rawContent.value), { await performSave()
editedSha: newSha,
})
initialRawContent.value = rawContent.value
}) })
const onConflictDiscard = async () => {
const newRaw = await pullLatest()
if (newRaw !== null) {
rawContent.value = newRaw
initialRawContent.value = newRaw
}
}
const onConflictOverwrite = async () => {
if (latestSha.value) {
await performSave(latestSha.value)
}
}
const onConflictCancel = () => {
if (mode.value === "read") toggleMode()
}
const onBadgeClick = async () => {
try {
if (freshnessStatus.value !== "outdated") {
await checkFreshness()
return
}
const hasUnsavedEdits = rawContent.value !== initialRawContent.value
if (hasUnsavedEdits) {
conflictOpen.value = true
return
}
const newRaw = await pullLatest()
if (newRaw !== null) {
rawContent.value = newRaw
initialRawContent.value = newRaw
}
} catch (error) {
console.error("freshness badge click failed", error)
errorMessage("❌ Couldn't pull latest from GitHub")
}
}
</script> </script>
<template> <template>
@@ -137,86 +272,96 @@ watch(mode, async (newMode) => {
:class="{ :class="{
[className]: true, [className]: true,
overlay: displayNoteOverlay, overlay: displayNoteOverlay,
[`note-${sha}`]: true, [`note-${sha}`]: true
}" }"
> >
<div class="title-stacked-note breadcrumbs text-sm" :class="titleClassName">
<div class="action-bar">
<note-freshness-badge
:status="freshnessStatus"
:last-checked-at="lastCheckedAt"
@click="onBadgeClick"
class="action"
/>
<button
v-if="isMarkdown"
class="action button is-text is-light"
:class="{ 'is-link': mode === 'edit' }"
:style="mode === 'edit' ? 'color: var(--color-primary)' : ''"
@click="toggleMode"
>
<svg
v-if="mode === 'read'"
xmlns="http://www.w3.org/2000/svg"
class="icon icon-tabler icon-tabler-edit"
width="24"
height="24"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path
d="M7 7h-1a2 2 0 0 0 -2 2v9a2 2 0 0 0 2 2h9a2 2 0 0 0 2 -2v-1"
/>
<path
d="M20.385 6.585a2.1 2.1 0 0 0 -2.97 -2.97l-8.415 8.385v3h3l8.385 -8.415z"
/>
<path d="M16 5l3 3" />
</svg>
<svg
v-else
xmlns="http://www.w3.org/2000/svg"
class="icon icon-tabler icon-tabler-device-floppy"
width="24"
height="24"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path
d="M6 4h10l4 4v10a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2v-12a2 2 0 0 1 2 -2"
/>
<path d="M12 14m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" />
<path d="M14 4l0 4l-6 0l0 -4" />
</svg>
</button>
</div>
<a <a
class="title-stacked-note-link" class="title-stacked-note-link"
@click.prevent="scrollToFocusedNote(props.sha)" @click.prevent="scrollToFocusedNote({ noteId: props.sha })"
>
<div
class="title-stacked-note breadcrumbs text-sm"
:class="titleClassName"
> >
<ul> <ul>
<li v-for="(part, i) in breadcrumbs" :key="i"> <li v-for="(part, i) in breadcrumbs" :key="i">
{{ part }} {{ part }}
</li> </li>
</ul> </ul>
</div>
</a> </a>
<section class="text-content">
<button
class="action button is-text is-light"
:class="{ 'is-link': mode === 'edit' }"
@click="toggleMode"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="icon icon-tabler icon-tabler-edit"
width="24"
height="24"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path
d="M7 7h-1a2 2 0 0 0 -2 2v9a2 2 0 0 0 2 2h9a2 2 0 0 0 2 -2v-1"
/>
<path
d="M20.385 6.585a2.1 2.1 0 0 0 -2.97 -2.97l-8.415 8.385v3h3l8.385 -8.415z"
/>
<path d="M16 5l3 3" />
</svg>
</button>
<div v-if="mode === 'edit'" class="edit">
<edit-note v-model="rawContent" />
<button
class="action button is-text is-light"
:class="{ 'is-link': mode === 'edit' }"
@click="toggleMode"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="icon icon-tabler icon-tabler-edit"
width="24"
height="24"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path
d="M7 7h-1a2 2 0 0 0 -2 2v9a2 2 0 0 0 2 2h9a2 2 0 0 0 2 -2v-1"
/>
<path
d="M20.385 6.585a2.1 2.1 0 0 0 -2.97 -2.97l-8.415 8.385v3h3l8.385 -8.415z"
/>
<path d="M16 5l3 3" />
</svg>
</button>
</div> </div>
<div v-if="mode === 'read'" class="note-content" v-html="content"></div> <section class="text-content">
<div v-if="mode === 'edit' && isMarkdown" class="edit">
<edit-note v-model="rawContent" />
</div>
<div
v-if="mode === 'read'"
class="note-content"
v-html="displayedContent"
></div>
</section> </section>
<linked-notes v-if="hasBacklinks && content" :sha="sha" /> <linked-notes v-if="hasBacklinks && content" :sha="sha" />
<note-conflict-modal
v-model:open="conflictOpen"
@discard="onConflictDiscard"
@overwrite="onConflictOverwrite"
@cancel="onConflictCancel"
/>
</div> </div>
</template> </template>
@@ -234,7 +379,7 @@ $border-color: rgba(18, 19, 58, 0.2);
} }
section { section {
padding: 0 0.5rem 2rem; padding: 0 0.5rem;
} }
} }
@@ -248,7 +393,6 @@ $border-color: rgba(18, 19, 58, 0.2);
background-color: var(--color-base-100); background-color: var(--color-base-100);
color: var(--color-base-content); color: var(--color-base-content);
font-size: 0.8em; font-size: 0.8em;
overflow: hidden;
ul, ul,
li { li {
@@ -263,14 +407,25 @@ $border-color: rgba(18, 19, 58, 0.2);
flex: 1; flex: 1;
scrollbar-width: none; scrollbar-width: none;
div { > .edit,
> .note-content {
height: 100%; height: 100%;
} }
} }
.action-bar {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 0.25rem;
}
.action { .action {
float: right; margin: 0;
margin: 0.2rem;
&:hover {
cursor: pointer;
}
img { img {
vertical-align: bottom; vertical-align: bottom;
@@ -280,9 +435,10 @@ $border-color: rgba(18, 19, 58, 0.2);
@media screen and (max-width: 768px) { @media screen and (max-width: 768px) {
.stacked-note { .stacked-note {
padding: 0 0.75rem 1rem; padding: 0 0.75rem 1rem;
height: 100svh;
section { section {
padding: 1rem 0 2rem; padding: 1rem 0;
overflow-x: auto; overflow-x: auto;
} }
@@ -297,10 +453,12 @@ $border-color: rgba(18, 19, 58, 0.2);
.stacked-note { .stacked-note {
border-top: 0; border-top: 0;
border-left: 1px solid $border-color; border-left: 1px solid $border-color;
position: sticky;
top: 0;
} }
.title-stacked-note { .title-stacked-note {
padding: 0 1rem; padding: 0;
transform-origin: 0 0; transform-origin: 0 0;
transform: rotate(90deg); transform: rotate(90deg);
} }
@@ -308,6 +466,12 @@ $border-color: rgba(18, 19, 58, 0.2);
a { a {
white-space: nowrap; white-space: nowrap;
} }
.action-bar {
.action {
transform: rotate(-90deg);
}
}
} }
@media print { @media print {

View File

@@ -1,16 +1,19 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computedAsync } from "@vueuse/core"
import { computed, nextTick, ref, watch } from "vue" import { computed, nextTick, ref, watch } from "vue"
import { errorMessage } from "@/utils/notif" import { useRoute } from "vue-router"
import SkeletonLoader from "@/components/SkeletonLoader.vue"
import { useATProtoLinks } from "@/hooks/useATProtoLinks.hook" import { useATProtoLinks } from "@/hooks/useATProtoLinks.hook"
import { markdownBuilder } from "@/hooks/useMarkdown.hook"
import { useNoteOverlay } from "@/hooks/useNoteOverlay.hook" import { useNoteOverlay } from "@/hooks/useNoteOverlay.hook"
import { useRouteQueryStackedNotes } from "@/hooks/useRouteQueryStackedNotes.hook" import { useRouteQueryStackedNotes } from "@/hooks/useRouteQueryStackedNotes.hook"
import { markdownBuilder } from "@/hooks/useMarkdown.hook"
import { computedAsync } from "@vueuse/core"
import { getUrl } from "@/modules/atproto/getUrl"
import { withATProtoImages } from "@/modules/atproto/withATProtoImages"
import { getAuthor } from "@/modules/atproto/getAuthor" import { getAuthor } from "@/modules/atproto/getAuthor"
import { fromShortDid } from "@/modules/atproto/shortDid" import { getUrl } from "@/modules/atproto/getUrl"
import { PublicNoteRecord } from "@/modules/atproto/publicNote.types" import { PublicNoteRecord } from "@/modules/atproto/publicNote.types"
import { fromShortDid } from "@/modules/atproto/shortDid"
import { withATProtoImages } from "@/modules/atproto/withATProtoImages"
import { errorMessage } from "@/utils/notif"
const props = defineProps<{ const props = defineProps<{
didrkey: string didrkey: string
@@ -26,14 +29,22 @@ const index = computed(() => props.index)
const author = computedAsync(async () => getAuthor(did.value)) const author = computedAsync(async () => getAuthor(did.value))
const url = computedAsync(async () => const url = computedAsync(async () =>
getUrl({ did: did.value, rkey: rkey.value }), getUrl({ did: did.value, rkey: rkey.value })
) )
const className = computed(() => `stacked-note-${props.index}`) const className = computed(() => `stacked-note-${props.index}`)
const titleClassName = computed(() => `title-${className.value}`) const titleClassName = computed(() => `title-${className.value}`)
const route = useRoute()
const mainNoteId = computed(
() => `${route.params.shortDid}-${route.params.rkey}`
)
const { scrollToFocusedNote } = useRouteQueryStackedNotes() const { scrollToFocusedNote } = useRouteQueryStackedNotes()
const { listenToClick } = useATProtoLinks(className.value, didrkey) const { listenToClick } = useATProtoLinks(className.value, {
currentAtUri: didrkey,
mainNoteId
})
const { displayNoteOverlay } = useNoteOverlay(className.value, index) const { displayNoteOverlay } = useNoteOverlay(className.value, index)
const noteNotFound = ref(false) const noteNotFound = ref(false)
@@ -59,10 +70,10 @@ const content = computed(() =>
? toHTML( ? toHTML(
withATProtoImages(noteRecord.value.value.content, { withATProtoImages(noteRecord.value.value.content, {
pds: author.value.pds, pds: author.value.pds,
did: did.value, did: did.value
}), })
) )
: "", : ""
) )
watch( watch(
@@ -71,7 +82,7 @@ watch(
await nextTick() await nextTick()
listenToClick() listenToClick()
}, },
{ immediate: true }, { immediate: true }
) )
</script> </script>
@@ -81,12 +92,12 @@ watch(
:class="{ :class="{
[className]: true, [className]: true,
overlay: displayNoteOverlay, overlay: displayNoteOverlay,
[`note-${classNameId}`]: true, [`note-${classNameId}`]: true
}" }"
> >
<a <a
class="title-stacked-note-link" class="title-stacked-note-link"
@click.prevent="scrollToFocusedNote(didrkey)" @click.prevent="scrollToFocusedNote({ noteId: didrkey })"
> >
<div <div
class="title-stacked-note breadcrumbs text-sm" class="title-stacked-note breadcrumbs text-sm"
@@ -99,7 +110,8 @@ watch(
<div v-if="noteNotFound" class="alert alert-error"> <div v-if="noteNotFound" class="alert alert-error">
This note no longer exists. This note no longer exists.
</div> </div>
<div class="note-content" v-else v-html="content"></div> <div class="note-content" v-else-if="content" v-html="content"></div>
<skeleton-loader v-else-if="!noteNotFound" />
</section> </section>
</div> </div>
</template> </template>
@@ -118,7 +130,7 @@ $border-color: rgba(18, 19, 58, 0.2);
} }
section { section {
padding: 0 0.5rem 2rem; padding: 0 0.5rem;
} }
} }
@@ -132,7 +144,6 @@ $border-color: rgba(18, 19, 58, 0.2);
background-color: var(--color-base-100); background-color: var(--color-base-100);
color: var(--color-base-content); color: var(--color-base-content);
font-size: 0.8em; font-size: 0.8em;
overflow: hidden;
ul, ul,
li { li {
@@ -166,7 +177,7 @@ $border-color: rgba(18, 19, 58, 0.2);
padding: 0 0.75rem 1rem; padding: 0 0.75rem 1rem;
section { section {
padding: 1rem 0 2rem; padding: 1rem 0;
overflow-x: auto; overflow-x: auto;
} }
@@ -181,6 +192,8 @@ $border-color: rgba(18, 19, 58, 0.2);
.stacked-note { .stacked-note {
border-top: 0; border-top: 0;
border-left: 1px solid $border-color; border-left: 1px solid $border-color;
position: sticky;
top: 0;
} }
.title-stacked-note { .title-stacked-note {

File diff suppressed because it is too large Load Diff

View File

@@ -4,9 +4,13 @@ export const getNoteWidth = () => {
if (cached === undefined) { if (cached === undefined) {
cached = parseInt( cached = parseInt(
getComputedStyle(document.documentElement).getPropertyValue( getComputedStyle(document.documentElement).getPropertyValue(
"--note-width", "--note-width"
), )
) )
} }
return cached return cached
} }
export const resetNoteWidthCache = () => {
cached = undefined
}

View File

@@ -1,11 +1,11 @@
export enum DataType { export enum DataType {
GithubAccessToken = 'GithubAccessToken', GithubAccessToken = "GithubAccessToken",
FavoriteRepo = 'FavoriteRepo', FavoriteRepo = "FavoriteRepo",
SavedRepo = 'SavedRepo', SavedRepo = "SavedRepo",
Note = 'Note', Note = "Note",
BacklinkNote = 'BacklinkNote', BacklinkNote = "BacklinkNote",
RepetitionCard = 'RepetitionCard', RepetitionCard = "RepetitionCard",
History = 'History', History = "History",
UserSettings = 'UserSettings', UserSettings = "UserSettings",
AtprotoSession = 'AtprotoSession' AtprotoSession = "AtprotoSession"
} }

View File

@@ -1,166 +1,31 @@
import { wrap } from "comlink"
import { nanoid } from "nanoid" import { nanoid } from "nanoid"
import indexedDb from "pouchdb-adapter-indexeddb"
import PouchDb from "pouchdb-browser"
import { DataType } from "./DataType.enum" import { DataType } from "./DataType.enum"
import { Model } from "./models/Model" import { Model } from "./models/Model"
PouchDb.plugin(indexedDb) export interface DataApi {
add<DT extends DataType>(model: Model<DT>): Promise<boolean>
interface GetAllParams { update<DT extends DataType, T extends Model<DT>>(model: T): Promise<boolean>
remove(id: string): Promise<boolean>
get<DT extends DataType, T extends Model<DT>>(id: string): Promise<T | null>
getOrCreate<DT extends DataType, T extends Model<DT>>(
id: string,
initialValue: T
): Promise<T>
getAll<DT extends DataType, T extends Model<DT>>(params: {
prefix?: string prefix?: string
includeDocs?: boolean includeDocs?: boolean
includeAttachments?: boolean includeAttachments?: boolean
keys?: string[] keys?: string[]
}): Promise<T[]>
} }
class Data { export const generateId = (type?: DataType | string, id?: string): string => {
// eslint-disable-next-line @typescript-eslint/ban-types if (!type) return id || nanoid()
private readonly locale: PouchDB.Database<{}> | null = null
constructor() {
try {
this.locale = new PouchDb("remanso", {
adapter: "indexeddb",
})
} catch (error) {
console.warn("data error", error)
}
}
public async add<DT extends DataType>(model: Model<DT>): Promise<boolean> {
try {
const result = await this.locale?.put(model)
return result?.ok ?? false
} catch (error) {
console.warn(error)
return false
}
}
public async update<DT extends DataType, T extends Model<DT>>(
model: T,
): Promise<boolean> {
try {
if (!model._id) {
const result = await this.locale?.put(model)
return result?.ok ?? false
}
const oldModel = await this.get(model._id)
if (oldModel) {
const result = await this.locale?.put({ ...oldModel, ...model })
return result?.ok ?? false
}
const result = await this.locale?.put(model)
return result?.ok ?? false
} catch (error) {
console.warn(error)
return false
}
}
public async remove(id: string): Promise<boolean> {
try {
const doc = await this.get(id)
if (!doc) {
return false
}
const result = await this.locale?.put({
...doc,
_deleted: true,
})
return result?.ok ?? false
} catch {
return false
}
}
public async get<DT extends DataType, T extends Model<DT>>(
id: string,
): Promise<T | null> {
try {
return ((await this.locale?.get(id)) as T) || null
} catch {
return null
}
}
public async getOrCreate<DT extends DataType, T extends Model<DT>>(
id: string,
initialValue: T,
): Promise<T> {
const element = await this.get<DT, T>(id)
if (element) {
return element
}
await data.add<DT>({ ...initialValue, _id: id })
return this.getOrCreate(id, initialValue)
}
public async getAll<DT extends DataType, T extends Model<DT>>({
prefix,
includeDocs = true,
includeAttachments = false,
keys = [],
}: GetAllParams): Promise<T[]> {
if (!this.locale) {
return []
}
if (keys.length) {
const response = await this.locale.allDocs({
include_docs: includeDocs,
attachments: includeAttachments,
keys: keys.map((key) => this.generateId(prefix, key)),
})
if (includeDocs) {
return response.rows
.map((row) => {
if ("error" in row) {
return null
}
return row.doc
})
.filter(Boolean) as T[]
} else {
return response.rows
.map((row) => {
if ("error" in row) {
return null
}
return { _id: row.id }
})
.filter(Boolean) as T[]
}
}
const response = await this.locale.allDocs({
include_docs: includeDocs,
attachments: includeAttachments,
startkey: prefix ? prefix : undefined,
endkey: prefix ? `${prefix}\ufff0` : undefined,
})
return response.rows.map((row) => row.doc) as T[]
}
public generateId(type?: DataType | string, id?: string) {
if (!type) {
return id || nanoid()
}
return `${type}-${id || nanoid()}` return `${type}-${id || nanoid()}`
} }
}
export const data = new Data() import DataWorker from "./data.worker?worker"
export const data = wrap(new DataWorker()) as unknown as DataApi

156
src/data/data.worker.ts Normal file
View File

@@ -0,0 +1,156 @@
import { expose } from "comlink"
import { nanoid } from "nanoid"
import indexedDb from "pouchdb-adapter-indexeddb"
import PouchDb from "pouchdb-browser"
import { DataType } from "./DataType.enum"
import { Model } from "./models/Model"
PouchDb.plugin(indexedDb)
interface GetAllParams {
prefix?: string
includeDocs?: boolean
includeAttachments?: boolean
keys?: string[]
}
class Data {
// oxlint-disable-next-line typescript/ban-types
private readonly locale: PouchDB.Database<{}> | null = null
constructor() {
try {
this.locale = new PouchDb("remanso", {
adapter: "indexeddb"
})
} catch (error) {
console.warn("data error", error)
}
}
private buildId(type?: DataType | string, id?: string): string {
if (!type) return id || nanoid()
return `${type}-${id || nanoid()}`
}
public async add<DT extends DataType>(model: Model<DT>): Promise<boolean> {
try {
const result = await this.locale?.put(model)
return result?.ok ?? false
} catch (error) {
console.warn(error)
return false
}
}
public async update<DT extends DataType, T extends Model<DT>>(
model: T
): Promise<boolean> {
try {
if (!model._id) {
const result = await this.locale?.put(model)
return result?.ok ?? false
}
const oldModel = await this.get(model._id)
if (oldModel) {
const result = await this.locale?.put({ ...oldModel, ...model })
return result?.ok ?? false
}
const result = await this.locale?.put(model)
return result?.ok ?? false
} catch (error) {
console.warn(error)
return false
}
}
public async remove(id: string): Promise<boolean> {
try {
const doc = await this.get(id)
if (!doc) {
return false
}
const result = await this.locale?.put({
...doc,
_deleted: true
})
return result?.ok ?? false
} catch {
return false
}
}
public async get<DT extends DataType, T extends Model<DT>>(
id: string
): Promise<T | null> {
try {
return ((await this.locale?.get(id)) as T) || null
} catch {
return null
}
}
public async getOrCreate<DT extends DataType, T extends Model<DT>>(
id: string,
initialValue: T
): Promise<T> {
const element = await this.get<DT, T>(id)
if (element) {
return element
}
await this.add<DT>({ ...initialValue, _id: id })
return this.getOrCreate(id, initialValue)
}
public async getAll<DT extends DataType, T extends Model<DT>>({
prefix,
includeDocs = true,
includeAttachments = false,
keys = []
}: GetAllParams): Promise<T[]> {
if (!this.locale) {
return []
}
if (keys.length) {
const response = await this.locale.allDocs({
include_docs: includeDocs,
attachments: includeAttachments,
keys: keys.map((key) => this.buildId(prefix, key))
})
if (includeDocs) {
return response.rows
.map((row) => {
if ("error" in row) return null
return row.doc
})
.filter(Boolean) as T[]
} else {
return response.rows
.map((row) => {
if ("error" in row) return null
return { _id: row.id }
})
.filter(Boolean) as T[]
}
}
const response = await this.locale.allDocs({
include_docs: includeDocs,
attachments: includeAttachments,
startkey: prefix ? prefix : undefined,
endkey: prefix ? `${prefix}\ufff0` : undefined
})
return response.rows.map((row) => row.doc) as T[]
}
}
expose(new Data())

View File

@@ -1,5 +1,5 @@
import { DataType } from '@/data/DataType.enum' import { DataType } from "@/data/DataType.enum"
import { Model } from '@/data/models/Model' import { Model } from "@/data/models/Model"
export interface AtprotoSession extends Model<DataType.AtprotoSession> { export interface AtprotoSession extends Model<DataType.AtprotoSession> {
did: string did: string

View File

@@ -1,5 +1,5 @@
import { DataType } from '@/data/DataType.enum' import { DataType } from "@/data/DataType.enum"
import { Model } from '@/data/models/Model' import { Model } from "@/data/models/Model"
export interface GithubAccessToken extends Model<DataType.GithubAccessToken> { export interface GithubAccessToken extends Model<DataType.GithubAccessToken> {
username: string username: string

View File

@@ -1,5 +1,5 @@
import { DataType } from '@/data/DataType.enum' import { DataType } from "@/data/DataType.enum"
import { Model } from '@/data/models/Model' import { Model } from "@/data/models/Model"
export interface History extends Model<DataType.History> { export interface History extends Model<DataType.History> {
repos: ReadonlyArray<{ repos: ReadonlyArray<{

View File

@@ -1,4 +1,4 @@
import { DataType } from '../DataType.enum' import { DataType } from "../DataType.enum"
export interface Model<DT extends DataType> { export interface Model<DT extends DataType> {
_id?: string _id?: string

View File

@@ -1,19 +1,24 @@
import { ComputedRef, onUnmounted, Ref, toValue } from "vue" import { ComputedRef, onUnmounted, Ref, toValue } from "vue"
import { isExternalLink } from "@/utils/link"
import { useRouteQueryStackedNotes } from "@/hooks/useRouteQueryStackedNotes.hook" import { useRouteQueryStackedNotes } from "@/hooks/useRouteQueryStackedNotes.hook"
import { parseAtUri } from "@/modules/atproto/parseAtUri" import { parseAtUri } from "@/modules/atproto/parseAtUri"
import { toShortDid } from "@/modules/atproto/shortDid" import { toShortDid } from "@/modules/atproto/shortDid"
import { router } from "@/router/router" import { router } from "@/router/router"
import { isExternalLink } from "@/utils/link"
export const useATProtoLinks = ( export const useATProtoLinks = (
className: ComputedRef<string> | string, className: ComputedRef<string> | string,
currentAtUri?: Ref<string> | string, options: {
currentAtUri?: Ref<string> | string | ComputedRef<string>
mainNoteId: Ref<string> | string | ComputedRef<string>
}
) => { ) => {
const { addStackedNote } = useRouteQueryStackedNotes() const { addStackedNote, scrollToFocusedNote } = useRouteQueryStackedNotes()
const { currentAtUri, mainNoteId } = options
const linkNote = (event: Event) => { const linkNote = (event: Event) => {
const target = event.target as HTMLElement const anchor = (event.target as HTMLElement).closest("a")
const href = target.getAttribute("href") const href = anchor?.getAttribute("href")
if (!href) { if (!href) {
return return
@@ -33,7 +38,7 @@ export const useATProtoLinks = (
if (href.startsWith(window.location.origin)) { if (href.startsWith(window.location.origin)) {
const { params } = router.resolve( const { params } = router.resolve(
href.replace(window.location.origin, ""), href.replace(window.location.origin, "")
) )
if (!params.shortDid || !params.rkey) { if (!params.shortDid || !params.rkey) {
@@ -44,10 +49,15 @@ export const useATProtoLinks = (
? `${params.shortDid}-${params.rkey}-${params.slug}` ? `${params.shortDid}-${params.rkey}-${params.slug}`
: `${params.shortDid}-${params.rkey}` : `${params.shortDid}-${params.rkey}`
if (noteId === toValue(mainNoteId)) {
scrollToFocusedNote()
return
}
addStackedNote( addStackedNote(
toValue(currentAtUri) ?? "", toValue(currentAtUri) ?? "",
noteId, noteId,
`${params.shortDid}-${params.rkey}`, `${params.shortDid}-${params.rkey}`
) )
return return
} }
@@ -56,6 +66,11 @@ export const useATProtoLinks = (
const { did, rkey } = parseAtUri(href) const { did, rkey } = parseAtUri(href)
const noteId = `${toShortDid(did)}-${rkey}` const noteId = `${toShortDid(did)}-${rkey}`
if (noteId === toValue(mainNoteId)) {
scrollToFocusedNote()
return
}
addStackedNote(toValue(currentAtUri) ?? "", noteId) addStackedNote(toValue(currentAtUri) ?? "", noteId)
} }
} }
@@ -96,6 +111,6 @@ export const useATProtoLinks = (
}) })
return { return {
listenToClick, listenToClick
} }
} }

View File

@@ -1,30 +1,62 @@
import { computed, ref } from 'vue' import { computed, ref } from "vue"
import { getAuthor } from '@/modules/atproto/getAuthor' import { getAuthor } from "@/modules/atproto/getAuthor"
import { restoreSession, sdkSignOut, signInWithHandle } from '@/modules/atproto/service/atprotoOAuth' import {
import { clearSession, loadSession, saveSession } from '@/modules/atproto/service/atprotoSession' restoreSession,
sdkSignOut,
signInWithHandle
} from "@/modules/atproto/service/atprotoOAuth"
import {
clearSession,
loadSession,
saveSession
} from "@/modules/atproto/service/atprotoSession"
const did = ref<string | null>(null) const did = ref<string | null>(null)
const handle = ref<string | null>(null) const handle = ref<string | null>(null)
const avatarUrl = ref<string | null>(null)
let init = true let init = true
const initializeAuth = async () => { const fetchAvatar = async (actorDid: string) => {
const session = await restoreSession() try {
const res = await fetch(
`https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(actorDid)}`
)
if (res.ok) {
const data = await res.json()
avatarUrl.value = data.avatar ?? null
}
} catch {
avatarUrl.value = null
}
}
const initializeAuth = async () => {
// Load cached session from IndexedDB first (fast, local) so the UI can render immediately
const stored = await loadSession()
did.value = stored?.did ?? ""
handle.value = stored?.handle ?? ""
if (stored?.did) {
fetchAvatar(stored.did)
}
// Then restore OAuth session in the background (may involve network)
const session = await restoreSession()
if (session) { if (session) {
const author = await getAuthor(session.did) const author = await getAuthor(session.did)
const resolvedHandle = author?.handle ?? '' const resolvedHandle = author?.handle ?? ""
did.value = session.did did.value = session.did
handle.value = resolvedHandle handle.value = resolvedHandle
await saveSession(session.did, resolvedHandle) await saveSession(session.did, resolvedHandle)
fetchAvatar(session.did)
window.history.replaceState(null, '', window.location.pathname + window.location.search) window.history.replaceState(
} else { null,
const stored = await loadSession() "",
did.value = stored?.did ?? '' window.location.pathname + window.location.search
handle.value = stored?.handle ?? '' )
} }
} }
@@ -46,16 +78,18 @@ export const useATProtoLogin = () => {
await sdkSignOut(did.value) await sdkSignOut(did.value)
} }
await clearSession() await clearSession()
did.value = '' did.value = ""
handle.value = '' handle.value = ""
avatarUrl.value = null
} }
return { return {
did, did,
handle, handle,
avatarUrl,
isLoggedIn, isLoggedIn,
isATProtoReady, isATProtoReady,
signIn, signIn,
signOut, signOut
} }
} }

View File

@@ -1,17 +1,17 @@
import { useAsyncState } from '@vueuse/core' import { useAsyncState } from "@vueuse/core"
import { ComputedRef, onUnmounted, toValue } from 'vue' import { ComputedRef, onUnmounted, toValue } from "vue"
import { backlinkEventBus } from '@/bus/backlinkEventBus' import { backlinkEventBus } from "@/bus/backlinkEventBus"
import { data } from '@/data/data' import { data, generateId } from "@/data/data"
import { DataType } from '@/data/DataType.enum' import { DataType } from "@/data/DataType.enum"
import { BacklinkNote } from '@/modules/note/models/BacklinkNote' import { BacklinkNote } from "@/modules/note/models/BacklinkNote"
export const useBacklinks = (sha: string | ComputedRef<string>) => { export const useBacklinks = (sha: string | ComputedRef<string>) => {
sha = toValue(sha) sha = toValue(sha)
const { state: backlink, execute } = useAsyncState( const { state: backlink, execute } = useAsyncState(
data.get<DataType.BacklinkNote, BacklinkNote>( data.get<DataType.BacklinkNote, BacklinkNote>(
data.generateId(DataType.BacklinkNote, sha) generateId(DataType.BacklinkNote, sha)
), ),
null, null,
{ {

View File

@@ -1,5 +1,6 @@
import { ref, Ref, toValue, onUnmounted } from "vue"
import { useDebounceFn } from "@vueuse/core" import { useDebounceFn } from "@vueuse/core"
import { onUnmounted, Ref, ref, toValue } from "vue"
import { useGitHubContent } from "@/hooks/useGitHubContent.hook" import { useGitHubContent } from "@/hooks/useGitHubContent.hook"
const CHECKBOX_PATTERN = /\[([ xX])\]/g const CHECKBOX_PATTERN = /\[([ xX])\]/g
@@ -7,7 +8,7 @@ const CHECKBOX_PATTERN = /\[([ xX])\]/g
const setCheckboxInMarkdown = ( const setCheckboxInMarkdown = (
markdown: string, markdown: string,
index: number, index: number,
checked: boolean, checked: boolean
): string => { ): string => {
let currentIndex = 0 let currentIndex = 0
@@ -21,7 +22,7 @@ const setCheckboxInMarkdown = (
const findCheckboxIndex = ( const findCheckboxIndex = (
container: Element, container: Element,
checkbox: HTMLInputElement, checkbox: HTMLInputElement
): number => { ): number => {
const allCheckboxes = container.querySelectorAll('input[type="checkbox"]') const allCheckboxes = container.querySelectorAll('input[type="checkbox"]')
return Array.from(allCheckboxes).indexOf(checkbox) return Array.from(allCheckboxes).indexOf(checkbox)
@@ -34,7 +35,7 @@ export const useCheckboxCommit = ({
initialContent, initialContent,
initialSha, initialSha,
containerSelector, containerSelector,
debounceMs = 1000, debounceMs = 1000
}: { }: {
user: string user: string
repo: string repo: string
@@ -73,10 +74,10 @@ export const useCheckboxCommit = ({
isCommitting.value = true isCommitting.value = true
const newSha = await updateFile({ const { sha: newSha } = await updateFile({
content: pendingContent.value, content: pendingContent.value,
path: pathValue, path: pathValue,
sha: currentSha.value, sha: currentSha.value
}) })
if (newSha) { if (newSha) {
@@ -109,7 +110,7 @@ export const useCheckboxCommit = ({
pendingContent.value = setCheckboxInMarkdown( pendingContent.value = setCheckboxInMarkdown(
pendingContent.value, pendingContent.value,
index, index,
target.checked, target.checked
) )
hasPendingChanges.value = true hasPendingChanges.value = true
@@ -142,6 +143,6 @@ export const useCheckboxCommit = ({
isCommitting, isCommitting,
hasPendingChanges, hasPendingChanges,
syncContent, syncContent,
listenToCheckboxes, listenToCheckboxes
} }
} }

View File

@@ -1,23 +1,34 @@
import { watch } from 'vue' import { watch } from "vue"
import { backlinkEventBus } from '@/bus/backlinkEventBus' import { backlinkEventBus } from "@/bus/backlinkEventBus"
import { data } from '@/data/data' import { data, generateId } from "@/data/data"
import { DataType } from '@/data/DataType.enum' import { DataType } from "@/data/DataType.enum"
import { useFile } from '@/hooks/useFile.hook' import { useFile } from "@/hooks/useFile.hook"
import { Backlink } from '@/modules/note/models/Backlink' import { Backlink } from "@/modules/note/models/Backlink"
import { BacklinkNote } from '@/modules/note/models/BacklinkNote' import { BacklinkNote } from "@/modules/note/models/BacklinkNote"
import { resolvePath } from '@/modules/repo/services/resolvePath' import { resolvePath } from "@/modules/repo/services/resolvePath"
import { useUserRepoStore } from '@/modules/repo/store/userRepo.store' import { useUserRepoStore } from "@/modules/repo/store/userRepo.store"
import { isExternalLink } from '@/utils/link' import { isExternalLink } from "@/utils/link"
import { filenameToNoteTitle } from '@/utils/noteTitle' import { filenameToNoteTitle } from "@/utils/noteTitle"
import { confirmMessage } from '@/utils/notif' import { confirmMessage } from "@/utils/notif"
const isMarkdown = (filename?: string) => filename?.endsWith('.md') ?? false const isMarkdown = (filename?: string) => filename?.endsWith(".md") ?? false
const yieldToMain = () =>
"scheduler" in globalThis
? (
globalThis as unknown as { scheduler: { yield: () => Promise<void> } }
).scheduler.yield()
: new Promise<void>((r) => setTimeout(r, 0))
export const useComputeBacklinks = () => { export const useComputeBacklinks = () => {
const store = useUserRepoStore() const store = useUserRepoStore()
watch(store, async () => { watch(
() => store.files,
async () => {
await new Promise<void>((r) => setTimeout(r, 300))
if (!store.userSettings?.backlink) { if (!store.userSettings?.backlink) {
return return
} }
@@ -27,14 +38,17 @@ export const useComputeBacklinks = () => {
const backlinks: Map<string, Backlink[]> = new Map() const backlinks: Map<string, Backlink[]> = new Map()
for (const file of store.files) { for (const file of store.files) {
await yieldToMain()
if (!isMarkdown(file.path) || !file.sha) { if (!isMarkdown(file.path) || !file.sha) {
continue continue
} }
const fileBacklinkId = data.generateId(DataType.BacklinkNote, file.sha) const fileBacklinkId = generateId(DataType.BacklinkNote, file.sha)
const fileBacklink = await data.get<DataType.BacklinkNote, BacklinkNote>( const fileBacklink = await data.get<
fileBacklinkId DataType.BacklinkNote,
) BacklinkNote
>(fileBacklinkId)
if (fileBacklink) { if (fileBacklink) {
continue continue
} }
@@ -51,18 +65,18 @@ export const useComputeBacklinks = () => {
} }
const parser = new DOMParser() const parser = new DOMParser()
const htmlDoc = parser.parseFromString(note, 'text/html') const htmlDoc = parser.parseFromString(note, "text/html")
const links = htmlDoc.querySelectorAll('a') const links = htmlDoc.querySelectorAll("a")
for (const link of links) { for (const link of links) {
const href = link.getAttribute('href') ?? '' const href = link.getAttribute("href") ?? ""
if (isExternalLink(href) || !isMarkdown(href)) { if (isExternalLink(href) || !isMarkdown(href)) {
continue continue
} }
const path = resolvePath(file.path ?? '', href) const path = resolvePath(file.path ?? "", href)
const backlinkFile = store.files.find((file) => file.path === path) const backlinkFile = store.files.find((file) => file.path === path)
if (!backlinkFile?.sha || !backlinkFile?.path) { if (!backlinkFile?.sha || !backlinkFile?.path) {
@@ -77,21 +91,21 @@ export const useComputeBacklinks = () => {
if (!notifiedForComputation) { if (!notifiedForComputation) {
notifiedForComputation = true notifiedForComputation = true
confirmMessage('Updating backlinks...') confirmMessage("Updating backlinks...")
} }
backlinks.set(backlinkFile.sha, [ backlinks.set(backlinkFile.sha, [
...previousBacklinks, ...previousBacklinks,
{ {
sha: file.sha, sha: file.sha,
title: filenameToNoteTitle(file.path ?? '') title: filenameToNoteTitle(file.path ?? "")
} }
]) ])
} }
} }
for (const [sha, fileBacklinks] of backlinks) { for (const [sha, fileBacklinks] of backlinks) {
const fileBacklinkId = data.generateId(DataType.BacklinkNote, sha) const fileBacklinkId = generateId(DataType.BacklinkNote, sha)
const backlinkNote: BacklinkNote = { const backlinkNote: BacklinkNote = {
_id: fileBacklinkId, _id: fileBacklinkId,
$type: DataType.BacklinkNote, $type: DataType.BacklinkNote,
@@ -102,5 +116,6 @@ export const useComputeBacklinks = () => {
await data.update(backlinkNote) await data.update(backlinkNote)
backlinkEventBus.emit({ fileSha: sha }) backlinkEventBus.emit({ fileSha: sha })
} }
}) }
)
} }

View File

@@ -1,16 +1,16 @@
import { useMagicKeys } from '@vueuse/core' import { useMagicKeys } from "@vueuse/core"
import { ref, watch } from 'vue' import { ref, watch } from "vue"
export const useEditionMode = () => { export const useEditionMode = () => {
const mode = ref<'read' | 'edit'>('read') const mode = ref<"read" | "edit">("read")
const toggleMode = () => { const toggleMode = () => {
mode.value = mode.value === 'read' ? 'edit' : 'read' mode.value = mode.value === "read" ? "edit" : "read"
} }
const { escape } = useMagicKeys() const { escape } = useMagicKeys()
watch(escape, () => { watch(escape, () => {
if (mode.value === 'edit') { if (mode.value === "edit") {
toggleMode() toggleMode()
} }
}) })

View File

@@ -17,18 +17,18 @@ export const useFile = (sha: Ref<string> | string, retrieveContent = true) => {
const { const {
render, render,
renderFromUTF8, renderFromUTF8,
getRawContent: getRawContentFromFile, getRawContent: getRawContentFromFile
} = markdownBuilder(shaValue) } = markdownBuilder(shaValue)
const { getCachedNote, saveCacheNote } = prepareNoteCache( const { getCachedNote, saveCacheNote } = prepareNoteCache(
shaValue, shaValue,
toValue(path), toValue(path)
) )
const fromCache = ref(false) const fromCache = ref(false)
const rawContent = ref("") const rawContent = ref("")
const content = computed(() => const content = computed(() =>
rawContent.value ? renderFromUTF8(rawContent.value) : "", rawContent.value ? renderFromUTF8(rawContent.value) : ""
) )
const getEditedSha = async () => { const getEditedSha = async () => {
@@ -55,7 +55,7 @@ export const useFile = (sha: Ref<string> | string, retrieveContent = true) => {
} }
saveCacheNote(fileContent) saveCacheNote(fileContent)
rawContent.value = getRawContentFromFile(fileContent) rawContent.value = getRawContentFromFile(fileContent)
}, }
) )
} }
@@ -111,6 +111,6 @@ export const useFile = (sha: Ref<string> | string, retrieveContent = true) => {
getCachedFileContent, getCachedFileContent,
getEditedSha, getEditedSha,
fromCache, fromCache,
saveCacheNote, saveCacheNote
} }
} }

View File

@@ -1,17 +1,18 @@
import { computedAsync } from "@vueuse/core"
import { computed, Ref, ref, watch } from "vue"
import { Author, getAuthors } from "@/modules/atproto/getAuthor" import { Author, getAuthors } from "@/modules/atproto/getAuthor"
import { PublicNoteListItem } from "@/modules/note/models/Note" import { PublicNoteListItem } from "@/modules/note/models/Note"
import { computedAsync } from "@vueuse/core"
import { computed, ref, Ref, watch } from "vue"
export function useFollowingNoteList( export function useFollowingNoteList(
dids: Ref<Set<string>>, dids: Ref<Set<string>>,
enabled: Ref<boolean>, enabled: Ref<boolean>
) { ) {
const isLoading = ref(false) const isLoading = ref(false)
const notes = ref<PublicNoteListItem[]>([]) const notes = ref<PublicNoteListItem[]>([])
const cursor = ref<string | null | undefined>(null) const cursor = ref<string | null | undefined>(null)
const canLoadMore = computed( const canLoadMore = computed(
() => dids.value.size > 0 && cursor.value !== undefined, () => dids.value.size > 0 && cursor.value !== undefined
) )
const onLoadMore = async () => { const onLoadMore = async () => {
@@ -22,7 +23,7 @@ export function useFollowingNoteList(
const body: { dids: string[]; limit: number; cursor?: string } = { const body: { dids: string[]; limit: number; cursor?: string } = {
dids: [...dids.value], dids: [...dids.value],
limit: 20, limit: 20
} }
if (cursor.value) { if (cursor.value) {
@@ -32,7 +33,7 @@ export function useFollowingNoteList(
const response = await fetch("https://api.remanso.space/notes/feed", { const response = await fetch("https://api.remanso.space/notes/feed", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(body), body: JSON.stringify(body)
}) })
const data: { notes: PublicNoteListItem[]; cursor?: string } = const data: { notes: PublicNoteListItem[]; cursor?: string } =

View File

@@ -1,6 +1,6 @@
import { Ref, ref, watch } from 'vue' import { Ref, ref, watch } from "vue"
import { getFollows } from '@/modules/atproto/service/getFollows' import { getFollows } from "@/modules/atproto/service/getFollows"
export const useFollows = (did: Ref<string | null>) => { export const useFollows = (did: Ref<string | null>) => {
const follows = ref<Set<string>>(new Set()) const follows = ref<Set<string>>(new Set())
@@ -14,7 +14,7 @@ export const useFollows = (did: Ref<string | null>) => {
follows.value = new Set() follows.value = new Set()
} }
}, },
{ immediate: true }, { immediate: true }
) )
return { follows } return { follows }

View File

@@ -1,9 +1,9 @@
import { ref } from 'vue' import { ref } from "vue"
import { useRouter } from 'vue-router' import { useRouter } from "vue-router"
export const useForm = () => { export const useForm = () => {
const userInput = ref('') const userInput = ref("")
const repoInput = ref('') const repoInput = ref("")
const { push } = useRouter() const { push } = useRouter()
const submit = () => { const submit = () => {
@@ -12,7 +12,7 @@ export const useForm = () => {
} }
push({ push({
name: 'FluxNoteView', name: "FluxNoteView",
params: { params: {
user: userInput.value, user: userInput.value,
repo: repoInput.value repo: repoInput.value

View File

@@ -2,22 +2,44 @@ import { getOctokit } from "@/modules/repo/services/octo"
import { encodeUTF8ToBase64 } from "@/utils/decodeBase64ToUTF8" import { encodeUTF8ToBase64 } from "@/utils/decodeBase64ToUTF8"
import { confirmMessage, errorMessage } from "@/utils/notif" import { confirmMessage, errorMessage } from "@/utils/notif"
const isConflictStatus = (status: number) => status === 409 || status === 422
export const useGitHubContent = ({ export const useGitHubContent = ({
user, user,
repo, repo
}: { }: {
user: string user: string
repo: string repo: string
}) => { }) => {
const fetchLatestSha = async (path: string): Promise<string | null> => {
try {
const octokit = await getOctokit()
const response = await octokit.request(
"GET /repos/{owner}/{repo}/contents/{path}",
{
owner: user,
repo,
path,
headers: { "X-GitHub-Api-Version": "2022-11-28" }
}
)
const data = response?.data
if (Array.isArray(data) || !data) return null
return "sha" in data ? data.sha : null
} catch {
return null
}
}
const putFile = async ({ const putFile = async ({
content, content,
path, path,
sha, sha
}: { }: {
content: string content: string
path: string path: string
sha?: string sha?: string
}) => { }): Promise<{ sha: string | null; conflict: boolean }> => {
try { try {
const octokit = await getOctokit() const octokit = await getOctokit()
@@ -29,25 +51,34 @@ export const useGitHubContent = ({
path, path,
message: `Updating ${path} from Remanso`, message: `Updating ${path} from Remanso`,
content: encodeUTF8ToBase64(content), content: encodeUTF8ToBase64(content),
sha, sha
}, }
) )
confirmMessage("✅ Note saved") confirmMessage("✅ Note saved")
return response?.data.content?.sha ?? null return { sha: response?.data.content?.sha ?? null, conflict: false }
} catch (error) { } catch (error) {
const status = (error as { status?: number })?.status
if (status && isConflictStatus(status)) {
errorMessage("⚠ Conflict: this note changed on GitHub")
console.warn(error)
return { sha: null, conflict: true }
}
errorMessage("❌ Note could not be saved") errorMessage("❌ Note could not be saved")
console.warn(error) console.warn(error)
return { sha: null, conflict: false }
} }
return null
} }
return { return {
updateFile: async (props: { content: string; path: string; sha: string }) => fetchLatestSha,
putFile(props), updateFile: async (props: {
content: string
path: string
sha: string
}) => putFile(props),
createFile: async (props: { content: string; path: string }) => createFile: async (props: { content: string; path: string }) =>
putFile(props), putFile(props)
} }
} }

View File

@@ -1,8 +1,8 @@
import { computed, ref } from 'vue' import { computed, ref } from "vue"
import { GithubToken } from '@/modules/user/interfaces/GithubToken' import { GithubToken } from "@/modules/user/interfaces/GithubToken"
import { getAccessToken, saveAccessToken } from '@/modules/user/service/signIn' import { getAccessToken, saveAccessToken } from "@/modules/user/service/signIn"
import { confirmMessage } from '@/utils/notif' import { confirmMessage } from "@/utils/notif"
const username = ref<string | null>(null) const username = ref<string | null>(null)
const accessToken = ref<string | null>(null) const accessToken = ref<string | null>(null)
@@ -11,8 +11,8 @@ let init = true
const saveAccessTokenToLocal = async () => { const saveAccessTokenToLocal = async () => {
const response = await getAccessToken() const response = await getAccessToken()
username.value = response?.username || '' username.value = response?.username || ""
accessToken.value = response?.token || '' accessToken.value = response?.token || ""
} }
const saveCredentials = async (token: GithubToken): Promise<void> => { const saveCredentials = async (token: GithubToken): Promise<void> => {

View File

@@ -1,10 +1,10 @@
import { computed, watch } from 'vue' import { computed, watch } from "vue"
import { useFile } from '@/hooks/useFile.hook' import { useFile } from "@/hooks/useFile.hook"
import { resolvePath } from '@/modules/repo/services/resolvePath' import { resolvePath } from "@/modules/repo/services/resolvePath"
import { useUserRepoStore } from '@/modules/repo/store/userRepo.store' import { useUserRepoStore } from "@/modules/repo/store/userRepo.store"
const SRC_PREFIX = 'data:image/jpeg;charset=utf-8;base64,' const SRC_PREFIX = "data:image/jpeg;charset=utf-8;base64,"
export const useImages = (sha: string) => { export const useImages = (sha: string) => {
const store = useUserRepoStore() const store = useUserRepoStore()
@@ -23,14 +23,14 @@ export const useImages = (sha: string) => {
const images = document.querySelectorAll(`.note-${sha} img`) const images = document.querySelectorAll(`.note-${sha} img`)
images.forEach(async (image) => { images.forEach(async (image) => {
const src = image.getAttribute('src') const src = image.getAttribute("src")
if (!src || src.startsWith(SRC_PREFIX)) { if (!src || src.startsWith(SRC_PREFIX)) {
return return
} }
const imageFilePath = resolvePath( const imageFilePath = resolvePath(
filePath, filePath,
image.getAttribute('src') ?? '' image.getAttribute("src") ?? ""
) )
const imageFile = store.files.find( const imageFile = store.files.find(
@@ -43,7 +43,7 @@ export const useImages = (sha: string) => {
const { getCachedFileContent } = useFile(imageFile.sha, false) const { getCachedFileContent } = useFile(imageFile.sha, false)
const fileContent = await getCachedFileContent() const fileContent = await getCachedFileContent()
image.setAttribute('src', `${SRC_PREFIX} ${fileContent}`) image.setAttribute("src", `${SRC_PREFIX} ${fileContent}`)
}) })
}, },
{ immediate: true } { immediate: true }

View File

@@ -6,19 +6,28 @@ import { isExternalLink } from "@/utils/link"
export const useLinks = ( export const useLinks = (
className: ComputedRef<string> | string, className: ComputedRef<string> | string,
sha?: Ref<string> | string, sha?: Ref<string> | string
) => { ) => {
const store = useUserRepoStore() const store = useUserRepoStore()
const linkNote: EventListener = (event) => { const linkNote: EventListener = (event) => {
const target = event.target as HTMLElement const anchor = (event.target as HTMLElement).closest("a")
const href = target.getAttribute("href") const href = anchor?.getAttribute("href")
if (!href) { if (!href) {
return return
} }
if (href.startsWith("#")) { if (href.startsWith("#")) {
event.preventDefault()
const id = href.slice(1)
const container = document.querySelector(`.${toValue(className)}`)
const heading = container?.querySelector(`#${CSS.escape(id)}`)
heading?.scrollIntoView({
block: "start",
inline: "nearest",
behavior: "smooth"
})
return return
} }
@@ -30,11 +39,16 @@ export const useLinks = (
return return
} }
const hashIndex = href.indexOf("#")
const path = hashIndex === -1 ? href : href.slice(0, hashIndex)
const hash = hashIndex === -1 ? undefined : href.slice(hashIndex + 1)
noteEventBus.emit({ noteEventBus.emit({
path: href, path,
hash,
currentNoteSHA: toValue(sha), currentNoteSHA: toValue(sha),
user: store.user, user: store.user,
repo: store.repo, repo: store.repo
}) })
} }
@@ -74,6 +88,6 @@ export const useLinks = (
}) })
return { return {
listenToClick, listenToClick
} }
} }

View File

@@ -1,17 +1,24 @@
import type { MarkdownItTabData, MarkdownItTabInfo } from "@mdit/plugin-tab"
import { tab } from "@mdit/plugin-tab"
import markdownItKatex from "@vscode/markdown-it-katex" import markdownItKatex from "@vscode/markdown-it-katex"
import GithubSlugger from "github-slugger"
import MarkdownIt, { Options } from "markdown-it" import MarkdownIt, { Options } from "markdown-it"
import Renderer, { type RenderRuleRecord } from "markdown-it/lib/renderer.mjs"
import type Token from "markdown-it/lib/token.mjs"
import markdownItAnchor from "markdown-it-anchor"
import blockEmbedPlugin from "markdown-it-block-embed" import blockEmbedPlugin from "markdown-it-block-embed"
import markdownItCheckbox from "markdown-it-checkbox" import markdownItCheckbox from "markdown-it-checkbox"
import MarkdownItGitHubAlerts from "markdown-it-github-alerts" import MarkdownItGitHubAlerts from "markdown-it-github-alerts"
import markdownItIframe from "markdown-it-iframe" import markdownItIframe from "markdown-it-iframe"
import Shikiji from "markdown-it-shikiji" import Shikiji from "markdown-it-shikiji"
import mermaid from "mermaid"
import type { LanguageRegistration } from "shikiji-core"
import { Ref, toValue } from "vue" import { Ref, toValue } from "vue"
import alloyGrammar from "@/utils/alloy.tmLanguage.json"
import { decodeBase64ToUTF8 } from "@/utils/decodeBase64ToUTF8" import { decodeBase64ToUTF8 } from "@/utils/decodeBase64ToUTF8"
import { html5Media } from "@/utils/markdown/markdown-html5-media" import { html5Media } from "@/utils/markdown/markdown-html5-media"
import mermaid from "mermaid" import { markdownItTablerIcons } from "@/utils/markdown/markdown-it-tabler-icons"
import type Token from "markdown-it/lib/token.mjs"
import Renderer, { type RenderRuleRecord } from "markdown-it/lib/renderer.mjs"
const markdownItMermaidExtractor = (md: MarkdownIt) => { const markdownItMermaidExtractor = (md: MarkdownIt) => {
const defaultFence = const defaultFence =
@@ -21,7 +28,7 @@ const markdownItMermaidExtractor = (md: MarkdownIt) => {
index: number, index: number,
options: Options, options: Options,
_: unknown, _: unknown,
self: Renderer, self: Renderer
) { ) {
return self.renderToken(tokens, index, options) return self.renderToken(tokens, index, options)
} }
@@ -31,7 +38,7 @@ const markdownItMermaidExtractor = (md: MarkdownIt) => {
index: number, index: number,
options: Options, options: Options,
env: unknown, env: unknown,
self: Renderer, self: Renderer
) { ) {
const token = tokens[index] const token = tokens[index]
@@ -44,38 +51,60 @@ const markdownItMermaidExtractor = (md: MarkdownIt) => {
} }
} }
const slugger = new GithubSlugger()
let tabGroupCounter = 0
let currentTabGroup = 0
let currentTabActiveSet = false
const md = new MarkdownIt({ const md = new MarkdownIt({
typographer: true, typographer: true,
quotes: ["«\xA0", "\xA0»", "\xA0", "\xA0"], quotes: ["«\xA0", "\xA0»", "\xA0", "\xA0"]
}) })
.use(markdownItMermaidExtractor) .use(markdownItMermaidExtractor)
.use(html5Media) .use(html5Media)
.use(blockEmbedPlugin, { .use(blockEmbedPlugin, {
youtube: { youtube: {
width: "100%", width: "100%",
height: 300, height: 300
}, }
}) })
.use(markdownItCheckbox) .use(markdownItCheckbox)
.use(markdownItKatex) .use(markdownItKatex)
.use(markdownItIframe, { .use(markdownItIframe, {
width: "100%", width: "100%"
}) })
.use(MarkdownItGitHubAlerts) .use(MarkdownItGitHubAlerts)
.use(markdownItTablerIcons)
.use(tab, {
name: "tabs",
openRender: (info: MarkdownItTabInfo) => {
currentTabGroup = ++tabGroupCounter
currentTabActiveSet = info.active >= 0
return '<div class="tabs tabs-box">\n'
},
closeRender: () => "</div>\n",
tabOpenRender: (data: MarkdownItTabData) => {
const isChecked =
data.isActive || (!currentTabActiveSet && data.index === 0)
const checked = isChecked ? " checked" : ""
const title = data.title.replace(/"/g, "&quot;")
return `<input type="radio" name="md-tabs-${currentTabGroup}" class="tab" aria-label="${title}"${checked}>\n<div class="tab-content bg-base-100 border-base-300 rounded-box p-2">\n`
},
tabCloseRender: () => "</div>\n"
})
.use(markdownItAnchor, {
slugify: (s: string) => slugger.slug(s)
})
let shikijiInitialized = false let shikijiPromise: Promise<void> | null = null
export const useShikiji = async () => { export const useShikiji = (): Promise<void> => {
if (shikijiInitialized) { if (!shikijiPromise) {
return shikijiPromise = Shikiji({
}
shikijiInitialized = true
md.use(
await Shikiji({
themes: { themes: {
light: "vitesse-light", light: "vitesse-light",
dark: "vitesse-black", dark: "vitesse-black"
}, },
langs: [ langs: [
"bash", "bash",
@@ -86,9 +115,17 @@ export const useShikiji = async () => {
"html", "html",
"css", "css",
"json", "json",
], {
}), ...alloyGrammar,
) name: "alloy",
aliases: ["als"]
} as unknown as LanguageRegistration
]
}).then((plugin) => {
md.use(plugin)
})
}
return shikijiPromise
} }
let mermaidInitialized = false let mermaidInitialized = false
@@ -99,19 +136,19 @@ export const runMermaid = (querySelector: string) => {
mermaid.initialize({ mermaid.initialize({
theme: "dark", theme: "dark",
startOnLoad: false, startOnLoad: false,
flowchart: { curve: "natural" }, flowchart: { curve: "natural" }
}) })
} }
mermaid.run({ mermaid.run({
querySelector, querySelector
}) })
} }
const rules: RenderRuleRecord = { const rules: RenderRuleRecord = {
table_open: () => table_open: () =>
'<div class="overflow-x-auto"><table class="table table-zebra">', '<div class="overflow-x-auto"><table class="table table-zebra">',
table_close: () => "</table></div>", table_close: () => "</table></div>"
} }
md.renderer.rules = { ...md.renderer.rules, ...rules } md.renderer.rules = { ...md.renderer.rules, ...rules }
@@ -121,22 +158,44 @@ const stripFrontmatter = (content: string): string => {
return match ? content.slice(match[0].length) : content return match ? content.slice(match[0].length) : content
} }
const renderMarkdown = (content: string, env?: Record<string, unknown>) => {
slugger.reset()
return env ? md.render(content, env) : md.render(content)
}
export const renderCodeFile = async ({
rawContent,
lang,
filename
}: {
rawContent: string
lang: string | null
filename?: string
}): Promise<string> => {
await useShikiji()
const heading = filename ? `# ${filename}\n\n` : ""
if (lang !== null) {
return renderMarkdown(`${heading}\`\`\`\`${lang}\n${rawContent}\n\`\`\`\``)
}
return `${renderMarkdown(heading)}<pre><code>${md.utils.escapeHtml(rawContent)}</code></pre>`
}
export const markdownBuilder = (defaultPrefix?: Ref<string> | string) => { export const markdownBuilder = (defaultPrefix?: Ref<string> | string) => {
const getRawContent = (content: string) => decodeBase64ToUTF8(content) const getRawContent = (content: string) => decodeBase64ToUTF8(content)
const renderFromUTF8 = (content: string, prefix?: string) => { const renderFromUTF8 = (content: string, prefix?: string) => {
return content return content
? md.render(stripFrontmatter(content), { ? renderMarkdown(stripFrontmatter(content), {
docId: defaultPrefix ? toValue(defaultPrefix) : (prefix ?? ""), docId: defaultPrefix ? toValue(defaultPrefix) : (prefix ?? "")
}) })
: "" : ""
} }
return { return {
toHTML: (content: string) => toHTML: (content: string) =>
content ? md.render(stripFrontmatter(content)) : "", content ? renderMarkdown(stripFrontmatter(content)) : "",
render: (content: string, prefix?: string) => render: (content: string, prefix?: string) =>
renderFromUTF8(decodeBase64ToUTF8(content), prefix), renderFromUTF8(decodeBase64ToUTF8(content), prefix),
renderFromUTF8, renderFromUTF8,
getRawContent, getRawContent
} }
} }

View File

@@ -0,0 +1,93 @@
import { Ref, ref } from "vue"
import { useGitHubContent } from "@/hooks/useGitHubContent.hook"
import { markdownBuilder } from "@/hooks/useMarkdown.hook"
import { prepareNoteCache } from "@/modules/note/cache/prepareNoteCache"
import { queryFileContent } from "@/modules/repo/services/repo"
import { useUserRepoStore } from "@/modules/repo/store/userRepo.store"
export type FreshnessStatus =
| "unknown"
| "checking"
| "verified"
| "outdated"
| "offline"
export const useNoteFreshness = ({
user,
repo,
sha,
path,
getEditedSha
}: {
user: string
repo: string
sha: Ref<string>
path: Ref<string | undefined>
getEditedSha: () => Promise<string | null>
}) => {
const store = useUserRepoStore()
const { fetchLatestSha } = useGitHubContent({ user, repo })
const status = ref<FreshnessStatus>("unknown")
const lastCheckedAt = ref<Date | null>(null)
const latestSha = ref<string | null>(null)
const expectedSha = async () => (await getEditedSha()) ?? sha.value
const check = async () => {
if (!path.value) return
status.value = "checking"
const remoteSha = await fetchLatestSha(path.value)
if (remoteSha === null) {
status.value = "offline"
return
}
latestSha.value = remoteSha
lastCheckedAt.value = new Date()
const local = await expectedSha()
status.value = remoteSha === local ? "verified" : "outdated"
}
const pullLatest = async (): Promise<string | null> => {
if (!path.value) return null
const usedCachedSha = latestSha.value !== null
const remoteSha = latestSha.value ?? (await fetchLatestSha(path.value))
if (!remoteSha) {
console.warn("pullLatest: could not resolve remote sha", { path: path.value })
status.value = "offline"
return null
}
const fileContent = await queryFileContent(user, repo, remoteSha)
if (!fileContent) {
console.warn("pullLatest: failed to fetch blob content", {
path: path.value,
remoteSha,
usedCachedSha
})
// Cached SHA may be stale — clear so the next click re-resolves it.
if (usedCachedSha) latestSha.value = null
status.value = "offline"
return null
}
const { saveCacheNote } = prepareNoteCache(sha.value, path.value)
await saveCacheNote(fileContent, {
editedSha: remoteSha,
path: path.value
})
store.addFile({ path: path.value, sha: remoteSha })
latestSha.value = remoteSha
lastCheckedAt.value = new Date()
status.value = "verified"
const { getRawContent } = markdownBuilder(sha.value)
return getRawContent(fileContent)
}
return {
status,
lastCheckedAt,
latestSha,
check,
pullLatest
}
}

View File

@@ -1,13 +1,16 @@
import { computed, onMounted, Ref, ref, toValue } from "vue" import { computed, onMounted, Ref, ref, toValue } from "vue"
import { BOOKMARK_WIDTH_REM, getBookmarkWidthPx } from "@/constants/bookmark-width" import {
BOOKMARK_WIDTH_REM,
getBookmarkWidthPx
} from "@/constants/bookmark-width"
import { getNoteWidth } from "@/constants/note-width" import { getNoteWidth } from "@/constants/note-width"
import { useOverlay } from "@/hooks/useOverlay.hook" import { useOverlay } from "@/hooks/useOverlay.hook"
import { useRouteQueryStackedNotes } from "@/hooks/useRouteQueryStackedNotes.hook" import { useRouteQueryStackedNotes } from "@/hooks/useRouteQueryStackedNotes.hook"
export const useNoteOverlay = ( export const useNoteOverlay = (
className: string, className: string,
index: Ref<number> | number, index: Ref<number> | number
) => { ) => {
const { x, y, isMobile } = useOverlay() const { x, y, isMobile } = useOverlay()
const noteHeight = ref(0) const noteHeight = ref(0)
@@ -18,14 +21,17 @@ export const useNoteOverlay = (
if (isMobile.value) { if (isMobile.value) {
return y.value > valueIndex * noteHeight.value return y.value > valueIndex * noteHeight.value
} else { } else {
return x.value > valueIndex * getNoteWidth() - valueIndex * getBookmarkWidthPx() return (
x.value >
valueIndex * getNoteWidth() - valueIndex * getBookmarkWidthPx()
)
} }
}) })
onMounted(() => { onMounted(() => {
const { stackedNotes } = useRouteQueryStackedNotes() const { stackedNotes } = useRouteQueryStackedNotes()
const noteElement = document.querySelector( const noteElement = document.querySelector(
`.${className}`, `.${className}`
) satisfies HTMLElement | null ) satisfies HTMLElement | null
if (!noteElement) { if (!noteElement) {
@@ -40,7 +46,7 @@ export const useNoteOverlay = (
noteElement.style.left = `${(toValue(index) + 1) * BOOKMARK_WIDTH_REM}rem` noteElement.style.left = `${(toValue(index) + 1) * BOOKMARK_WIDTH_REM}rem`
const stackedNoteContainers = document.querySelectorAll( const stackedNoteContainers = document.querySelectorAll(
".stacked-note", ".stacked-note"
) satisfies NodeListOf<HTMLElement> ) satisfies NodeListOf<HTMLElement>
stackedNoteContainers.forEach((stackedNote, ind) => { stackedNoteContainers.forEach((stackedNote, ind) => {
@@ -52,6 +58,6 @@ export const useNoteOverlay = (
}) })
return { return {
displayNoteOverlay, displayNoteOverlay
} }
} }

View File

@@ -21,13 +21,13 @@ export const useNoteView = () => {
obj[note] = pathToNotePathTitle(filePath) obj[note] = pathToNotePathTitle(filePath)
return obj return obj
}, {}), }, {})
) )
const unsubscribeLink = noteEventBus.addEventBusListener( const unsubscribeLink = noteEventBus.addEventBusListener(
({ path, currentNoteSHA }) => { ({ path, hash, currentNoteSHA }) => {
const currentFile = store.files.find( const currentFile = store.files.find(
(file) => file.sha === currentNoteSHA, (file) => file.sha === currentNoteSHA
) )
const absolutePath = resolvePath(currentFile?.path ?? "", path) const absolutePath = resolvePath(currentFile?.path ?? "", path)
@@ -38,8 +38,8 @@ export const useNoteView = () => {
return return
} }
addStackedNote(currentNoteSHA ?? "", file.sha) addStackedNote(currentNoteSHA ?? "", file.sha, undefined, hash)
}, }
) )
onUnmounted(() => { onUnmounted(() => {
@@ -47,6 +47,6 @@ export const useNoteView = () => {
}) })
return { return {
titles, titles
} }
} }

View File

@@ -1,12 +1,12 @@
import { useAsyncState } from '@vueuse/core' import { useAsyncState } from "@vueuse/core"
import { computed, ref } from 'vue' import { computed, ref } from "vue"
import { data } from '@/data/data' import { data, generateId } from "@/data/data"
import { DataType } from '@/data/DataType.enum' import { DataType } from "@/data/DataType.enum"
import { prepareNoteCache } from '@/modules/note/cache/prepareNoteCache' import { prepareNoteCache } from "@/modules/note/cache/prepareNoteCache"
import { Note } from '@/modules/note/models/Note' import { Note } from "@/modules/note/models/Note"
import { queryFileContent } from '@/modules/repo/services/repo' import { queryFileContent } from "@/modules/repo/services/repo"
import { useUserRepoStore } from '@/modules/repo/store/userRepo.store' import { useUserRepoStore } from "@/modules/repo/store/userRepo.store"
export const useOfflineNotes = () => { export const useOfflineNotes = () => {
const store = useUserRepoStore() const store = useUserRepoStore()
@@ -36,7 +36,7 @@ export const useOfflineNotes = () => {
if ( if (
!file.sha || !file.sha ||
cachedNotesSet.has(data.generateId(DataType.Note, file.sha)) cachedNotesSet.has(generateId(DataType.Note, file.sha))
) { ) {
continue continue
} }

View File

@@ -10,31 +10,28 @@ export const useOverlay = (listen = true) => {
const isMobile = computed(() => width.value <= MOBILE_BREAKPOINT) const isMobile = computed(() => width.value <= MOBILE_BREAKPOINT)
if (listen) { if (listen) {
// In Firefox/Chrome, body is the horizontal scroll container (body has
// computed overflow-x: auto from overflow-y: hidden). In Safari, the
// viewport (documentElement) is used instead. Listen on both.
const updateScroll = () => { const updateScroll = () => {
x.value = document.body.scrollLeft || window.scrollX const mainApp = document.getElementById("main-app")
y.value = document.body.scrollTop || window.scrollY x.value = mainApp?.scrollLeft ?? 0
y.value = mainApp?.scrollTop ?? 0
} }
useEventListener(window, "scroll", updateScroll, { useEventListener(
passive: true, () => document.getElementById("main-app"),
capture: false, "scroll",
}) updateScroll,
useEventListener(document.body, "scroll", updateScroll, { { passive: true }
passive: true, )
capture: false,
})
} }
const scrollToNote = (to: number) => { const scrollToNote = (to: number) => {
const go = () => { const go = () => {
const mainApp = document.getElementById("main-app")
if (!mainApp) return
if (isMobile.value) { if (isMobile.value) {
document.body.scrollTop = to mainApp.scrollTo({ top: to, behavior: "smooth" })
document.documentElement.scrollTop = to
} else { } else {
document.body.scrollLeft = to mainApp.scrollTo({ left: to, behavior: "smooth" })
document.documentElement.scrollLeft = to
} }
} }
@@ -43,10 +40,22 @@ export const useOverlay = (listen = true) => {
}, 80) }, 80)
} }
const scrollToElement = (element: HTMLElement, anchorTop?: number) => {
const mainApp = document.getElementById("main-app")
if (mainApp && anchorTop !== undefined) {
mainApp.scrollTop = anchorTop
}
requestAnimationFrame(() => {
element.scrollIntoView({ behavior: "smooth", block: "start" })
})
}
return { return {
x, x,
y, y,
isMobile, isMobile,
scrollToNote, scrollToNote,
scrollToElement
} }
} }

View File

@@ -1,7 +1,8 @@
import { computedAsync } from "@vueuse/core"
import { computed, Ref, ref } from "vue"
import { Author, getAuthors } from "@/modules/atproto/getAuthor" import { Author, getAuthors } from "@/modules/atproto/getAuthor"
import { PublicNoteListItem } from "@/modules/note/models/Note" import { PublicNoteListItem } from "@/modules/note/models/Note"
import { computedAsync } from "@vueuse/core"
import { computed, ref, Ref } from "vue"
interface UsePublicNoteListOptions { interface UsePublicNoteListOptions {
did?: Ref<string | undefined> did?: Ref<string | undefined>
@@ -50,6 +51,6 @@ export function usePublicNoteList(options?: UsePublicNoteListOptions) {
canLoadMore, canLoadMore,
onLoadMore, onLoadMore,
authors, authors,
getAuthor, getAuthor
} }
} }

View File

@@ -1,34 +1,96 @@
import { useAsyncState } from '@vueuse/core' import { computed, ref, watch } from "vue"
import { useGitHubLogin } from '@/hooks/useGitHubLogin.hook' import { useGitHubLogin } from "@/hooks/useGitHubLogin.hook"
import { RepoBase } from '@/modules/repo/interfaces/RepoBase' import { RepoBase } from "@/modules/repo/interfaces/RepoBase"
import { getOctokit } from '@/modules/repo/services/octo' import { getOctokit } from "@/modules/repo/services/octo"
const PER_PAGE = 30
const STALE_TIME_MS = 20 * 60 * 1000
const repos = ref<RepoBase[]>([])
const isReady = ref(false)
const isLoading = ref(false)
const hasCredentialError = ref(false)
const currentPage = ref(0)
const totalCount = ref(0)
let lastFetchedAt = 0
export const useRepos = () => {
const { username, accessToken } = useGitHubLogin() const { username, accessToken } = useGitHubLogin()
const repos = useAsyncState<RepoBase[]>(async () => {
if (!accessToken.value || !username.value) { const resetState = () => {
return [] repos.value = []
currentPage.value = 0
totalCount.value = 0
isReady.value = false
isLoading.value = false
hasCredentialError.value = false
lastFetchedAt = 0
} }
const loadMore = async () => {
if (!accessToken.value || !username.value) {
isReady.value = true
return
}
if (isLoading.value) return
isLoading.value = true
try {
const octokit = await getOctokit() const octokit = await getOctokit()
const nextPage = currentPage.value + 1
const repoList = await octokit.request('GET /search/repositories', { const repoList = await octokit.request("GET /search/repositories", {
q: `user:${username.value}`, q: `user:${username.value}`,
per_page: 100 per_page: PER_PAGE,
page: nextPage
}) })
currentPage.value = nextPage
return repoList.data.items totalCount.value = repoList.data.total_count
.map((item) => ({ const newItems = repoList.data.items.map((item) => ({
id: `${item.id}`, id: `${item.id}`,
name: item.name, name: item.name,
isPrivate: item.private isPrivate: item.private
})) }))
.sort((a, b) => (a.name < b.name ? -1 : 1)) repos.value = [...repos.value, ...newItems].sort((a, b) =>
}, []) a.name < b.name ? -1 : 1
)
} catch (err: unknown) {
if (
typeof err === "object" &&
err !== null &&
"status" in err &&
(err as { status: number }).status === 401
) {
hasCredentialError.value = true
} else {
throw err
}
} finally {
isReady.value = true
isLoading.value = false
}
}
return { watch(accessToken, (next, prev) => {
repos: repos.state, if (next === prev) return
isReady: repos.isReady resetState()
if (next && username.value) {
lastFetchedAt = Date.now()
loadMore()
} }
})
export const useRepos = () => {
const canLoadMore = computed(
() => !isLoading.value && repos.value.length < totalCount.value
)
const isStale = Date.now() - lastFetchedAt > STALE_TIME_MS
if (!isReady.value || isStale) {
if (isStale && isReady.value) {
resetState()
}
lastFetchedAt = Date.now()
loadMore()
}
return { repos, isReady, hasCredentialError, canLoadMore, loadMore }
} }

View File

@@ -1,17 +1,17 @@
import { onMounted, watch, type Ref } from "vue" import { onMounted, onUnmounted, type Ref, watch } from "vue"
import { getNoteWidth } from "@/constants/note-width" import { getNoteWidth } from "@/constants/note-width"
import { useOverlay } from "@/hooks/useOverlay.hook" import { useOverlay } from "@/hooks/useOverlay.hook"
export const useResizeContainer = ( export const useResizeContainer = (
containerClass: string, containerClass: string,
stackedNotes: Readonly<Ref<readonly string[]>>, stackedNotes: Readonly<Ref<readonly string[]>>
) => { ) => {
const { isMobile } = useOverlay(false) const { isMobile } = useOverlay(false)
const resizeContainer = () => { const resizeContainer = () => {
const container = document.querySelector( const container = document.querySelector(
`.${containerClass}`, `.${containerClass}`
) as HTMLElement | null ) as HTMLElement | null
if (!container) { if (!container) {
@@ -19,9 +19,9 @@ export const useResizeContainer = (
} }
if (isMobile.value) { if (isMobile.value) {
container.style.height = `${(stackedNotes.value.length + 1) * 100}vh` container.style.height = `${(stackedNotes.value.length + 1) * 100}svh`
} else { } else {
container.style.width = `${ container.style.minWidth = `${
getNoteWidth() * (stackedNotes.value.length + 1) getNoteWidth() * (stackedNotes.value.length + 1)
}px` }px`
} }
@@ -29,9 +29,14 @@ export const useResizeContainer = (
onMounted(() => { onMounted(() => {
resizeContainer() resizeContainer()
window.addEventListener("resize", resizeContainer)
})
onUnmounted(() => {
window.removeEventListener("resize", resizeContainer)
}) })
watch(stackedNotes, resizeContainer, { watch(stackedNotes, resizeContainer, {
immediate: true, immediate: true
}) })
} }

View File

@@ -14,28 +14,85 @@ export const useRouteQueryStackedNotes = () => {
} }
return Array.isArray(value) ? value : [value] return Array.isArray(value) ? value : [value]
}, }
}) })
const { height } = useWindowSize() const { height } = useWindowSize()
const { scrollToNote, isMobile } = useOverlay(false) const { scrollToNote, scrollToElement, isMobile } = useOverlay(false)
const scrollToFocusedNote = ( const scrollToHashInNote = (
noteId: string | null = null, cleanSha: string,
notes: string[] = stackedNotes.value, hash: string,
smooth: boolean,
attempts = 30
) => { ) => {
if (attempts <= 0) {
return
}
const heading = document.querySelector(
`.note-${cleanSha} #${CSS.escape(hash)}`
)
if (heading) {
heading.scrollIntoView({
block: "start",
inline: "nearest",
behavior: smooth ? "smooth" : "auto"
})
return
}
requestAnimationFrame(() => {
scrollToHashInNote(cleanSha, hash, smooth, attempts - 1)
})
}
const scrollToNoteElement = (
cleanNoteId: string,
index: number,
anchorTop?: number,
attempts = 30
) => {
const element = document.querySelector(
`.note-${cleanNoteId}`
) as HTMLElement | null
if (element) {
scrollToElement(element, anchorTop)
return
}
if (attempts <= 0) {
scrollToNote((index + 1) * height.value)
return
}
requestAnimationFrame(() => {
scrollToNoteElement(cleanNoteId, index, anchorTop, attempts - 1)
})
}
type ScrollToFocusedNoteOptions = {
noteId?: string | null
notes?: string[]
hash?: string
smoothHash?: boolean
anchorTop?: number
}
const scrollToFocusedNote = ({
noteId = null,
notes = stackedNotes.value,
hash,
smoothHash = false,
anchorTop
}: ScrollToFocusedNoteOptions = {}) => {
nextTick(() => { nextTick(() => {
const index = noteId ? notes.findIndex((nid) => nid === noteId) : 0 const index = noteId ? notes.findIndex((nid) => nid === noteId) : 0
if (isMobile.value) { if (isMobile.value) {
if (noteId) { if (noteId) {
const cleanNoteId = noteId.replaceAll(":", "-") scrollToNoteElement(noteId.replaceAll(":", "-"), index, anchorTop)
const element = document.querySelector(
`.note-${cleanNoteId}`,
) as HTMLElement
const top = (index + 1) * (element?.clientHeight ?? height.value)
scrollToNote(top)
} else { } else {
scrollToNote(0) scrollToNote(0)
} }
@@ -47,6 +104,10 @@ export const useRouteQueryStackedNotes = () => {
scrollToNote(0) scrollToNote(0)
} }
} }
if (hash && noteId) {
scrollToHashInNote(noteId.replaceAll(":", "-"), hash, smoothHash)
}
}) })
} }
@@ -54,9 +115,18 @@ export const useRouteQueryStackedNotes = () => {
currentSha: string, currentSha: string,
sha: string, sha: string,
selector?: string, selector?: string,
hash?: string
) => { ) => {
const anchorTop =
document.getElementById("main-app")?.scrollTop ?? undefined
if (stackedNotes.value.includes(sha)) { if (stackedNotes.value.includes(sha)) {
scrollToFocusedNote(selector ?? sha) scrollToFocusedNote({
noteId: selector ?? sha,
hash,
smoothHash: true,
anchorTop
})
return return
} }
@@ -70,18 +140,18 @@ export const useRouteQueryStackedNotes = () => {
const newStackedNotes = [ const newStackedNotes = [
...splittedStackedNotes.replaceAll(";;", ";").split(";"), ...splittedStackedNotes.replaceAll(";;", ";").split(";"),
currentSha, currentSha,
sha, sha
].filter((sha) => !!sha) ].filter((sha) => !!sha)
stackedNotes.value = newStackedNotes stackedNotes.value = newStackedNotes
} }
scrollToFocusedNote(selector ?? sha, stackedNotes.value) scrollToFocusedNote({ noteId: selector ?? sha, hash, anchorTop })
} }
return { return {
stackedNotes: readonly(stackedNotes), stackedNotes: readonly(stackedNotes),
addStackedNote, addStackedNote,
scrollToFocusedNote, scrollToFocusedNote
} }
} }

View File

@@ -1,19 +1,19 @@
import { useTitle } from '@vueuse/core' import { useTitle } from "@vueuse/core"
import { computed, Ref, toValue, watch } from 'vue' import { computed, Ref, toValue, watch } from "vue"
import { useRouteQueryStackedNotes } from '@/hooks/useRouteQueryStackedNotes.hook' import { useRouteQueryStackedNotes } from "@/hooks/useRouteQueryStackedNotes.hook"
import { useNotes } from '@/modules/note/hooks/useNotes' import { useNotes } from "@/modules/note/hooks/useNotes"
import { pathToNoteTitle } from '@/utils/noteTitle' import { pathToNoteTitle } from "@/utils/noteTitle"
export const generateTitle = (titles: string[]) => titles.join(' | ') export const generateTitle = (titles: string[]) => titles.join(" | ")
export const useTitleNotes = (prefix: Ref<string> | string) => { export const useTitleNotes = (prefix: Ref<string> | string) => {
const { stackedNotes } = useRouteQueryStackedNotes() const { stackedNotes } = useRouteQueryStackedNotes()
const { notes } = useNotes() const { notes } = useNotes()
const titleNotes = computed(() => const titleNotes = computed(() =>
notes.value notes.value
.filter((note) => stackedNotes.value.includes(note.sha ?? '')) .filter((note) => stackedNotes.value.includes(note.sha ?? ""))
.map((note) => pathToNoteTitle(note.path ?? '')) .map((note) => pathToNoteTitle(note.path ?? ""))
) )
const title = useTitle(generateTitle([toValue(prefix), ...titleNotes.value])) const title = useTitle(generateTitle([toValue(prefix), ...titleNotes.value]))

View File

@@ -1,5 +1,5 @@
import en from './en.json' import en from "./en.json"
import fr from './fr.json' import fr from "./fr.json"
export const messages = { export const messages = {
en, en,

View File

@@ -1,5 +1,6 @@
import "notyf/notyf.min.css" import "notyf/notyf.min.css"
import "./styles/app.css" import "./styles/app.css"
import "@/analytics/openpanel"
import { VueQueryPlugin } from "@tanstack/vue-query" import { VueQueryPlugin } from "@tanstack/vue-query"
import { createPinia } from "pinia" import { createPinia } from "pinia"
@@ -13,7 +14,7 @@ import App from "./App.vue"
const i18n = createI18n({ const i18n = createI18n({
locale: "en", locale: "en",
messages, messages
}) })
createApp(App) createApp(App)

View File

@@ -1,4 +1,4 @@
import { createSchema, createFetch } from "@better-fetch/fetch" import { createFetch, createSchema } from "@better-fetch/fetch"
import { type } from "arktype" import { type } from "arktype"
export type Author = { handle: string; pds: string } export type Author = { handle: string; pds: string }
@@ -12,20 +12,20 @@ const schema = createSchema(
did: "string", did: "string",
handle: "string", handle: "string",
pds: "string", pds: "string",
signing_key: "string", signing_key: "string"
}), }),
query: type({ query: type({
identifier: "string", identifier: "string"
}), })
}
}, },
}, { strict: true }
{ strict: true },
) )
const microcosmSlingshot = createFetch({ const microcosmSlingshot = createFetch({
baseURL: "https://slingshot.microcosm.blue", baseURL: "https://slingshot.microcosm.blue",
// plugins: [logger()], // plugins: [logger()],
schema, schema
}) })
export const getAuthor = async (did: string): Promise<Author | null> => { export const getAuthor = async (did: string): Promise<Author | null> => {
@@ -36,7 +36,7 @@ export const getAuthor = async (did: string): Promise<Author | null> => {
try { try {
const { data: author } = await microcosmSlingshot( const { data: author } = await microcosmSlingshot(
"/xrpc/blue.microcosm.identity.resolveMiniDoc", "/xrpc/blue.microcosm.identity.resolveMiniDoc",
{ query: { identifier: did } }, { query: { identifier: did } }
) )
if (!author) { if (!author) {
@@ -62,7 +62,7 @@ export const getAuthors = async (dids: Set<string>) => {
const { data: author } = await microcosmSlingshot( const { data: author } = await microcosmSlingshot(
"/xrpc/blue.microcosm.identity.resolveMiniDoc", "/xrpc/blue.microcosm.identity.resolveMiniDoc",
{ query: { identifier: did } }, { query: { identifier: did } }
) )
if (!author) { if (!author) {
@@ -72,7 +72,7 @@ export const getAuthors = async (dids: Set<string>) => {
correspondanceCache.set(did, author) correspondanceCache.set(did, author)
return [did, author] as [string, Author | null] return [did, author] as [string, Author | null]
}), })
) )
return new Map(correspondance) return new Map(correspondance)

View File

@@ -1,6 +1,6 @@
import { import {
BrowserOAuthClient, BrowserOAuthClient,
buildLoopbackClientId, buildLoopbackClientId
} from "@atproto/oauth-client-browser" } from "@atproto/oauth-client-browser"
const getClientId = () => const getClientId = () =>
@@ -14,7 +14,7 @@ export const getOAuthClient = (): Promise<BrowserOAuthClient> => {
if (!clientPromise) { if (!clientPromise) {
clientPromise = BrowserOAuthClient.load({ clientPromise = BrowserOAuthClient.load({
clientId: getClientId(), clientId: getClientId(),
handleResolver: "https://bsky.social", handleResolver: "https://bsky.social"
}) })
} }
return clientPromise return clientPromise

View File

@@ -1,6 +1,6 @@
import { data } from '@/data/data' import { data } from "@/data/data"
import { DataType } from '@/data/DataType.enum' import { DataType } from "@/data/DataType.enum"
import { AtprotoSession } from '@/data/models/AtprotoSession' import { AtprotoSession } from "@/data/models/AtprotoSession"
const SESSION_ID = `${DataType.AtprotoSession}-current` const SESSION_ID = `${DataType.AtprotoSession}-current`
@@ -8,12 +8,15 @@ export const loadSession = (): Promise<AtprotoSession | null> => {
return data.get<DataType.AtprotoSession, AtprotoSession>(SESSION_ID) return data.get<DataType.AtprotoSession, AtprotoSession>(SESSION_ID)
} }
export const saveSession = async (did: string, handle: string): Promise<void> => { export const saveSession = async (
did: string,
handle: string
): Promise<void> => {
const session: AtprotoSession = { const session: AtprotoSession = {
_id: SESSION_ID, _id: SESSION_ID,
$type: DataType.AtprotoSession, $type: DataType.AtprotoSession,
did, did,
handle, handle
} }
await data.update<DataType.AtprotoSession, AtprotoSession>(session) await data.update<DataType.AtprotoSession, AtprotoSession>(session)
} }

View File

@@ -3,15 +3,18 @@ export const getFollows = async (did: string): Promise<Set<string>> => {
let cursor: string | undefined let cursor: string | undefined
do { do {
const url = new URL('https://public.api.bsky.app/xrpc/app.bsky.graph.getFollows') const url = new URL(
url.searchParams.set('actor', did) "https://public.api.bsky.app/xrpc/app.bsky.graph.getFollows"
url.searchParams.set('limit', '100') )
url.searchParams.set("actor", did)
url.searchParams.set("limit", "100")
if (cursor) { if (cursor) {
url.searchParams.set('cursor', cursor) url.searchParams.set("cursor", cursor)
} }
const response = await fetch(url) const response = await fetch(url)
const result: { follows: { did: string }[]; cursor?: string } = await response.json() const result: { follows: { did: string }[]; cursor?: string } =
await response.json()
for (const follow of result.follows) { for (const follow of result.follows) {
follows.add(follow.did) follows.add(follow.did)

View File

@@ -1,6 +1,6 @@
export const withATProtoImages = ( export const withATProtoImages = (
markdown: string, markdown: string,
{ pds, did }: { pds: string; did: string }, { pds, did }: { pds: string; did: string }
): string => { ): string => {
const imageLinkPattern = /!\[([^\]]*)\]\((bafkrei[a-z0-9]+)\)/g const imageLinkPattern = /!\[([^\]]*)\]\((bafkrei[a-z0-9]+)\)/g

View File

@@ -1,8 +1,8 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed, ref } from 'vue' import { computed, ref } from "vue"
import FlipCard from '@/modules/card/components/FlipCard.vue' import FlipCard from "@/modules/card/components/FlipCard.vue"
import { Repetition } from '@/modules/card/hooks/useSpacedRepetitionCards' import { Repetition } from "@/modules/card/hooks/useSpacedRepetitionCards"
const props = defineProps<{ cards: Repetition[] }>() const props = defineProps<{ cards: Repetition[] }>()
const emits = defineEmits<{ const emits = defineEmits<{
@@ -22,24 +22,24 @@ const sortedCards = ref(
const currentIndex = ref(0) const currentIndex = ref(0)
const goToNextCard = (success: boolean) => { const goToNextCard = (success: boolean) => {
const id = sortedCards.value[currentIndex.value].repetition._id ?? '' const id = sortedCards.value[currentIndex.value].repetition._id ?? ""
if (success) { if (success) {
emits('success', id) emits("success", id)
} else { } else {
const failedCard = sortedCards.value.at(currentIndex.value) const failedCard = sortedCards.value.at(currentIndex.value)
if (failedCard) { if (failedCard) {
sortedCards.value.push(failedCard) sortedCards.value.push(failedCard)
} }
emits('fail', id) emits("fail", id)
} }
currentIndex.value++ currentIndex.value++
} }
const needsReview = () => { const needsReview = () => {
const id = sortedCards.value[currentIndex.value].repetition._id ?? '' const id = sortedCards.value[currentIndex.value].repetition._id ?? ""
emits('needsReview', id) emits("needsReview", id)
currentIndex.value++ currentIndex.value++
} }
</script> </script>

View File

@@ -1,8 +1,8 @@
import { useAsyncState } from '@vueuse/core' import { useAsyncState } from "@vueuse/core"
import { data } from '@/data/data' import { data } from "@/data/data"
import { DataType } from '@/data/DataType.enum' import { DataType } from "@/data/DataType.enum"
import { RepetitionCard } from '@/modules/card/models/RepetitionCard' import { RepetitionCard } from "@/modules/card/models/RepetitionCard"
export const useNeedReviewCards = () => { export const useNeedReviewCards = () => {
const { state: cardsToReview, isReady } = useAsyncState(async () => { const { state: cardsToReview, isReady } = useAsyncState(async () => {

View File

@@ -4,7 +4,7 @@ import { useAsyncState } from "@vueuse/core"
import { addDays, isAfter } from "date-fns" import { addDays, isAfter } from "date-fns"
import { computed, nextTick, watch } from "vue" import { computed, nextTick, watch } from "vue"
import { data } from "@/data/data" import { data, generateId } from "@/data/data"
import { DataType } from "@/data/DataType.enum" import { DataType } from "@/data/DataType.enum"
import { useFile } from "@/hooks/useFile.hook" import { useFile } from "@/hooks/useFile.hook"
import { useLinks } from "@/hooks/useLinks.hook" import { useLinks } from "@/hooks/useLinks.hook"
@@ -31,14 +31,14 @@ export const useSpacedRepetitionCards = () => {
(file) => (file) =>
file.path !== undefined && file.path !== undefined &&
file.path.startsWith("_cards") && file.path.startsWith("_cards") &&
file.path.endsWith(".md"), file.path.endsWith(".md")
), )
) )
const { const {
state: cards, state: cards,
isReady, isReady,
execute, execute
} = useAsyncState( } = useAsyncState(
async () => { async () => {
const cards: Repetition[] = [] const cards: Repetition[] = []
@@ -51,11 +51,11 @@ export const useSpacedRepetitionCards = () => {
const repetition = await data.getOrCreate< const repetition = await data.getOrCreate<
DataType.RepetitionCard, DataType.RepetitionCard,
RepetitionCard RepetitionCard
>(data.generateId(DataType.RepetitionCard, cardFile.path), { >(generateId(DataType.RepetitionCard, cardFile.path), {
$type: DataType.RepetitionCard, $type: DataType.RepetitionCard,
level: 1, level: 1,
repeatDate: new Date(), repeatDate: new Date(),
needsReview: false, needsReview: false
}) })
if ( if (
@@ -77,20 +77,20 @@ export const useSpacedRepetitionCards = () => {
card: { card: {
front: toHTML(front), front: toHTML(front),
back: toHTML(back), back: toHTML(back),
references: toHTML(references), references: toHTML(references)
}, }
}) })
} }
return cards return cards
}, },
[], [],
{ immediate: false }, { immediate: false }
) )
const successRepetition = async (cardId: string) => { const successRepetition = async (cardId: string) => {
const repetition = await data.get<DataType.RepetitionCard, RepetitionCard>( const repetition = await data.get<DataType.RepetitionCard, RepetitionCard>(
cardId, cardId
) )
if (!repetition) { if (!repetition) {
return return
@@ -100,13 +100,13 @@ export const useSpacedRepetitionCards = () => {
...repetition, ...repetition,
needsReview: false, needsReview: false,
level: Math.min(repetition.level + 1, MAX_LEVEL), level: Math.min(repetition.level + 1, MAX_LEVEL),
repeatDate: addDays(new Date(), 2 ** repetition.level), repeatDate: addDays(new Date(), 2 ** repetition.level)
}) })
} }
const failRepetition = async (cardId: string) => { const failRepetition = async (cardId: string) => {
const repetition = await data.get<DataType.RepetitionCard, RepetitionCard>( const repetition = await data.get<DataType.RepetitionCard, RepetitionCard>(
cardId, cardId
) )
if (!repetition) { if (!repetition) {
return return
@@ -118,13 +118,13 @@ export const useSpacedRepetitionCards = () => {
...repetition, ...repetition,
level, level,
needsReview: false, needsReview: false,
repeatDate: addDays(new Date(), level), repeatDate: addDays(new Date(), level)
}) })
} }
const needsReview = async (cardId: string) => { const needsReview = async (cardId: string) => {
const repetition = await data.get<DataType.RepetitionCard, RepetitionCard>( const repetition = await data.get<DataType.RepetitionCard, RepetitionCard>(
cardId, cardId
) )
if (!repetition) { if (!repetition) {
return return
@@ -132,7 +132,7 @@ export const useSpacedRepetitionCards = () => {
await data.update<DataType.RepetitionCard, RepetitionCard>({ await data.update<DataType.RepetitionCard, RepetitionCard>({
...repetition, ...repetition,
needsReview: true, needsReview: true
}) })
} }
@@ -142,7 +142,7 @@ export const useSpacedRepetitionCards = () => {
nextTick(() => { nextTick(() => {
listenToClick() listenToClick()
}), }),
{ immediate: true }, { immediate: true }
) )
watch(cardFiles, () => execute()) watch(cardFiles, () => execute())
@@ -152,6 +152,6 @@ export const useSpacedRepetitionCards = () => {
successRepetition, successRepetition,
failRepetition, failRepetition,
needsReview, needsReview,
isLoading: !isReady, isLoading: !isReady
} }
} }

View File

@@ -1,5 +1,5 @@
import { DataType } from '@/data/DataType.enum' import { DataType } from "@/data/DataType.enum"
import { Model } from '@/data/models/Model' import { Model } from "@/data/models/Model"
export interface RepetitionCard extends Model<DataType.RepetitionCard> { export interface RepetitionCard extends Model<DataType.RepetitionCard> {
level: number level: number

View File

@@ -1,5 +1,5 @@
<script lang="ts" setup> <script lang="ts" setup>
import { useLastVisitedRepos } from '@/modules/history/hooks/useLastVisitedRepos.hook' import { useLastVisitedRepos } from "@/modules/history/hooks/useLastVisitedRepos.hook"
const { lastVisitedRepos } = useLastVisitedRepos() const { lastVisitedRepos } = useLastVisitedRepos()
</script> </script>

View File

@@ -1,17 +1,17 @@
import { useAsyncState } from '@vueuse/core' import { useAsyncState } from "@vueuse/core"
import { computed } from 'vue' import { computed } from "vue"
import { data } from '@/data/data' import { data, generateId } from "@/data/data"
import { DataType } from '@/data/DataType.enum' import { DataType } from "@/data/DataType.enum"
import { History } from '@/data/models/History' import { History } from "@/data/models/History"
const HISTORY_ID = data.generateId(DataType.History, 'history') const HISTORY_ID = generateId(DataType.History, "history")
export const useLastVisitedRepos = () => { export const useLastVisitedRepos = () => {
const history = useAsyncState( const history = useAsyncState(
() => () =>
data.get<DataType.History, History>( data.get<DataType.History, History>(
data.generateId(DataType.History, 'history') generateId(DataType.History, "history")
), ),
null null
) )

View File

@@ -1,10 +1,10 @@
import { Ref, toValue } from 'vue' import { Ref, toValue } from "vue"
import { data } from '@/data/data' import { data, generateId } from "@/data/data"
import { DataType } from '@/data/DataType.enum' import { DataType } from "@/data/DataType.enum"
import { History } from '@/data/models/History' import { History } from "@/data/models/History"
const HISTORY_ID = data.generateId(DataType.History, 'history') const HISTORY_ID = generateId(DataType.History, "history")
const MAX_REPO_HISTORY = 10 const MAX_REPO_HISTORY = 10
export const useVisitRepo = (newRepo: { export const useVisitRepo = (newRepo: {

View File

@@ -1,26 +1,26 @@
import { data } from '@/data/data' import { data, generateId } from "@/data/data"
import { DataType } from '@/data/DataType.enum' import { DataType } from "@/data/DataType.enum"
import { Note } from '@/modules/note/models/Note' import { Note } from "@/modules/note/models/Note"
import { useUserRepoStore } from '@/modules/repo/store/userRepo.store' import { useUserRepoStore } from "@/modules/repo/store/userRepo.store"
type NoteCacheResult = type NoteCacheResult =
| { | {
note: Note note: Note
from: 'sha' from: "sha"
} }
| { note: Note; from: 'path' } | { note: Note; from: "path" }
| { note: null; from: null } | { note: null; from: null }
export const prepareNoteCache = (sha: string, path?: string) => { export const prepareNoteCache = (sha: string, path?: string) => {
const store = useUserRepoStore() const store = useUserRepoStore()
const noteId = data.generateId(DataType.Note, sha) const noteId = generateId(DataType.Note, sha)
const notePath = path ? data.generateId(DataType.Note, path) : null const notePath = path ? generateId(DataType.Note, path) : null
const getCachedNote = async (): Promise<NoteCacheResult> => { const getCachedNote = async (): Promise<NoteCacheResult> => {
const note = await data.get<DataType.Note, Note>(noteId) const note = await data.get<DataType.Note, Note>(noteId)
if (note) { if (note) {
return { note, from: 'sha' } return { note, from: "sha" }
} }
if (notePath) { if (notePath) {
@@ -33,7 +33,7 @@ export const prepareNoteCache = (sha: string, path?: string) => {
} }
return { return {
note, note,
from: 'path' from: "path"
} }
} }

View File

@@ -1,4 +1,5 @@
import { computed } from "vue" import { computed } from "vue"
import { useUserRepoStore } from "@/modules/repo/store/userRepo.store" import { useUserRepoStore } from "@/modules/repo/store/userRepo.store"
export const useFolderNotes = (folders: string[]) => { export const useFolderNotes = (folders: string[]) => {
@@ -8,8 +9,8 @@ export const useFolderNotes = (folders: string[]) => {
store.files.filter( store.files.filter(
(file) => (file) =>
folders.some((folder) => file.path?.startsWith(folder)) && folders.some((folder) => file.path?.startsWith(folder)) &&
file.path?.endsWith(".md"), file.path?.endsWith(".md")
), )
) )
const content = computed(() => const content = computed(() =>
@@ -23,10 +24,10 @@ export const useFolderNotes = (folders: string[]) => {
})` })`
}) })
.join("\n") .join("\n")
: "", : ""
) )
return { return {
content, content
} }
} }

Some files were not shown because too many files have changed in this diff Show More