Merge branch 'main' of ssh://git.apoena.dev:22222/julien/remanso
All checks were successful
CI / verify (push) Successful in 2m3s
All checks were successful
CI / verify (push) Successful in 2m3s
This commit is contained in:
@@ -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>
|
||||||
|
|
||||||
|
|||||||
54
src/components/ImageLightbox.vue
Normal file
54
src/components/ImageLightbox.vue
Normal 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>
|
||||||
19
src/hooks/useImageLightbox.hook.ts
Normal file
19
src/hooks/useImageLightbox.hook.ts
Normal 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 }
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user