Merge branch 'main' of ssh://git.apoena.dev:22222/julien/remanso
All checks were successful
CI / verify (push) Successful in 2m3s

This commit is contained in:
Julien Calixte
2026-06-21 10:29:23 +02:00
6 changed files with 231 additions and 5 deletions

View File

@@ -1,4 +1,5 @@
<script lang="ts" setup> <script lang="ts" setup>
import ImageLightbox from "@/components/ImageLightbox.vue"
import NewVersion from "@/components/NewVersion.vue" import NewVersion from "@/components/NewVersion.vue"
import { useATProtoLogin } from "@/hooks/useATProtoLogin.hook" import { useATProtoLogin } from "@/hooks/useATProtoLogin.hook"
import { useGitHubLogin } from "@/hooks/useGitHubLogin.hook" import { useGitHubLogin } from "@/hooks/useGitHubLogin.hook"
@@ -12,6 +13,7 @@ const { isATProtoReady } = useATProtoLogin()
<router-view v-if="isReady && isATProtoReady" /> <router-view v-if="isReady && isATProtoReady" />
<new-version /> <new-version />
<image-lightbox />
</div> </div>
</template> </template>

View File

@@ -0,0 +1,54 @@
<script lang="ts" setup>
import { useEventListener } from "@vueuse/core"
import { ref, watch } from "vue"
import { useImageLightbox } from "@/hooks/useImageLightbox.hook"
import { svgToDataUrl } from "@/utils/svgDownload"
const { isOpen, src, alt, open, close } = useImageLightbox()
const dialogRef = ref<HTMLDialogElement | null>(null)
const onClick = (event: MouseEvent) => {
const target = event.target as HTMLElement
if (target.closest(".svg-download-buttons")) return
const svg = target.closest<SVGSVGElement>(".tikz svg, .mermaid svg")
if (svg) {
event.preventDefault()
open(svgToDataUrl(svg), "diagram")
return
}
const img = target.closest("img")
if (img && img.closest(".note-content, .note-display")) {
event.preventDefault()
open(img.currentSrc || img.src, img.alt)
}
}
useEventListener(document, "click", onClick)
watch(isOpen, (value) => {
const el = dialogRef.value
if (!el) return
if (value && !el.open) el.showModal()
else if (!value && el.open) el.close()
})
</script>
<template>
<dialog
ref="dialogRef"
class="modal image-lightbox not-prose"
@close="close()"
@click="close()"
>
<img
v-if="src"
:src="src"
:alt="alt"
class="max-h-[96vh] max-w-[96vw] rounded-lg bg-white p-3 cursor-zoom-out"
/>
</dialog>
</template>

View File

@@ -0,0 +1,19 @@
import { ref } from "vue"
const isOpen = ref(false)
const src = ref("")
const alt = ref("")
export const useImageLightbox = () => {
const open = (imageSrc: string, imageAlt = "") => {
src.value = imageSrc
alt.value = imageAlt
isOpen.value = true
}
const close = () => {
isOpen.value = false
}
return { isOpen, src, alt, open, close }
}

View File

@@ -344,3 +344,72 @@ iframe {
padding-bottom: 0.2rem; padding-bottom: 0.2rem;
padding-inline-start: 0.2rem; padding-inline-start: 0.2rem;
} }
/* Image lightbox — desktop click-to-fullscreen */
/* Affordance: zoomable content images and diagrams (desktop only). */
@media (min-width: 769px) {
.note-content img,
.note-display img,
.tikz svg,
.mermaid svg {
cursor: zoom-in;
}
}
/* DaisyUI hides the native ::backdrop and uses the .modal element's own
background-color for the dim, so the blurred overlay lives on the element. */
.image-lightbox {
background-color: rgb(0 0 0 / 0);
-webkit-backdrop-filter: blur(0);
backdrop-filter: blur(0);
transition:
visibility 0.3s allow-discrete,
background-color 0.3s ease-out,
-webkit-backdrop-filter 0.3s ease-out,
backdrop-filter 0.3s ease-out;
}
.image-lightbox[open] {
background-color: rgb(0 0 0 / 0.7);
-webkit-backdrop-filter: blur(8px);
backdrop-filter: blur(8px);
}
@starting-style {
.image-lightbox[open] {
background-color: rgb(0 0 0 / 0);
-webkit-backdrop-filter: blur(0);
backdrop-filter: blur(0);
}
}
.image-lightbox img {
transition:
opacity 0.3s ease-out,
scale 0.3s ease-out;
}
.image-lightbox[open] img {
opacity: 1;
scale: 1;
}
.image-lightbox:not([open]) img {
opacity: 0;
scale: 0.95;
}
@starting-style {
.image-lightbox[open] img {
opacity: 0;
scale: 0.95;
}
}
@media (prefers-reduced-motion: reduce) {
.image-lightbox,
.image-lightbox img {
transition: none;
}
}

View File

