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,5 +1,5 @@
<script lang="ts" setup>
import GoBack from '@/components/GoBack.vue'
import GoBack from "@/components/GoBack.vue"
</script>
<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">
import BackButton from "@/components/BackButton.vue"
import { Author, getAka } from "@/modules/atproto/getAka"
import { PublicNoteListItem } from "@/modules/note/models/Note"
import { computedAsync, useAsyncState } from "@vueuse/core"
import { usePublicNoteList } from "@/hooks/usePublicNoteList.hook"
import { slugify } from "@/utils/slugify"
import { vInfiniteScroll } from "@vueuse/components"
const { state, isLoading } = useAsyncState<{
notes: PublicNoteListItem[]
}>(
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 : ""
const { notes, isLoading, canLoadMore, onLoadMore, getAuthor } =
usePublicNoteList()
</script>
<template>
<main class="public-note-list-view">
<h1>Remanso notes</h1>
<div class="header">
<back-button class="back-button" :fallback="{ name: 'Home' }" />
<h1>Remanso notes</h1>
</div>
<div v-if="isLoading"></div>
<div v-else>
<ul class="list rounded-box shadow-sm">
<li v-for="note in state.notes" class="list-row">
<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 },
params: {
did: note.did,
rkey: note.rkey,
slug: slugify(note.title),
},
}"
class="btn btn-link"
>{{ note.title }}</router-link
>
<div class="text-xs opacity-90 alias">
<span v-if="getAlias(note.did)">
{{ getAlias(note.did) }}
</span>
<span v-if="note.publishedAt"
>&nbsp;&nbsp;{{
<div class="text-xs opacity-80 alias">
<router-link
v-if="getAuthor(note.did)"
:to="{
name: 'PublicNoteListByDidView',
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()
}}
</span>
}}</span>
</template>
<div v-else class="skeleton h-4 w-20"></div>
</div>
</div>
</li>
</ul>
</div>
<BackButton class="back-button" />
</main>
</template>
@@ -67,10 +67,20 @@ const getAlias = (did: string) =>
display: flex;
flex: 1;
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 {
margin-top: 1rem;
flex: 1;
text-align: center;
margin-bottom: 0;
}
.back-button {
@@ -98,5 +108,9 @@ const getAlias = (did: string) =>
justify-content: flex-end;
}
}
@media screen and (min-width: 769px) {
overflow-y: auto;
}
}
</style>

View File

@@ -2,103 +2,83 @@
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 { 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 { downloadFont } from "@/utils/downloadFont"
import { slugify } from "@/utils/slugify"
import { computedAsync } from "@vueuse/core"
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 {
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
}
const props = defineProps<{ did: string; rkey: string }>()
const props = defineProps<{ did: string; rkey: string; slug?: string }>()
const router = useRouter()
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 author = computedAsync(async () => getAuthor(did.value))
const url = computedAsync(
async () => getUrl({ did: did.value, rkey: rkey.value }),
null,
)
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.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
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 `![${altText}](${imageUrl.toString()})`
})
}
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, {
pds: author.value.pds,
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,8 +91,32 @@ 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="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">
<a
class="title-stacked-note-link"
@@ -122,13 +126,15 @@ watch(
>
</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>
<BackButton />
</div>
<stacked-public-note
v-for="(stackedNote, index) in stackedNotes"
:key="stackedNote"
class="note"
:index="index"
:at-uri="stackedNote"
/>
</div>
</template>
@@ -137,32 +143,22 @@ watch(
display: flex;
flex: 1;
.back-button {
position: absolute;
left: 1.5rem;
top: 0.4rem;
.header {
margin-top: 1rem;
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 1rem;
}
h1 {
font-size: 1.5rem;
}
.badge-author {
position: absolute;
top: 0.4rem;
right: 2rem;
font-size: 2rem;
}
.article {
position: sticky;
padding: 0 2rem;
scrollbar-width: none;
article {
margin-top: 1rem;
}
}
&.content {
@@ -211,6 +207,7 @@ watch(
transform-origin: 0 0;
transform: rotate(90deg);
font-size: 0.8em;
text-wrap: nowrap;
a {
color: var(--color-base-content);
@@ -224,5 +221,21 @@ watch(
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>

View File

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