feat(notes): share an always-latest link, not just a snapshot
Some checks failed
CI / verify (push) Has been cancelled

The Share button now offers two links: the pinned snapshot (default) and
an "always latest" link that carries note paths in `?liveNotes` instead of
blob shas. On open they resolve to the current shas against the HEAD file
list and the URL is rewritten to the ordinary pinned form, so freshness,
editing and the snapshot banner keep working unchanged.
This commit is contained in:
Julien Calixte
2026-07-11 11:11:51 +02:00
parent 70fbe5156f
commit a88d0dbac7
6 changed files with 325 additions and 0 deletions

View File

@@ -0,0 +1,168 @@
<script lang="ts" setup>
import { computed } from "vue"
import { LocationQueryRaw, useRoute, useRouter } from "vue-router"
import { stackToLivePaths } from "@/modules/note/liveNotes"
import { useUserRepoStore } from "@/modules/repo/store/userRepo.store"
import { confirmMessage, errorMessage } from "@/utils/notif"
const route = useRoute()
const router = useRouter()
const store = useUserRepoStore()
const stackedShas = computed<string[]>(() => {
const raw = route.query.stackedNotes
if (!raw) return []
return (Array.isArray(raw) ? raw : [raw]).filter(
(sha): sha is string => typeof sha === "string"
)
})
const hasStack = computed(() => stackedShas.value.length > 0)
const absoluteUrl = (query: LocationQueryRaw): string => {
const { href } = router.resolve({ path: route.path, query })
return `${window.location.origin}${href}`
}
// The exact snapshot: the current URL, with each note pinned to its blob sha.
const snapshotUrl = (): string => absoluteUrl({ ...route.query })
// The living link: the same notes referenced by path, so the recipient always
// lands on the latest version. See useResolveLiveNotes for the read side.
const livingUrl = (): string => {
const query = { ...route.query }
delete query.stackedNotes
return absoluteUrl({
...query,
liveNotes: stackToLivePaths(stackedShas.value, store.files)
})
}
const close = () =>
(document.getElementById("share_modal") as HTMLDialogElement | null)?.close()
const copy = async (url: string, label: string) => {
close()
try {
await navigator.clipboard.writeText(url)
confirmMessage(`🔗 ${label} link copied`)
} catch {
errorMessage("❌ Couldn't copy the link")
}
}
</script>
<template>
<button
class="btn btn-ghost btn-circle text-base-content"
title="Share this view"
aria-label="Share this view"
onclick="share_modal.showModal()"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="icon icon-tabler icon-tabler-share"
width="30"
height="30"
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" />
<circle cx="6" cy="12" r="3" />
<circle cx="18" cy="6" r="3" />
<circle cx="18" cy="18" r="3" />
<line x1="8.7" y1="10.7" x2="15.3" y2="7.3" />
<line x1="8.7" y1="13.3" x2="15.3" y2="16.7" />
</svg>
</button>
<dialog id="share_modal" class="modal">
<div class="modal-box max-w-md">
<h3 class="text-lg font-bold">Share this view</h3>
<button
type="button"
class="share-option"
@click="copy(snapshotUrl(), 'Snapshot')"
>
<span class="share-option-icon">📌</span>
<span class="share-option-text">
<span class="share-option-title">This exact version</span>
<span class="share-option-desc">
Pinned to what you see now it never changes.
</span>
</span>
</button>
<button
v-if="hasStack"
type="button"
class="share-option"
@click="copy(livingUrl(), 'Always-latest')"
>
<span class="share-option-icon">🔄</span>
<span class="share-option-text">
<span class="share-option-title">Always latest</span>
<span class="share-option-desc">
Follows these notes the reader gets the current version.
</span>
</span>
</button>
<div class="modal-action">
<form method="dialog">
<button class="btn btn-ghost">Close</button>
</form>
</div>
</div>
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog>
</template>
<style scoped lang="scss">
.share-option {
display: flex;
align-items: flex-start;
gap: 0.75rem;
width: 100%;
margin-top: 1rem;
padding: 0.75rem 1rem;
text-align: left;
border: 1px solid var(--color-base-300);
border-radius: var(--radius-box, 0.5rem);
background-color: var(--color-base-100);
color: var(--color-base-content);
cursor: pointer;
&:hover {
border-color: var(--color-primary);
}
}
.share-option-icon {
font-size: 1.4rem;
line-height: 1.6rem;
}
.share-option-text {
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.share-option-title {
font-weight: 600;
}
.share-option-desc {
font-size: 0.85em;
opacity: 0.75;
}
</style>