@@ -71,17 +71,22 @@ const buildExportableSvgString = (svg: SVGSVGElement): string => {
clone.setAttribute("xmlns:xlink", "http://www.w3.org/1999/xlink") clone.setAttribute("xmlns:xlink", "http://www.w3.org/1999/xlink")
const { width, height } = getSvgPixelSize(svg) const { width, height } = getSvgPixelSize(svg)
const viewBox = svg.viewBox?.baseVal
const originX = viewBox && viewBox.width > 0 ? viewBox.x : 0
const originY = viewBox && viewBox.height > 0 ? viewBox.y : 0
if (!clone.getAttribute("viewBox")) { if (!clone.getAttribute("viewBox")) {
clone.setAttribute("viewBox", `0 0 ${width} ${height}`) clone.setAttribute("viewBox", `${originX} ${originY} ${width} ${height}`)
} }
clone.setAttribute("width", String(width)) clone.setAttribute("width", String(width))
clone.setAttribute("height", String(height)) clone.setAttribute("height", String(height))
// Cover the full viewBox (its origin can be negative — e.g. a diagram whose
// content extends above y=0), otherwise that content falls outside the fill.
const bg = document.createElementNS(SVG_NS, "rect") const bg = document.createElementNS(SVG_NS, "rect")
bg.setAttribute("x", "0") bg.setAttribute("x", String(originX))
bg.setAttribute("y", "0") bg.setAttribute("y", String(originY))
bg.setAttribute("width", "100%") bg.setAttribute("width", String(width))
bg.setAttribute("height", "100%") bg.setAttribute("height", String(height))
bg.setAttribute("fill", "#ffffff") bg.setAttribute("fill", "#ffffff")
clone.insertBefore(bg, clone.firstChild) clone.insertBefore(bg, clone.firstChild)
@@ -96,6 +101,9 @@ const buildExportableSvgString = (svg: SVGSVGElement): string => {
return new XMLSerializer().serializeToString(clone) return new XMLSerializer().serializeToString(clone)
} }
export const svgToDataUrl = (svg: SVGSVGElement): string =>
`data:image/svg+xml;charset=utf-8,${encodeURIComponent(buildExportableSvgString(svg))}`
const triggerBlobDownload = (blob: Blob, filename: string): void => { const triggerBlobDownload = (blob: Blob, filename: string): void => {
const url = URL.createObjectURL(blob) const url = URL.createObjectURL(blob)
const a = document.createElement("a") const a = document.createElement("a")

View File

@@ -24,6 +24,7 @@ type Prop = {
} }
const TODO_PATH = "todo.txt" const TODO_PATH = "todo.txt"
const DONE_PATH = "done.txt"
const FluxNote = defineAsyncComponent(() => import("@/components/FluxNote.vue")) const FluxNote = defineAsyncComponent(() => import("@/components/FluxNote.vue"))
@@ -59,6 +60,34 @@ watch(
{ immediate: true } { immediate: true }
) )
const doneFile = computed(() => store.files.find((f) => f.path === DONE_PATH))
const doneSha = computed(() => doneFile.value?.sha ?? "")
const donePath = computed(() => doneFile.value?.path ?? DONE_PATH)
// Reuse useTodoTxtCommit as a read-only loader. We never call its `mutate`,
// so nothing is ever written to done.txt from this view — the file is
// produced by an external tool.
const { items: doneItems, syncContent: syncDoneContent } = useTodoTxtCommit({
user: props.user,
repo: props.repo,
path: donePath,
initialContent: "",
initialSha: doneSha,
debounceMs: 1000
})
watch(
doneSha,
async (newSha) => {
if (!newSha) return
const base64 = await queryFileContent(props.user, props.repo, newSha)
if (base64) {
syncDoneContent(decodeBase64ToUTF8(base64), newSha)
}
},
{ immediate: true }
)
// Rank `A` = 65 .. `Z` = 90; absent priority is Infinity so it sorts last // Rank `A` = 65 .. `Z` = 90; absent priority is Infinity so it sorts last
// regardless of locale collation quirks. // regardless of locale collation quirks.
const priorityRank = (p?: string): number => const priorityRank = (p?: string): number =>
@@ -76,6 +105,10 @@ const taskEntries = computed(() => {
return out return out
}) })
const archivedTasks = computed(() =>
doneItems.value.filter((line): line is Task => !isBlank(line))
)
const allProjects = computed(() => { const allProjects = computed(() => {
const set = new Set<string>() const set = new Set<string>()
items.value.forEach((line) => { items.value.forEach((line) => {
@@ -440,6 +473,15 @@ const createTodoFile = async () => {
> >
Your todo list is empty. Add a task above. Your todo list is empty. Add a task above.
</p> </p>
<details v-if="doneFile" class="todo-archive">
<summary>Archived ({{ archivedTasks.length }})</summary>
<ul class="todo-archive-list">
<li v-for="(task, i) in archivedTasks" :key="i">
<todo-txt-item :task="task" :can-edit="false" />
</li>
</ul>
</details>
</template> </template>
</flux-note> </flux-note>
</div> </div>
@@ -540,5 +582,37 @@ const createTodoFile = async () => {
.todo-filter-chip { .todo-filter-chip {
cursor: pointer; cursor: pointer;
} }
.todo-archive {
margin-top: 1rem;
> summary {
cursor: pointer;
opacity: 0.7;
font-size: 0.8rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
list-style: none;
}
> summary::-webkit-details-marker {
display: none;
}
}
.todo-archive-list {
list-style: none;
padding-left: 0;
margin: 0.5rem 0 0;
display: flex;
flex-direction: column;
li {
list-style: none;
opacity: 0.7;
padding-bottom: 0.25rem;
}
}
} }
</style> </style>