feat: add stacked public notes
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { createEventBus } from "retrobus"
|
||||
|
||||
interface EventBusParams {
|
||||
path: string
|
||||
atUri: 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 { isExternalLink } from "@/utils/link"
|
||||
import { publicNoteEventBus } from "@/bus/publicNoteEventBus"
|
||||
import { useRouteQueryStackedNotes } from "@/hooks/useRouteQueryStackedNotes.hook"
|
||||
|
||||
export const useATProtoLinks = (
|
||||
className: ComputedRef<string> | string,
|
||||
rkey?: Ref<string> | string,
|
||||
currentAtUri?: Ref<string> | string,
|
||||
) => {
|
||||
const { addStackedNote } = useRouteQueryStackedNotes()
|
||||
const linkNote: EventListener = (event) => {
|
||||
const target = event.target as HTMLElement
|
||||
const href = target.getAttribute("href")
|
||||
const atUri = target.getAttribute("href")
|
||||
|
||||
if (!href) {
|
||||
if (!atUri) {
|
||||
return
|
||||
}
|
||||
|
||||
if (href.startsWith("#")) {
|
||||
if (atUri.startsWith("#")) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
if (isExternalLink(href)) {
|
||||
window.open(href, "_blank")
|
||||
if (isExternalLink(atUri)) {
|
||||
window.open(atUri, "_blank")
|
||||
return
|
||||
}
|
||||
|
||||
publicNoteEventBus.emit({
|
||||
path: href,
|
||||
currentNoteRkey: toValue(rkey),
|
||||
})
|
||||
addStackedNote(toValue(currentAtUri) ?? "", atUri)
|
||||
}
|
||||
|
||||
const LINK_SELECTOR = `.${toValue(className)} a`
|
||||
|
||||
@@ -2,8 +2,6 @@ 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
|
||||
|
||||
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 { markdownBuilder } from "@/hooks/useMarkdown.hook"
|
||||
import BackButton from "@/components/BackButton.vue"
|
||||
import StackedPublicNote from "@/components/StackedPublicNote.vue"
|
||||
import { useRouteQueryStackedNotes } from "@/hooks/useRouteQueryStackedNotes.hook"
|
||||
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 { downloadFont } from "@/utils/downloadFont"
|
||||
import { computedAsync } from "@vueuse/core"
|
||||
import { computed, nextTick, watch } from "vue"
|
||||
|
||||
export interface Root {
|
||||
uri: string
|
||||
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
|
||||
}
|
||||
import { useResizeContainer } from "@/hooks/useResizeContainer.hook"
|
||||
import { publicNoteEventBus } from "@/bus/publicNoteEventBus"
|
||||
import { errorMessage } from "@/utils/notif"
|
||||
|
||||
const props = defineProps<{ did: string; rkey: string }>()
|
||||
const did = computed(() => props.did)
|
||||
const rkey = computed(() => props.rkey)
|
||||
const { scrollToFocusedNote } = useRouteQueryStackedNotes()
|
||||
|
||||
const author = computedAsync(async () => getUniqueAka(did.value))
|
||||
const url = computedAsync(async () =>
|
||||
getUrl({ did: did.value, rkey: rkey.value }),
|
||||
)
|
||||
|
||||
const article = computedAsync(async () =>
|
||||
url.value ? ((await fetch(url.value).then()).json() as Promise<Root>) : null,
|
||||
const noteRecord = computedAsync(async () =>
|
||||
url.value
|
||||
? ((await fetch(url.value).then()).json() as Promise<PublicNoteRecord>)
|
||||
: null,
|
||||
)
|
||||
|
||||
watch(article, () => {
|
||||
if (article.value?.value.fontFamily) {
|
||||
downloadFont(article.value.value.fontFamily)
|
||||
watch(noteRecord, () => {
|
||||
if (noteRecord.value?.value.fontFamily) {
|
||||
downloadFont(noteRecord.value.value.fontFamily)
|
||||
}
|
||||
|
||||
if (article.value?.value.fontSize) {
|
||||
if (noteRecord.value?.value.fontSize) {
|
||||
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 withATProtoImages = (markdown: string) => {
|
||||
if (!author.value) {
|
||||
return markdown
|
||||
}
|
||||
|
||||
const endpoint = author.value.endpoint
|
||||
|
||||
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 title = computed(() => noteRecord.value?.value.title)
|
||||
const content = computed(() =>
|
||||
article.value?.value.content
|
||||
? toHTML(withATProtoImages(article.value?.value.content))
|
||||
noteRecord.value?.value.content && author.value
|
||||
? toHTML(
|
||||
withATProtoImages(noteRecord.value.value.content, {
|
||||
endpoint: author.value.endpoint,
|
||||
did: did.value,
|
||||
}),
|
||||
)
|
||||
: "",
|
||||
)
|
||||
const publishedAt = computed(() =>
|
||||
article.value?.value.publishedAt
|
||||
? new Date(article.value?.value.publishedAt).toLocaleDateString()
|
||||
noteRecord.value?.value.publishedAt
|
||||
? new Date(noteRecord.value?.value.publishedAt).toLocaleDateString()
|
||||
: null,
|
||||
)
|
||||
|
||||
const { stackedNotes, scrollToFocusedNote } = useRouteQueryStackedNotes()
|
||||
const { listenToClick } = useATProtoLinks("note-display")
|
||||
useResizeContainer("note-container", stackedNotes)
|
||||
|
||||
watch(
|
||||
content,
|
||||
@@ -111,7 +78,7 @@ watch(
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="public-note-view repo-note">
|
||||
<div class="public-note-view repo-note note-container">
|
||||
<div class="note article">
|
||||
<div class="repo-title-breadcrumb">
|
||||
<a
|
||||
@@ -127,8 +94,16 @@ watch(
|
||||
<span v-if="publishedAt"> • {{ publishedAt }}</span>
|
||||
</span>
|
||||
<article class="note-display" v-html="content"></article>
|
||||
|
||||
<BackButton />
|
||||
</div>
|
||||
<stacked-public-note
|
||||
v-for="(stackedNote, index) in stackedNotes"
|
||||
:key="stackedNote"
|
||||
class="note"
|
||||
:index="index"
|
||||
:at-uri="stackedNote"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user