feat: add stacked public notes
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
import { createEventBus } from "retrobus"
|
import { createEventBus } from "retrobus"
|
||||||
|
|
||||||
interface EventBusParams {
|
interface EventBusParams {
|
||||||
path: string
|
atUri: string
|
||||||
currentNoteRkey?: string
|
currentNoteRkey?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
191
src/components/StackedPublicNote.vue
Normal file
191
src/components/StackedPublicNote.vue
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
<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 { getUniqueAka } from "@/modules/atproto/getAka"
|
||||||
|
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 () => getUniqueAka(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, {
|
||||||
|
endpoint: author.value.endpoint,
|
||||||
|
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>
|
||||||
@@ -1,36 +1,34 @@
|
|||||||
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"
|
||||||
|
|
||||||
export const useATProtoLinks = (
|
export const useATProtoLinks = (
|
||||||
className: ComputedRef<string> | string,
|
className: ComputedRef<string> | string,
|
||||||
rkey?: Ref<string> | string,
|
currentAtUri?: Ref<string> | string,
|
||||||
) => {
|
) => {
|
||||||
|
const { addStackedNote } = useRouteQueryStackedNotes()
|
||||||
const linkNote: EventListener = (event) => {
|
const linkNote: EventListener = (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
|
||||||
}
|
}
|
||||||
|
|
||||||
publicNoteEventBus.emit({
|
addStackedNote(toValue(currentAtUri) ?? "", atUri)
|
||||||
path: href,
|
|
||||||
currentNoteRkey: toValue(rkey),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const LINK_SELECTOR = `.${toValue(className)} a`
|
const LINK_SELECTOR = `.${toValue(className)} a`
|
||||||
|
|||||||
@@ -2,8 +2,6 @@ export type Author = { alias: string; endpoint: string }
|
|||||||
|
|
||||||
const correspondanceCache = new Map<string, Author>()
|
const correspondanceCache = new Map<string, Author>()
|
||||||
|
|
||||||
console.log({ correspondanceCache })
|
|
||||||
|
|
||||||
export const getUniqueAka = async (did: string): Promise<Author> => {
|
export const getUniqueAka = async (did: string): Promise<Author> => {
|
||||||
if (correspondanceCache.has(did)) {
|
if (correspondanceCache.has(did)) {
|
||||||
return correspondanceCache.get(did) as Author
|
return correspondanceCache.get(did) as Author
|
||||||
|
|||||||
7
src/modules/atproto/parseAtUri.ts
Normal file
7
src/modules/atproto/parseAtUri.ts
Normal 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] }
|
||||||
|
}
|
||||||
29
src/modules/atproto/publicNote.types.ts
Normal file
29
src/modules/atproto/publicNote.types.ts
Normal 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
|
||||||
|
}
|
||||||
13
src/modules/atproto/withATProtoImages.ts
Normal file
13
src/modules/atproto/withATProtoImages.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
export const withATProtoImages = (
|
||||||
|
markdown: string,
|
||||||
|
{ endpoint, did }: { endpoint: 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", endpoint)
|
||||||
|
imageUrl.searchParams.set("did", did)
|
||||||
|
imageUrl.searchParams.set("cid", cid)
|
||||||
|
return `})`
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -2,103 +2,70 @@
|
|||||||
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 { getUniqueAka } from "@/modules/atproto/getAka"
|
||||||
|
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 { computedAsync } from "@vueuse/core"
|
import { computedAsync } from "@vueuse/core"
|
||||||
import { computed, nextTick, watch } from "vue"
|
import { computed, nextTick, watch } from "vue"
|
||||||
|
import { useResizeContainer } from "@/hooks/useResizeContainer.hook"
|
||||||
export interface Root {
|
import { publicNoteEventBus } from "@/bus/publicNoteEventBus"
|
||||||
uri: string
|
import { errorMessage } from "@/utils/notif"
|
||||||
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 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 () => getUniqueAka(did.value))
|
||||||
const url = computedAsync(async () =>
|
const url = computedAsync(async () =>
|
||||||
getUrl({ did: did.value, rkey: rkey.value }),
|
getUrl({ did: did.value, rkey: rkey.value }),
|
||||||
)
|
)
|
||||||
|
|
||||||
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 (noteRecord.value?.value.fontFamily) {
|
||||||
downloadFont(article.value.value.fontFamily)
|
downloadFont(noteRecord.value.value.fontFamily)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (article.value?.value.fontSize) {
|
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 `})`
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
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, {
|
||||||
|
endpoint: author.value.endpoint,
|
||||||
|
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,7 +78,7 @@ 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="repo-title-breadcrumb">
|
<div class="repo-title-breadcrumb">
|
||||||
<a
|
<a
|
||||||
@@ -127,8 +94,16 @@ watch(
|
|||||||
<span v-if="publishedAt"> • {{ publishedAt }}</span>
|
<span v-if="publishedAt"> • {{ publishedAt }}</span>
|
||||||
</span>
|
</span>
|
||||||
<article class="note-display" v-html="content"></article>
|
<article class="note-display" v-html="content"></article>
|
||||||
|
|
||||||
<BackButton />
|
<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>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user