Compare commits

67 Commits

Author SHA1 Message Date
Julien Calixte
248dea6ade style(repos): use tabler lock icon for private repos 2026-05-14 16:18:11 +02:00
Julien Calixte
4fd72226ff refactor(repos): redesign RepoList in editorial style
Align the repo manager with WelcomeWorld and PublicNoteView: editorial
top nav, serif hero, pastel favorite tiles, A-Z grouped list, skeleton
and credential-error states, and a name filter.
2026-05-14 16:13:41 +02:00
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
39 changed files with 3018 additions and 396 deletions

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"
}
} }
} }

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."

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

@@ -1,11 +1,11 @@
# ---- Stage 1: deps (only invalidated when lockfile changes) ---- # ---- Stage 1: deps (only invalidated when lockfile changes) ----
FROM node:22-alpine AS deps FROM node:22-alpine AS deps
RUN corepack enable && corepack prepare pnpm@latest --activate RUN corepack enable
WORKDIR /app WORKDIR /app
COPY package.json pnpm-lock.yaml ./ COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
RUN pnpm install --frozen-lockfile RUN pnpm install --frozen-lockfile
@@ -13,7 +13,7 @@ RUN pnpm install --frozen-lockfile
# ---- Stage 2: build (invalidated on any source change) ---- # ---- Stage 2: build (invalidated on any source change) ----
FROM node:22-alpine AS builder FROM node:22-alpine AS builder
RUN corepack enable && corepack prepare pnpm@latest --activate RUN corepack enable
WORKDIR /app WORKDIR /app

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

@@ -1,5 +1,5 @@
<!doctype html> <!doctype html>
<html lang="en" data-theme="emerald"> <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" />

View File

@@ -2,6 +2,7 @@
"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",
@@ -12,10 +13,8 @@
"lint:fix": "oxlint --fix", "lint:fix": "oxlint --fix",
"fmt": "oxfmt", "fmt": "oxfmt",
"fmt:check": "oxfmt --check", "fmt:check": "oxfmt --check",
"prepare": "husky",
"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",
@@ -85,7 +84,6 @@
"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",
"esno": "^4.8.0", "esno": "^4.8.0",
"husky": "^9.1.7",
"oxfmt": "^0.42.0", "oxfmt": "^0.42.0",
"oxlint": "^1.57.0", "oxlint": "^1.57.0",
"prettier": "^3.8.1", "prettier": "^3.8.1",

10
pnpm-lock.yaml generated
View File

@@ -204,9 +204,6 @@ importers:
esno: esno:
specifier: ^4.8.0 specifier: ^4.8.0
version: 4.8.0 version: 4.8.0
husky:
specifier: ^9.1.7
version: 9.1.7
oxfmt: oxfmt:
specifier: ^0.42.0 specifier: ^0.42.0
version: 0.42.0 version: 0.42.0
@@ -4052,11 +4049,6 @@ packages:
resolution: {integrity: sha512-nZXjEF2nbo7lIw3mgYjItAfgQXog3OjJogSbKa2CQIIvSGWcKgeJnQlNXip6NglNzYH45nSRiEVimMvYL8DDqQ==} resolution: {integrity: sha512-nZXjEF2nbo7lIw3mgYjItAfgQXog3OjJogSbKa2CQIIvSGWcKgeJnQlNXip6NglNzYH45nSRiEVimMvYL8DDqQ==}
engines: {node: '>=14.18.0'} engines: {node: '>=14.18.0'}
husky@9.1.7:
resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==}
engines: {node: '>=18'}
hasBin: true
ico-endec@0.1.6: ico-endec@0.1.6:
resolution: {integrity: sha512-ZdLU38ZoED3g1j3iEyzcQj+wAkY2xfWNkymszfJPoxucIUhK7NayQ+/C4Kv0nDFMIsbtbEHldv3V8PU494/ueQ==} resolution: {integrity: sha512-ZdLU38ZoED3g1j3iEyzcQj+wAkY2xfWNkymszfJPoxucIUhK7NayQ+/C4Kv0nDFMIsbtbEHldv3V8PU494/ueQ==}
@@ -10762,8 +10754,6 @@ snapshots:
human-signals@4.3.1: {} human-signals@4.3.1: {}
husky@9.1.7: {}
ico-endec@0.1.6: {} ico-endec@0.1.6: {}
iconv-lite@0.4.24: iconv-lite@0.4.24:

View File

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

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

@@ -19,8 +19,7 @@ onBeforeMount(async () => {
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" })

View File

@@ -2,8 +2,10 @@
import { computed, nextTick, onMounted, onUnmounted, toRefs, watch } from "vue" import { computed, nextTick, onMounted, onUnmounted, toRefs, watch } from "vue"
import HeaderNote from "@/components/HeaderNote.vue" import HeaderNote from "@/components/HeaderNote.vue"
import SignInGithub from "@/components/SignInGithub.vue"
import SkeletonLoader from "@/components/SkeletonLoader.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"
@@ -43,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(() =>
@@ -102,6 +105,15 @@ onUnmounted(() => {
</div> </div>
<slot /> <slot />
<skeleton-loader v-if="isLoading" /> <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 && hasContent" v-else-if="withContent && hasContent"
class="note-display" class="note-display"
@@ -189,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;
} }
@@ -197,12 +218,6 @@ $header-height: 40px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow-y: auto; overflow-y: auto;
height: 100%;
position: sticky;
&:not(:first-child) {
border-top: 1px solid rgba(18, 19, 58, 0.2);
}
.title { .title {
text-align: left; text-align: left;
@@ -210,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;
@@ -226,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);
} }
} }
} }
@@ -244,7 +266,7 @@ $header-height: 40px;
.note { .note {
width: 100vw; width: 100vw;
height: 100dvh; height: 100svh;
overflow-y: visible; overflow-y: visible;
} }

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

