Compare commits
3 Commits
8a8509a0f3
...
6a0f0d08d2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a0f0d08d2 | ||
|
|
0b3411626c | ||
|
|
01bb4b8c70 |
@@ -10,6 +10,7 @@ import {
|
|||||||
import { useEditionMode } from "@/hooks/useEditionMode"
|
import { useEditionMode } from "@/hooks/useEditionMode"
|
||||||
import { useFile } from "@/hooks/useFile.hook"
|
import { useFile } from "@/hooks/useFile.hook"
|
||||||
import { useGitHubContent } from "@/hooks/useGitHubContent.hook"
|
import { useGitHubContent } from "@/hooks/useGitHubContent.hook"
|
||||||
|
import { useImageUpload } from "@/hooks/useImageUpload.hook"
|
||||||
import { useLinks } from "@/hooks/useLinks.hook"
|
import { useLinks } from "@/hooks/useLinks.hook"
|
||||||
import { renderCodeFile } from "@/hooks/useMarkdown.hook"
|
import { renderCodeFile } from "@/hooks/useMarkdown.hook"
|
||||||
import { useMarkdownPostRender } from "@/hooks/useMarkdownPostRender.hook"
|
import { useMarkdownPostRender } from "@/hooks/useMarkdownPostRender.hook"
|
||||||
@@ -109,6 +110,27 @@ const { updateFile } = useGitHubContent({
|
|||||||
repo: repo.value
|
repo: repo.value
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const { uploadImage } = useImageUpload({
|
||||||
|
user: user.value,
|
||||||
|
repo: repo.value,
|
||||||
|
notePath: path
|
||||||
|
})
|
||||||
|
|
||||||
|
const fileInput = ref<HTMLInputElement | null>(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}\n`
|
||||||
|
editKey.value++
|
||||||
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
status: freshnessStatus,
|
status: freshnessStatus,
|
||||||
lastCheckedAt,
|
lastCheckedAt,
|
||||||
@@ -273,6 +295,42 @@ const onBadgeClick = async () => {
|
|||||||
@click="onBadgeClick"
|
@click="onBadgeClick"
|
||||||
class="action"
|
class="action"
|
||||||
/>
|
/>
|
||||||
|
<button
|
||||||
|
v-if="isMarkdown && mode === 'edit'"
|
||||||
|
class="action button is-text is-light"
|
||||||
|
title="Upload image"
|
||||||
|
@click="fileInput?.click()"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="icon icon-tabler icon-tabler-photo-plus"
|
||||||
|
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="M15 8h.01" />
|
||||||
|
<path
|
||||||
|
d="M12.5 21h-6.5a3 3 0 0 1 -3 -3v-12a3 3 0 0 1 3 -3h12a3 3 0 0 1 3 3v6.5"
|
||||||
|
/>
|
||||||
|
<path d="M3 16l5 -5c.928 -.893 2.072 -.893 3 0l4 4" />
|
||||||
|
<path d="M14 14l1 -1c.928 -.893 2.072 -.893 3 0l2 2" />
|
||||||
|
<path d="M16 19h6" />
|
||||||
|
<path d="M19 16v6" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
ref="fileInput"
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
class="hidden-input"
|
||||||
|
@change="onImagePicked"
|
||||||
|
/>
|
||||||
<button
|
<button
|
||||||
v-if="isMarkdown"
|
v-if="isMarkdown"
|
||||||
class="action button is-text is-light"
|
class="action button is-text is-light"
|
||||||
@@ -337,7 +395,7 @@ const onBadgeClick = async () => {
|
|||||||
</div>
|
</div>
|
||||||
<section class="text-content">
|
<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 :key="editKey" v-model="rawContent" />
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="mode === 'read'"
|
v-if="mode === 'read'"
|
||||||
@@ -410,6 +468,10 @@ $border-color: rgba(18, 19, 58, 0.2);
|
|||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hidden-input {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.action {
|
.action {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|
||||||
|
|||||||
@@ -37,14 +37,22 @@ export const useGitHubContent = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const putFile = async ({
|
const putRaw = async ({
|
||||||
content,
|
contentBase64,
|
||||||
path,
|
path,
|
||||||
sha
|
sha,
|
||||||
|
message,
|
||||||
|
successMessage,
|
||||||
|
conflictMessage,
|
||||||
|
failureMessage
|
||||||
}: {
|
}: {
|
||||||
content: string
|
contentBase64: string
|
||||||
path: string
|
path: string
|
||||||
sha?: string
|
sha?: string
|
||||||
|
message: string
|
||||||
|
successMessage: string
|
||||||
|
conflictMessage: string
|
||||||
|
failureMessage: string
|
||||||
}): Promise<{ sha: string | null; conflict: boolean }> => {
|
}): Promise<{ sha: string | null; conflict: boolean }> => {
|
||||||
try {
|
try {
|
||||||
const octokit = await getOctokit()
|
const octokit = await getOctokit()
|
||||||
@@ -55,28 +63,63 @@ export const useGitHubContent = ({
|
|||||||
owner: user,
|
owner: user,
|
||||||
repo,
|
repo,
|
||||||
path,
|
path,
|
||||||
message: `Updating ${path} from Remanso`,
|
message,
|
||||||
content: encodeUTF8ToBase64(content),
|
content: contentBase64,
|
||||||
sha
|
sha
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
confirmMessage("✅ Note saved")
|
confirmMessage(successMessage)
|
||||||
|
|
||||||
return { sha: response?.data.content?.sha ?? null, conflict: false }
|
return { sha: response?.data.content?.sha ?? null, conflict: false }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const status = (error as { status?: number })?.status
|
const status = (error as { status?: number })?.status
|
||||||
if (status && isConflictStatus(status)) {
|
if (status && isConflictStatus(status)) {
|
||||||
errorMessage("⚠ Conflict: this note changed on GitHub")
|
errorMessage(conflictMessage)
|
||||||
console.warn(error)
|
console.warn(error)
|
||||||
return { sha: null, conflict: true }
|
return { sha: null, conflict: true }
|
||||||
}
|
}
|
||||||
errorMessage("❌ Note could not be saved")
|
errorMessage(failureMessage)
|
||||||
console.warn(error)
|
console.warn(error)
|
||||||
return { sha: null, conflict: false }
|
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 {
|
return {
|
||||||
fetchLatestSha,
|
fetchLatestSha,
|
||||||
updateFile: async (props: {
|
updateFile: async (props: {
|
||||||
@@ -85,6 +128,7 @@ export const useGitHubContent = ({
|
|||||||
sha: string
|
sha: string
|
||||||
}) => putFile(props),
|
}) => putFile(props),
|
||||||
createFile: async (props: { content: string; path: string }) =>
|
createFile: async (props: { content: string; path: string }) =>
|
||||||
putFile(props)
|
putFile(props),
|
||||||
|
uploadBinaryFile
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
104
src/hooks/useImageUpload.hook.ts
Normal file
104
src/hooks/useImageUpload.hook.ts
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import { Ref, toValue } from "vue"
|
||||||
|
|
||||||
|
import { useGitHubContent } from "@/hooks/useGitHubContent.hook"
|
||||||
|
import { useUserRepoStore } from "@/modules/repo/store/userRepo.store"
|
||||||
|
import { errorMessage } from "@/utils/notif"
|
||||||
|
import { uniqueFilename } from "@/utils/uniqueFilename"
|
||||||
|
|
||||||
|
const arrayBufferToBase64 = (buffer: ArrayBuffer): string => {
|
||||||
|
const bytes = new Uint8Array(buffer)
|
||||||
|
const chunkSize = 0x8000
|
||||||
|
let binary = ""
|
||||||
|
for (let i = 0; i < bytes.length; i += chunkSize) {
|
||||||
|
const chunk = bytes.subarray(i, i + chunkSize)
|
||||||
|
binary += String.fromCharCode(...chunk)
|
||||||
|
}
|
||||||
|
return btoa(binary)
|
||||||
|
}
|
||||||
|
|
||||||
|
const splitPath = (fullPath: string): { directory: string; filename: string } => {
|
||||||
|
const lastSlash = fullPath.lastIndexOf("/")
|
||||||
|
if (lastSlash === -1) return { directory: "", filename: fullPath }
|
||||||
|
return {
|
||||||
|
directory: fullPath.slice(0, lastSlash),
|
||||||
|
filename: fullPath.slice(lastSlash + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const stripMarkdownExtension = (filename: string): string =>
|
||||||
|
filename.replace(/\.(md|markdown|mdx)$/i, "")
|
||||||
|
|
||||||
|
const extractExtension = (filename: string): string => {
|
||||||
|
const dot = filename.lastIndexOf(".")
|
||||||
|
if (dot <= 0) return ".png"
|
||||||
|
return filename.slice(dot).toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useImageUpload = ({
|
||||||
|
user,
|
||||||
|
repo,
|
||||||
|
notePath
|
||||||
|
}: {
|
||||||
|
user: string
|
||||||
|
repo: string
|
||||||
|
notePath: Ref<string | undefined> | string | undefined
|
||||||
|
}) => {
|
||||||
|
const store = useUserRepoStore()
|
||||||
|
const { uploadBinaryFile } = useGitHubContent({ user, repo })
|
||||||
|
|
||||||
|
const uploadImage = async (
|
||||||
|
file: File
|
||||||
|
): Promise<{ filename: string } | null> => {
|
||||||
|
const currentNotePath = toValue(notePath)
|
||||||
|
if (!currentNotePath) {
|
||||||
|
errorMessage("❌ Image upload failed")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { directory, filename: noteFilename } = splitPath(currentNotePath)
|
||||||
|
const basename = stripMarkdownExtension(noteFilename)
|
||||||
|
const extension = extractExtension(file.name)
|
||||||
|
|
||||||
|
const existingPaths = store.files
|
||||||
|
.map((f) => f.path)
|
||||||
|
.filter((p): p is string => typeof p === "string")
|
||||||
|
|
||||||
|
const filename = uniqueFilename({
|
||||||
|
basename,
|
||||||
|
extension,
|
||||||
|
existingPaths,
|
||||||
|
directory
|
||||||
|
})
|
||||||
|
|
||||||
|
const targetPath = directory ? `${directory}/${filename}` : filename
|
||||||
|
|
||||||
|
const buffer = await file.arrayBuffer()
|
||||||
|
const base64 = arrayBufferToBase64(buffer)
|
||||||
|
|
||||||
|
const { sha, conflict } = await uploadBinaryFile({
|
||||||
|
base64,
|
||||||
|
path: targetPath
|
||||||
|
})
|
||||||
|
|
||||||
|
if (conflict || !sha) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!store.files.some((f) => f.path === targetPath)) {
|
||||||
|
store.files = [
|
||||||
|
...store.files,
|
||||||
|
{ path: targetPath, sha, type: "blob", size: file.size }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
return { filename }
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("image upload failed", error)
|
||||||
|
errorMessage("❌ Image upload failed")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { uploadImage }
|
||||||
|
}
|
||||||
71
src/utils/uniqueFilename.spec.ts
Normal file
71
src/utils/uniqueFilename.spec.ts
Normal file
@@ -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")
|
||||||
|
})
|
||||||
|
})
|
||||||
21
src/utils/uniqueFilename.ts
Normal file
21
src/utils/uniqueFilename.ts
Normal file
@@ -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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user