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.
475 lines
10 KiB
Vue
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>
|