@@ -13,18 +13,33 @@ 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 { runMermaid, useShikiji } from "@/hooks/useMarkdown.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 { 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")
) )
@@ -53,6 +68,35 @@ const {
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}`)
@@ -70,10 +114,34 @@ const { updateFile } = useGitHubContent({
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>
@@ -140,23 +275,16 @@ watch(mode, async (newMode) => {
[`note-${sha}`]: true [`note-${sha}`]: true
}" }"
> >
<a <div class="title-stacked-note breadcrumbs text-sm" :class="titleClassName">
class="title-stacked-note-link" <div class="action-bar">
@click.prevent="scrollToFocusedNote({ noteId: props.sha })" <note-freshness-badge
> :status="freshnessStatus"
<div :last-checked-at="lastCheckedAt"
class="title-stacked-note breadcrumbs text-sm" @click="onBadgeClick"
:class="titleClassName" class="action"
> />
<ul>
<li v-for="(part, i) in breadcrumbs" :key="i">
{{ part }}
</li>
</ul>
</div>
</a>
<section class="text-content">
<button <button
v-if="isMarkdown"
class="action button is-text is-light" class="action button is-text is-light"
:class="{ 'is-link': mode === 'edit' }" :class="{ 'is-link': mode === 'edit' }"
:style="mode === 'edit' ? 'color: var(--color-primary)' : ''" :style="mode === 'edit' ? 'color: var(--color-primary)' : ''"
@@ -205,12 +333,35 @@ watch(mode, async (newMode) => {
<path d="M14 4l0 4l-6 0l0 -4" /> <path d="M14 4l0 4l-6 0l0 -4" />
</svg> </svg>
</button> </button>
<div v-if="mode === 'edit'" class="edit"> </div>
<a
class="title-stacked-note-link"
@click.prevent="scrollToFocusedNote({ noteId: props.sha })"
>
<ul>
<li v-for="(part, i) in breadcrumbs" :key="i">
{{ part }}
</li>
</ul>
</a>
</div>
<section class="text-content">
<div v-if="mode === 'edit' && isMarkdown" class="edit">
<edit-note v-model="rawContent" /> <edit-note v-model="rawContent" />
</div> </div>
<div v-if="mode === 'read'" class="note-content" v-html="content"></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>
@@ -228,7 +379,7 @@ $border-color: rgba(18, 19, 58, 0.2);
} }
section { section {
padding: 0 0.5rem 2rem; padding: 0 0.5rem;
} }
} }
@@ -242,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 {
@@ -257,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;
@@ -274,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;
} }
@@ -291,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);
} }
@@ -302,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

@@ -130,7 +130,7 @@ $border-color: rgba(18, 19, 58, 0.2);
} }
section { section {
padding: 0 0.5rem 2rem; padding: 0 0.5rem;
} }
} }
@@ -144,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 {
@@ -178,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;
} }
@@ -193,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 {

View File

@@ -1,7 +1,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import fontColorContrast from "font-color-contrast" import fontColorContrast from "font-color-contrast"
import { getHex } from "pastel-color" import { getHex } from "pastel-color"
import { computed, ref } from "vue" import { computed, onMounted, ref } from "vue"
import { useRouter } from "vue-router" import { useRouter } from "vue-router"
import SignInAtproto from "@/components/SignInAtproto.vue" import SignInAtproto from "@/components/SignInAtproto.vue"
@@ -10,9 +10,12 @@ import ThemeSwap from "@/components/ThemeSwap.vue"
import { useATProtoLogin } from "@/hooks/useATProtoLogin.hook" import { useATProtoLogin } from "@/hooks/useATProtoLogin.hook"
import { useForm } from "@/hooks/useForm.hook" import { useForm } from "@/hooks/useForm.hook"
import { useGitHubLogin } from "@/hooks/useGitHubLogin.hook" import { useGitHubLogin } from "@/hooks/useGitHubLogin.hook"
import { usePublicNoteList } from "@/hooks/usePublicNoteList.hook"
import { toShortDid } from "@/modules/atproto/shortDid"
import { useNeedReviewCards } from "@/modules/card/hooks/useNeedReviewCards" import { useNeedReviewCards } from "@/modules/card/hooks/useNeedReviewCards"
import { useLastVisitedRepos } from "@/modules/history/hooks/useLastVisitedRepos.hook" import { useLastVisitedRepos } from "@/modules/history/hooks/useLastVisitedRepos.hook"
import { useFavoriteRepos } from "@/modules/repo/hooks/useFavoriteRepos.hook" import { useFavoriteRepos } from "@/modules/repo/hooks/useFavoriteRepos.hook"
import { slugify } from "@/utils/slugify"
const { username, accessToken } = useGitHubLogin() const { username, accessToken } = useGitHubLogin()
const { isLoggedIn: isATProtoLoggedIn, handle, avatarUrl } = useATProtoLogin() const { isLoggedIn: isATProtoLoggedIn, handle, avatarUrl } = useATProtoLogin()
@@ -65,10 +68,57 @@ const flashOffset = () => {
}, 900) }, 900)
} }
const firstHighlighted = ref(false)
const flashFirst = () => {
firstHighlighted.value = true
setTimeout(() => {
firstHighlighted.value = false
}, 900)
}
const zettelRevealed = ref(false)
const toggleZettel = () => {
zettelRevealed.value = !zettelRevealed.value
}
const reviewRepo = computed(() => savedFavoriteRepos.value[0] ?? null) const reviewRepo = computed(() => savedFavoriteRepos.value[0] ?? null)
const showReviewCard = computed( const showReviewCard = computed(
() => cardsToReview.value.length > 0 && reviewRepo.value !== null () => cardsToReview.value.length > 0 && reviewRepo.value !== null
) )
const {
notes: publicNotes,
isLoading: publicNotesLoading,
onLoadMore: loadPublicNotes,
getAuthor: getPublicAuthor
} = usePublicNoteList()
const recentPublicNotes = computed(() => publicNotes.value.slice(0, 5))
const sidebarPublicNotes = computed(() => publicNotes.value.slice(0, 3))
const hasPublicNotes = computed(() => recentPublicNotes.value.length > 0)
const publicNotesLoadFailed = computed(
() => !publicNotesLoading.value && publicNotes.value.length === 0
)
const formatPublicDate = (iso: string) =>
new Date(iso).toLocaleDateString(undefined, {
month: "short",
day: "numeric"
})
const publicNoteHandle = (did: string) => {
const handle = getPublicAuthor(did)
return handle ? `@${handle}` : toShortDid(did)
}
const publicNoteRoute = (note: { did: string; rkey: string; title: string }) => ({
name: "PublicNoteView",
params: {
shortDid: toShortDid(note.did),
rkey: note.rkey,
slug: slugify(note.title)
}
})
onMounted(() => {
loadPublicNotes()
})
</script> </script>
<template> <template>
@@ -297,12 +347,43 @@ const showReviewCard = computed(
</div> </div>
</template> </template>
<div class="section-label mono">§ from the network</div> <div class="section-label mono">§ drifting in</div>
<ul
v-if="hasPublicNotes"
class="network-side-list"
>
<li
v-for="note in sidebarPublicNotes"
:key="`${note.did}-${note.rkey}`"
>
<router-link
:to="publicNoteRoute(note)"
class="network-side-link"
>
<span class="network-side-title">{{ note.title }}</span>
<span class="mono network-side-meta">
{{ publicNoteHandle(note.did) }}
<span class="li-dot">·</span>
{{ formatPublicDate(note.publishedAt) }}
</span>
</router-link>
</li>
</ul>
<ul
v-else-if="publicNotesLoading"
class="network-side-list"
aria-hidden="true"
>
<li v-for="i in 3" :key="i" class="network-side-skel">
<span class="skel skel-title" />
<span class="skel skel-date" />
</li>
</ul>
<router-link <router-link
:to="{ name: 'PublicNoteListView' }" :to="{ name: 'PublicNoteListView' }"
class="hw-btn pub-notes-btn" class="network-side-all"
> >
Browse public notes See all public notes
</router-link> </router-link>
</aside> </aside>
</div> </div>
@@ -363,23 +444,62 @@ const showReviewCard = computed(
</button> </button>
</form> </form>
</div> </div>
<!-- CTA 02: Public notes --> <!-- CTA 02: Public notes (live preview) -->
<div class="hero-ed-path"> <div class="hero-ed-path hero-ed-path-network">
<div class="hep-head"> <div class="hep-head">
<span class="hep-n mono">02</span> <span class="hep-n mono">02</span>
<div> <div>
<div class="hep-t">From the open network</div> <div class="hep-t">From the open network</div>
<div class="hep-d"> <div class="hep-d">
Your `.pub.md` files become public. Read public notes Live notes published via ATProto. No account needed
published via ATProto no account needed. tap one and read.
</div> </div>
</div> </div>
</div> </div>
<div
v-if="hasPublicNotes"
class="network-strip"
role="list"
>
<router-link
v-for="note in recentPublicNotes"
:key="`${note.did}-${note.rkey}`"
:to="publicNoteRoute(note)"
class="network-card"
role="listitem"
>
<span class="mono network-card-handle">{{
publicNoteHandle(note.did)
}}</span>
<span class="network-card-title">{{ note.title }}</span>
<span class="mono network-card-date">{{
formatPublicDate(note.publishedAt)
}}</span>
</router-link>
</div>
<div
v-else-if="publicNotesLoading"
class="network-strip"
aria-hidden="true"
>
<div
v-for="i in 3"
:key="i"
class="network-card network-card--skel"
>
<span class="skel skel-handle" />
<span class="skel skel-title" />
<span class="skel skel-date" />
</div>
</div>
<div v-else-if="publicNotesLoadFailed" class="network-empty">
The pool is quiet right now.
</div>
<router-link <router-link
:to="{ name: 'PublicNoteListView' }" :to="{ name: 'PublicNoteListView' }"
class="hw-btn hw-btn-pink hep-btn" class="network-all"
> >
Browse public notes Browse all public notes
</router-link> </router-link>
</div> </div>
</div> </div>
@@ -425,7 +545,23 @@ const showReviewCard = computed(
</p> </p>
<div class="feature-row"> <div class="feature-row">
<div class="feat"> <div class="feat">
<span class="feat-icon"></span> <span class="feat-icon" aria-hidden="true">
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M12 4l-8 4l8 4l8 -4l-8 -4" />
<path d="M4 12l8 4l8 -4" />
<path d="M4 16l8 4l8 -4" />
</svg>
</span>
<h4>Stacked notes</h4> <h4>Stacked notes</h4>
<p> <p>
Click a link and the next note slides in beside the current one. Click a link and the next note slides in beside the current one.
@@ -434,7 +570,22 @@ const showReviewCard = computed(
</p> </p>
</div> </div>
<div class="feat"> <div class="feat">
<span class="feat-icon"></span> <span class="feat-icon" aria-hidden="true">
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M9 14l-4 -4l4 -4" />
<path d="M5 10h11a4 4 0 1 1 0 8h-1" />
</svg>
</span>
<h4>Automatic backlinks</h4> <h4>Automatic backlinks</h4>
<p> <p>
Every mention becomes a two-way link. See every note that points Every mention becomes a two-way link. See every note that points
@@ -442,7 +593,29 @@ const showReviewCard = computed(
</p> </p>
</div> </div>
<div class="feat"> <div class="feat">
<span class="feat-icon">◈</span> <span class="feat-icon" aria-hidden="true">
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M15 12a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M11 8a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M11 16a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M12 15v-6" />
<path d="M15 11l-2 -2" />
<path d="M11 7l-1.9 -1.9" />
<path
d="M13.446 2.6l7.955 7.954a2.045 2.045 0 0 1 0 2.892l-7.955 7.955a2.045 2.045 0 0 1 -2.892 0l-7.955 -7.955a2.045 2.045 0 0 1 0 -2.892l7.955 -7.955a2.045 2.045 0 0 1 2.892 0"
/>
</svg>
</span>
<h4>Your files, your Git</h4> <h4>Your files, your Git</h4>
<p> <p>
Remanso reads GitHub directly. Edit in your favourite editor. Remanso reads GitHub directly. Edit in your favourite editor.
@@ -464,7 +637,10 @@ const showReviewCard = computed(
</span> </span>
</h2> </h2>
<div class="demo-notes"> <div class="demo-notes">
<article class="note-preview"> <article
class="note-preview"
:class="{ 'note-preview--flash': firstHighlighted }"
>
<div class="note-header"> <div class="note-header">
<span class="mono note-breadcrumb" <span class="mono note-breadcrumb"
>remanso-space / getting-started</span >remanso-space / getting-started</span
@@ -481,9 +657,10 @@ const showReviewCard = computed(
</p> </p>
<p> <p>
Luhmann called this the Luhmann called this the
<a class="note-link" href="#" @click.prevent>zettelkasten</a>: <a class="note-link" href="#" @click.prevent="toggleZettel"
a slip-box of thoughts you converse with, rather than a hoard >zettelkasten</a
of pages you re-read. >: a slip-box of thoughts you converse with, rather than a
hoard of pages you re-read.
</p> </p>
</div> </div>
<div class="note-backlinks"> <div class="note-backlinks">
@@ -498,6 +675,7 @@ const showReviewCard = computed(
</ul> </ul>
</div> </div>
</article> </article>
<div class="note-stack">
<article <article
class="note-preview note-preview-offset" class="note-preview note-preview-offset"
:class="{ 'note-preview--flash': offsetHighlighted }" :class="{ 'note-preview--flash': offsetHighlighted }"
@@ -510,14 +688,50 @@ const showReviewCard = computed(
<h3 class="note-title">Durable enough</h3> <h3 class="note-title">Durable enough</h3>
<div class="note-body"> <div class="note-body">
<p> <p>
A durable note survives its own context. You should be able to A durable note survives its own context. You should be able
pick it up six months from now and still know what it means. to pick it up six months from now and still know what it
means.
</p> </p>
<p> <p>
Rule of thumb: write the title as the claim, and the body as Rule of thumb: write the title as the claim, and the body as
the argument. the argument.
</p> </p>
</div> </div>
<div class="note-backlinks">
<div class="backlinks-h mono"> linked from</div>
<ul>
<li>
<a @click.prevent="flashFirst" href="#"
>on keeping notes</a
>
</li>
</ul>
</div>
</article>
<article
class="note-preview note-preview-zettel"
:class="{ 'is-revealed': zettelRevealed }"
:aria-hidden="!zettelRevealed"
>
<div class="note-header">
<span class="mono note-breadcrumb"
>remanso-space / getting-started</span
>
</div>
<h3 class="note-title">Zettelkasten</h3>
<div class="note-body">
<p>
A <em>slip-box</em> of atomic notes wired together by links
instead of filed away in folders. Niklas Luhmann kept ninety
thousand of them in a wooden cabinet and wrote with them,
not just about them.
</p>
<p>
Each <em>Zettel</em> earns its keep by being linked to. Open
one, follow a thread, end up somewhere you didn't plan to
go.
</p>
</div>
<div class="note-backlinks"> <div class="note-backlinks">
<div class="backlinks-h mono">↖ linked from</div> <div class="backlinks-h mono">↖ linked from</div>
<ul> <ul>
@@ -527,6 +741,7 @@ const showReviewCard = computed(
</article> </article>
</div> </div>
</div> </div>
</div>
</section> </section>
<!-- Zettelkasten primer --> <!-- Zettelkasten primer -->
@@ -547,8 +762,7 @@ const showReviewCard = computed(
<h4>Link, don't nest.</h4> <h4>Link, don't nest.</h4>
<p> <p>
Folders calcify. Links compound. Every Folders calcify. Links compound. Every
<code>[like this](new-idea.md)</code> becomes a door to a new <code>[link](note.md)</code> becomes a door to a new idea.
idea.
</p> </p>
</div> </div>
</li> </li>
@@ -557,8 +771,8 @@ const showReviewCard = computed(
<div> <div>
<h4>Let the web emerge.</h4> <h4>Let the web emerge.</h4>
<p> <p>
Don't plan the structure. Write, link, re-read. Structure is a Don't plan the structure. Write, link, re-read. Structure
consequence of attention. emerges from what you return to.
</p> </p>
</div> </div>
</li> </li>
@@ -607,7 +821,13 @@ const showReviewCard = computed(
<div class="footer-h">Learn</div> <div class="footer-h">Learn</div>
<a href="#about" class="footer-link">What is Remanso?</a> <a href="#about" class="footer-link">What is Remanso?</a>
<a href="#zk" class="footer-link">Zettelkasten</a> <a href="#zk" class="footer-link">Zettelkasten</a>
<a href="#" class="footer-link">ATProto &amp; Bluesky</a> <a
href="https://atproto.com/"
target="_blank"
rel="noreferrer"
class="footer-link"
>atproto</a
>
</div> </div>
</div> </div>
</div> </div>
@@ -1043,8 +1263,8 @@ main {
} }
.hero-ed-paths { .hero-ed-paths {
display: grid; display: flex;
grid-template-columns: 1fr 1fr; flex-direction: column;
gap: 1rem; gap: 1rem;
margin-top: 0.5rem; margin-top: 0.5rem;
} }
@@ -1069,6 +1289,163 @@ main {
} }
} }
.hero-ed-path-network {
gap: 1.25rem;
&:hover {
transform: none;
border-color: var(--hw-rule);
box-shadow: none;
}
}
.network-strip {
display: flex;
gap: 0.85rem;
overflow-x: auto;
padding: 0.25rem 0.25rem 0.75rem;
margin: 0 -0.25rem;
scroll-snap-type: x proximity;
scrollbar-width: thin;
&::-webkit-scrollbar {
height: 6px;
}
&::-webkit-scrollbar-thumb {
background: var(--hw-pink-wash-2);
border-radius: 999px;
}
}
.network-card {
flex: 0 0 220px;
scroll-snap-align: start;
display: flex;
flex-direction: column;
gap: 0.45rem;
padding: 0.9rem 1rem 0.85rem;
background: var(--hw-paper);
border: 1px solid var(--hw-rule);
border-radius: 4px;
text-decoration: none;
color: var(--hw-ink);
position: relative;
min-height: 130px;
box-shadow: 0 1px 0 rgba(0, 0, 0, 0.02);
transition:
transform 0.15s ease,
border-color 0.15s ease,
box-shadow 0.2s ease;
&:hover {
transform: translateY(-2px);
border-color: var(--hw-pink-wash-2);
box-shadow: 0 14px 24px -18px rgba(201, 74, 125, 0.35);
.network-card-title {
color: var(--hw-pink-deep);
}
}
}
.network-card-handle {
font-size: 0.72rem;
color: var(--hw-ink-faint);
letter-spacing: 0.02em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.network-card-title {
font-family: var(--hw-serif);
font-size: 1rem;
font-weight: 600;
line-height: 1.3;
color: var(--hw-ink);
flex: 1;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
transition: color 0.15s ease;
}
.network-card-date {
font-size: 0.7rem;
color: var(--hw-ink-faint);
letter-spacing: 0.04em;
text-transform: lowercase;
margin-top: auto;
}
.network-card--skel {
pointer-events: none;
}
.skel {
display: block;
border-radius: 3px;
background: linear-gradient(
90deg,
var(--hw-pink-wash) 0%,
var(--hw-pink-wash-2) 50%,
var(--hw-pink-wash) 100%
);
background-size: 200% 100%;
animation: skel-shimmer 1.4s ease-in-out infinite;
}
.skel-handle {
height: 0.6rem;
width: 60%;
}
.skel-title {
height: 0.95rem;
width: 90%;
margin-top: 0.1rem;
}
.skel-date {
height: 0.55rem;
width: 30%;
margin-top: auto;
}
@keyframes skel-shimmer {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
.network-empty {
font-size: 0.9rem;
color: var(--hw-ink-faint);
font-style: italic;
padding: 0.5rem 0;
}
.network-all {
align-self: flex-start;
font-family: var(--hw-mono);
font-size: 0.82rem;
letter-spacing: 0.04em;
color: var(--hw-pink-deep);
text-decoration: none;
border-bottom: 1px dashed var(--hw-pink-wash-2);
padding-bottom: 1px;
&:hover {
color: var(--hw-pink-deep);
border-bottom-color: var(--hw-pink);
}
}
.hep-head { .hep-head {
display: flex; display: flex;
gap: 0.85rem; gap: 0.85rem;
@@ -1119,6 +1496,10 @@ main {
} }
} }
.hero-ed-left {
min-width: 0;
}
.hero-ed-right { .hero-ed-right {
display: flex; display: flex;
justify-content: center; justify-content: center;
@@ -1223,16 +1604,19 @@ main {
} }
.feat-icon { .feat-icon {
width: 28px; width: 32px;
height: 28px; height: 32px;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-family: var(--hw-mono);
font-size: 0.75rem;
color: var(--hw-pink-deep); color: var(--hw-pink-deep);
border: 1px solid var(--hw-pink); border: 1px solid var(--hw-pink);
border-radius: 50%; border-radius: 50%;
svg {
width: 16px;
height: 16px;
}
} }
/* ── Demo notes ─────────────────────────────────────────────── */ /* ── Demo notes ─────────────────────────────────────────────── */
@@ -1300,6 +1684,39 @@ main {
animation: note-flash 0.9s ease forwards; animation: note-flash 0.9s ease forwards;
} }
.note-stack {
position: relative;
display: flex;
align-items: flex-start;
}
.note-preview-zettel {
position: absolute;
inset: 0;
margin-top: 2.5rem;
transform: rotate(-0.6deg) translate(0, 0) scale(0.97);
opacity: 0;
pointer-events: none;
z-index: -1;
transition:
transform 0.4s cubic-bezier(0.2, 0.7, 0.2, 1),
opacity 0.3s ease;
&.is-revealed {
transform: rotate(-2deg) translate(28px, 28px) scale(0.97);
opacity: 1;
pointer-events: auto;
}
}
@media (max-width: 640px) {
.note-preview-zettel {
&.is-revealed {
transform: rotate(-1.5deg) translate(12px, 22px) scale(0.96);
}
}
}
@keyframes note-flash { @keyframes note-flash {
0% { 0% {
box-shadow: box-shadow:
@@ -1706,10 +2123,79 @@ img {
padding: 0.35rem 0.8rem; padding: 0.35rem 0.8rem;
} }
.pub-notes-btn { .network-side-list {
width: 100%; list-style: none;
justify-content: center; padding: 0;
margin: 0 0 0.75rem;
li {
border-bottom: 1px dashed var(--hw-rule);
&:last-child {
border-bottom: 0;
}
}
}
.network-side-link {
display: flex;
flex-direction: column;
gap: 0.2rem;
padding: 0.65rem 0;
text-decoration: none;
color: var(--hw-ink);
transition: color 0.15s ease;
&:hover {
.network-side-title {
color: var(--hw-pink-deep);
}
}
}
.network-side-title {
font-family: var(--hw-serif);
font-size: 0.95rem;
font-weight: 600;
line-height: 1.3;
color: var(--hw-ink);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
transition: color 0.15s ease;
}
.network-side-meta {
font-size: 0.72rem;
color: var(--hw-ink-faint);
display: flex;
gap: 0.4rem;
align-items: center;
letter-spacing: 0.02em;
}
.network-side-skel {
display: flex;
flex-direction: column;
gap: 0.3rem;
padding: 0.65rem 0;
}
.network-side-all {
display: inline-block;
margin-top: 0.25rem; margin-top: 0.25rem;
font-family: var(--hw-mono);
font-size: 0.78rem;
letter-spacing: 0.04em;
color: var(--hw-pink-deep);
text-decoration: none;
border-bottom: 1px dashed var(--hw-pink-wash-2);
padding-bottom: 1px;
&:hover {
border-bottom-color: var(--hw-pink);
}
} }
/* ── Footer ─────────────────────────────────────────────────── */ /* ── Footer ─────────────────────────────────────────────────── */
@@ -1868,10 +2354,6 @@ img {
height: 160px; height: 160px;
} }
.hero-ed-paths {
grid-template-columns: 1fr;
}
.feature-row { .feature-row {
grid-template-columns: 1fr; grid-template-columns: 1fr;
gap: 1.5rem; gap: 1.5rem;

View File

@@ -74,7 +74,7 @@ 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

View File

@@ -2,6 +2,8 @@ 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
@@ -9,6 +11,26 @@ export const useGitHubContent = ({
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,
@@ -17,7 +39,7 @@ export const useGitHubContent = ({
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()
@@ -35,18 +57,27 @@ export const useGitHubContent = ({
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,3 +1,4 @@
import type { MarkdownItTabData, MarkdownItTabInfo } from "@mdit/plugin-tab"
import { tab } 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 GithubSlugger from "github-slugger"
@@ -11,8 +12,10 @@ 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 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 { markdownItTablerIcons } from "@/utils/markdown/markdown-it-tabler-icons" import { markdownItTablerIcons } from "@/utils/markdown/markdown-it-tabler-icons"
@@ -50,6 +53,10 @@ const markdownItMermaidExtractor = (md: MarkdownIt) => {
const slugger = new GithubSlugger() 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"]
@@ -70,22 +77,31 @@ const md = new MarkdownIt({
.use(MarkdownItGitHubAlerts) .use(MarkdownItGitHubAlerts)
.use(markdownItTablerIcons) .use(markdownItTablerIcons)
.use(tab, { .use(tab, {
name: "tabs" 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, { .use(markdownItAnchor, {
slugify: (s: string) => slugger.slug(s) 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"
@@ -98,10 +114,18 @@ export const useShikiji = async () => {
"mermaid", "mermaid",
"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
@@ -139,6 +163,23 @@ const renderMarkdown = (content: string, env?: Record<string, unknown>) => {
return env ? md.render(content, env) : md.render(content) 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) => {

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

@@ -40,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,4 +1,4 @@
import { computed, ref } from "vue" 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"
@@ -9,18 +9,32 @@ const STALE_TIME_MS = 20 * 60 * 1000
const repos = ref<RepoBase[]>([]) const repos = ref<RepoBase[]>([])
const isReady = ref(false) const isReady = ref(false)
const isLoading = ref(false)
const hasCredentialError = ref(false)
const currentPage = ref(0) const currentPage = ref(0)
const totalCount = ref(0) const totalCount = ref(0)
let lastFetchedAt = 0 let lastFetchedAt = 0
export const useRepos = () => { const { username, accessToken } = useGitHubLogin()
const { username, accessToken } = useGitHubLogin()
const loadMore = async () => { const resetState = () => {
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) { if (!accessToken.value || !username.value) {
isReady.value = true isReady.value = true
return return
} }
if (isLoading.value) return
isLoading.value = true
try {
const octokit = await getOctokit() const octokit = await getOctokit()
const nextPage = currentPage.value + 1 const nextPage = currentPage.value + 1
const repoList = await octokit.request("GET /search/repositories", { const repoList = await octokit.request("GET /search/repositories", {
@@ -38,22 +52,45 @@ export const useRepos = () => {
repos.value = [...repos.value, ...newItems].sort((a, b) => repos.value = [...repos.value, ...newItems].sort((a, b) =>
a.name < b.name ? -1 : 1 a.name < b.name ? -1 : 1
) )
isReady.value = true } 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
}
}
const canLoadMore = computed(() => repos.value.length < totalCount.value) watch(accessToken, (next, prev) => {
if (next === prev) return
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 const isStale = Date.now() - lastFetchedAt > STALE_TIME_MS
if (!isReady.value || isStale) { if (!isReady.value || isStale) {
if (isStale && isReady.value) { if (isStale && isReady.value) {
repos.value = [] resetState()
currentPage.value = 0
totalCount.value = 0
isReady.value = false
} }
lastFetchedAt = Date.now() lastFetchedAt = Date.now()
loadMore() loadMore()
} }
return { repos, isReady, canLoadMore, loadMore } return { repos, isReady, hasCredentialError, canLoadMore, loadMore }
} }

View File

@@ -19,7 +19,7 @@ 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.minWidth = `${ container.style.minWidth = `${
getNoteWidth() * (stackedNotes.value.length + 1) getNoteWidth() * (stackedNotes.value.length + 1)

View File

@@ -18,7 +18,7 @@ export const useRouteQueryStackedNotes = () => {
}) })
const { height } = useWindowSize() const { height } = useWindowSize()
const { scrollToNote, isMobile } = useOverlay(false) const { scrollToNote, scrollToElement, isMobile } = useOverlay(false)
const scrollToHashInNote = ( const scrollToHashInNote = (
cleanSha: string, cleanSha: string,
@@ -47,31 +47,52 @@ export const useRouteQueryStackedNotes = () => {
}) })
} }
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 = { type ScrollToFocusedNoteOptions = {
noteId?: string | null noteId?: string | null
notes?: string[] notes?: string[]
hash?: string hash?: string
smoothHash?: boolean smoothHash?: boolean
anchorTop?: number
} }
const scrollToFocusedNote = ({ const scrollToFocusedNote = ({
noteId = null, noteId = null,
notes = stackedNotes.value, notes = stackedNotes.value,
hash, hash,
smoothHash = false smoothHash = false,
anchorTop
}: ScrollToFocusedNoteOptions = {}) => { }: 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)
} }
@@ -96,11 +117,15 @@ export const useRouteQueryStackedNotes = () => {
selector?: string, selector?: string,
hash?: string hash?: string
) => { ) => {
const anchorTop =
document.getElementById("main-app")?.scrollTop ?? undefined
if (stackedNotes.value.includes(sha)) { if (stackedNotes.value.includes(sha)) {
scrollToFocusedNote({ scrollToFocusedNote({
noteId: selector ?? sha, noteId: selector ?? sha,
hash, hash,
smoothHash: true smoothHash: true,
anchorTop
}) })
return return
} }
@@ -121,7 +146,7 @@ export const useRouteQueryStackedNotes = () => {
stackedNotes.value = newStackedNotes stackedNotes.value = newStackedNotes
} }
scrollToFocusedNote({ noteId: selector ?? sha, hash }) scrollToFocusedNote({ noteId: selector ?? sha, hash, anchorTop })
} }
return { return {

View File

@@ -121,12 +121,12 @@ export const queryFileContent = async (
repo: string, repo: string,
sha: string sha: string
) => { ) => {
const octokit = await getOctokit()
if (!user || !repo) { if (!user || !repo) {
null return null
} }
try {
const octokit = await getOctokit()
const file = await octokit.request( const file = await octokit.request(
"GET /repos/{owner}/{repo}/git/blobs/{file_sha}", "GET /repos/{owner}/{repo}/git/blobs/{file_sha}",
{ {
@@ -135,6 +135,9 @@ export const queryFileContent = async (
file_sha: sha file_sha: sha
} }
) )
return file?.data.content ?? null return file?.data.content ?? null
} catch (error) {
console.warn("queryFileContent failed", { user, repo, sha, error })
return null
}
} }

View File

@@ -1,4 +1,5 @@
import { defineStore } from "pinia" import { defineStore } from "pinia"
import { toRaw } from "vue"
import { data, generateId } from "@/data/data" import { data, generateId } from "@/data/data"
import { DataType } from "@/data/DataType.enum" import { DataType } from "@/data/DataType.enum"
@@ -156,7 +157,7 @@ export const useUserRepoStore = defineStore("USER_REPO_STATE", {
chosenFontSize: _s, chosenFontSize: _s,
chosenFontFamily: _f, chosenFontFamily: _f,
...repoConfig ...repoConfig
} = this.userSettings } = toRaw(this.userSettings)
data.update<DataType.UserSettings, UserSettings>({ data.update<DataType.UserSettings, UserSettings>({
...repoConfig, ...repoConfig,
_id: userSettingsId _id: userSettingsId
@@ -165,8 +166,10 @@ export const useUserRepoStore = defineStore("USER_REPO_STATE", {
getCachedMainReadme(user, repo).then(async (cachedReadme) => { getCachedMainReadme(user, repo).then(async (cachedReadme) => {
if (requestId !== this._requestId) return if (requestId !== this._requestId) return
this.readme = cachedReadme if (cachedReadme) this.readme = cachedReadme
this.readme = await getMainReadme(user, repo) const fetched = await getMainReadme(user, repo)
if (requestId !== this._requestId) return
this.readme = fetched
}) })
}, },
addFile(file: RepoFile) { addFile(file: RepoFile) {
@@ -184,7 +187,10 @@ export const useUserRepoStore = defineStore("USER_REPO_STATE", {
DataType.SavedRepo, DataType.SavedRepo,
`${this.user}-${this.repo}` `${this.user}-${this.repo}`
) )
const newFiles = [...this.files.filter((f) => f.sha !== file.sha), file] const newFiles = [
...toRaw(this.files).filter((f) => f.sha !== file.sha),
toRaw(file)
]
data.update<DataType.SavedRepo, SavedRepo>({ data.update<DataType.SavedRepo, SavedRepo>({
_id: savedRepoId, _id: savedRepoId,
$type: DataType.SavedRepo, $type: DataType.SavedRepo,

View File

@@ -95,8 +95,9 @@ export const router = createRouter({
routes routes
}) })
router.beforeEach(() => { router.beforeEach((to, from) => {
if (!("startViewTransition" in document)) return if (!("startViewTransition" in document)) return
if (to.path === from.path) return
return new Promise<void>((resolve) => { return new Promise<void>((resolve) => {
;( ;(
document as Document & { document as Document & {

View File

@@ -13,6 +13,12 @@
--light-link: lighten(#445fb9, 45%); --light-link: lighten(#445fb9, 45%);
--background-color: #ffffff; --background-color: #ffffff;
--note-width: 500px; --note-width: 500px;
--note-canvas-bg: color-mix(
in oklch,
var(--color-base-100) 60%,
var(--color-base-200)
);
--note-sheet-shadow: 1px 0 8px rgb(0 0 0 / 6%);
--color-contrast-content: var(--color-success); --color-contrast-content: var(--color-success);
--notyf-margin: 0.5rem; --notyf-margin: 0.5rem;
} }
@@ -25,8 +31,8 @@
@plugin 'daisyui' { @plugin 'daisyui' {
themes: themes:
emerald --default, light --default,
forest --prefersdark; dracula --prefersdark;
} }
@config '../../tailwind.config.js'; @config '../../tailwind.config.js';
@@ -89,18 +95,36 @@ a {
} }
} }
a.title-stacked-note-link { .title-stacked-note {
color: var(--color-base-content); color: var(--color-base-content);
display: block;
text-decoration: none; text-decoration: none;
position: sticky; position: sticky;
top: 0; top: 0;
overflow: visible;
display: flex;
gap: 0.5rem;
align-items: center;
}
a.title-stacked-note-link {
display: block;
overflow: visible;
&:hover { &:hover {
cursor: pointer; cursor: pointer;
text-decoration: none;
} }
} }
.title-stacked-note ul,
.title-stacked-note li {
margin-top: 0;
margin-bottom: 0;
padding-left: 0;
text-decoration: none;
display: flex;
gap: 1rem;
}
.notyf__toast { .notyf__toast {
border-radius: revert-layer; border-radius: revert-layer;
border: none; border: none;
@@ -233,3 +257,12 @@ iframe {
.todo-notes li:has(> input[type="checkbox"]) { .todo-notes li:has(> input[type="checkbox"]) {
list-style: none; list-style: none;
} }
.tabs :where(pre):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
margin-top: 0;
margin-bottom: 0;
padding-top: 0.2rem;
padding-inline-end: 0.2rem;
padding-bottom: 0.2rem;
padding-inline-start: 0.2rem;
}

View File

@@ -2,6 +2,6 @@
// Update these values to change the light and dark themes // Update these values to change the light and dark themes
export const themeConfig = { export const themeConfig = {
light: "emerald", light: 'light',
dark: "forest" dark: 'dracula'
} }

View File

@@ -0,0 +1,296 @@
{
"$schema": "https://raw.githubusercontent.com/martinring/tmlanguage/master/tmlanguage.json",
"name": "Alloy",
"scopeName": "source.als",
"patterns": [
{ "include": "#comments" },
{ "include": "#declaration" },
{ "include": "#expression" },
{ "include": "#built-in" },
{ "include": "#keywords" },
{ "include": "#digit" }
],
"repository": {
"comments": {
"patterns": [
{ "begin": "/\\*", "end": "\\*/", "name": "comment.block.alloy" },
{ "begin": "//", "end": "\n", "name": "comment.line.double-slash" },
{ "begin": "--", "end": "\n", "name": "comment.line.double-dash" }
]
},
"keywords": {
"patterns": [
{ "include": "#define" },
{ "include": "#modifier" },
{ "include": "#operator" },
{ "include": "#control" },
{ "include": "#variable" }
],
"repository": {
"define": {
"patterns": [
{ "match": "\\b(sig)\\b", "name": "keyword.language.sig.alloy" },
{ "match": "\\b(fact)\\b", "name": "keyword.language.fact.alloy" },
{ "match": "\\b(pred)\\b", "name": "keyword.language.pred.alloy" },
{ "match": "\\b(fun)\\b", "name": "keyword.language.fun.alloy" },
{
"match": "\\b(module)\\b",
"name": "keyword.language.module.alloy"
},
{
"match": "\\b(extends)\\b",
"name": "keyword.language.extends.alloy"
},
{ "match": ":", "name": "keyword.other.colon.alloy" },
{
"match": "\\b(check)\\b",
"name": "keyword.language.check.alloy"
},
{
"match": "\\b(assert)\\b",
"name": "keyword.language.assert.alloy"
},
{ "match": "\\b(run)\\b", "name": "keyword.language.run.alloy" },
{ "match": "\\b(open)\\b", "name": "keyword.other.open.alloy" },
{ "match": "\\b(as)\\b", "name": "keyword.other.as.alloy" },
{ "match": "\\b(in)\\b", "name": "keyword.other.in.alloy" }
]
},
"modifier": {
"patterns": [
{ "match": "\\b(var)\\b", "name": "keyword.modifier.var.alloy" },
{
"match": "\\b(private)\\b",
"name": "keyword.modifier.private.alloy"
},
{
"match": "\\b(abstract)\\b",
"name": "keyword.modifier.abstract.alloy"
},
{
"match": "\\b(all|disj|lone|no|one|set|seq|some|sum|univ|none)\\b",
"name": "keyword.modifier.set.alloy"
}
]
},
"operator": {
"patterns": [
{ "include": "#temporal" },
{ "include": "#unary" },
{ "include": "#binary" }
],
"repository": {
"temporal": {
"patterns": [
{
"match": "\\b(always|eventually|after|before|historically|once|prev)\\b",
"name": "keyword.operator.temporal.unary.alloy"
},
{
"match": "\\b(until|releases|since|triggered)\\b",
"name": "keyword.operator.temporal.binary.alloy"
}
]
},
"unary": {
"patterns": [
{
"match": "!|#|~|\\*|\\^|(\\b(not)\\b)",
"name": "keyword.operator.unary.alloy"
}
]
},
"binary": {
"patterns": [
{
"match": "(?:\\|\\|)|&&|<=>|=>|&|\\+|-|\\+\\+|<:|:>|\\.|=|->",
"name": "keyword.operator.binary.alloy"
},
{
"match": "\\b(and|or|iff|implies|else|in)\\b",
"name": "keyword.operator.binary.alloy"
},
{
"match": "=|<|>|=<|>=",
"name": "keyword.operator.binary.alloy"
},
{ "match": ",", "name": "keyword.other.comma.alloy" },
{ "match": "\\|", "name": "keyword.other.split.alloy" }
]
}
}
},
"variable": {
"patterns": [
{ "match": "\\b(let)\\b", "name": "keyword.language.let.alloy" },
{ "match": "\\b(this)\\b", "name": "keyword.language.this.alloy" }
]
},
"control": {
"patterns": [
{ "match": "\\b(for)\\b", "name": "keyword.control.for.alloy" },
{ "match": "\\b(but)\\b", "name": "keyword.control.but.alloy" },
{
"match": "\\b(exactly)\\b",
"name": "keyword.control.exactly.alloy"
},
{
"match": "\\b(expect)\\b",
"name": "keyword.control.expect.alloy"
},
{ "match": "\\b(steps)\\b", "name": "keyword.control.steps.alloy" }
]
}
}
},
"declaration": {
"patterns": [
{ "include": "#module" },
{ "include": "#predict" },
{ "include": "#signature" },
{ "include": "#fact" },
{ "include": "#fun" }
],
"repository": {
"module": {
"patterns": [
{
"match": "(module)\\b\\s*((?:\\w|'|_|\\d|/)+)",
"captures": {
"1": { "name": "keyword.language.module.alloy" },
"2": { "name": "support.class.module.alloy" }
}
}
]
},
"predict": {
"patterns": [
{
"match": "(pred)\\b\\s*((?:\\w|'|_|\\d|/)+)",
"captures": {
"1": { "name": "keyword.language.pred.alloy" },
"2": { "name": "entity.name.function.pred.alloy" }
}
}
]
},
"signature": {
"patterns": [
{
"begin": "(abstract)?\\s*(lone|some|one)?\\s*(var)?\\s*(sig)\\b\\s*",
"end": "(?=\\{)",
"beginCaptures": {
"1": { "name": "keyword.modifier.abstract.alloy" },
"2": { "name": "keyword.modifier.set.alloy" },
"3": { "name": "keyword.modifier.var.alloy" },
"4": { "name": "keyword.language.sig.alloy" }
},
"patterns": [
{
"begin": "(extends)",
"end": "(?=\\{)",
"beginCaptures": {
"1": { "name": "keyword.language.extends.alloy" }
},
"patterns": [
{
"match": "(?:\\w|'|_|\\d|/)+",
"name": "entity.other.inherited-class.alloy"
}
]
},
{
"begin": "(in)",
"end": "(?=\\{)",
"beginCaptures": {
"1": { "name": "keyword.other.in.alloy" }
},
"patterns": [
{
"match": "(?:\\w|'|_|\\d|/)+",
"name": "entity.other.inherited-class.alloy"
},
{ "match": "\\+", "name": "keyword.operator.binary.alloy" }
]
},
{
"match": "(?:\\w|'|_|\\d|/)+",
"name": "entity.name.type.signature.alloy"
},
{ "match": ",", "name": "keyword.other.comma.alloy" }
]
}
]
},
"fact": {
"patterns": [
{
"match": "(fact)\\b\\s*((?:\\w|'|_|\\d|/)+)",
"captures": {
"1": { "name": "keyword.language.fact.alloy" },
"2": { "name": "entity.name.function.fact.alloy" }
}
}
]
},
"fun": {
"patterns": [
{
"match": "(fun)\\b\\s*((?:\\w|'|_|\\d|/)+)",
"captures": {
"1": { "name": "keyword.language.fun.alloy" },
"2": { "name": "entity.name.function.fun.alloy" }
}
}
]
}
}
},
"expression": {
"patterns": [
{
"match": "(check)\\b\\s*((?:\\w|'|_|\\d|/)+)",
"captures": {
"1": { "name": "keyword.language.check.alloy" },
"2": { "name": "entity.name.function.check.alloy" }
}
},
{
"match": "(assert)\\b\\s*((?:\\w|'|_|\\d|/)+)",
"captures": {
"1": { "name": "keyword.language.assert.alloy" },
"2": { "name": "entity.name.function.check.alloy" }
}
}
]
},
"digit": {
"patterns": [
{ "match": "\\b(\\d+)\\b", "name": "constant.numeric.alloy" }
]
},
"built-in": {
"patterns": [
{
"match": "\\b(plus|minus|mul|div|rem|sum)\\[",
"captures": { "1": { "name": "support.function.numeric.alloy" } }
},
{
"match": "\\b(open)\\b\\s*((?:\\w|'|_|\\d|/)+)\\[",
"captures": {
"1": { "name": "keyword.other.open.alloy" },
"2": { "name": "support.class.module.alloy" }
}
},
{
"match": "(/(?:\\w|'|_|\\d|/)+)",
"captures": { "1": { "name": "support.function.order.alloy" } }
},
{
"match": "((?:\\w|'|_|\\d)+)\\s*\\[",
"captures": { "1": { "name": "support.function.order.alloy" } }
}
]
}
}
}

View File

@@ -1,33 +1,56 @@
import FontFaceObserver from "fontfaceobserver" import FontFaceObserver from "fontfaceobserver"
const assembleFontLink = (font: string) => { const GENERIC_FAMILIES = new Set([
return `https://api.fonts.coollabs.io/css2?display=swap&family=${font "serif",
.replaceAll(",", "&family=") "sans-serif",
.replaceAll(" ", "+")}` "monospace",
"cursive",
"fantasy",
"system-ui",
"ui-serif",
"ui-sans-serif",
"ui-monospace",
"ui-rounded"
])
const parseWebFontFamilies = (font: string): string[] =>
font
.split(",")
.map((f) => f.trim().replace(/^["']|["']$/g, ""))
.filter((f) => f && !GENERIC_FAMILIES.has(f))
const assembleFontLink = (families: string[]): string | null => {
if (families.length === 0) return null
return `https://api.fonts.coollabs.io/css2?display=swap&${families
.map((f) => `family=${f.replaceAll(" ", "+")}`)
.join("&")}`
} }
export const downloadFont = async ( export const downloadFont = async (
font: string, font: string,
cssVar = "--font-family" cssVar = "--font-family"
): Promise<void> => { ): Promise<void> => {
const href = assembleFontLink(font) const families = parseWebFontFamilies(font)
const href = assembleFontLink(families)
// check if the href already exists if (href) {
const existingLink = document.querySelector(`link[href="${href}"]`) const alreadyLoaded = Array.from(
document.head.querySelectorAll<HTMLLinkElement>('link[rel="stylesheet"]')
).some((link) => link.href === href)
if (!existingLink) { if (!alreadyLoaded) {
const link = document.createElement("link") const link = document.createElement("link")
link.href = href link.href = href
link.rel = "stylesheet" link.rel = "stylesheet"
document.head.appendChild(link) document.head.appendChild(link)
} }
try { try {
await new FontFaceObserver(font).load() await new FontFaceObserver(families[0]).load()
} catch {
document.documentElement.style.setProperty(cssVar, font)
} catch (error) {
console.warn("error when loading font") console.warn("error when loading font")
} }
}
document.documentElement.style.setProperty(cssVar, font)
} }

31
src/utils/fileLanguage.ts Normal file
View File

@@ -0,0 +1,31 @@
const EXT_TO_LANG: Record<string, string> = {
sh: "bash",
bash: "bash",
js: "javascript",
mjs: "javascript",
cjs: "javascript",
ts: "typescript",
mts: "typescript",
cts: "typescript",
md: "markdown",
mdx: "markdown",
html: "html",
htm: "html",
css: "css",
scss: "css",
json: "json",
jsonc: "json",
als: "alloy"
}
const MARKDOWN_EXTS = new Set(["md", "mdx"])
export function isMarkdownPath(path: string): boolean {
const ext = path.split(".").pop()?.toLowerCase() ?? ""
return MARKDOWN_EXTS.has(ext)
}
export function getFileLanguage(path: string): string | null {
const ext = path.split(".").pop()?.toLowerCase() ?? ""
return EXT_TO_LANG[ext] ?? null
}

View File

@@ -82,7 +82,7 @@ watch(mode, async (newMode) => {
newContent.value newContent.value
}` }`
const newSha = await createFile({ const { sha: newSha } = await createFile({
content, content,
path: newContentPath path: newContentPath
}) })

View File

@@ -241,7 +241,6 @@ watch(
overflow-y: auto; overflow-y: auto;
height: 100vh; height: 100vh;
width: 100%; width: 100%;
position: sticky;
.title { .title {
text-align: left; text-align: left;
@@ -249,6 +248,8 @@ watch(
} }
@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;
@@ -266,6 +267,11 @@ watch(
.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);
}
.article {
box-shadow: var(--note-sheet-shadow);
} }
} }

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,6 @@
<script lang="ts" setup></script> <script lang="ts" setup>
import SignInGithub from "@/components/SignInGithub.vue"
</script>
<template> <template>
<main class="space-cowboy content"> <main class="space-cowboy content">
@@ -43,6 +45,7 @@
<router-link class="button is-links" :to="{ name: 'Home' }" <router-link class="button is-links" :to="{ name: 'Home' }"
>return to homepage</router-link >return to homepage</router-link
> >
<sign-in-github />
</main> </main>
</template> </template>

View File

@@ -22,7 +22,7 @@ export default defineConfig(({ command }) => {
"pwa-512x512.png", "pwa-512x512.png",
"masked-icon.png", "masked-icon.png",
"maskable-icon-512x512.png", "maskable-icon-512x512.png",
"monochrome-icon.png", "monochromeicon.png",
"assets/*.svg" "assets/*.svg"
], ],
manifest: { manifest: {
@@ -54,7 +54,7 @@ export default defineConfig(({ command }) => {
purpose: "maskable" purpose: "maskable"
}, },
{ {
src: "monochrome-icon.png", src: "monochromeicon.png",
sizes: "1024x1024", sizes: "1024x1024",
type: "image/png", type: "image/png",
purpose: "monochrome" purpose: "monochrome"