Compare commits

34 Commits

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

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

Regenerate as RGBA with the silhouette in alpha (derived from the
favicon's alpha channel via a sharp-based helper script). Rename to
monochromeicon.png to bust Safari's stuck PWA icon cache from prior
broken installs.
2026-05-05 17:40:40 +02:00
Julien Calixte
fd7d06ce69 design: change light theme to retro 2026-05-05 16:11:45 +02:00
Julien Calixte
5a9c0a3704 lint 2026-05-04 23:54:26 +02:00
Julien Calixte
e425be5c96 refactor(freshness): drop time-based stale-known status
The 2-minute timer + tick ref decayed verified to stale-known and rendered
a clock icon, but the user can always click the badge to re-check. Removing
the timer simplifies the hook and the badge has one fewer visual state.
2026-05-04 23:53:48 +02:00
Julien Calixte
84803c45dd refactor(scroll): clean up debug overlay and pass anchor by param
Removes the temporary on-screen scroll diagnosis panel and the global
window.__scrollAtClick stash. The anchor scrollTop is now captured
synchronously at addStackedNote entry and threaded through
scrollToFocusedNote and scrollToNoteElement to scrollToElement, so no
state survives across calls — nothing to reset on repo or page change.
2026-05-04 23:02:12 +02:00
Julien Calixte
a526a9f6af fix(scroll): snap to click-time scrollTop before smooth scroll
Capture mainApp.scrollTop synchronously when addStackedNote runs and
snap the scroll back to that value before scrollIntoView fires, so
the smooth scroll begins from where the user actually tapped rather
than from a position drifted by momentum or async work.
2026-05-04 19:57:00 +02:00
Julien Calixte
08e01d8484 revert: restore mobile body scroll for pull-to-reload
Reverts 550b3cf — removing the override broke pull-to-reload, and
single-scroll-container did not fix the offset glitch anyway.
2026-05-04 19:04:46 +02:00
Julien Calixte
c88340d5f1 chore(debug): add temporary scroll overlay for mobile diagnosis 2026-05-04 19:02:35 +02:00
Julien Calixte
550b3cf019 fix(layout): remove mobile body scroll to keep one scroll container
Both html/body and #main-app being scrollable on mobile made
scrollIntoView animate two ancestors at once, shifting the start
frame of the smooth scroll. With body locked, #main-app is the only
scroller and the animation matches the user's actual position.
2026-05-04 18:58:04 +02:00
Julien Calixte
2f05b93f51 fix(stacked-note): size mobile notes with svh to stabilize scroll target
Dynamic viewport units rescale every note when the mobile address bar
grows or shrinks, shifting the scroll target by the address-bar height
mid-flight. Small viewport units stay constant across address-bar
transitions so the smooth scroll lands where it was aimed.
2026-05-04 18:45:45 +02:00
Julien Calixte
cc266eac7c refactor(scroll): delegate note scroll to scrollIntoView
Native scrollIntoView reads the element position at scroll time and
picks the right scrollable ancestor itself, sidestepping iOS Safari
quirks with scrollTo on overflow containers and visual-viewport shifts.
2026-05-04 18:29:05 +02:00
Julien Calixte
be006f08b4 fix(stacked-note): align mobile scroll target to element rect
Replace the (index + 1) * clientHeight math and 80ms setTimeout with a
scrollToElement helper that reads getBoundingClientRect inside rAF, so
the smooth scroll starts from the user's actual position even when the
note is freshly mounted.
2026-05-04 18:15:10 +02:00
Julien Calixte
55ee3bddeb fix(router): skip view transition on query-only navigation
The root fade overlapped smooth scrolls triggered when stackedNotes
mutated, making the scroll appear to start from the snapshot's frame
instead of the user's actual position.
2026-05-04 18:15:04 +02:00
Julien Calixte
1f324208d2 design(stacked-notes): action buttons in vertical bar 2026-05-04 10:54:50 +02:00
Julien Calixte
002cf9a4b1 fix(stacked-note): act on outdated badge clicks
Clicking the badge while it shows outdated now pulls the latest version
from GitHub when there are no unsaved edits, or opens the conflict
modal when edits are in flight. Previously the click only re-ran the
same freshness check, so the badge appeared dead.
2026-05-03 23:37:28 +02:00
Julien Calixte
efe9c01e63 chore(github-content): pin api version on fetchLatestSha request
Silences the @octokit/request deprecation warning that prints whenever
the unversioned /repos/{owner}/{repo}/contents/{path} call fires.
2026-05-03 23:37:24 +02:00
Julien Calixte
d31c774ace feat(stacked-note): surface note freshness and guard saves on conflict
Adds a Tabler-icon badge in the stacked-note action bar showing whether
the loaded copy still matches GitHub HEAD (verified / outdated / offline
/ checking / unknown / stale-known). The save flow now re-checks before
the PUT and opens a conflict modal when GitHub has moved on, with three
explicit choices: discard local edits and pull, overwrite anyway, or
cancel. Race-condition 409s from the PUT itself are routed through the
same modal.
2026-05-03 23:32:54 +02:00
Julien Calixte
d8a59467a0 refactor(github-content): expose conflict info and add latest-sha lookup
updateFile/createFile now return { sha, conflict } so 409/422 from GitHub
can drive a UI flow instead of being swallowed as a generic save error.
Also adds fetchLatestSha(path) for cheap freshness checks against HEAD.
2026-05-03 23:32:37 +02:00
27 changed files with 843 additions and 182 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

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

@@ -18,6 +18,7 @@ import {
runMermaid, runMermaid,
useShikiji useShikiji
} from "@/hooks/useMarkdown.hook" } 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"
@@ -25,11 +26,20 @@ 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")
) )
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")
) )
@@ -104,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], () => {
@@ -132,7 +166,43 @@ watch([content, mode], () => {
}) })
}) })
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
@@ -141,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>
@@ -174,22 +275,14 @@ 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" v-if="isMarkdown"
class="action button is-text is-light" class="action button is-text is-light"
@@ -240,6 +333,19 @@ 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>
<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>
@@ -250,6 +356,12 @@ watch(mode, async (newMode) => {
></div> ></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>
@@ -281,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 {
@@ -296,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;
@@ -313,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;
@@ -336,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);
} }
@@ -344,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

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

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

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

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