Compare commits

30 Commits

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

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

Regenerate as RGBA with the silhouette in alpha (derived from the
favicon's alpha channel via a sharp-based helper script). Rename to
monochromeicon.png to bust Safari's stuck PWA icon cache from prior
broken installs.
2026-05-05 17:40:40 +02:00
Julien Calixte
fd7d06ce69 design: change light theme to retro 2026-05-05 16:11:45 +02:00
Julien Calixte
5a9c0a3704 lint 2026-05-04 23:54:26 +02:00
Julien Calixte
e425be5c96 refactor(freshness): drop time-based stale-known status
The 2-minute timer + tick ref decayed verified to stale-known and rendered
a clock icon, but the user can always click the badge to re-check. Removing
the timer simplifies the hook and the badge has one fewer visual state.
2026-05-04 23:53:48 +02:00
Julien Calixte
84803c45dd refactor(scroll): clean up debug overlay and pass anchor by param
Removes the temporary on-screen scroll diagnosis panel and the global
window.__scrollAtClick stash. The anchor scrollTop is now captured
synchronously at addStackedNote entry and threaded through
scrollToFocusedNote and scrollToNoteElement to scrollToElement, so no
state survives across calls — nothing to reset on repo or page change.
2026-05-04 23:02:12 +02:00
Julien Calixte
a526a9f6af fix(scroll): snap to click-time scrollTop before smooth scroll
Capture mainApp.scrollTop synchronously when addStackedNote runs and
snap the scroll back to that value before scrollIntoView fires, so
the smooth scroll begins from where the user actually tapped rather
than from a position drifted by momentum or async work.
2026-05-04 19:57:00 +02:00
Julien Calixte
08e01d8484 revert: restore mobile body scroll for pull-to-reload
Reverts 550b3cf — removing the override broke pull-to-reload, and
single-scroll-container did not fix the offset glitch anyway.
2026-05-04 19:04:46 +02:00
Julien Calixte
c88340d5f1 chore(debug): add temporary scroll overlay for mobile diagnosis 2026-05-04 19:02:35 +02:00
Julien Calixte
550b3cf019 fix(layout): remove mobile body scroll to keep one scroll container
Both html/body and #main-app being scrollable on mobile made
scrollIntoView animate two ancestors at once, shifting the start
frame of the smooth scroll. With body locked, #main-app is the only
scroller and the animation matches the user's actual position.
2026-05-04 18:58:04 +02:00
Julien Calixte
2f05b93f51 fix(stacked-note): size mobile notes with svh to stabilize scroll target
Dynamic viewport units rescale every note when the mobile address bar
grows or shrinks, shifting the scroll target by the address-bar height
mid-flight. Small viewport units stay constant across address-bar
transitions so the smooth scroll lands where it was aimed.
2026-05-04 18:45:45 +02:00
Julien Calixte
cc266eac7c refactor(scroll): delegate note scroll to scrollIntoView
Native scrollIntoView reads the element position at scroll time and
picks the right scrollable ancestor itself, sidestepping iOS Safari
quirks with scrollTo on overflow containers and visual-viewport shifts.
2026-05-04 18:29:05 +02:00
Julien Calixte
be006f08b4 fix(stacked-note): align mobile scroll target to element rect
Replace the (index + 1) * clientHeight math and 80ms setTimeout with a
scrollToElement helper that reads getBoundingClientRect inside rAF, so
the smooth scroll starts from the user's actual position even when the
note is freshly mounted.
2026-05-04 18:15:10 +02:00
Julien Calixte
55ee3bddeb fix(router): skip view transition on query-only navigation
The root fade overlapped smooth scrolls triggered when stackedNotes
mutated, making the scroll appear to start from the snapshot's frame
instead of the user's actual position.
2026-05-04 18:15:04 +02:00
Julien Calixte
1f324208d2 design(stacked-notes): action buttons in vertical bar 2026-05-04 10:54:50 +02:00
24 changed files with 321 additions and 232 deletions

View File

@@ -1,11 +1,11 @@
# ---- Stage 1: deps (only invalidated when lockfile changes) ----
FROM node:22-alpine AS deps
RUN corepack enable && corepack prepare pnpm@latest --activate
RUN corepack enable
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
RUN pnpm install --frozen-lockfile
@@ -13,7 +13,7 @@ RUN pnpm install --frozen-lockfile
# ---- Stage 2: build (invalidated on any source change) ----
FROM node:22-alpine AS builder
RUN corepack enable && corepack prepare pnpm@latest --activate
RUN corepack enable
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>
<html lang="en" data-theme="emerald">
<html lang="en" data-theme="light">
<head>
<meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />

View File

@@ -2,6 +2,7 @@
"name": "remanso",
"version": "1.0.0",
"private": true,
"packageManager": "pnpm@11.0.9",
"scripts": {
"dev": "vite",
"build": "vite build",

View File

@@ -1,2 +1,7 @@
allowBuilds:
'@parcel/watcher': 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) {
hasError.value = true
} else {
token.access_token
saveCredentials(token)
await saveCredentials(token)
}
router.replace({ name: "Home" })

View File

@@ -5,6 +5,7 @@ import HeaderNote from "@/components/HeaderNote.vue"
import SignInGithub from "@/components/SignInGithub.vue"
import SkeletonLoader from "@/components/SkeletonLoader.vue"
import StackedNote from "@/components/StackedNote.vue"
import { useGitHubLogin } from "@/hooks/useGitHubLogin.hook"
import { useLinks } from "@/hooks/useLinks.hook"
import { markdownBuilder } from "@/hooks/useMarkdown.hook"
import { useNoteView } from "@/hooks/useNoteView.hook"
@@ -44,6 +45,7 @@ const { listenToClick } = useLinks("note-display")
const { stackedNotes, scrollToFocusedNote } = useRouteQueryStackedNotes()
const { titles } = useNoteView()
const { isLogged } = useGitHubLogin()
useResizeContainer("note-container", stackedNotes)
const renderedContent = computed(() =>
@@ -104,8 +106,13 @@ onUnmounted(() => {
<slot />
<skeleton-loader v-if="isLoading" />
<div v-else-if="withContent && !hasContent" class="repo-not-found">
<p>This repository is not accessible.</p>
<sign-in-github />
<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
v-else-if="withContent && hasContent"
@@ -259,7 +266,7 @@ $header-height: 40px;
.note {
width: 100vw;
height: 100dvh;
height: 100svh;
overflow-y: visible;
}

View File

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

View File

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

View File

@@ -26,6 +26,7 @@ import { useUserRepoStore } from "@/modules/repo/store/userRepo.store"
import { encodeUTF8ToBase64 } from "@/utils/decodeBase64ToUTF8"
import { getFileLanguage, isMarkdownPath } from "@/utils/fileLanguage"
import { filenameToNoteTitle } from "@/utils/noteTitle"
import { errorMessage } from "@/utils/notif"
const LinkedNotes = defineAsyncComponent(
() => import("@/components/LinkedNotes.vue")
@@ -241,21 +242,26 @@ const onConflictCancel = () => {
}
const onBadgeClick = async () => {
if (freshnessStatus.value !== "outdated") {
await checkFreshness()
return
}
try {
if (freshnessStatus.value !== "outdated") {
await checkFreshness()
return
}
const hasUnsavedEdits = rawContent.value !== initialRawContent.value
if (hasUnsavedEdits) {
conflictOpen.value = true
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
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>
@@ -269,27 +275,13 @@ const onBadgeClick = async () => {
[`note-${sha}`]: true
}"
>
<a
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="title-stacked-note breadcrumbs text-sm" :class="titleClassName">
<div class="action-bar">
<note-freshness-badge
:status="freshnessStatus"
:last-checked-at="lastCheckedAt"
@click="onBadgeClick"
class="action"
/>
<button
v-if="isMarkdown"
@@ -298,50 +290,62 @@ const onBadgeClick = async () => {
:style="mode === 'edit' ? 'color: var(--color-primary)' : ''"
@click="toggleMode"
>
<svg
v-if="mode === 'read'"
xmlns="http://www.w3.org/2000/svg"
class="icon icon-tabler icon-tabler-edit"
width="24"
height="24"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path
d="M7 7h-1a2 2 0 0 0 -2 2v9a2 2 0 0 0 2 2h9a2 2 0 0 0 2 -2v-1"
/>
<path
d="M20.385 6.585a2.1 2.1 0 0 0 -2.97 -2.97l-8.415 8.385v3h3l8.385 -8.415z"
/>
<path d="M16 5l3 3" />
</svg>
<svg
v-else
xmlns="http://www.w3.org/2000/svg"
class="icon icon-tabler icon-tabler-device-floppy"
width="24"
height="24"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path
d="M6 4h10l4 4v10a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2v-12a2 2 0 0 1 2 -2"
/>
<path d="M12 14m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" />
<path d="M14 4l0 4l-6 0l0 -4" />
</svg>
</button>
<svg
v-if="mode === 'read'"
xmlns="http://www.w3.org/2000/svg"
class="icon icon-tabler icon-tabler-edit"
width="24"
height="24"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path
d="M7 7h-1a2 2 0 0 0 -2 2v9a2 2 0 0 0 2 2h9a2 2 0 0 0 2 -2v-1"
/>
<path
d="M20.385 6.585a2.1 2.1 0 0 0 -2.97 -2.97l-8.415 8.385v3h3l8.385 -8.415z"
/>
<path d="M16 5l3 3" />
</svg>
<svg
v-else
xmlns="http://www.w3.org/2000/svg"
class="icon icon-tabler icon-tabler-device-floppy"
width="24"
height="24"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path
d="M6 4h10l4 4v10a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2v-12a2 2 0 0 1 2 -2"
/>
<path d="M12 14m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" />
<path d="M14 4l0 4l-6 0l0 -4" />
</svg>
</button>
</div>
<a
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" />
</div>
@@ -389,7 +393,6 @@ $border-color: rgba(18, 19, 58, 0.2);
background-color: var(--color-base-100);
color: var(--color-base-content);
font-size: 0.8em;
overflow: hidden;
ul,
li {
@@ -415,12 +418,15 @@ $border-color: rgba(18, 19, 58, 0.2);
align-items: center;
justify-content: flex-end;
gap: 0.25rem;
margin: 0.2rem 0;
}
.action {
margin: 0;
&:hover {
cursor: pointer;
}
img {
vertical-align: bottom;
}
@@ -429,7 +435,7 @@ $border-color: rgba(18, 19, 58, 0.2);
@media screen and (max-width: 768px) {
.stacked-note {
padding: 0 0.75rem 1rem;
height: 100dvh;
height: 100svh;
section {
padding: 1rem 0;
@@ -452,7 +458,7 @@ $border-color: rgba(18, 19, 58, 0.2);
}
.title-stacked-note {
padding: 0 1rem;
padding: 0;
transform-origin: 0 0;
transform: rotate(90deg);
}
@@ -460,6 +466,12 @@ $border-color: rgba(18, 19, 58, 0.2);
a {
white-space: nowrap;
}
.action-bar {
.action {
transform: rotate(-90deg);
}
}
}
@media print {

View File

@@ -144,7 +144,6 @@ $border-color: rgba(18, 19, 58, 0.2);
background-color: var(--color-base-100);
color: var(--color-base-content);
font-size: 0.8em;
overflow: hidden;
ul,
li {

View File

@@ -1,4 +1,4 @@
import { computed, Ref, ref } from "vue"
import { Ref, ref } from "vue"
import { useGitHubContent } from "@/hooks/useGitHubContent.hook"
import { markdownBuilder } from "@/hooks/useMarkdown.hook"
@@ -10,12 +10,9 @@ export type FreshnessStatus =
| "unknown"
| "checking"
| "verified"
| "stale-known"
| "outdated"
| "offline"
const STALE_AFTER_MS = 2 * 60 * 1000
export const useNoteFreshness = ({
user,
repo,
@@ -32,54 +29,45 @@ export const useNoteFreshness = ({
const store = useUserRepoStore()
const { fetchLatestSha } = useGitHubContent({ user, repo })
const rawStatus = ref<FreshnessStatus>("unknown")
const status = ref<FreshnessStatus>("unknown")
const lastCheckedAt = ref<Date | 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 check = async () => {
if (!path.value) return
rawStatus.value = "checking"
status.value = "checking"
const remoteSha = await fetchLatestSha(path.value)
if (remoteSha === null) {
rawStatus.value = "offline"
status.value = "offline"
return
}
latestSha.value = remoteSha
lastCheckedAt.value = new Date()
const local = await expectedSha()
rawStatus.value = remoteSha === local ? "verified" : "outdated"
armStaleTimer()
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) {
rawStatus.value = "offline"
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) {
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
}
const { saveCacheNote } = prepareNoteCache(sha.value, path.value)
@@ -90,8 +78,7 @@ export const useNoteFreshness = ({
store.addFile({ path: path.value, sha: remoteSha })
latestSha.value = remoteSha
lastCheckedAt.value = new Date()
rawStatus.value = "verified"
armStaleTimer()
status.value = "verified"
const { getRawContent } = markdownBuilder(sha.value)
return getRawContent(fileContent)
}

View File

@@ -40,10 +40,22 @@ export const useOverlay = (listen = true) => {
}, 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 {
x,
y,
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 { RepoBase } from "@/modules/repo/interfaces/RepoBase"
@@ -15,51 +15,70 @@ const currentPage = ref(0)
const totalCount = ref(0)
let lastFetchedAt = 0
export const useRepos = () => {
const { username, accessToken } = useGitHubLogin()
const { username, accessToken } = useGitHubLogin()
const loadMore = async () => {
if (!accessToken.value || !username.value) {
isReady.value = true
return
}
if (isLoading.value) return
isLoading.value = true
try {
const octokit = await getOctokit()
const nextPage = currentPage.value + 1
const repoList = await octokit.request("GET /search/repositories", {
q: `user:${username.value}`,
per_page: PER_PAGE,
page: nextPage
})
currentPage.value = nextPage
totalCount.value = repoList.data.total_count
const newItems = repoList.data.items.map((item) => ({
id: `${item.id}`,
name: item.name,
isPrivate: item.private
}))
repos.value = [...repos.value, ...newItems].sort((a, b) =>
a.name < b.name ? -1 : 1
)
} catch (err: unknown) {
if (
typeof err === "object" &&
err !== null &&
"status" in err &&
(err as { status: number }).status === 401
) {
hasCredentialError.value = true
} else {
throw err
}
} finally {
isReady.value = true
isLoading.value = false
}
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) {
isReady.value = true
return
}
if (isLoading.value) return
isLoading.value = true
try {
const octokit = await getOctokit()
const nextPage = currentPage.value + 1
const repoList = await octokit.request("GET /search/repositories", {
q: `user:${username.value}`,
per_page: PER_PAGE,
page: nextPage
})
currentPage.value = nextPage
totalCount.value = repoList.data.total_count
const newItems = repoList.data.items.map((item) => ({
id: `${item.id}`,
name: item.name,
isPrivate: item.private
}))
repos.value = [...repos.value, ...newItems].sort((a, b) =>
a.name < b.name ? -1 : 1
)
} catch (err: unknown) {
if (
typeof err === "object" &&
err !== null &&
"status" in err &&
(err as { status: number }).status === 401
) {
hasCredentialError.value = true
} else {
throw err
}
} finally {
isReady.value = true
isLoading.value = false
}
}
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
)
@@ -67,12 +86,7 @@ export const useRepos = () => {
const isStale = Date.now() - lastFetchedAt > STALE_TIME_MS
if (!isReady.value || isStale) {
if (isStale && isReady.value) {
repos.value = []
currentPage.value = 0
totalCount.value = 0
isReady.value = false
isLoading.value = false
hasCredentialError.value = false
resetState()
}
lastFetchedAt = Date.now()
loadMore()

View File

@@ -19,7 +19,7 @@ export const useResizeContainer = (
}
if (isMobile.value) {
container.style.height = `${(stackedNotes.value.length + 1) * 100}dvh`
container.style.height = `${(stackedNotes.value.length + 1) * 100}svh`
} else {
container.style.minWidth = `${
getNoteWidth() * (stackedNotes.value.length + 1)

View File

@@ -18,7 +18,7 @@ export const useRouteQueryStackedNotes = () => {
})
const { height } = useWindowSize()
const { scrollToNote, isMobile } = useOverlay(false)
const { scrollToNote, scrollToElement, isMobile } = useOverlay(false)
const scrollToHashInNote = (
cleanSha: string,
@@ -50,6 +50,7 @@ export const useRouteQueryStackedNotes = () => {
const scrollToNoteElement = (
cleanNoteId: string,
index: number,
anchorTop?: number,
attempts = 30
) => {
const element = document.querySelector(
@@ -57,7 +58,7 @@ export const useRouteQueryStackedNotes = () => {
) as HTMLElement | null
if (element) {
scrollToNote((index + 1) * element.clientHeight)
scrollToElement(element, anchorTop)
return
}
@@ -67,7 +68,7 @@ export const useRouteQueryStackedNotes = () => {
}
requestAnimationFrame(() => {
scrollToNoteElement(cleanNoteId, index, attempts - 1)
scrollToNoteElement(cleanNoteId, index, anchorTop, attempts - 1)
})
}
@@ -76,20 +77,22 @@ export const useRouteQueryStackedNotes = () => {
notes?: string[]
hash?: string
smoothHash?: boolean
anchorTop?: number
}
const scrollToFocusedNote = ({
noteId = null,
notes = stackedNotes.value,
hash,
smoothHash = false
smoothHash = false,
anchorTop
}: ScrollToFocusedNoteOptions = {}) => {
nextTick(() => {
const index = noteId ? notes.findIndex((nid) => nid === noteId) : 0
if (isMobile.value) {
if (noteId) {
scrollToNoteElement(noteId.replaceAll(":", "-"), index)
scrollToNoteElement(noteId.replaceAll(":", "-"), index, anchorTop)
} else {
scrollToNote(0)
}
@@ -114,11 +117,15 @@ export const useRouteQueryStackedNotes = () => {
selector?: string,
hash?: string
) => {
const anchorTop =
document.getElementById("main-app")?.scrollTop ?? undefined
if (stackedNotes.value.includes(sha)) {
scrollToFocusedNote({
noteId: selector ?? sha,
hash,
smoothHash: true
smoothHash: true,
anchorTop
})
return
}
@@ -139,7 +146,7 @@ export const useRouteQueryStackedNotes = () => {
stackedNotes.value = newStackedNotes
}
scrollToFocusedNote({ noteId: selector ?? sha, hash })
scrollToFocusedNote({ noteId: selector ?? sha, hash, anchorTop })
}
return {

View File

@@ -121,20 +121,23 @@ export const queryFileContent = async (
repo: string,
sha: string
) => {
const octokit = await getOctokit()
if (!user || !repo) {
null
return null
}
const file = await octokit.request(
"GET /repos/{owner}/{repo}/git/blobs/{file_sha}",
{
owner: user,
repo: repo,
file_sha: sha
}
)
return file?.data.content ?? null
try {
const octokit = await getOctokit()
const file = await octokit.request(
"GET /repos/{owner}/{repo}/git/blobs/{file_sha}",
{
owner: user,
repo: repo,
file_sha: sha
}
)
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 { toRaw } from "vue"
import { data, generateId } from "@/data/data"
import { DataType } from "@/data/DataType.enum"
@@ -166,8 +166,10 @@ export const useUserRepoStore = defineStore("USER_REPO_STATE", {
getCachedMainReadme(user, repo).then(async (cachedReadme) => {
if (requestId !== this._requestId) return
this.readme = cachedReadme
this.readme = await getMainReadme(user, repo)
if (cachedReadme) this.readme = cachedReadme
const fetched = await getMainReadme(user, repo)
if (requestId !== this._requestId) return
this.readme = fetched
})
},
addFile(file: RepoFile) {

View File

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

View File

@@ -13,7 +13,11 @@
--light-link: lighten(#445fb9, 45%);
--background-color: #ffffff;
--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%);
--color-contrast-content: var(--color-success);
--notyf-margin: 0.5rem;
@@ -27,8 +31,8 @@
@plugin 'daisyui' {
themes:
emerald --default,
forest --prefersdark;
light --default,
dracula --prefersdark;
}
@config '../../tailwind.config.js';
@@ -91,18 +95,36 @@ a {
}
}
a.title-stacked-note-link {
.title-stacked-note {
color: var(--color-base-content);
display: block;
text-decoration: none;
position: sticky;
top: 0;
overflow: visible;
display: flex;
gap: 0.5rem;
align-items: center;
}
a.title-stacked-note-link {
display: block;
overflow: visible;
&:hover {
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 {
border-radius: revert-layer;
border: none;

View File

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

View File

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