diff --git a/src/App.vue b/src/App.vue
index 9d7363a..9b54610 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -1,4 +1,5 @@
+
+
+
+
diff --git a/src/hooks/useImageLightbox.hook.ts b/src/hooks/useImageLightbox.hook.ts
new file mode 100644
index 0000000..edf2ed8
--- /dev/null
+++ b/src/hooks/useImageLightbox.hook.ts
@@ -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 }
+}
diff --git a/src/styles/app.css b/src/styles/app.css
index 657c9ab..058bd4c 100644
--- a/src/styles/app.css
+++ b/src/styles/app.css
@@ -344,3 +344,72 @@ iframe {
padding-bottom: 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;
+ }
+}
diff --git a/src/utils/svgDownload.ts b/src/utils/svgDownload.ts
index 76099a7..fe48ab0 100644
--- a/src/utils/svgDownload.ts
+++ b/src/utils/svgDownload.ts
@@ -71,17 +71,22 @@ const buildExportableSvgString = (svg: SVGSVGElement): string => {
clone.setAttribute("xmlns:xlink", "http://www.w3.org/1999/xlink")
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")) {
- clone.setAttribute("viewBox", `0 0 ${width} ${height}`)
+ clone.setAttribute("viewBox", `${originX} ${originY} ${width} ${height}`)
}
clone.setAttribute("width", String(width))
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")
- bg.setAttribute("x", "0")
- bg.setAttribute("y", "0")
- bg.setAttribute("width", "100%")
- bg.setAttribute("height", "100%")
+ bg.setAttribute("x", String(originX))
+ bg.setAttribute("y", String(originY))
+ bg.setAttribute("width", String(width))
+ bg.setAttribute("height", String(height))
bg.setAttribute("fill", "#ffffff")
clone.insertBefore(bg, clone.firstChild)
@@ -96,6 +101,9 @@ const buildExportableSvgString = (svg: SVGSVGElement): string => {
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 url = URL.createObjectURL(blob)
const a = document.createElement("a")
diff --git a/src/views/TodoNotes.vue b/src/views/TodoNotes.vue
index a10c5c4..ebb8126 100644
--- a/src/views/TodoNotes.vue
+++ b/src/views/TodoNotes.vue
@@ -24,6 +24,7 @@ type Prop = {
}
const TODO_PATH = "todo.txt"
+const DONE_PATH = "done.txt"
const FluxNote = defineAsyncComponent(() => import("@/components/FluxNote.vue"))
@@ -59,6 +60,34 @@ watch(
{ 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
// regardless of locale collation quirks.
const priorityRank = (p?: string): number =>
@@ -76,6 +105,10 @@ const taskEntries = computed(() => {
return out
})
+const archivedTasks = computed(() =>
+ doneItems.value.filter((line): line is Task => !isBlank(line))
+)
+
const allProjects = computed(() => {
const set = new Set