diff --git a/bun.lockb b/bun.lockb index 4a644bd..04ac9f1 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/components/LoginInput.vue b/components/LoginInput.vue index 9556ad7..9334164 100644 --- a/components/LoginInput.vue +++ b/components/LoginInput.vue @@ -9,7 +9,7 @@ + + \ No newline at end of file diff --git a/components/buttons/base.vue b/components/buttons/base.vue index 8df0f44..693622f 100644 --- a/components/buttons/base.vue +++ b/components/buttons/base.vue @@ -8,7 +8,7 @@ diff --git a/components/dropdowns/AdaptiveDropdown.vue b/components/dropdowns/AdaptiveDropdown.vue new file mode 100644 index 0000000..2c97e7e --- /dev/null +++ b/components/dropdowns/AdaptiveDropdown.vue @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/components/skeleton/Skeleton.vue b/components/skeleton/Skeleton.vue index 6a167ae..a40b262 100644 --- a/components/skeleton/Skeleton.vue +++ b/components/skeleton/Skeleton.vue @@ -12,29 +12,38 @@ \ No newline at end of file + + + \ No newline at end of file diff --git a/composables/AccountTimeline.ts b/composables/AccountTimeline.ts new file mode 100644 index 0000000..6b2fd0d --- /dev/null +++ b/composables/AccountTimeline.ts @@ -0,0 +1,92 @@ +import type { Mastodon } from "megalodon"; +import type { Status } from "~/types/mastodon/status"; + +export const useAccountTimeline = ( + client: Mastodon | null, + id: string | null, + options: MaybeRef< + Partial<{ + limit?: number | undefined; + max_id?: string | undefined; + since_id?: string | undefined; + min_id?: string | undefined; + pinned?: boolean | undefined; + exclude_replies?: boolean | undefined; + exclude_reblogs?: boolean | undefined; + only_media: boolean; + }> + >, +): { + timeline: Ref; + loadNext: () => Promise; + loadPrev: () => Promise; +} => { + if (!client || !id) { + return { + timeline: ref([]), + loadNext: async () => {}, + loadPrev: async () => {}, + }; + } + + const fetchedNotes = ref([]); + const fetchedNoteIds = new Set(); + let nextMaxId: string | undefined = undefined; + let prevMinId: string | undefined = undefined; + + const loadNext = async () => { + const response = await client.getAccountStatuses(id, { + only_media: false, + ...ref(options).value, + max_id: nextMaxId, + limit: useConfig().NOTES_PER_PAGE, + }); + + const newNotes = response.data.filter( + (note) => !fetchedNoteIds.has(note.id), + ); + if (newNotes.length > 0) { + fetchedNotes.value = [...fetchedNotes.value, ...newNotes]; + nextMaxId = newNotes[newNotes.length - 1].id; + for (const note of newNotes) { + fetchedNoteIds.add(note.id); + } + } else { + nextMaxId = undefined; + } + }; + + const loadPrev = async () => { + const response = await client.getAccountStatuses(id, { + only_media: false, + ...ref(options).value, + min_id: prevMinId, + limit: useConfig().NOTES_PER_PAGE, + }); + + const newNotes = response.data.filter( + (note) => !fetchedNoteIds.has(note.id), + ); + if (newNotes.length > 0) { + fetchedNotes.value = [...newNotes, ...fetchedNotes.value]; + prevMinId = newNotes[0].id; + for (const note of newNotes) { + fetchedNoteIds.add(note.id); + } + } else { + prevMinId = undefined; + } + }; + + watch( + () => ref(options).value, + async ({ max_id, min_id }) => { + nextMaxId = max_id; + prevMinId = min_id; + await loadNext(); + }, + { immediate: true }, + ); + + return { timeline: fetchedNotes, loadNext, loadPrev }; +}; diff --git a/composables/ParsedContent.ts b/composables/ParsedContent.ts index fca2127..ec77ee7 100644 --- a/composables/ParsedContent.ts +++ b/composables/ParsedContent.ts @@ -1,7 +1,7 @@ +import { renderToString } from "vue/server-renderer"; import type { Account } from "~/types/mastodon/account"; import type { Emoji } from "~/types/mastodon/emoji"; import MentionComponent from "../components/social-elements/notes/mention.vue"; -import { renderToString } from "vue/server-renderer"; /** * Takes in an HTML string, parses emojis and returns a reactive object with the parsed content. @@ -55,5 +55,24 @@ export const useParsedContent = async ( link.outerHTML = await renderToString(renderedMention); } + + // Highlight code blocks + const codeBlocks = contentHtml.querySelectorAll("pre code"); + for (const codeBlock of codeBlocks) { + const code = codeBlock.textContent; + if (!code) { + continue; + } + const newCode = (await getShikiHighlighter()).highlight(code, { + lang: codeBlock.getAttribute("class")?.replace("language-", ""), + }); + + // Replace parent pre tag with highlighted code + const parent = codeBlock.parentElement; + if (!parent) { + continue; + } + parent.outerHTML = newCode; + } return ref(contentHtml.innerHTML); }; diff --git a/composables/usePublicTimeline.ts b/composables/PublicTimeline.ts similarity index 100% rename from composables/usePublicTimeline.ts rename to composables/PublicTimeline.ts diff --git a/nuxt.config.ts b/nuxt.config.ts index ba3bb5d..56f9320 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -8,6 +8,7 @@ export default defineNuxtConfig({ "@nuxt/fonts", "nuxt-icon", "@vee-validate/nuxt", + "nuxt-shiki", ], app: { head: { @@ -21,6 +22,24 @@ export default defineNuxtConfig({ htmlAttrs: { lang: "en-us" }, }, }, + shiki: { + defaultTheme: "rose-pine", + bundledLangs: [ + "javascript", + "typescript", + "html", + "css", + "json", + "python", + "toml", + "rust", + "sql", + "scss", + "bash", + "shell", + "yaml", + ], + }, nitro: { preset: "bun", minify: true, diff --git a/package.json b/package.json index 97c2d61..c182562 100644 --- a/package.json +++ b/package.json @@ -1,58 +1,59 @@ { - "name": "lysand-fe", - "private": true, - "type": "module", - "license": "AGPL-3.0", - "author": { - "email": "contact@cpluspatch.com", - "name": "CPlusPatch", - "url": "https://cpluspatch.com" - }, - "maintainers": [ - { - "email": "contact@cpluspatch.com", - "name": "CPlusPatch", - "url": "https://cpluspatch.com" - } - ], - "repository": { - "type": "git", - "url": "git+https://github.com/lysand-org/lysand-fe.git" - }, - "scripts": { - "build": "nuxt build", - "dev": "nuxt dev", - "generate": "nuxt generate", - "preview": "nuxt preview", - "postinstall": "nuxt prepare" - }, - "dependencies": { - "@nuxt/fonts": "^0.6.1", - "@tailwindcss/typography": "^0.5.12", - "@vee-validate/nuxt": "^4.12.6", - "@vee-validate/zod": "^4.12.6", - "c12": "^1.10.0", - "megalodon": "^10.0.0", - "nuxt": "^3.11.2", - "nuxt-headlessui": "^1.2.0", - "nuxt-icon": "^0.6.10", - "shiki": "^1.3.0", - "vue": "^3.4.21", - "vue-router": "^4.3.0", - "zod": "^3.23.0" - }, - "devDependencies": { - "@biomejs/biome": "^1.6.4", - "@nuxtjs/seo": "^2.0.0-rc.10", - "@nuxtjs/tailwindcss": "^6.11.4", - "@tailwindcss/forms": "^0.5.7", - "@vue-email/nuxt": "^0.8.19" - }, - "trustedDependencies": [ - "@biomejs/biome", - "@fortawesome/fontawesome-common-types", - "@fortawesome/free-regular-svg-icons", - "@fortawesome/free-solid-svg-icons", - "json-editor-vue" - ] + "name": "lysand-fe", + "private": true, + "type": "module", + "license": "AGPL-3.0", + "author": { + "email": "contact@cpluspatch.com", + "name": "CPlusPatch", + "url": "https://cpluspatch.com" + }, + "maintainers": [ + { + "email": "contact@cpluspatch.com", + "name": "CPlusPatch", + "url": "https://cpluspatch.com" + } + ], + "repository": { + "type": "git", + "url": "git+https://github.com/lysand-org/lysand-fe.git" + }, + "scripts": { + "build": "nuxt build", + "dev": "nuxt dev", + "generate": "nuxt generate", + "preview": "nuxt preview", + "postinstall": "nuxt prepare" + }, + "dependencies": { + "@nuxt/fonts": "^0.6.1", + "@tailwindcss/typography": "^0.5.12", + "@vee-validate/nuxt": "^4.12.6", + "@vee-validate/zod": "^4.12.6", + "c12": "^1.10.0", + "megalodon": "^10.0.0", + "nuxt": "^3.11.2", + "nuxt-headlessui": "^1.2.0", + "nuxt-icon": "^0.6.10", + "nuxt-shiki": "^0.3.0", + "shiki": "^1.3.0", + "vue": "^3.4.21", + "vue-router": "^4.3.0", + "zod": "^3.23.0" + }, + "devDependencies": { + "@biomejs/biome": "^1.6.4", + "@nuxtjs/seo": "^2.0.0-rc.10", + "@nuxtjs/tailwindcss": "^6.11.4", + "@tailwindcss/forms": "^0.5.7", + "@vue-email/nuxt": "^0.8.19" + }, + "trustedDependencies": [ + "@biomejs/biome", + "@fortawesome/fontawesome-common-types", + "@fortawesome/free-regular-svg-icons", + "@fortawesome/free-solid-svg-icons", + "json-editor-vue" + ] } diff --git a/pages/[username]/[uuid].vue b/pages/[username]/[uuid].vue index 396aeb3..2d9378b 100644 --- a/pages/[username]/[uuid].vue +++ b/pages/[username]/[uuid].vue @@ -11,7 +11,7 @@ import { useRoute } from "vue-router"; const route = useRoute(); const client = await useMegalodon(); -const uuid = (route.params.uuid as string); +const uuid = route.params.uuid as string; const note = await useNote(client, uuid); \ No newline at end of file diff --git a/pages/[username]/index.vue b/pages/[username]/index.vue index 266249d..9ec7cfb 100644 --- a/pages/[username]/index.vue +++ b/pages/[username]/index.vue @@ -1,6 +1,7 @@ - - + + @@ -22,7 +23,7 @@ - + @@ -46,7 +47,27 @@ Followers + + + + + + + + + + + + + + + + No more posts, you've seen them all + + + @@ -56,11 +77,55 @@ import { useRoute } from "vue-router"; const route = useRoute(); const client = await useMegalodon(); const username = (route.params.username as string).replace("@", ""); -const id = await useAccountSearch(client, username); - -const account = id ? await useAccount(client, id[0].id) : null; +const accounts = await useAccountSearch(client, username); +const account = + (await accounts?.find((account) => account.acct === username)) ?? null; const formattedJoin = Intl.DateTimeFormat("en-US", { month: "long", year: "numeric", }).format(new Date(account?.created_at ?? 0)); + +useServerSeoMeta({ + title: account?.display_name, + description: account?.note, + ogImage: account?.avatar, +}); + +const isLoadingTimeline = ref(true); + +const timelineParameters = ref({}); +const hasReachedEnd = ref(false); +const { timeline, loadNext, loadPrev } = useAccountTimeline( + client, + account?.id ?? null, + timelineParameters, +); +const skeleton = ref(null); + +const parsedNote = account ? await useParsedContent(account?.note, account?.emojis, []) : ref(""); +const parsedFields = await Promise.all(account?.fields.map(async (field) => ({ + name: await useParsedContent(field.name, account.emojis, []), + value: await useParsedContent(field.value, account.emojis, []), +})) ?? []); + +onMounted(() => { + useIntersectionObserver(skeleton, async (entries) => { + if ( + entries[0].isIntersecting && + !hasReachedEnd.value && + !isLoadingTimeline.value + ) { + isLoadingTimeline.value = true; + await loadNext(); + } + }); +}); + +watch(timeline, (newTimeline, oldTimeline) => { + isLoadingTimeline.value = false; + // If less than NOTES_PER_PAGE statuses are returned, we have reached the end + if (newTimeline.length - oldTimeline.length < useConfig().NOTES_PER_PAGE) { + hasReachedEnd.value = true; + } +}); \ No newline at end of file diff --git a/pages/index.vue b/pages/index.vue index dfbf74d..bd78802 100644 --- a/pages/index.vue +++ b/pages/index.vue @@ -56,7 +56,7 @@ \ No newline at end of file diff --git a/pages/oauth/consent.vue b/pages/oauth/consent.vue index 115c577..3b1a6cb 100644 --- a/pages/oauth/consent.vue +++ b/pages/oauth/consent.vue @@ -97,7 +97,9 @@ const url = useRequestURL(); const query = useRoute().query; const application = "Soapbox"; //query.application; -const website = query.website ? decodeURIComponent(query.website as string) : null; +const website = query.website + ? decodeURIComponent(query.website as string) + : null; const redirect_uri = query.redirect_uri as string; const client_id = query.client_id; const scope = query.scope ? decodeURIComponent(query.scope as string) : ""; diff --git a/pages/public.vue b/pages/public.vue index 37213c6..a1ab92e 100644 --- a/pages/public.vue +++ b/pages/public.vue @@ -21,17 +21,24 @@ const isLoading = ref(true); const timelineParameters = ref({}); const hasReachedEnd = ref(false); -const { timeline, loadNext, loadPrev } = usePublicTimeline(client, timelineParameters); +const { timeline, loadNext, loadPrev } = usePublicTimeline( + client, + timelineParameters, +); const skeleton = ref(null); onMounted(() => { useIntersectionObserver(skeleton, async (entries) => { - if (entries[0].isIntersecting && !hasReachedEnd.value && !isLoading.value) { + if ( + entries[0].isIntersecting && + !hasReachedEnd.value && + !isLoading.value + ) { isLoading.value = true; await loadNext(); } }); -}) +}); watch(timeline, (newTimeline, oldTimeline) => { isLoading.value = false; diff --git a/pages/register/index.vue b/pages/register/index.vue index 57e4d64..676caac 100644 --- a/pages/register/index.vue +++ b/pages/register/index.vue @@ -84,28 +84,35 @@ \ No newline at end of file