Merge branch 'main' of github.com:remanso-space/remanso

This commit is contained in:
Julien Calixte
2026-02-19 20:42:21 +01:00
40 changed files with 4264 additions and 488 deletions

View File

@@ -1 +1 @@
22.12.0 24

View File

@@ -12,7 +12,7 @@ export const commitTheme = (mode: string, newTheme: string) => {
console.log(`Commit créé avec succès: "${commitMessage}"`) console.log(`Commit créé avec succès: "${commitMessage}"`)
execSync(`git push"`, { stdio: "inherit" }) execSync(`git push`, { stdio: "inherit" })
console.log(`Push sur origin`) console.log(`Push sur origin`)
} catch (error) { } catch (error) {

View File

@@ -1,5 +1,5 @@
<!doctype html> <!doctype html>
<html lang="en" data-theme="retro"> <html lang="en" data-theme="garden">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" /> <meta http-equiv="X-UA-Compatible" content="IE=edge" />

View File

@@ -15,6 +15,8 @@
"generate-pwa-assets": "pwa-assets-generator" "generate-pwa-assets": "pwa-assets-generator"
}, },
"dependencies": { "dependencies": {
"@better-fetch/fetch": "^1.1.21",
"@better-fetch/logger": "^1.1.21",
"@intlify/unplugin-vue-i18n": "^6.0.8", "@intlify/unplugin-vue-i18n": "^6.0.8",
"@octokit/core": "^7.0.6", "@octokit/core": "^7.0.6",
"@octokit/rest": "^22.0.1", "@octokit/rest": "^22.0.1",
@@ -22,8 +24,10 @@
"@tanstack/vue-query": "^5.92.9", "@tanstack/vue-query": "^5.92.9",
"@toycode/markdown-it-class": "^1.2.4", "@toycode/markdown-it-class": "^1.2.4",
"@vscode/markdown-it-katex": "^1.1.2", "@vscode/markdown-it-katex": "^1.1.2",
"@vueuse/components": "^14.2.1",
"@vueuse/core": "^13.6.0", "@vueuse/core": "^13.6.0",
"@vueuse/router": "^13.6.0", "@vueuse/router": "^13.6.0",
"arktype": "^2.1.29",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"events": "^3.3.0", "events": "^3.3.0",
"font-color-contrast": "^11.1.0", "font-color-contrast": "^11.1.0",
@@ -63,21 +67,22 @@
"@typescript-eslint/parser": "^8.46.2", "@typescript-eslint/parser": "^8.46.2",
"@vite-pwa/assets-generator": "^1.0.2", "@vite-pwa/assets-generator": "^1.0.2",
"@vitejs/plugin-vue": "^5.2.4", "@vitejs/plugin-vue": "^5.2.4",
"@vue/compiler-sfc": "^3.5.22", "@vue/compiler-sfc": "^3.5.28",
"@vue/eslint-config-prettier": "^10.2.0", "@vue/eslint-config-prettier": "^10.2.0",
"@vue/eslint-config-typescript": "^14.6.0", "@vue/eslint-config-typescript": "^14.6.0",
"autoprefixer": "^10.4.21", "autoprefixer": "^10.4.24",
"daisyui": "^5.3.11", "daisyui": "^5.5.18",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"eslint": "^8.57.1", "eslint": "^8.57.1",
"eslint-config-prettier": "^9.1.0", "eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier-vue": "^5.0.0", "eslint-plugin-prettier-vue": "^5.0.0",
"eslint-plugin-simple-import-sort": "^10.0.0", "eslint-plugin-simple-import-sort": "^12.1.1",
"eslint-plugin-unused-imports": "^3.2.0", "eslint-plugin-unused-imports": "^4.4.1",
"eslint-plugin-vue": "^9.31.0", "eslint-plugin-vue": "^10.8.0",
"esno": "^4.8.0", "esno": "^4.8.0",
"husky": "^9.1.7", "husky": "^9.1.7",
"prettier": "^3.6.2", "prettier": "^3.8.1",
"prettier-vue": "^1.1.2",
"sass": "^1.93.3", "sass": "^1.93.3",
"tailwindcss": "^4.1.16", "tailwindcss": "^4.1.16",
"typescript": "~5.9.3", "typescript": "~5.9.3",

3563
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
import { createEventBus } from "retrobus" import { createEventBus } from "retrobus"
interface EventBusParams { interface EventBusParams {
path: string atUri: string
currentNoteRkey?: string currentNoteRkey?: string
} }

View File

@@ -1,8 +1,26 @@
<script setup lang="ts"> <script setup lang="ts">
import { useRouter } from "vue-router" import { useRouter, type RouteLocationRaw } from "vue-router"
const props = withDefaults(
defineProps<{ fallback?: RouteLocationRaw; preferFallback?: boolean }>(),
{ preferFallback: true },
)
const router = useRouter() const router = useRouter()
const goBack = () => router.back() const goBack = () => {
if (props.preferFallback && props.fallback) {
router.push(props.fallback)
return
}
if (window.history.state?.back) {
router.back()
} else if (props.fallback) {
router.push(props.fallback)
} else {
router.back()
}
}
</script> </script>
<template> <template>

View File

@@ -13,6 +13,7 @@ import StackedNote from "@/components/StackedNote.vue"
import { useLinks } from "@/hooks/useLinks.hook" import { useLinks } from "@/hooks/useLinks.hook"
import { markdownBuilder } from "@/hooks/useMarkdown.hook" import { markdownBuilder } from "@/hooks/useMarkdown.hook"
import { useNoteView } from "@/hooks/useNoteView.hook" import { useNoteView } from "@/hooks/useNoteView.hook"
import { useResizeContainer } from "@/hooks/useResizeContainer.hook"
import { useRouteQueryStackedNotes } from "@/hooks/useRouteQueryStackedNotes.hook" import { useRouteQueryStackedNotes } from "@/hooks/useRouteQueryStackedNotes.hook"
import { useVisitRepo } from "@/modules/history/hooks/useVisitRepo.hook" import { useVisitRepo } from "@/modules/history/hooks/useVisitRepo.hook"
import CacheAllNotes from "@/modules/note/components/CacheAllNote.vue" import CacheAllNotes from "@/modules/note/components/CacheAllNote.vue"
@@ -52,7 +53,8 @@ const { toHTML } = markdownBuilder(repo)
const { listenToClick } = useLinks("note-display") const { listenToClick } = useLinks("note-display")
const { stackedNotes, scrollToFocusedNote } = useRouteQueryStackedNotes() const { stackedNotes, scrollToFocusedNote } = useRouteQueryStackedNotes()
const { titles } = useNoteView("note-container") const { titles } = useNoteView()
useResizeContainer("note-container", stackedNotes)
const renderedContent = computed(() => const renderedContent = computed(() =>
props.content !== null props.content !== null

View File

@@ -1,8 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from "vue" import { computed } from "vue"
import { useUserRepoStore } from "../modules/repo/store/userRepo.store"
import ThemeSwap from "@/components/ThemeSwap.vue" import ThemeSwap from "@/components/ThemeSwap.vue"
import { useUserRepoStore } from "../modules/repo/store/userRepo.store"
const store = useUserRepoStore() const store = useUserRepoStore()
const fontFamilies = computed(() => store.userSettings?.fontFamilies ?? []) const fontFamilies = computed(() => store.userSettings?.fontFamilies ?? [])

View File

@@ -0,0 +1,192 @@
<script lang="ts" setup>
import { computed, nextTick, watch } from "vue"
import { useATProtoLinks } from "@/hooks/useATProtoLinks.hook"
import { useNoteOverlay } from "@/hooks/useNoteOverlay.hook"
import { useRouteQueryStackedNotes } from "@/hooks/useRouteQueryStackedNotes.hook"
import { markdownBuilder } from "@/hooks/useMarkdown.hook"
import { computedAsync } from "@vueuse/core"
import { getUrl } from "@/modules/atproto/getUrl"
import { withATProtoImages } from "@/modules/atproto/withATProtoImages"
import { getAuthor } from "@/modules/atproto/getAuthor"
import { PublicNoteRecord } from "@/modules/atproto/publicNote.types"
import { parseAtUri } from "@/modules/atproto/parseAtUri"
const props = defineProps<{
atUri: string
index: number
title?: string
}>()
const atUri = computed(() => props.atUri)
const atUriProps = computed(() => parseAtUri(atUri.value))
const did = computed(() => atUriProps.value.did)
const rkey = computed(() => atUriProps.value.rkey)
const index = computed(() => props.index)
const author = computedAsync(async () => getAuthor(did.value))
const url = computedAsync(async () =>
getUrl({ did: did.value, rkey: rkey.value }),
)
const className = computed(() => `stacked-note-${props.index}`)
const titleClassName = computed(() => `title-${className.value}`)
const { scrollToFocusedNote } = useRouteQueryStackedNotes()
const { listenToClick } = useATProtoLinks(className.value, atUri)
const { displayNoteOverlay } = useNoteOverlay(className.value, index)
const noteRecord = computedAsync(async () =>
url.value
? ((await fetch(url.value).then()).json() as Promise<PublicNoteRecord>)
: null,
)
const { toHTML } = markdownBuilder()
const title = computed(() => noteRecord.value?.value.title)
const content = computed(() =>
noteRecord.value?.value.content && author.value
? toHTML(
withATProtoImages(noteRecord.value.value.content, {
pds: author.value.pds,
did: did.value,
}),
)
: "",
)
watch(
content,
async () => {
await nextTick()
listenToClick()
},
{ immediate: true },
)
</script>
<template>
<div
class="stacked-note"
:class="{
[className]: true,
overlay: displayNoteOverlay,
[`note-${rkey}`]: true,
}"
>
<a
class="title-stacked-note-link"
@click.prevent="scrollToFocusedNote(rkey)"
>
<div
class="title-stacked-note breadcrumbs text-sm"
:class="titleClassName"
>
{{ title }}
</div>
</a>
<section class="text-content">
<div class="note-content" v-html="content"></div>
</section>
</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 2rem;
}
}
.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;
div {
height: 100%;
}
}
.action {
float: right;
margin: 0.2rem;
img {
vertical-align: bottom;
}
}
@media screen and (max-width: 768px) {
.stacked-note {
padding: 0 0.75rem 1rem;
section {
padding: 1rem 0 2rem;
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;
}
.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>

View File

@@ -1,3 +1,15 @@
<script lang="ts" setup>
import RepoList from "@/components/RepoList.vue"
import SignInGithub from "@/components/SignInGithub.vue"
import ThemeSwap from "@/components/ThemeSwap.vue"
import { useForm } from "@/hooks/useForm.hook"
import { useGitHubLogin } from "@/hooks/useGitHubLogin.hook"
import LastVisited from "@/modules/history/components/LastVisited.vue"
const { isLogged } = useGitHubLogin()
const { userInput, repoInput, submit } = useForm()
</script>
<template> <template>
<div class="welcome-world"> <div class="welcome-world">
<h1 class="title is-1"> <h1 class="title is-1">
@@ -70,18 +82,6 @@
</div> </div>
</template> </template>
<script lang="ts" setup>
import RepoList from "@/components/RepoList.vue"
import SignInGithub from "@/components/SignInGithub.vue"
import ThemeSwap from "@/components/ThemeSwap.vue"
import { useForm } from "@/hooks/useForm.hook"
import { useGitHubLogin } from "@/hooks/useGitHubLogin.hook"
import LastVisited from "@/modules/history/components/LastVisited.vue"
const { isLogged } = useGitHubLogin()
const { userInput, repoInput, submit } = useForm()
</script>
<style lang="scss" scoped> <style lang="scss" scoped>
h1 { h1 {
img { img {

View File

@@ -0,0 +1,5 @@
export const BOOKMARK_WIDTH_REM = 2
export const getBookmarkWidthPx = () =>
BOOKMARK_WIDTH_REM *
parseFloat(getComputedStyle(document.documentElement).fontSize)

View File

@@ -1 +1,12 @@
export const NOTE_WIDTH = 620 let cached: number | undefined
export const getNoteWidth = () => {
if (cached === undefined) {
cached = parseInt(
getComputedStyle(document.documentElement).getPropertyValue(
"--note-width",
),
)
}
return cached
}

View File

@@ -1,36 +1,36 @@
import { ComputedRef, onUnmounted, Ref, toValue } from "vue" import { ComputedRef, onUnmounted, Ref, toValue } from "vue"
import { isExternalLink } from "@/utils/link" import { isExternalLink } from "@/utils/link"
import { publicNoteEventBus } from "@/bus/publicNoteEventBus" import { useRouteQueryStackedNotes } from "@/hooks/useRouteQueryStackedNotes.hook"
import { parseAtUri } from "@/modules/atproto/parseAtUri"
export const useATProtoLinks = ( export const useATProtoLinks = (
className: ComputedRef<string> | string, className: ComputedRef<string> | string,
rkey?: Ref<string> | string, currentAtUri?: Ref<string> | string,
) => { ) => {
const linkNote: EventListener = (event) => { const { addStackedNote } = useRouteQueryStackedNotes()
const linkNote = (event: Event) => {
const target = event.target as HTMLElement const target = event.target as HTMLElement
const href = target.getAttribute("href") const atUri = target.getAttribute("href")
if (!href) { if (!atUri) {
return return
} }
if (href.startsWith("#")) { if (atUri.startsWith("#")) {
return return
} }
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
if (isExternalLink(href)) { if (isExternalLink(atUri)) {
window.open(href, "_blank") window.open(atUri, "_blank")
return return
} }
const { rkey } = parseAtUri(atUri)
publicNoteEventBus.emit({ addStackedNote(toValue(currentAtUri) ?? "", atUri, rkey)
path: href,
currentNoteRkey: toValue(rkey),
})
} }
const LINK_SELECTOR = `.${toValue(className)} a` const LINK_SELECTOR = `.${toValue(className)} a`

View File

@@ -1,5 +1,5 @@
import markdownItLatex from "@vscode/markdown-it-katex" import markdownItKatex from "@vscode/markdown-it-katex"
import MarkdownIt, { Options, Renderer, Token } from "markdown-it" import MarkdownIt, { Options } from "markdown-it"
import blockEmbedPlugin from "markdown-it-block-embed" import blockEmbedPlugin from "markdown-it-block-embed"
import markdownItCheckbox from "markdown-it-checkbox" import markdownItCheckbox from "markdown-it-checkbox"
import MarkdownItGitHubAlerts from "markdown-it-github-alerts" import MarkdownItGitHubAlerts from "markdown-it-github-alerts"
@@ -9,8 +9,9 @@ import { Ref, toValue } from "vue"
import { decodeBase64ToUTF8 } from "@/utils/decodeBase64ToUTF8" import { decodeBase64ToUTF8 } from "@/utils/decodeBase64ToUTF8"
import { html5Media } from "@/utils/markdown/markdown-html5-media" import { html5Media } from "@/utils/markdown/markdown-html5-media"
import { twitterPlugin } from "@/utils/markdown/markdown-it-twitter"
import mermaid from "mermaid" import mermaid from "mermaid"
import type Token from "markdown-it/lib/token.mjs"
import Renderer, { type RenderRuleRecord } from "markdown-it/lib/renderer.mjs"
const markdownItMermaidExtractor = (md: MarkdownIt) => { const markdownItMermaidExtractor = (md: MarkdownIt) => {
const defaultFence = const defaultFence =
@@ -55,9 +56,8 @@ const md = new MarkdownIt({
height: 300, height: 300,
}, },
}) })
.use(twitterPlugin)
.use(markdownItCheckbox) .use(markdownItCheckbox)
.use(markdownItLatex) .use(markdownItKatex)
.use(markdownItIframe, { .use(markdownItIframe, {
width: "100%", width: "100%",
}) })
@@ -108,7 +108,7 @@ export const runMermaid = (querySelector: string) => {
}) })
} }
const rules: Renderer.RenderRuleRecord = { const rules: RenderRuleRecord = {
table_open: () => table_open: () =>
'<div class="overflow-x-auto"><table class="table table-zebra">', '<div class="overflow-x-auto"><table class="table table-zebra">',
table_close: () => "</table></div>", table_close: () => "</table></div>",
@@ -116,17 +116,24 @@ const rules: Renderer.RenderRuleRecord = {
md.renderer.rules = { ...md.renderer.rules, ...rules } md.renderer.rules = { ...md.renderer.rules, ...rules }
const stripFrontmatter = (content: string): string => {
const match = content.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/)
return match ? content.slice(match[0].length) : content
}
export const markdownBuilder = (defaultPrefix?: Ref<string> | string) => { export const markdownBuilder = (defaultPrefix?: Ref<string> | string) => {
const getRawContent = (content: string) => decodeBase64ToUTF8(content) const getRawContent = (content: string) => decodeBase64ToUTF8(content)
const renderFromUTF8 = (content: string, prefix?: string) => const renderFromUTF8 = (content: string, prefix?: string) => {
content return content
? md.render(content, { ? md.render(stripFrontmatter(content), {
docId: defaultPrefix ? toValue(defaultPrefix) : (prefix ?? ""), docId: defaultPrefix ? toValue(defaultPrefix) : (prefix ?? ""),
}) })
: "" : ""
}
return { return {
toHTML: (content: string) => (content ? md.render(content) : ""), toHTML: (content: string) =>
content ? md.render(stripFrontmatter(content)) : "",
render: (content: string, prefix?: string) => render: (content: string, prefix?: string) =>
renderFromUTF8(decodeBase64ToUTF8(content), prefix), renderFromUTF8(decodeBase64ToUTF8(content), prefix),
renderFromUTF8, renderFromUTF8,

View File

@@ -1,12 +1,10 @@
import { computed, onMounted, Ref, ref, toValue } from "vue" import { computed, onMounted, Ref, ref, toValue } from "vue"
import { NOTE_WIDTH } from "@/constants/note-width" import { BOOKMARK_WIDTH_REM, getBookmarkWidthPx } from "@/constants/bookmark-width"
import { getNoteWidth } from "@/constants/note-width"
import { useOverlay } from "@/hooks/useOverlay.hook" import { useOverlay } from "@/hooks/useOverlay.hook"
import { useRouteQueryStackedNotes } from "@/hooks/useRouteQueryStackedNotes.hook" import { useRouteQueryStackedNotes } from "@/hooks/useRouteQueryStackedNotes.hook"
const BOOKMARK_WIDTH = 2
const OFFSET = 32 // stacked-note padding
export const useNoteOverlay = ( export const useNoteOverlay = (
className: string, className: string,
index: Ref<number> | number, index: Ref<number> | number,
@@ -20,7 +18,7 @@ export const useNoteOverlay = (
if (isMobile.value) { if (isMobile.value) {
return y.value > valueIndex * noteHeight.value return y.value > valueIndex * noteHeight.value
} else { } else {
return x.value > valueIndex * NOTE_WIDTH - valueIndex * OFFSET return x.value > valueIndex * getNoteWidth() - valueIndex * getBookmarkWidthPx()
} }
}) })
@@ -28,25 +26,26 @@ export const useNoteOverlay = (
const { stackedNotes } = useRouteQueryStackedNotes() const { stackedNotes } = useRouteQueryStackedNotes()
const noteElement = document.querySelector( const noteElement = document.querySelector(
`.${className}`, `.${className}`,
) as HTMLElement | null ) satisfies HTMLElement | null
if (!noteElement) { if (!noteElement) {
return return
} }
noteHeight.value = noteElement.clientHeight noteHeight.value = noteElement.clientHeight
if (isMobile.value) { if (isMobile.value) {
noteElement.style.top = `0` noteElement.style.top = `0`
} else { } else {
noteElement.style.left = `${(toValue(index) + 1) * BOOKMARK_WIDTH}rem` noteElement.style.left = `${(toValue(index) + 1) * BOOKMARK_WIDTH_REM}rem`
const stackedNoteContainers = document.querySelectorAll( const stackedNoteContainers = document.querySelectorAll(
".stacked-note", ".stacked-note",
) as NodeListOf<HTMLElement> ) satisfies NodeListOf<HTMLElement>
stackedNoteContainers.forEach((stackedNote, ind) => { stackedNoteContainers.forEach((stackedNote, ind) => {
stackedNote.style.right = `calc(-${NOTE_WIDTH}px + ${ stackedNote.style.right = `calc(-${getNoteWidth()}px + ${
(stackedNotes.value.length - ind) * BOOKMARK_WIDTH (stackedNotes.value.length - ind) * BOOKMARK_WIDTH_REM
}rem)` }rem)`
}) })
} }

View File

@@ -1,17 +1,14 @@
import { computed, onMounted, onUnmounted, watch } from "vue" import { computed, onUnmounted } from "vue"
import { noteEventBus } from "@/bus/noteEventBus" import { noteEventBus } from "@/bus/noteEventBus"
import { NOTE_WIDTH } from "@/constants/note-width"
import { useOverlay } from "@/hooks/useOverlay.hook"
import { useRouteQueryStackedNotes } from "@/hooks/useRouteQueryStackedNotes.hook" import { useRouteQueryStackedNotes } from "@/hooks/useRouteQueryStackedNotes.hook"
import { resolvePath } from "@/modules/repo/services/resolvePath" import { resolvePath } from "@/modules/repo/services/resolvePath"
import { useUserRepoStore } from "@/modules/repo/store/userRepo.store" import { useUserRepoStore } from "@/modules/repo/store/userRepo.store"
import { pathToNotePathTitle } from "@/utils/noteTitle" import { pathToNotePathTitle } from "@/utils/noteTitle"
import { errorMessage } from "@/utils/notif" import { errorMessage } from "@/utils/notif"
export const useNoteView = (containerClass: string) => { export const useNoteView = () => {
const store = useUserRepoStore() const store = useUserRepoStore()
const { isMobile } = useOverlay(false)
const { stackedNotes, addStackedNote } = useRouteQueryStackedNotes() const { stackedNotes, addStackedNote } = useRouteQueryStackedNotes()
const titles = computed(() => const titles = computed(() =>
@@ -45,36 +42,10 @@ export const useNoteView = (containerClass: string) => {
}, },
) )
const resizeContainer = () => {
const container = document.querySelector(
`.${containerClass}`,
) as HTMLElement | null
if (!container) {
return
}
if (isMobile.value) {
container.style.height = `${(stackedNotes.value.length + 1) * 100}vh`
} else {
container.style.width = `${
NOTE_WIDTH * (stackedNotes.value.length + 1)
}px`
}
}
onMounted(() => {
resizeContainer()
})
onUnmounted(() => { onUnmounted(() => {
unsubscribeLink() unsubscribeLink()
}) })
watch(stackedNotes, resizeContainer, {
immediate: true,
})
return { return {
titles, titles,
} }

View File

@@ -1,42 +1,41 @@
import { useEventListener, useWindowSize } from '@vueuse/core' import { useEventListener, useWindowSize } from "@vueuse/core"
import { computed, ref } from 'vue' import { computed, ref } from "vue"
import { MOBILE_BREAKPOINT } from '@/constants/mobile' import { MOBILE_BREAKPOINT } from "@/constants/mobile"
export const useOverlay = (listen = true) => { export const useOverlay = (listen = true) => {
const body = document.body
const x = ref(0) const x = ref(0)
const y = ref(0) const y = ref(0)
const { width } = useWindowSize() const { width } = useWindowSize()
const isMobile = computed(() => width.value <= MOBILE_BREAKPOINT) const isMobile = computed(() => width.value <= MOBILE_BREAKPOINT)
if (listen) { if (listen) {
useEventListener( // In Firefox/Chrome, body is the horizontal scroll container (body has
body, // computed overflow-x: auto from overflow-y: hidden). In Safari, the
'scroll', // viewport (documentElement) is used instead. Listen on both.
(event) => { const updateScroll = () => {
const target = event.target as HTMLElement x.value = document.body.scrollLeft || window.scrollX
x.value = target.scrollLeft y.value = document.body.scrollTop || window.scrollY
y.value = target.scrollTop
},
{
passive: true,
capture: false
} }
) useEventListener(window, "scroll", updateScroll, {
passive: true,
capture: false,
})
useEventListener(document.body, "scroll", updateScroll, {
passive: true,
capture: false,
})
} }
const scrollToNote = (to: number) => { const scrollToNote = (to: number) => {
const go = () => { const go = () => {
const scrollOptions = isMobile.value if (isMobile.value) {
? { document.body.scrollTop = to
top: to document.documentElement.scrollTop = to
} else {
document.body.scrollLeft = to
document.documentElement.scrollLeft = to
} }
: {
left: to
}
body.scroll(scrollOptions)
} }
setTimeout(() => { setTimeout(() => {
@@ -48,6 +47,6 @@ export const useOverlay = (listen = true) => {
x, x,
y, y,
isMobile, isMobile,
scrollToNote scrollToNote,
} }
} }

View File

@@ -0,0 +1,50 @@
import { Author, getAuthors } from "@/modules/atproto/getAuthor"
import { PublicNoteListItem } from "@/modules/note/models/Note"
import { computedAsync } from "@vueuse/core"
import { computed, ref, Ref } from "vue"
export function usePublicNoteList(did?: Ref<string | undefined>) {
const isLoading = ref(false)
const notes = ref<PublicNoteListItem[]>([])
const cursor = ref<string | null | undefined>(null)
const canLoadMore = computed(() => cursor.value !== undefined)
const onLoadMore = async () => {
isLoading.value = true
const path = did?.value ? `/${did.value}/notes` : "/notes"
const noteAPI = new URL(path, "https://api.litenote.li212.fr")
if (cursor.value) {
noteAPI.searchParams.set("cursor", cursor.value)
}
const response = await fetch(noteAPI)
const data: { notes: PublicNoteListItem[]; cursor: string | undefined } =
await response.json()
notes.value.push(...data.notes)
cursor.value = data.cursor
isLoading.value = false
}
const authors = computedAsync<Map<string, Author>>(async () => {
if (notes.value.length === 0) {
return new Map()
}
return getAuthors(new Set(notes.value.map((n) => n.did)))
}, new Map())
const getAuthor = (did: string) =>
authors.value.has(did) ? authors.value.get(did)?.handle : ""
return {
notes,
isLoading,
canLoadMore,
onLoadMore,
authors,
getAuthor,
}
}

View File

@@ -0,0 +1,37 @@
import { onMounted, watch, type Ref } from "vue"
import { getNoteWidth } from "@/constants/note-width"
import { useOverlay } from "@/hooks/useOverlay.hook"
export const useResizeContainer = (
containerClass: string,
stackedNotes: Readonly<Ref<readonly string[]>>,
) => {
const { isMobile } = useOverlay(false)
const resizeContainer = () => {
const container = document.querySelector(
`.${containerClass}`,
) as HTMLElement | null
if (!container) {
return
}
if (isMobile.value) {
container.style.height = `${(stackedNotes.value.length + 1) * 100}vh`
} else {
container.style.width = `${
getNoteWidth() * (stackedNotes.value.length + 1)
}px`
}
}
onMounted(() => {
resizeContainer()
})
watch(stackedNotes, resizeContainer, {
immediate: true,
})
}

View File

@@ -2,7 +2,8 @@ import { useWindowSize } from "@vueuse/core"
import { useRouteQuery } from "@vueuse/router" import { useRouteQuery } from "@vueuse/router"
import { nextTick, readonly } from "vue" import { nextTick, readonly } from "vue"
import { NOTE_WIDTH } from "@/constants/note-width" import { getBookmarkWidthPx } from "@/constants/bookmark-width"
import { getNoteWidth } from "@/constants/note-width"
import { useOverlay } from "@/hooks/useOverlay.hook" import { useOverlay } from "@/hooks/useOverlay.hook"
export const useRouteQueryStackedNotes = () => { export const useRouteQueryStackedNotes = () => {
@@ -20,24 +21,26 @@ export const useRouteQueryStackedNotes = () => {
const { scrollToNote, isMobile } = useOverlay(false) const { scrollToNote, isMobile } = useOverlay(false)
const scrollToFocusedNote = ( const scrollToFocusedNote = (
sha: string | null = null, noteId: string | null = null,
notes: string[] = stackedNotes.value, notes: string[] = stackedNotes.value,
) => { ) => {
nextTick(() => { nextTick(() => {
const index = sha ? notes.findIndex((noteSHA) => noteSHA === sha) : 0 const index = noteId ? notes.findIndex((nid) => nid.includes(noteId)) : 0
if (isMobile.value) { if (isMobile.value) {
if (sha) { if (noteId) {
const element = document.querySelector(`.note-${sha}`) as HTMLElement const element = document.querySelector(
`.note-${noteId}`,
) as HTMLElement
const top = (index + 1) * (element?.clientHeight ?? height.value) const top = (index + 1) * (element?.clientHeight ?? height.value)
scrollToNote(top) scrollToNote(top)
} else { } else {
scrollToNote(0) scrollToNote(0)
} }
} else { } else {
if (sha) { if (noteId) {
const margin = index * 44 const left = (index + 1) * (getNoteWidth() - getBookmarkWidthPx())
const left = (index + 1) * NOTE_WIDTH - margin
scrollToNote(left) scrollToNote(left)
} else { } else {
scrollToNote(0) scrollToNote(0)
@@ -46,9 +49,13 @@ export const useRouteQueryStackedNotes = () => {
}) })
} }
const addStackedNote = (currentSha: string, sha: string) => { const addStackedNote = (
currentSha: string,
sha: string,
selector?: string,
) => {
if (stackedNotes.value.includes(sha)) { if (stackedNotes.value.includes(sha)) {
scrollToFocusedNote(sha) scrollToFocusedNote(selector ?? sha)
return return
} }
@@ -68,7 +75,7 @@ export const useRouteQueryStackedNotes = () => {
stackedNotes.value = newStackedNotes stackedNotes.value = newStackedNotes
} }
scrollToFocusedNote(sha, stackedNotes.value) scrollToFocusedNote(selector ?? sha, stackedNotes.value)
} }
return { return {

View File

@@ -1,13 +1,13 @@
import "notyf/notyf.min.css" import "notyf/notyf.min.css"
import "./styles/app.css" import "./styles/app.css"
import { VueQueryPlugin } from "@tanstack/vue-query"
import { createPinia } from "pinia" import { createPinia } from "pinia"
import { createApp } from "vue" import { createApp } from "vue"
import { createI18n } from "vue-i18n" import { createI18n } from "vue-i18n"
import { messages } from "@/locales/message" import { messages } from "@/locales/message"
import { router } from "@/router/router" import { router } from "@/router/router"
import { VueQueryPlugin } from "@tanstack/vue-query"
import App from "./App.vue" import App from "./App.vue"

View File

@@ -1,49 +0,0 @@
export type Author = { alias: string; endpoint: string }
const correspondanceCache = new Map<string, Author>()
console.log({ correspondanceCache })
export const getUniqueAka = async (did: string): Promise<Author> => {
if (correspondanceCache.has(did)) {
return correspondanceCache.get(did) as Author
}
const response = await fetch(`https://plc.directory/${did}`)
const {
alsoKnownAs: [aka],
service: [{ serviceEndpoint }],
} = await response.json()
const alias = aka.replace("at://", "")
const author = { alias, endpoint: serviceEndpoint }
correspondanceCache.set(did, author)
return author
}
export const getAka = async (dids: Set<string>) => {
const correspondance = await Promise.all(
[...dids].map(async (did) => {
if (correspondanceCache.has(did)) {
return [did, correspondanceCache.get(did)] as [string, Author]
}
const response = await fetch(`https://plc.directory/${did}`)
const {
alsoKnownAs: [aka],
service: [{ serviceEndpoint }],
} = await response.json()
const alias = aka.replace("at://", "")
const author = { alias, endpoint: serviceEndpoint }
correspondanceCache.set(did, author)
return [did, author] as [string, Author]
}),
)
return new Map(correspondance)
}

View File

@@ -0,0 +1,79 @@
import { createSchema, createFetch } from "@better-fetch/fetch"
import { type } from "arktype"
export type Author = { handle: string; pds: string }
const correspondanceCache = new Map<string, Author>()
const schema = createSchema(
{
"/xrpc/blue.microcosm.identity.resolveMiniDoc": {
output: type({
did: "string",
handle: "string",
pds: "string",
signing_key: "string",
}),
query: type({
identifier: "string",
}),
},
},
{ strict: true },
)
const microcosmSlingshot = createFetch({
baseURL: "https://slingshot.microcosm.blue",
// plugins: [logger()],
schema,
})
export const getAuthor = async (did: string): Promise<Author | null> => {
if (correspondanceCache.has(did)) {
return correspondanceCache.get(did) as Author
}
try {
const { data: author, error } = await microcosmSlingshot(
"/xrpc/blue.microcosm.identity.resolveMiniDoc",
{ query: { identifier: did } },
)
if (!author) {
return null
}
correspondanceCache.set(did, author)
return author
} catch (e) {
console.warn(e)
return null
}
}
export const getAuthors = async (dids: Set<string>) => {
const correspondance = await Promise.all(
[...dids].map(async (did) => {
if (correspondanceCache.has(did)) {
return [did, correspondanceCache.get(did)] as [string, Author | null]
}
const { data: author } = await microcosmSlingshot(
"/xrpc/blue.microcosm.identity.resolveMiniDoc",
{ query: { identifier: did } },
)
if (!author) {
return [did, null] as [string, Author | null]
}
correspondanceCache.set(did, author)
return [did, author] as [string, Author | null]
}),
)
return new Map(correspondance)
}

View File

@@ -1,3 +1,5 @@
import { getAuthor } from "@/modules/atproto/getAuthor"
const endpointCache = new Map<string, string>() const endpointCache = new Map<string, string>()
const getEndpoint = async (did: string) => { const getEndpoint = async (did: string) => {
@@ -15,10 +17,13 @@ const getEndpoint = async (did: string) => {
} }
export const getUrl = async ({ did, rkey }: { did: string; rkey: string }) => { export const getUrl = async ({ did, rkey }: { did: string; rkey: string }) => {
const url = new URL( const author = await getAuthor(did)
"/xrpc/com.atproto.repo.getRecord",
await getEndpoint(did), if (!author) {
) return null
}
const url = new URL("/xrpc/com.atproto.repo.getRecord", author.pds)
url.searchParams.set("repo", did) url.searchParams.set("repo", did)
url.searchParams.set("collection", "space.remanso.note") url.searchParams.set("collection", "space.remanso.note")
url.searchParams.set("rkey", rkey) url.searchParams.set("rkey", rkey)

View File

@@ -0,0 +1,7 @@
export const parseAtUri = (atUri: string): { did: string; rkey: string } => {
const match = atUri.match(/^at:\/\/(did:[^/]+)\/[^/]+\/(.+)$/)
if (!match) {
throw new Error(`Invalid AT URI: ${atUri}`)
}
return { did: match[1], rkey: match[2] }
}

View File

@@ -0,0 +1,29 @@
export interface PublicNoteRecord {
uri: string
cid: string
value: PublicNote
}
export interface PublicNote {
$type: string
title: string
images: PublicNoteImage[]
content: string
createdAt: string
publishedAt: string
theme?: string
fontFamily?: string
fontSize?: string
}
export interface PublicNoteImage {
alt: string
image: PublicNoteBlob
}
export interface PublicNoteBlob {
$type: string
ref: { $link: string }
mimeType: string
size: number
}

View File

@@ -0,0 +1,13 @@
export const withATProtoImages = (
markdown: string,
{ pds, did }: { pds: string; did: string },
): string => {
const imageLinkPattern = /!\[([^\]]*)\]\((bafkrei[a-z0-9]+)\)/g
return markdown.replace(imageLinkPattern, (_, altText, cid) => {
const imageUrl = new URL("/xrpc/com.atproto.sync.getBlob", pds)
imageUrl.searchParams.set("did", did)
imageUrl.searchParams.set("cid", cid)
return `![${altText}](${imageUrl.toString()})`
})
}

View File

@@ -1,4 +1,5 @@
import { computed } from "vue" import { computed } from "vue"
import { useUserRepoStore } from "@/modules/repo/store/userRepo.store" import { useUserRepoStore } from "@/modules/repo/store/userRepo.store"
export const useNotes = () => { export const useNotes = () => {

View File

@@ -10,10 +10,6 @@ export const useUserSettings = () => {
const store = useUserRepoStore() const store = useUserRepoStore()
watchEffect(() => { watchEffect(() => {
if (store.userSettings === undefined) {
return
}
const root = document.documentElement const root = document.documentElement
const fontFamily = store.userSettings?.chosenFontFamily const fontFamily = store.userSettings?.chosenFontFamily

View File

@@ -20,12 +20,18 @@ const routes: Array<RouteRecordRaw> = [
component: () => import("@/views/PublicNoteListView.vue"), component: () => import("@/views/PublicNoteListView.vue"),
}, },
{ {
path: "/notes", path: "/pub",
name: "PublicNoteListView", name: "PublicNoteListView",
component: () => import("@/views/PublicNoteListView.vue"), component: () => import("@/views/PublicNoteListView.vue"),
}, },
{ {
path: "/notes/:did/:rkey", path: "/pub/:did",
name: "PublicNoteListByDidView",
props: true,
component: () => import("@/views/PublicNoteListByDidView.vue"),
},
{
path: "/pub/:did/:rkey/:slug?",
name: "PublicNoteView", name: "PublicNoteView",
props: true, props: true,
component: () => import("@/views/PublicNoteView.vue"), component: () => import("@/views/PublicNoteView.vue"),

View File

@@ -11,7 +11,7 @@
--link: #445fb9; --link: #445fb9;
--light-link: lighten(#445fb9, 45%); --light-link: lighten(#445fb9, 45%);
--background-color: #ffffff; --background-color: #ffffff;
--note-width: 480px; --note-width: 500px;
--color-contrast-content: var(--color-success); --color-contrast-content: var(--color-success);
--notyf-margin: 0.5rem; --notyf-margin: 0.5rem;
} }
@@ -24,8 +24,8 @@
@plugin 'daisyui' { @plugin 'daisyui' {
themes: themes:
retro --default, garden --default,
coffee --prefersdark; night --prefersdark;
} }
@config '../../tailwind.config.js'; @config '../../tailwind.config.js';
@@ -109,7 +109,7 @@ a.title-stacked-note-link {
border: none; border: none;
} }
@media only screen and (max-width: 480px) { @media only screen and (max-width: 500px) {
.notyf__toast { .notyf__toast {
margin: var(--notyf-margin); margin: var(--notyf-margin);
width: calc(100% - 2 * var(--notyf-margin)); width: calc(100% - 2 * var(--notyf-margin));

View File

@@ -2,6 +2,6 @@
// Update these values to change the light and dark themes // Update these values to change the light and dark themes
export const themeConfig = { export const themeConfig = {
light: 'retro', light: 'garden',
dark: 'coffee', dark: 'night',
} }

9
src/utils/slugify.ts Normal file
View File

@@ -0,0 +1,9 @@
export function slugify(text: string): string {
return text
.toLowerCase()
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "")
}

View File

@@ -1,5 +1,5 @@
<script lang="ts" setup> <script lang="ts" setup>
import GoBack from '@/components/GoBack.vue' import GoBack from "@/components/GoBack.vue"
</script> </script>
<template> <template>

View File

@@ -0,0 +1,104 @@
<script setup lang="ts">
import BackButton from "@/components/BackButton.vue"
import { usePublicNoteList } from "@/hooks/usePublicNoteList.hook"
import { getAuthor } from "@/modules/atproto/getAuthor"
import { slugify } from "@/utils/slugify"
import { computedAsync } from "@vueuse/core"
import { computed } from "vue"
import { vInfiniteScroll } from "@vueuse/components"
const props = defineProps<{ did: string }>()
const did = computed(() => props.did)
const { notes, isLoading, canLoadMore, onLoadMore } = usePublicNoteList(did)
const author = computedAsync(async () => getAuthor(did.value))
</script>
<template>
<main class="public-note-list-view">
<div class="header">
<back-button class="back-button" :fallback="{ name: 'Home' }" />
<h1>{{ author?.handle ?? did }}</h1>
</div>
<div v-if="isLoading"></div>
<div v-else>
<ul
class="list rounded-box shadow-sm"
v-infinite-scroll="[onLoadMore, { canLoadMore: () => canLoadMore }]"
>
<li v-for="note in notes" class="list-row">
<div class="list-col">
<router-link
:to="{
name: 'PublicNoteView',
params: { did: note.did, rkey: note.rkey, slug: slugify(note.title) },
}"
class="btn btn-link"
>{{ note.title }}</router-link
>
<div class="text-xs opacity-80 alias">
<span v-if="note.publishedAt">
{{ new Date(note.publishedAt).toLocaleDateString() }}
</span>
</div>
</div>
</li>
</ul>
</div>
</main>
</template>
<style scoped lang="scss">
.public-note-list-view {
display: flex;
flex: 1;
flex-direction: column;
padding-left: 1rem;
padding-right: 1rem;
.header {
margin-top: 1rem;
display: flex;
justify-content: space-between;
align-items: center;
}
h1 {
flex: 1;
text-align: center;
margin-bottom: 0;
}
.back-button {
display: flex;
gap: 0.5rem;
align-items: center;
}
li {
display: flex;
.list-col {
display: flex;
flex-direction: column;
flex: 1;
}
a {
text-align: left;
}
.alias {
text-align: right;
display: flex;
justify-content: flex-end;
}
}
@media screen and (min-width: 769px) {
overflow-y: auto;
}
}
</style>

View File

@@ -1,64 +1,64 @@
<script setup lang="ts"> <script setup lang="ts">
import BackButton from "@/components/BackButton.vue" import BackButton from "@/components/BackButton.vue"
import { Author, getAka } from "@/modules/atproto/getAka" import { usePublicNoteList } from "@/hooks/usePublicNoteList.hook"
import { PublicNoteListItem } from "@/modules/note/models/Note" import { slugify } from "@/utils/slugify"
import { computedAsync, useAsyncState } from "@vueuse/core" import { vInfiniteScroll } from "@vueuse/components"
const { state, isLoading } = useAsyncState<{ const { notes, isLoading, canLoadMore, onLoadMore, getAuthor } =
notes: PublicNoteListItem[] usePublicNoteList()
}>(
async () => {
const response = await fetch("https://api.litenote.li212.fr/notes")
return response.json()
},
{ notes: [] },
)
const aka = computedAsync<Map<string, Author>>(async () => {
if (state.value.notes.length === 0) {
return new Map()
}
return getAka(new Set(state.value.notes.map((n) => n.did)))
}, new Map())
const getAlias = (did: string) =>
aka.value.has(did) ? aka.value.get(did)?.alias : ""
</script> </script>
<template> <template>
<main class="public-note-list-view"> <main class="public-note-list-view">
<div class="header">
<back-button class="back-button" :fallback="{ name: 'Home' }" />
<h1>Remanso notes</h1> <h1>Remanso notes</h1>
</div>
<div v-if="isLoading"></div> <div v-if="isLoading"></div>
<div v-else> <div v-else>
<ul class="list rounded-box shadow-sm"> <ul
<li v-for="note in state.notes" class="list-row"> class="list rounded-box shadow-sm"
v-infinite-scroll="[onLoadMore, { canLoadMore: () => canLoadMore }]"
>
<li v-for="note in notes" class="list-row">
<div class="list-col"> <div class="list-col">
<router-link <router-link
:to="{ :to="{
name: 'PublicNoteView', name: 'PublicNoteView',
params: { did: note.did, rkey: note.rkey }, params: {
did: note.did,
rkey: note.rkey,
slug: slugify(note.title),
},
}" }"
class="btn btn-link" class="btn btn-link"
>{{ note.title }}</router-link >{{ note.title }}</router-link
> >
<div class="text-xs opacity-90 alias"> <div class="text-xs opacity-80 alias">
<span v-if="getAlias(note.did)"> <router-link
{{ getAlias(note.did) }} v-if="getAuthor(note.did)"
</span> :to="{
<span v-if="note.publishedAt" name: 'PublicNoteListByDidView',
>&nbsp;&nbsp;{{ params: { did: note.did },
}"
class="link link-hover"
>
{{ getAuthor(note.did) }}
</router-link>
<template v-if="note.publishedAt">
<span>&nbsp;&nbsp;</span>
<span>{{
new Date(note.publishedAt).toLocaleDateString() new Date(note.publishedAt).toLocaleDateString()
}} }}</span>
</span> </template>
<div v-else class="skeleton h-4 w-20"></div> <div v-else class="skeleton h-4 w-20"></div>
</div> </div>
</div> </div>
</li> </li>
</ul> </ul>
</div> </div>
<BackButton class="back-button" />
</main> </main>
</template> </template>
@@ -67,10 +67,20 @@ const getAlias = (did: string) =>
display: flex; display: flex;
flex: 1; flex: 1;
flex-direction: column; flex-direction: column;
margin-left: 1rem; padding-left: 1rem;
padding-right: 1rem;
.header {
margin-top: 1rem;
display: flex;
justify-content: space-between;
align-items: center;
}
h1 { h1 {
margin-top: 1rem; flex: 1;
text-align: center;
margin-bottom: 0;
} }
.back-button { .back-button {
@@ -98,5 +108,9 @@ const getAlias = (did: string) =>
justify-content: flex-end; justify-content: flex-end;
} }
} }
@media screen and (min-width: 769px) {
overflow-y: auto;
}
} }
</style> </style>

View File

@@ -2,103 +2,83 @@
import { useATProtoLinks } from "@/hooks/useATProtoLinks.hook" import { useATProtoLinks } from "@/hooks/useATProtoLinks.hook"
import { markdownBuilder } from "@/hooks/useMarkdown.hook" import { markdownBuilder } from "@/hooks/useMarkdown.hook"
import BackButton from "@/components/BackButton.vue" import BackButton from "@/components/BackButton.vue"
import StackedPublicNote from "@/components/StackedPublicNote.vue"
import { useRouteQueryStackedNotes } from "@/hooks/useRouteQueryStackedNotes.hook" import { useRouteQueryStackedNotes } from "@/hooks/useRouteQueryStackedNotes.hook"
import { getUniqueAka } from "@/modules/atproto/getAka" import { getAuthor } from "@/modules/atproto/getAuthor"
import type { PublicNoteRecord } from "@/modules/atproto/publicNote.types"
import { withATProtoImages } from "@/modules/atproto/withATProtoImages"
import { getUrl } from "@/modules/atproto/getUrl" import { getUrl } from "@/modules/atproto/getUrl"
import { downloadFont } from "@/utils/downloadFont" import { downloadFont } from "@/utils/downloadFont"
import { slugify } from "@/utils/slugify"
import { computedAsync } from "@vueuse/core" import { computedAsync } from "@vueuse/core"
import { computed, nextTick, watch } from "vue" import { computed, nextTick, watch } from "vue"
import { useRouter } from "vue-router"
import { useResizeContainer } from "@/hooks/useResizeContainer.hook"
import ThemeSwap from "@/components/ThemeSwap.vue"
export interface Root { const props = defineProps<{ did: string; rkey: string; slug?: string }>()
uri: string const router = useRouter()
cid: string
value: Value
}
export interface Value {
$type: string
title: string
images: Image[]
content: string
createdAt: string
publishedAt: string
theme?: string
fontFamily?: string
fontSize?: string
}
export interface Image {
alt: string
image: Image2
}
export interface Image2 {
$type: string
ref: Ref
mimeType: string
size: number
}
export interface Ref {
$link: string
}
const props = defineProps<{ did: string; rkey: string }>()
const did = computed(() => props.did) const did = computed(() => props.did)
const rkey = computed(() => props.rkey) const rkey = computed(() => props.rkey)
const { scrollToFocusedNote } = useRouteQueryStackedNotes()
const author = computedAsync(async () => getUniqueAka(did.value)) const author = computedAsync(async () => getAuthor(did.value))
const url = computedAsync(async () => const url = computedAsync(
getUrl({ did: did.value, rkey: rkey.value }), async () => getUrl({ did: did.value, rkey: rkey.value }),
null,
) )
const article = computedAsync(async () => const noteRecord = computedAsync(async () =>
url.value ? ((await fetch(url.value).then()).json() as Promise<Root>) : null, url.value
? ((await fetch(url.value).then()).json() as Promise<PublicNoteRecord>)
: null,
) )
watch(article, () => { watch(noteRecord, () => {
if (article.value?.value.fontFamily) { if (
downloadFont(article.value.value.fontFamily) noteRecord.value?.value.title &&
props.slug &&
props.slug !== slugify(noteRecord.value.value.title)
) {
router.replace({ name: "SpaceCowboy" })
return
} }
if (article.value?.value.fontSize) { if (noteRecord.value?.value.fontFamily) {
downloadFont(noteRecord.value.value.fontFamily)
}
if (noteRecord.value?.value.fontSize) {
const root = document.documentElement const root = document.documentElement
root.style.setProperty("--font-size", `${article.value.value.fontSize}pt`) root.style.setProperty(
"--font-size",
`${noteRecord.value.value.fontSize}pt`,
)
} }
}) })
const { toHTML } = markdownBuilder() const { toHTML } = markdownBuilder()
const withATProtoImages = (markdown: string) => {
if (!author.value) {
return markdown
}
const endpoint = author.value.endpoint const title = computed(() => noteRecord.value?.value.title)
const imageLinkPattern = /!\[([^\]]*)\]\((bafkrei[a-z0-9]+)\)/g
return markdown.replace(imageLinkPattern, (_, altText, cid) => {
const imageUrl = new URL("/xrpc/com.atproto.sync.getBlob", endpoint)
imageUrl.searchParams.set("did", did.value)
imageUrl.searchParams.set("cid", cid)
return `![${altText}](${imageUrl.toString()})`
})
}
const title = computed(() => article.value?.value.title)
const content = computed(() => const content = computed(() =>
article.value?.value.content noteRecord.value?.value.content && author.value
? toHTML(withATProtoImages(article.value?.value.content)) ? toHTML(
withATProtoImages(noteRecord.value.value.content, {
pds: author.value.pds,
did: did.value,
}),
)
: "", : "",
) )
const publishedAt = computed(() => const publishedAt = computed(() =>
article.value?.value.publishedAt noteRecord.value?.value.publishedAt
? new Date(article.value?.value.publishedAt).toLocaleDateString() ? new Date(noteRecord.value?.value.publishedAt).toLocaleDateString()
: null, : null,
) )
const { stackedNotes, scrollToFocusedNote } = useRouteQueryStackedNotes()
const { listenToClick } = useATProtoLinks("note-display") const { listenToClick } = useATProtoLinks("note-display")
useResizeContainer("note-container", stackedNotes)
watch( watch(
content, content,
@@ -111,8 +91,32 @@ watch(
</script> </script>
<template> <template>
<div class="public-note-view repo-note"> <div class="public-note-view repo-note note-container">
<div class="note article"> <div class="note article">
<div class="header">
<back-button
:fallback="{ name: 'PublicNoteListByDidView', params: { did } }"
:prefer-fallback="false"
/>
<theme-swap />
<span
class="badge badge-author badge-soft badge-accent"
v-if="author && content"
>
<router-link
:to="{ name: 'PublicNoteListByDidView', params: { did: did } }"
class="link link-hover"
>
{{ author.handle }}
</router-link>
<template v-if="publishedAt">
<span>&nbsp;&nbsp;</span>
<span>{{ publishedAt }}</span>
</template>
</span>
<div class="badge skeleton h-4 w-50" v-else></div>
</div>
<div class="repo-title-breadcrumb"> <div class="repo-title-breadcrumb">
<a <a
class="title-stacked-note-link" class="title-stacked-note-link"
@@ -122,13 +126,15 @@ watch(
> >
</div> </div>
<span class="badge badge-author" v-if="author">
{{ author.alias }}
<span v-if="publishedAt">&nbsp;&nbsp;{{ publishedAt }}</span>
</span>
<article class="note-display" v-html="content"></article> <article class="note-display" v-html="content"></article>
<BackButton />
</div> </div>
<stacked-public-note
v-for="(stackedNote, index) in stackedNotes"
:key="stackedNote"
class="note"
:index="index"
:at-uri="stackedNote"
/>
</div> </div>
</template> </template>
@@ -137,32 +143,22 @@ watch(
display: flex; display: flex;
flex: 1; flex: 1;
.back-button { .header {
position: absolute; margin-top: 1rem;
left: 1.5rem;
top: 0.4rem;
display: flex; display: flex;
justify-content: space-between;
align-items: center; align-items: center;
flex-wrap: wrap;
gap: 1rem;
} }
h1 { h1 {
font-size: 1.5rem; font-size: 2rem;
}
.badge-author {
position: absolute;
top: 0.4rem;
right: 2rem;
} }
.article { .article {
position: sticky;
padding: 0 2rem; padding: 0 2rem;
scrollbar-width: none; scrollbar-width: none;
article {
margin-top: 1rem;
}
} }
&.content { &.content {
@@ -211,6 +207,7 @@ watch(
transform-origin: 0 0; transform-origin: 0 0;
transform: rotate(90deg); transform: rotate(90deg);
font-size: 0.8em; font-size: 0.8em;
text-wrap: nowrap;
a { a {
color: var(--color-base-content); color: var(--color-base-content);
@@ -224,5 +221,21 @@ watch(
max-width: var(--note-width); max-width: var(--note-width);
} }
} }
.note {
width: 100%;
}
@media screen and (max-width: 768px) {
flex-wrap: wrap;
.repo-title-breadcrumb {
display: none;
}
.article article {
margin-top: 48px;
}
}
} }
</style> </style>

View File

@@ -6,8 +6,13 @@ import { markdownBuilder } from "@/hooks/useMarkdown.hook"
import { queryFileContent } from "@/modules/repo/services/repo" import { queryFileContent } from "@/modules/repo/services/repo"
import { decodeBase64ToUTF8 } from "@/utils/decodeBase64ToUTF8" import { decodeBase64ToUTF8 } from "@/utils/decodeBase64ToUTF8"
type Prop = {
user: string
repo: string
}
const FluxNote = defineAsyncComponent(() => import("@/components/FluxNote.vue")) const FluxNote = defineAsyncComponent(() => import("@/components/FluxNote.vue"))
const props = defineProps<{ user: string; repo: string }>() const props = defineProps<Prop>()
const user = computed(() => props.user) const user = computed(() => props.user)
const repo = computed(() => props.repo) const repo = computed(() => props.repo)

View File

@@ -32,7 +32,7 @@ module.exports = {
p: { p: {
"margin-top": "0.8em", "margin-top": "0.8em",
"margin-bottom": "0.8em", "margin-bottom": "0.8em",
"text-align": "justify", "text-align": "left",
// "text-wrap": "balance", // "text-wrap": "balance",
}, },
"img, video": { "img, video": {