Files
remanso/src/hooks/useCheckboxCommit.hook.ts
Julien Calixte a09e541fa8 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.
2026-05-29 15:24:12 +02:00

161 lines
3.6 KiB
TypeScript

import { useDebounceFn } from "@vueuse/core"
import { onUnmounted, Ref, ref, toValue } from "vue"
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,
enabled = true
}: {
user: string
repo: string
path: Ref<string | undefined> | string | undefined
initialContent: Ref<string> | string
initialSha: Ref<string> | string
containerSelector: string
debounceMs?: number
enabled?: Ref<boolean> | boolean
}) => {
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 { sha: 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) => {
if (!toValue(enabled)) return
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) return
if (toValue(enabled)) {
container.addEventListener("change", handleCheckboxChange)
} else {
container
.querySelectorAll<HTMLInputElement>('input[type="checkbox"]')
.forEach((checkbox) => {
checkbox.disabled = true
})
}
}
onUnmounted(() => {
removeListeners()
})
return {
pendingContent,
currentSha,
isCommitting,
hasPendingChanges,
syncContent,
listenToCheckboxes
}
}