Compare commits

33 Commits

Author SHA1 Message Date
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
26 changed files with 1371 additions and 308 deletions

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

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

@@ -5,6 +5,7 @@ import HeaderNote from "@/components/HeaderNote.vue"
import SignInGithub from "@/components/SignInGithub.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"
@@ -44,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(() =>
@@ -104,8 +106,13 @@ onUnmounted(() => {
<slot /> <slot />
<skeleton-loader v-if="isLoading" /> <skeleton-loader v-if="isLoading" />
<div v-else-if="withContent && !hasContent" class="repo-not-found"> <div v-else-if="withContent && !hasContent" class="repo-not-found">
<template v-if="isLogged">
<p>This repository is not accessible.</p> <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 /> <sign-in-github />
</template>
</div> </div>
<p <p
v-else-if="withContent && hasContent" v-else-if="withContent && hasContent"
@@ -259,7 +266,7 @@ $header-height: 40px;
.note { .note {
width: 100vw; width: 100vw;
height: 100dvh; height: 100svh;
overflow-y: visible; overflow-y: visible;
} }

View File

@@ -51,7 +51,7 @@ onMounted(() => {
started editing. If you save now, their changes will be overwritten. started editing. If you save now, their changes will be overwritten.
</p> </p>
<div class="modal-action flex-col gap-2 sm:flex-row sm:justify-end"> <div class="modal-action flex-col gap-2">
<button <button
type="button" type="button"
class="btn btn-ghost" class="btn btn-ghost"

View File

@@ -13,9 +13,6 @@ defineEmits<{ (e: "click"): void }>()
const formatTime = (d: Date) => const formatTime = (d: Date) =>
d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" }) d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" })
const minutesAgo = (d: Date) =>
Math.max(1, Math.round((Date.now() - d.getTime()) / 60000))
const label = computed(() => { const label = computed(() => {
switch (props.status) { switch (props.status) {
case "verified": case "verified":
@@ -24,10 +21,6 @@ const label = computed(() => {
return "Checking…" return "Checking…"
case "outdated": case "outdated":
return "Outdated" return "Outdated"
case "stale-known":
return props.lastCheckedAt
? `Checked ${minutesAgo(props.lastCheckedAt)}m ago`
: "Not checked"
case "offline": case "offline":
return "Cant reach GitHub" return "Cant reach GitHub"
case "unknown": case "unknown":
@@ -44,8 +37,6 @@ const tooltip = computed(() => {
: "Click to re-check." : "Click to re-check."
case "outdated": case "outdated":
return "GitHub has a newer version. Click to pull latest." return "GitHub has a newer version. Click to pull latest."
case "stale-known":
return "Click to verify against GitHub."
case "offline": case "offline":
return "Could not reach GitHub. Click to retry." return "Could not reach GitHub. Click to retry."
case "checking": case "checking":
@@ -88,14 +79,9 @@ const isBusy = computed(() => props.status === "checking")
<path d="M15 19l2 2l4 -4" /> <path d="M15 19l2 2l4 -4" />
</svg> </svg>
<svg <svg
v-else-if="status === 'unknown' || status === 'stale-known'" v-else-if="status === 'unknown'"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
class="icon icon-tabler" class="icon icon-tabler icon-tabler-cloud-question"
:class="
status === 'stale-known'
? 'icon-tabler-clock'
: 'icon-tabler-cloud-question'
"
width="20" width="20"
height="20" height="20"
viewBox="0 0 24 24" viewBox="0 0 24 24"
@@ -105,11 +91,6 @@ const isBusy = computed(() => props.status === "checking")
stroke-linecap="round" stroke-linecap="round"
stroke-linejoin="round" stroke-linejoin="round"
> >
<template v-if="status === 'stale-known'">
<path d="M3 12a9 9 0 1 0 18 0a9 9 0 0 0 -18 0" />
<path d="M12 7v5l3 3" />
</template>
<template v-else>
<path <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" 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"
/> />
@@ -117,7 +98,6 @@ const isBusy = computed(() => props.status === "checking")
<path <path
d="M19 19a2.003 2.003 0 0 0 .914 -3.782a1.98 1.98 0 0 0 -2.414 .483" d="M19 19a2.003 2.003 0 0 0 .914 -3.782a1.98 1.98 0 0 0 -2.414 .483"
/> />
</template>
</svg> </svg>
<svg <svg
v-else-if="status === 'outdated'" v-else-if="status === 'outdated'"
@@ -171,7 +151,6 @@ const isBusy = computed(() => props.status === "checking")
/> />
<path d="M3 3l18 18" /> <path d="M3 3l18 18" />
</svg> </svg>
<span class="freshness-label">{{ label }}</span>
</button> </button>
</template> </template>
@@ -213,7 +192,6 @@ const isBusy = computed(() => props.status === "checking")
} }
.state-unknown, .state-unknown,
.state-stale-known,
.state-checking { .state-checking {
color: var(--color-base-content); color: var(--color-base-content);
opacity: 0.6; opacity: 0.6;

View File

@@ -26,6 +26,7 @@ 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 { 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")
@@ -239,6 +240,30 @@ const onConflictOverwrite = async () => {
const onConflictCancel = () => { const onConflictCancel = () => {
if (mode.value === "read") toggleMode() 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>
@@ -250,27 +275,13 @@ const onConflictCancel = () => {
[`note-${sha}`]: true [`note-${sha}`]: true
}" }"
> >
<a <div class="title-stacked-note breadcrumbs text-sm" :class="titleClassName">
class="title-stacked-note-link"
@click.prevent="scrollToFocusedNote({ noteId: props.sha })"
>
<div
class="title-stacked-note breadcrumbs text-sm"
:class="titleClassName"
>
<ul>
<li v-for="(part, i) in breadcrumbs" :key="i">
{{ part }}
</li>
</ul>
</div>
</a>
<section class="text-content">
<div class="action-bar"> <div class="action-bar">
<note-freshness-badge <note-freshness-badge
:status="freshnessStatus" :status="freshnessStatus"
:last-checked-at="lastCheckedAt" :last-checked-at="lastCheckedAt"
@click="checkFreshness" @click="onBadgeClick"
class="action"
/> />
<button <button
v-if="isMarkdown" v-if="isMarkdown"
@@ -323,6 +334,18 @@ const onConflictCancel = () => {
</svg> </svg>
</button> </button>
</div> </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"> <div v-if="mode === 'edit' && isMarkdown" class="edit">
<edit-note v-model="rawContent" /> <edit-note v-model="rawContent" />
</div> </div>
@@ -370,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 {
@@ -396,12 +418,15 @@ $border-color: rgba(18, 19, 58, 0.2);
align-items: center; align-items: center;
justify-content: flex-end; justify-content: flex-end;
gap: 0.25rem; gap: 0.25rem;
margin: 0.2rem 0;
} }
.action { .action {
margin: 0; margin: 0;
&:hover {
cursor: pointer;
}
img { img {
vertical-align: bottom; vertical-align: bottom;
} }
@@ -410,7 +435,7 @@ $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: 100dvh; height: 100svh;
section { section {
padding: 1rem 0; padding: 1rem 0;
@@ -433,7 +458,7 @@ $border-color: rgba(18, 19, 58, 0.2);
} }
.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);
} }
@@ -441,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

@@ -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 {

View File

@@ -16,7 +16,12 @@ export const useGitHubContent = ({
const octokit = await getOctokit() const octokit = await getOctokit()
const response = await octokit.request( const response = await octokit.request(
"GET /repos/{owner}/{repo}/contents/{path}", "GET /repos/{owner}/{repo}/contents/{path}",
{ owner: user, repo, path } {
owner: user,
repo,
path,
headers: { "X-GitHub-Api-Version": "2022-11-28" }
}
) )
const data = response?.data const data = response?.data
if (Array.isArray(data) || !data) return null if (Array.isArray(data) || !data) return null

View File

@@ -1,4 +1,4 @@
import { computed, Ref, ref } from "vue" import { Ref, ref } from "vue"
import { useGitHubContent } from "@/hooks/useGitHubContent.hook" import { useGitHubContent } from "@/hooks/useGitHubContent.hook"
import { markdownBuilder } from "@/hooks/useMarkdown.hook" import { markdownBuilder } from "@/hooks/useMarkdown.hook"
@@ -10,12 +10,9 @@ export type FreshnessStatus =
| "unknown" | "unknown"
| "checking" | "checking"
| "verified" | "verified"
| "stale-known"
| "outdated" | "outdated"
| "offline" | "offline"
const STALE_AFTER_MS = 2 * 60 * 1000
export const useNoteFreshness = ({ export const useNoteFreshness = ({
user, user,
repo, repo,
@@ -32,54 +29,45 @@ export const useNoteFreshness = ({
const store = useUserRepoStore() const store = useUserRepoStore()
const { fetchLatestSha } = useGitHubContent({ user, repo }) const { fetchLatestSha } = useGitHubContent({ user, repo })
const rawStatus = ref<FreshnessStatus>("unknown") const status = ref<FreshnessStatus>("unknown")
const lastCheckedAt = ref<Date | null>(null) const lastCheckedAt = ref<Date | null>(null)
const latestSha = ref<string | null>(null) const latestSha = ref<string | null>(null)
const tick = ref(0)
let staleTimer: ReturnType<typeof setTimeout> | null = null
const status = computed<FreshnessStatus>(() => {
void tick.value
if (rawStatus.value !== "verified") return rawStatus.value
if (!lastCheckedAt.value) return rawStatus.value
const age = Date.now() - lastCheckedAt.value.getTime()
return age > STALE_AFTER_MS ? "stale-known" : "verified"
})
const armStaleTimer = () => {
if (staleTimer) clearTimeout(staleTimer)
staleTimer = setTimeout(() => {
tick.value++
}, STALE_AFTER_MS + 100)
}
const expectedSha = async () => (await getEditedSha()) ?? sha.value const expectedSha = async () => (await getEditedSha()) ?? sha.value
const check = async () => { const check = async () => {
if (!path.value) return if (!path.value) return
rawStatus.value = "checking" status.value = "checking"
const remoteSha = await fetchLatestSha(path.value) const remoteSha = await fetchLatestSha(path.value)
if (remoteSha === null) { if (remoteSha === null) {
rawStatus.value = "offline" status.value = "offline"
return return
} }
latestSha.value = remoteSha latestSha.value = remoteSha
lastCheckedAt.value = new Date() lastCheckedAt.value = new Date()
const local = await expectedSha() const local = await expectedSha()
rawStatus.value = remoteSha === local ? "verified" : "outdated" status.value = remoteSha === local ? "verified" : "outdated"
armStaleTimer()
} }
const pullLatest = async (): Promise<string | null> => { const pullLatest = async (): Promise<string | null> => {
if (!path.value) return null if (!path.value) return null
const usedCachedSha = latestSha.value !== null
const remoteSha = latestSha.value ?? (await fetchLatestSha(path.value)) const remoteSha = latestSha.value ?? (await fetchLatestSha(path.value))
if (!remoteSha) { if (!remoteSha) {
rawStatus.value = "offline" console.warn("pullLatest: could not resolve remote sha", { path: path.value })
status.value = "offline"
return null return null
} }
const fileContent = await queryFileContent(user, repo, remoteSha) const fileContent = await queryFileContent(user, repo, remoteSha)
if (!fileContent) { if (!fileContent) {
rawStatus.value = "offline" 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 return null
} }
const { saveCacheNote } = prepareNoteCache(sha.value, path.value) const { saveCacheNote } = prepareNoteCache(sha.value, path.value)
@@ -90,8 +78,7 @@ export const useNoteFreshness = ({
store.addFile({ path: path.value, sha: remoteSha }) store.addFile({ path: path.value, sha: remoteSha })
latestSha.value = remoteSha latestSha.value = remoteSha
lastCheckedAt.value = new Date() lastCheckedAt.value = new Date()
rawStatus.value = "verified" status.value = "verified"
armStaleTimer()
const { getRawContent } = markdownBuilder(sha.value) const { getRawContent } = markdownBuilder(sha.value)
return getRawContent(fileContent) return getRawContent(fileContent)
} }

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"
@@ -15,10 +15,19 @@ 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
@@ -58,8 +67,18 @@ export const useRepos = () => {
isReady.value = true isReady.value = true
isLoading.value = false isLoading.value = false
} }
} }
watch(accessToken, (next, prev) => {
if (next === prev) return
resetState()
if (next && username.value) {
lastFetchedAt = Date.now()
loadMore()
}
})
export const useRepos = () => {
const canLoadMore = computed( const canLoadMore = computed(
() => !isLoading.value && repos.value.length < totalCount.value () => !isLoading.value && repos.value.length < totalCount.value
) )
@@ -67,12 +86,7 @@ export const useRepos = () => {
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
isLoading.value = false
hasCredentialError.value = false
} }
lastFetchedAt = Date.now() lastFetchedAt = Date.now()
loadMore() loadMore()

View File

@@ -19,7 +19,7 @@ export const useResizeContainer = (
} }
if (isMobile.value) { if (isMobile.value) {
container.style.height = `${(stackedNotes.value.length + 1) * 100}dvh` 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,
@@ -50,6 +50,7 @@ export const useRouteQueryStackedNotes = () => {
const scrollToNoteElement = ( const scrollToNoteElement = (
cleanNoteId: string, cleanNoteId: string,
index: number, index: number,
anchorTop?: number,
attempts = 30 attempts = 30
) => { ) => {
const element = document.querySelector( const element = document.querySelector(
@@ -57,7 +58,7 @@ export const useRouteQueryStackedNotes = () => {
) as HTMLElement | null ) as HTMLElement | null
if (element) { if (element) {
scrollToNote((index + 1) * element.clientHeight) scrollToElement(element, anchorTop)
return return
} }
@@ -67,7 +68,7 @@ export const useRouteQueryStackedNotes = () => {
} }
requestAnimationFrame(() => { requestAnimationFrame(() => {
scrollToNoteElement(cleanNoteId, index, attempts - 1) scrollToNoteElement(cleanNoteId, index, anchorTop, attempts - 1)
}) })
} }
@@ -76,20 +77,22 @@ export const useRouteQueryStackedNotes = () => {
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) {
scrollToNoteElement(noteId.replaceAll(":", "-"), index) scrollToNoteElement(noteId.replaceAll(":", "-"), index, anchorTop)
} else { } else {
scrollToNote(0) scrollToNote(0)
} }
@@ -114,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
} }
@@ -139,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,5 +1,5 @@
import { toRaw } from "vue"
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"
@@ -166,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) {

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,7 +13,11 @@
--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-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%); --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;
@@ -27,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';
@@ -91,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;

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

File diff suppressed because it is too large Load Diff

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"