From 01bb4b8c70241c0870a3653cb23ac832321ccc7e Mon Sep 17 00:00:00 2001 From: Julien Calixte Date: Mon, 25 May 2026 21:17:38 +0200 Subject: [PATCH 01/11] refactor(github): extract putRaw and add uploadBinaryFile --- src/hooks/useGitHubContent.hook.ts | 64 +++++++++++++++++++++++++----- 1 file changed, 54 insertions(+), 10 deletions(-) diff --git a/src/hooks/useGitHubContent.hook.ts b/src/hooks/useGitHubContent.hook.ts index a0e415d..8080b79 100644 --- a/src/hooks/useGitHubContent.hook.ts +++ b/src/hooks/useGitHubContent.hook.ts @@ -37,14 +37,22 @@ export const useGitHubContent = ({ } } - const putFile = async ({ - content, + const putRaw = async ({ + contentBase64, path, - sha + sha, + message, + successMessage, + conflictMessage, + failureMessage }: { - content: string + contentBase64: string path: string sha?: string + message: string + successMessage: string + conflictMessage: string + failureMessage: string }): Promise<{ sha: string | null; conflict: boolean }> => { try { const octokit = await getOctokit() @@ -55,28 +63,63 @@ export const useGitHubContent = ({ owner: user, repo, path, - message: `Updating ${path} from Remanso`, - content: encodeUTF8ToBase64(content), + message, + content: contentBase64, sha } ) - confirmMessage("✅ Note saved") + confirmMessage(successMessage) return { sha: response?.data.content?.sha ?? null, conflict: false } } catch (error) { const status = (error as { status?: number })?.status if (status && isConflictStatus(status)) { - errorMessage("⚠ Conflict: this note changed on GitHub") + errorMessage(conflictMessage) console.warn(error) return { sha: null, conflict: true } } - errorMessage("❌ Note could not be saved") + errorMessage(failureMessage) console.warn(error) return { sha: null, conflict: false } } } + const putFile = async ({ + content, + path, + sha + }: { + content: string + path: string + sha?: string + }): Promise<{ sha: string | null; conflict: boolean }> => + putRaw({ + contentBase64: encodeUTF8ToBase64(content), + path, + sha, + message: `Updating ${path} from Remanso`, + successMessage: "✅ Note saved", + conflictMessage: "⚠ Conflict: this note changed on GitHub", + failureMessage: "❌ Note could not be saved" + }) + + const uploadBinaryFile = async ({ + base64, + path + }: { + base64: string + path: string + }): Promise<{ sha: string | null; conflict: boolean }> => + putRaw({ + contentBase64: base64, + path, + message: `Uploading ${path} from Remanso`, + successMessage: "✅ Image uploaded", + conflictMessage: "⚠ A file already exists at this path on GitHub", + failureMessage: "❌ Image could not be uploaded" + }) + return { fetchLatestSha, updateFile: async (props: { @@ -85,6 +128,7 @@ export const useGitHubContent = ({ sha: string }) => putFile(props), createFile: async (props: { content: string; path: string }) => - putFile(props) + putFile(props), + uploadBinaryFile } } From 0b3411626c18371eca18957535c4e4a24b79cde9 Mon Sep 17 00:00:00 2001 From: Julien Calixte Date: Mon, 25 May 2026 21:17:42 +0200 Subject: [PATCH 02/11] feat(utils): add uniqueFilename helper with -2 collision suffix --- src/utils/uniqueFilename.spec.ts | 71 ++++++++++++++++++++++++++++++++ src/utils/uniqueFilename.ts | 21 ++++++++++ 2 files changed, 92 insertions(+) create mode 100644 src/utils/uniqueFilename.spec.ts create mode 100644 src/utils/uniqueFilename.ts diff --git a/src/utils/uniqueFilename.spec.ts b/src/utils/uniqueFilename.spec.ts new file mode 100644 index 0000000..cd0c5e2 --- /dev/null +++ b/src/utils/uniqueFilename.spec.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "vitest" + +import { uniqueFilename } from "./uniqueFilename" + +describe("uniqueFilename", () => { + it("returns basename.ext when nothing collides", () => { + expect( + uniqueFilename({ + basename: "myfile", + extension: ".png", + existingPaths: ["docs/notes/myfile.md"], + directory: "docs/notes" + }) + ).toEqual("myfile.png") + }) + + it("appends -2 on first collision (skips -1)", () => { + expect( + uniqueFilename({ + basename: "myfile", + extension: ".png", + existingPaths: ["docs/notes/myfile.png"], + directory: "docs/notes" + }) + ).toEqual("myfile-2.png") + }) + + it("appends -3 when both base and -2 exist", () => { + expect( + uniqueFilename({ + basename: "myfile", + extension: ".png", + existingPaths: ["docs/notes/myfile.png", "docs/notes/myfile-2.png"], + directory: "docs/notes" + }) + ).toEqual("myfile-3.png") + }) + + it("fills gaps when -3 exists but -2 does not", () => { + expect( + uniqueFilename({ + basename: "myfile", + extension: ".png", + existingPaths: ["docs/notes/myfile.png", "docs/notes/myfile-3.png"], + directory: "docs/notes" + }) + ).toEqual("myfile-2.png") + }) + + it("handles repo-root directory", () => { + expect( + uniqueFilename({ + basename: "myfile", + extension: ".jpg", + existingPaths: ["myfile.jpg"], + directory: "" + }) + ).toEqual("myfile-2.jpg") + }) + + it("does not collide with a different extension", () => { + expect( + uniqueFilename({ + basename: "myfile", + extension: ".png", + existingPaths: ["docs/myfile.jpg"], + directory: "docs" + }) + ).toEqual("myfile.png") + }) +}) diff --git a/src/utils/uniqueFilename.ts b/src/utils/uniqueFilename.ts new file mode 100644 index 0000000..0e5fbcf --- /dev/null +++ b/src/utils/uniqueFilename.ts @@ -0,0 +1,21 @@ +export const uniqueFilename = ({ + basename, + extension, + existingPaths, + directory +}: { + basename: string + extension: string + existingPaths: string[] + directory: string +}): string => { + const prefix = directory ? `${directory}/` : "" + const candidate = (n: number) => + n === 1 ? `${basename}${extension}` : `${basename}-${n}${extension}` + const taken = new Set(existingPaths) + let n = 1 + while (taken.has(`${prefix}${candidate(n)}`)) { + n = n === 1 ? 2 : n + 1 + } + return candidate(n) +} From 6a0f0d08d28e872f50c5af8b8bd166054720c127 Mon Sep 17 00:00:00 2001 From: Julien Calixte Date: Mon, 25 May 2026 21:17:46 +0200 Subject: [PATCH 03/11] feat(notes): add image upload button when editing a markdown note --- src/components/StackedNote.vue | 64 ++++++++++++++++++- src/hooks/useImageUpload.hook.ts | 104 +++++++++++++++++++++++++++++++ 2 files changed, 167 insertions(+), 1 deletion(-) create mode 100644 src/hooks/useImageUpload.hook.ts diff --git a/src/components/StackedNote.vue b/src/components/StackedNote.vue index e2b18e6..94a3efa 100644 --- a/src/components/StackedNote.vue +++ b/src/components/StackedNote.vue @@ -10,6 +10,7 @@ import { import { useEditionMode } from "@/hooks/useEditionMode" import { useFile } from "@/hooks/useFile.hook" import { useGitHubContent } from "@/hooks/useGitHubContent.hook" +import { useImageUpload } from "@/hooks/useImageUpload.hook" import { useLinks } from "@/hooks/useLinks.hook" import { renderCodeFile } from "@/hooks/useMarkdown.hook" import { useMarkdownPostRender } from "@/hooks/useMarkdownPostRender.hook" @@ -109,6 +110,27 @@ const { updateFile } = useGitHubContent({ repo: repo.value }) +const { uploadImage } = useImageUpload({ + user: user.value, + repo: repo.value, + notePath: path +}) + +const fileInput = ref(null) +const editKey = ref(0) + +const onImagePicked = async (e: Event) => { + const input = e.target as HTMLInputElement + const file = input.files?.[0] + input.value = "" + if (!file || !path.value) return + const result = await uploadImage(file) + if (!result) return + const suffix = rawContent.value.endsWith("\n") ? "" : "\n\n" + rawContent.value = `${rawContent.value}${suffix}![](${result.filename})\n` + editKey.value++ +} + const { status: freshnessStatus, lastCheckedAt, @@ -273,6 +295,42 @@ const onBadgeClick = async () => { @click="onBadgeClick" class="action" /> + + From b87836782b036836ea3ffa303391fe1f519942a8 Mon Sep 17 00:00:00 2001 From: Julien Calixte Date: Mon, 25 May 2026 21:29:51 +0200 Subject: [PATCH 08/11] fix(notes): persist uploaded image to cache so it survives reload --- src/hooks/useImageUpload.hook.ts | 12 ++++++------ src/modules/repo/store/userRepo.store.ts | 22 ++++++++++++++++++++++ 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/src/hooks/useImageUpload.hook.ts b/src/hooks/useImageUpload.hook.ts index 3396387..8dce8a8 100644 --- a/src/hooks/useImageUpload.hook.ts +++ b/src/hooks/useImageUpload.hook.ts @@ -85,12 +85,12 @@ export const useImageUpload = ({ return null } - if (!store.files.some((f) => f.path === targetPath)) { - store.files = [ - ...store.files, - { path: targetPath, sha, type: "blob", size: file.size } - ] - } + store.registerUploadedFile({ + path: targetPath, + sha, + type: "blob", + size: file.size + }) return { filename } } catch (error) { diff --git a/src/modules/repo/store/userRepo.store.ts b/src/modules/repo/store/userRepo.store.ts index b56ad0f..114b0b2 100644 --- a/src/modules/repo/store/userRepo.store.ts +++ b/src/modules/repo/store/userRepo.store.ts @@ -230,6 +230,28 @@ export const useUserRepoStore = defineStore("USER_REPO_STATE", { }) this.files = newFiles }, + registerUploadedFile(file: RepoFile) { + if (!file.path) { + return + } + + const savedRepoId = generateId( + DataType.SavedRepo, + `${this.user}-${this.repo}` + ) + const newFiles = [ + ...toRaw(this.files).filter((f) => f.path !== file.path), + toRaw(file) + ] + data.update({ + _id: savedRepoId, + $type: DataType.SavedRepo, + repo: this.repo, + user: this.user, + files: newFiles + }) + this.files = newFiles + }, resetUserRepo() { this.user = "" this.repo = "" From 455addf76019fbcc37e5cdbad39dd7f3393d6a1c Mon Sep 17 00:00:00 2001 From: Julien Calixte Date: Mon, 25 May 2026 21:29:54 +0200 Subject: [PATCH 09/11] style(notes): move image upload button after edit/save button --- src/components/StackedNote.vue | 84 +++++++++++++++++----------------- 1 file changed, 42 insertions(+), 42 deletions(-) diff --git a/src/components/StackedNote.vue b/src/components/StackedNote.vue index f892c08..523770c 100644 --- a/src/components/StackedNote.vue +++ b/src/components/StackedNote.vue @@ -302,48 +302,6 @@ const onBadgeClick = async () => { @click="onBadgeClick" class="action" /> - - + + Date: Fri, 29 May 2026 15:24:12 +0200 Subject: [PATCH 10/11] feat(repo): hide write UI for users without push access Fetch the viewer's push permission via GET /repos/{owner}/{repo} when entering a repo and gate every write affordance behind it: edit and image-upload buttons in StackedNote, new-fleeting-note and YouTube buttons in FleetingNotes, and interactive checkboxes in TodoNotes (rendered disabled when read-only). Anonymous viewers get the same treatment because the permissions field is absent without auth, which collapses to canPush = false. Previously every write button was visible regardless of role, so read-role collaborators and anonymous viewers could enter edit mode and type before the save failed with 401/403. --- src/components/StackedNote.vue | 5 ++- src/hooks/useCheckboxCommit.hook.ts | 16 ++++++- src/modules/repo/services/repo.spec.ts | 56 ++++++++++++++++++++++++ src/modules/repo/services/repo.ts | 12 +++++ src/modules/repo/store/userRepo.store.ts | 16 +++++++ src/views/FleetingNotes.vue | 10 ++++- src/views/TodoNotes.vue | 10 +++-- 7 files changed, 115 insertions(+), 10 deletions(-) create mode 100644 src/modules/repo/services/repo.spec.ts diff --git a/src/components/StackedNote.vue b/src/components/StackedNote.vue index 523770c..bc8cf31 100644 --- a/src/components/StackedNote.vue +++ b/src/components/StackedNote.vue @@ -100,6 +100,7 @@ useTitleNotes(repo) const store = useUserRepoStore() const hasBacklinks = computed(() => store.userSettings?.backlink) +const canPush = computed(() => store.canPush) const { displayNoteOverlay } = useNoteOverlay(className.value, index) const displayedTitle = computed(() => filenameToNoteTitle(props.title ?? "")) @@ -303,7 +304,7 @@ const onBadgeClick = async () => { class="action" /> - +
diff --git a/src/views/TodoNotes.vue b/src/views/TodoNotes.vue index 59a9d22..27c1ab3 100644 --- a/src/views/TodoNotes.vue +++ b/src/views/TodoNotes.vue @@ -26,9 +26,10 @@ const todoNote = computed(() => const sha = computed(() => todoNote.value?.sha ?? "") const path = computed(() => todoNote.value?.path) +const canPush = computed(() => store.canPush) + const { toHTML } = markdownBuilder(repo) -// Setup checkbox commit handler const { pendingContent, syncContent, listenToCheckboxes, hasPendingChanges } = useCheckboxCommit({ user: props.user, @@ -37,7 +38,8 @@ const { pendingContent, syncContent, listenToCheckboxes, hasPendingChanges } = initialContent: "", initialSha: sha, containerSelector: ".todo-notes .note-display", - debounceMs: 1000 + debounceMs: 1000, + enabled: canPush }) // Render pending content to HTML for display @@ -64,9 +66,9 @@ watch( { immediate: true } ) -// Setup checkbox listeners when content renders +// Setup checkbox listeners when content renders or canPush changes watch( - renderedContent, + [renderedContent, canPush], async () => { await nextTick() listenToCheckboxes() From d99672dbd8c0f398324f5cab37aaf8e2a28d226c Mon Sep 17 00:00:00 2001 From: Julien Calixte Date: Fri, 29 May 2026 16:49:44 +0200 Subject: [PATCH 11/11] fix(auth): return to origin path after GitHub login Sign-in saves the current path in sessionStorage so the OAuth callback can route back instead of always landing on Home. --- src/components/AuthorizeUser.vue | 9 ++++++++- src/components/SignInGithub.vue | 16 +++++++++++++++- src/modules/user/service/oauthReturnPath.ts | 7 +++++++ 3 files changed, 30 insertions(+), 2 deletions(-) create mode 100644 src/modules/user/service/oauthReturnPath.ts diff --git a/src/components/AuthorizeUser.vue b/src/components/AuthorizeUser.vue index b39aa0e..97d2f0c 100644 --- a/src/components/AuthorizeUser.vue +++ b/src/components/AuthorizeUser.vue @@ -3,6 +3,7 @@ import { onBeforeMount, ref } from "vue" import { useRoute, useRouter } from "vue-router" import { useGitHubLogin } from "@/hooks/useGitHubLogin.hook" +import { consumeGithubOAuthReturnPath } from "@/modules/user/service/oauthReturnPath" import { signIn } from "@/modules/user/service/signIn" const route = useRoute() @@ -22,7 +23,13 @@ onBeforeMount(async () => { await saveCredentials(token) } - router.replace({ name: "Home" }) + const returnPath = consumeGithubOAuthReturnPath() + + if (!hasError.value && returnPath) { + router.replace(returnPath) + } else { + router.replace({ name: "Home" }) + } } }) diff --git a/src/components/SignInGithub.vue b/src/components/SignInGithub.vue index 98a458a..2675bbf 100644 --- a/src/components/SignInGithub.vue +++ b/src/components/SignInGithub.vue @@ -1,20 +1,34 @@