Files
remanso/src/components/StackedNote.vue
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

475 lines
10 KiB
Vue

<script lang="ts" setup>
import {
computed,
defineAsyncComponent,
nextTick,
onMounted,
ref,
watch
} from "vue"
import { useEditionMode } from "@/hooks/useEditionMode"
import { useFile } from "@/hooks/useFile.hook"
import { useGitHubContent } from "@/hooks/useGitHubContent.hook"
import { useImages } from "@/hooks/useImages.hook"
import { useLinks } from "@/hooks/useLinks.hook"
import {
renderCodeFile,
runMermaid,
useShikiji
} from "@/hooks/useMarkdown.hook"
import { useNoteFreshness } from "@/hooks/useNoteFreshness.hook"
import { useNoteOverlay } from "@/hooks/useNoteOverlay.hook"
import { useRouteQueryStackedNotes } from "@/hooks/useRouteQueryStackedNotes.hook"
import { useTitleNotes } from "@/hooks/useTitleNotes.hook"
import { useUserRepoStore } from "@/modules/repo/store/userRepo.store"
import { encodeUTF8ToBase64 } from "@/utils/decodeBase64ToUTF8"
import { getFileLanguage, isMarkdownPath } from "@/utils/fileLanguage"
import { filenameToNoteTitle } from "@/utils/noteTitle"
const LinkedNotes = defineAsyncComponent(
() => import("@/components/LinkedNotes.vue")
)
const NoteFreshnessBadge = defineAsyncComponent(
() => import("@/components/NoteFreshnessBadge.vue")
)
const NoteConflictModal = defineAsyncComponent(
() => import("@/components/NoteConflictModal.vue")
)
const EditNote = defineAsyncComponent(
() => import("@/modules/note/components/EditNote.vue")
)
const props = defineProps<{
user: string
repo: string
index: number
title?: string
sha: string
}>()
const user = computed(() => props.user)
const repo = computed(() => props.repo)
const sha = computed(() => props.sha)
const index = computed(() => props.index)
const { scrollToFocusedNote } = useRouteQueryStackedNotes()
const {
path,
content,
rawContent,
getRawContent,
saveCacheNote,
getEditedSha
} = useFile(sha)
const initialRawContent = ref<string | null>(null)
const isMarkdown = computed(() =>
path.value ? isMarkdownPath(path.value) : true
)
const displayedContent = ref("")
watch(
[rawContent, isMarkdown, path],
async ([raw, isMd, p]) => {
if (!raw) {
displayedContent.value = ""
return
}
if (isMd) {
displayedContent.value = content.value
return
}
const lang = p ? getFileLanguage(p) : null
const filename = p?.split("/").pop()
const result = await renderCodeFile({ rawContent: raw, lang, filename })
if (rawContent.value === raw) {
displayedContent.value = result
}
},
{ immediate: true }
)
watch(content, (c) => {
if (isMarkdown.value) displayedContent.value = c
})
const className = computed(() => `stacked-note-${props.index}`)
const { listenToClick } = useLinks(className.value, sha)
const titleClassName = computed(() => `title-${className.value}`)
useTitleNotes(repo)
const store = useUserRepoStore()
const hasBacklinks = computed(() => store.userSettings?.backlink)
const { displayNoteOverlay } = useNoteOverlay(className.value, index)
const displayedTitle = computed(() => filenameToNoteTitle(props.title ?? ""))
const breadcrumbs = computed(() => displayedTitle.value.split(" / "))
const { updateFile } = useGitHubContent({
user: user.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 () => {
initialRawContent.value = await getRawContent()
})
watch(
path,
(p) => {
if (p) void checkFreshness()
},
{ immediate: true }
)
const { mode, toggleMode } = useEditionMode()
watch([content, mode], () => {
if (!content.value) {
return
}
nextTick(() => {
listenToClick()
if (/\!\[.*?\]\(.*?\)/.test(rawContent.value)) {
useImages(props.sha)
}
if (rawContent.value.includes("```mermaid")) {
runMermaid(`.note-${sha.value} .mermaid`)
}
if (isMarkdown.value && rawContent.value.includes("```")) {
useShikiji()
}
})
})
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) => {
if (newMode === "edit") {
void checkFreshness()
return
}
const hasUserFinishedToEdit =
newMode === "read" && rawContent.value !== initialRawContent.value
if (!hasUserFinishedToEdit) {
return
}
if (!path.value) {
console.warn("no path found for this file")
return
}
await checkFreshness()
if (freshnessStatus.value === "outdated") {
conflictOpen.value = true
return
}
await performSave()
})
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 () => {
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
}
}
</script>
<template>
<div
class="stacked-note"
:class="{
[className]: true,
overlay: displayNoteOverlay,
[`note-${sha}`]: true
}"
>
<a
class="title-stacked-note-link"
@click.prevent="scrollToFocusedNote({ noteId: props.sha })"
>
<div
class="title-stacked-note breadcrumbs text-sm"
:class="titleClassName"
>
<ul>
<li v-for="(part, i) in breadcrumbs" :key="i">
{{ part }}
</li>
</ul>
</div>
</a>
<section class="text-content">
<div class="action-bar">
<note-freshness-badge
:status="freshnessStatus"
:last-checked-at="lastCheckedAt"
@click="onBadgeClick"
/>
<button
v-if="isMarkdown"
class="action button is-text is-light"
:class="{ 'is-link': mode === 'edit' }"
:style="mode === 'edit' ? 'color: var(--color-primary)' : ''"
@click="toggleMode"
>
<svg
v-if="mode === 'read'"
xmlns="http://www.w3.org/2000/svg"
class="icon icon-tabler icon-tabler-edit"
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="M7 7h-1a2 2 0 0 0 -2 2v9a2 2 0 0 0 2 2h9a2 2 0 0 0 2 -2v-1"
/>
<path
d="M20.385 6.585a2.1 2.1 0 0 0 -2.97 -2.97l-8.415 8.385v3h3l8.385 -8.415z"
/>
<path d="M16 5l3 3" />
</svg>
<svg
v-else
xmlns="http://www.w3.org/2000/svg"
class="icon icon-tabler icon-tabler-device-floppy"
width="24"
height="24"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path
d="M6 4h10l4 4v10a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2v-12a2 2 0 0 1 2 -2"
/>
<path d="M12 14m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" />
<path d="M14 4l0 4l-6 0l0 -4" />
</svg>
</button>
</div>
<div v-if="mode === 'edit' && isMarkdown" class="edit">
<edit-note v-model="rawContent" />
</div>
<div
v-if="mode === 'read'"
class="note-content"
v-html="displayedContent"
></div>
</section>
<linked-notes v-if="hasBacklinks && content" :sha="sha" />
<note-conflict-modal
v-model:open="conflictOpen"
@discard="onConflictDiscard"
@overwrite="onConflictOverwrite"
@cancel="onConflictCancel"
/>
</div>
</template>
<style lang="scss" scoped>
$border-color: rgba(18, 19, 58, 0.2);
.stacked-note {
padding: 0 1.5rem 1rem;
background-color: var(--color-base-100);
color: var(--color-base-content);
scrollbar-width: none;
&.overlay {
box-shadow: -3px 0 0.4em $border-color;
}
section {
padding: 0 0.5rem;
}
}
.offline-ready {
position: absolute;
top: 1rem;
right: 1rem;
}
.title-stacked-note {
background-color: var(--color-base-100);
color: var(--color-base-content);
font-size: 0.8em;
overflow: hidden;
ul,
li {
margin-top: 0;
margin-bottom: 0;
padding-left: 0;
text-decoration: none;
}
}
.text-content {
flex: 1;
scrollbar-width: none;
> .edit,
> .note-content {
height: 100%;
}
}
.action-bar {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 0.25rem;
margin: 0.2rem 0;
}
.action {
margin: 0;
img {
vertical-align: bottom;
}
}
@media screen and (max-width: 768px) {
.stacked-note {
padding: 0 0.75rem 1rem;
height: 100dvh;
section {
padding: 1rem 0;
overflow-x: auto;
}
.note-content {
padding: 0;
scrollbar-width: none;
}
}
}
@media screen and (min-width: 769px) {
.stacked-note {
border-top: 0;
border-left: 1px solid $border-color;
position: sticky;
top: 0;
}
.title-stacked-note {
padding: 0 1rem;
transform-origin: 0 0;
transform: rotate(90deg);
}
a {
white-space: nowrap;
}
}
@media print {
.stacked-note {
break-after: always;
&.overlay {
box-shadow: none;
}
}
}
</style>