feat: saving on toggle

This commit is contained in:
Julien Calixte
2026-01-20 23:02:33 +01:00
parent 4a53a17de4
commit e316d8080b
2 changed files with 204 additions and 9 deletions

View File

@@ -0,0 +1,147 @@
import { ref, Ref, toValue, onUnmounted } from "vue"
import { useDebounceFn } from "@vueuse/core"
import { useGitHubContent } from "@/hooks/useGitHubContent.hook"
const CHECKBOX_PATTERN = /\[([ xX])\]/g
const setCheckboxInMarkdown = (
markdown: string,
index: number,
checked: boolean,
): string => {
let currentIndex = 0
return markdown.replace(CHECKBOX_PATTERN, (match) => {
if (currentIndex++ === index) {
return checked ? "[x]" : "[ ]"
}
return match
})
}
const findCheckboxIndex = (
container: Element,
checkbox: HTMLInputElement,
): number => {
const allCheckboxes = container.querySelectorAll('input[type="checkbox"]')
return Array.from(allCheckboxes).indexOf(checkbox)
}
export const useCheckboxCommit = ({
user,
repo,
path,
initialContent,
initialSha,
containerSelector,
debounceMs = 1000,
}: {
user: string
repo: string
path: Ref<string | undefined> | string | undefined
initialContent: Ref<string> | string
initialSha: Ref<string> | string
containerSelector: string
debounceMs?: number
}) => {
const { updateFile } = useGitHubContent({ user, repo })
const pendingContent = ref(toValue(initialContent))
const currentSha = ref(toValue(initialSha))
const isCommitting = ref(false)
const hasPendingChanges = ref(false)
// Update pending content when initial content changes (e.g., after fetch)
const syncContent = (content: string, sha: string) => {
if (!hasPendingChanges.value) {
pendingContent.value = content
currentSha.value = sha
}
}
const commitChanges = async () => {
const pathValue = toValue(path)
if (!pathValue || !hasPendingChanges.value) {
return
}
// If already committing, the debounce will re-trigger after
if (isCommitting.value) {
debouncedCommit()
return
}
isCommitting.value = true
const newSha = await updateFile({
content: pendingContent.value,
path: pathValue,
sha: currentSha.value,
})
if (newSha) {
currentSha.value = newSha
hasPendingChanges.value = false
}
isCommitting.value = false
}
const debouncedCommit = useDebounceFn(commitChanges, debounceMs)
const handleCheckboxChange = (event: Event) => {
const target = event.target as HTMLInputElement
if (target.tagName !== "INPUT" || target.type !== "checkbox") {
return
}
const container = document.querySelector(containerSelector)
if (!container) {
return
}
const index = findCheckboxIndex(container, target)
if (index === -1) {
return
}
pendingContent.value = setCheckboxInMarkdown(
pendingContent.value,
index,
target.checked,
)
hasPendingChanges.value = true
// Schedule commit
debouncedCommit()
}
const removeListeners = () => {
const container = document.querySelector(containerSelector)
if (container) {
container.removeEventListener("change", handleCheckboxChange)
}
}
const listenToCheckboxes = () => {
removeListeners()
const container = document.querySelector(containerSelector)
if (container) {
container.addEventListener("change", handleCheckboxChange)
}
}
onUnmounted(() => {
removeListeners()
})
return {
pendingContent,
currentSha,
isCommitting,
hasPendingChanges,
syncContent,
listenToCheckboxes,
}
}

View File

@@ -1,8 +1,10 @@
<script setup lang="ts">
import { computed, defineAsyncComponent, watch } from "vue"
import { computed, defineAsyncComponent, nextTick, ref, watch } from "vue"
import { useUserRepoStore } from "@/modules/repo/store/userRepo.store"
import { useFile } from "@/hooks/useFile.hook"
import { computedAsync } from "@vueuse/core"
import { useCheckboxCommit } from "@/hooks/useCheckboxCommit.hook"
import { useMarkdown } from "@/hooks/useMarkdown.hook"
import { queryFileContent } from "@/modules/repo/services/repo"
import { decodeBase64ToUTF8 } from "@/utils/decodeBase64ToUTF8"
const FluxNote = defineAsyncComponent(() => import("@/components/FluxNote.vue"))
const props = defineProps<{ user: string; repo: string }>()
@@ -15,14 +17,60 @@ const todoNote = computed(() =>
store.files.find((file) => file.path?.endsWith("_todo/todo.md")),
)
const content = computedAsync(() => {
if (!todoNote.value) {
const sha = computed(() => todoNote.value?.sha ?? "")
const path = computed(() => todoNote.value?.path)
const { toHTML } = useMarkdown(repo)
// Setup checkbox commit handler
const {
pendingContent,
syncContent,
listenToCheckboxes,
hasPendingChanges,
} = useCheckboxCommit({
user: props.user,
repo: props.repo,
path,
initialContent: "",
initialSha: sha,
containerSelector: ".todo-notes .note-display",
debounceMs: 1000,
})
// Render pending content to HTML for display
const renderedContent = computed(() => {
if (!pendingContent.value) {
return ""
}
const { getContent } = useFile(todoNote.value?.sha ?? "", false)
return getContent()
return toHTML(pendingContent.value)
})
// Fetch raw content when sha changes
watch(
sha,
async (newSha) => {
if (!newSha || hasPendingChanges.value) {
return
}
const base64Content = await queryFileContent(props.user, props.repo, newSha)
if (base64Content) {
const rawContent = decodeBase64ToUTF8(base64Content)
syncContent(rawContent, newSha)
}
},
{ immediate: true },
)
// Setup checkbox listeners when content renders
watch(
renderedContent,
async () => {
await nextTick()
listenToCheckboxes()
},
{ immediate: true },
)
</script>
<template>
@@ -31,7 +79,7 @@ const content = computedAsync(() => {
key="todo-notes"
:user="user"
:repo="repo"
:content="content"
:content="renderedContent"
:parse-content="false"
/>
</div>