mirror of
https://github.com/versia-pub/frontend.git
synced 2025-12-06 08:28:20 +01:00
feat: ✨ Create new user profile view, refine components, add dropdown to notes
This commit is contained in:
parent
a0d0737683
commit
a17df9fff8
|
|
@ -9,7 +9,7 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { InputHTMLAttributes } from 'vue';
|
import type { InputHTMLAttributes } from "vue";
|
||||||
|
|
||||||
interface Props extends /* @vue-ignore */ InputHTMLAttributes {
|
interface Props extends /* @vue-ignore */ InputHTMLAttributes {
|
||||||
isInvalid?: boolean;
|
isInvalid?: boolean;
|
||||||
|
|
|
||||||
19
components/buttons/DropdownElement.vue
Normal file
19
components/buttons/DropdownElement.vue
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
<template>
|
||||||
|
<ButtonsBase
|
||||||
|
class="bg-white/10 hover:bg-white/20 !text-left flex flex-row gap-x-3 !rounded-none !ring-0 !p-4 sm:!p-3">
|
||||||
|
<Icon :name="icon" class="h-5 w-5 text-gray-200" aria-hidden="true" />
|
||||||
|
<slot />
|
||||||
|
</ButtonsBase>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import type { ButtonHTMLAttributes } from "vue";
|
||||||
|
|
||||||
|
interface Props extends /* @vue-ignore */ ButtonHTMLAttributes { }
|
||||||
|
|
||||||
|
defineProps<Props & {
|
||||||
|
icon: string;
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style></style>
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import type { ButtonHTMLAttributes } from "vue";
|
import type { ButtonHTMLAttributes } from "vue";
|
||||||
|
|
||||||
interface Props extends /* @vue-ignore */ ButtonHTMLAttributes { }
|
interface Props extends /* @vue-ignore */ ButtonHTMLAttributes {}
|
||||||
|
|
||||||
defineProps<Props>();
|
defineProps<Props>();
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
29
components/dropdowns/AdaptiveDropdown.vue
Normal file
29
components/dropdowns/AdaptiveDropdown.vue
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
<template>
|
||||||
|
<HeadlessMenu v-slot="{ close }">
|
||||||
|
<slot name="button"></slot>
|
||||||
|
|
||||||
|
<HeadlessMenuItems @click="close" class="fixed inset-0 z-5 bg-black/50">
|
||||||
|
|
||||||
|
</HeadlessMenuItems>
|
||||||
|
|
||||||
|
<transition enter-active-class="transition ease-in duration-100"
|
||||||
|
enter-from-class="transform opacity-0 translate-y-full sm:translate-y-0 scale-95"
|
||||||
|
enter-to-class="transform translate-y-0 opacity-100 scale-100"
|
||||||
|
leave-active-class="transition ease-out duration-75" leave-from-class="transform opacity-100 scale-100"
|
||||||
|
leave-to-class="transform opacity-0 scale-95">
|
||||||
|
<HeadlessMenuItems
|
||||||
|
:class="['z-10 mt-2 rounded overflow-hidden bg-dark-900 shadow-lg ring-1 ring-white/10 focus:outline-none',
|
||||||
|
isSmallScreen ? 'bottom-0 fixed inset-x-0 w-full origin-bottom' : 'absolute right-0 origin-top-right top-full min-w-56']">
|
||||||
|
<div v-if="isSmallScreen" class="w-full bg-white/10 py-2">
|
||||||
|
<div class="rounded-full h-1 bg-gray-400 w-12 mx-auto"></div>
|
||||||
|
</div>
|
||||||
|
<slot name="items"></slot>
|
||||||
|
</HeadlessMenuItems>
|
||||||
|
</transition>
|
||||||
|
</HeadlessMenu>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const { width } = useWindowSize()
|
||||||
|
const isSmallScreen = computed(() => width.value < 640)
|
||||||
|
</script>
|
||||||
|
|
@ -12,7 +12,8 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
shape?: "circle" | "rect";
|
shape?: "circle" | "rect";
|
||||||
type?: "text" | "content";
|
type?: "text" | "content";
|
||||||
|
|
@ -20,21 +21,29 @@ const props = withDefaults(defineProps<{
|
||||||
maxWidth?: number;
|
maxWidth?: number;
|
||||||
widthUnit?: "px" | "%";
|
widthUnit?: "px" | "%";
|
||||||
class?: string;
|
class?: string;
|
||||||
}>(), {
|
}>(),
|
||||||
|
{
|
||||||
shape: "rect",
|
shape: "rect",
|
||||||
type: "text",
|
type: "text",
|
||||||
widthUnit: "px",
|
widthUnit: "px",
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const isContent = computed(() => props.type === "content");
|
const isContent = computed(() => props.type === "content");
|
||||||
const isText = computed(() => props.type === "text");
|
const isText = computed(() => props.type === "text");
|
||||||
const isWidthSpecified = computed(() => props.minWidth && props.maxWidth);
|
const isWidthSpecified = computed(() => props.minWidth && props.maxWidth);
|
||||||
const calculatedWidth = computed(() => Math.random() * ((props.maxWidth ?? 0) - (props.minWidth ?? 0)) + (props.minWidth ?? 0));
|
const calculatedWidth = computed(
|
||||||
|
() =>
|
||||||
|
Math.random() * ((props.maxWidth ?? 0) - (props.minWidth ?? 0)) +
|
||||||
|
(props.minWidth ?? 0),
|
||||||
|
);
|
||||||
|
|
||||||
const getWidth = (index: number, lines: number) => {
|
const getWidth = (index: number, lines: number) => {
|
||||||
if (isWidthSpecified.value) {
|
if (isWidthSpecified.value) {
|
||||||
if (isContent.value)
|
if (isContent.value)
|
||||||
return index === lines ? `${calculatedWidth.value}${props.widthUnit}` : '100%';
|
return index === lines
|
||||||
|
? `${calculatedWidth.value}${props.widthUnit}`
|
||||||
|
: "100%";
|
||||||
return `${calculatedWidth.value}${props.widthUnit}`;
|
return `${calculatedWidth.value}${props.widthUnit}`;
|
||||||
}
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,7 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import type { Attachment } from '~/types/mastodon/attachment';
|
import type { Attachment } from "~/types/mastodon/attachment";
|
||||||
|
|
||||||
const lightbox = ref(false);
|
const lightbox = ref(false);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,14 @@
|
||||||
<template>
|
<template>
|
||||||
<span
|
<a :href="`/@${account.acct}`"
|
||||||
class="shrink break-all rounded bg-pink-700/30 text-pink-200 px-2 py-1 not-prose font-semibold cursor-pointer [&:not(:last-child)]:mr-1 duration-200 hover:bg-pink-600/30">
|
class="shrink break-all rounded bg-pink-700/80 text-pink-200 px-2 py-1 not-prose font-semibold cursor-pointer [&:not(:last-child)]:mr-1 duration-200 hover:bg-pink-600/30">
|
||||||
<img class="h-[1em] w-[1em] rounded ring-1 ring-white/5 inline align-middle mb-1 mr-1" :src="account.avatar"
|
<img class="h-[1em] w-[1em] rounded ring-1 ring-white/5 inline align-middle mb-1 mr-1" :src="account.avatar"
|
||||||
alt="" />
|
alt="" />
|
||||||
{{ account.display_name }}
|
{{ account.display_name }}
|
||||||
</span>
|
</a>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import type { Account } from '~/types/mastodon/account';
|
import type { Account } from "~/types/mastodon/account";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
account: Account;
|
account: Account;
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@
|
||||||
<Skeleton :enabled="true" v-if="isLoading" :min-width="50" :max-width="100" width-unit="%" shape="rect"
|
<Skeleton :enabled="true" v-if="isLoading" :min-width="50" :max-width="100" width-unit="%" shape="rect"
|
||||||
type="content">
|
type="content">
|
||||||
</Skeleton>
|
</Skeleton>
|
||||||
<div v-else-if="content" class="prose prose-invert prose-a:no-underline" v-html="content">
|
<div v-else-if="content" class="prose prose-invert prose-a:no-underline content" v-html="content">
|
||||||
</div>
|
</div>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<div v-if="attachments.length > 0" class="[&:not(:first-child)]:mt-6">
|
<div v-if="attachments.length > 0" class="[&:not(:first-child)]:mt-6">
|
||||||
|
|
@ -43,7 +43,7 @@
|
||||||
</div>
|
</div>
|
||||||
<Skeleton class="!h-10 w-full mt-6" :enabled="true" v-if="isLoading"></Skeleton>
|
<Skeleton class="!h-10 w-full mt-6" :enabled="true" v-if="isLoading"></Skeleton>
|
||||||
<div v-else
|
<div v-else
|
||||||
class="mt-6 flex flex-row items-stretch justify-between text-sm h-10 hover:[&>button]:bg-dark-800 [&>button]:duration-200 [&>button]:rounded [&>button]:flex [&>button]:flex-1 [&>button]:flex-row [&>button]:items-center [&>button]:justify-center">
|
class="mt-6 flex flex-row items-stretch relative justify-between text-sm h-10 hover:[&>button]:bg-dark-800 [&>button]:duration-200 [&>button]:rounded [&>button]:flex [&>button]:flex-1 [&>button]:flex-row [&>button]:items-center [&>button]:justify-center">
|
||||||
<button>
|
<button>
|
||||||
<Icon name="tabler:arrow-back-up" class="h-5 w-5 text-gray-200" aria-hidden="true" />
|
<Icon name="tabler:arrow-back-up" class="h-5 w-5 text-gray-200" aria-hidden="true" />
|
||||||
<span class="text-gray-400 mt-0.5 ml-2">{{ numberFormat(note?.replies_count) }}</span>
|
<span class="text-gray-400 mt-0.5 ml-2">{{ numberFormat(note?.replies_count) }}</span>
|
||||||
|
|
@ -60,9 +60,28 @@
|
||||||
<Icon name="tabler:quote" class="h-5 w-5 text-gray-200" aria-hidden="true" />
|
<Icon name="tabler:quote" class="h-5 w-5 text-gray-200" aria-hidden="true" />
|
||||||
<span class="text-gray-400 mt-0.5 ml-2">{{ numberFormat(0) }}</span>
|
<span class="text-gray-400 mt-0.5 ml-2">{{ numberFormat(0) }}</span>
|
||||||
</button>
|
</button>
|
||||||
<button>
|
<DropdownsAdaptiveDropdown>
|
||||||
|
<template #button>
|
||||||
|
<HeadlessMenuButton>
|
||||||
<Icon name="tabler:dots" class="h-5 w-5 text-gray-200" aria-hidden="true" />
|
<Icon name="tabler:dots" class="h-5 w-5 text-gray-200" aria-hidden="true" />
|
||||||
</button>
|
</HeadlessMenuButton>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #items>
|
||||||
|
<HeadlessMenuItem>
|
||||||
|
<ButtonsDropdownElement @click="copy(JSON.stringify(note, null, 4))" icon="tabler:code"
|
||||||
|
class="w-full">
|
||||||
|
Copy API
|
||||||
|
Response
|
||||||
|
</ButtonsDropdownElement>
|
||||||
|
</HeadlessMenuItem>
|
||||||
|
<HeadlessMenuItem>
|
||||||
|
<ButtonsDropdownElement @click="note && copy(note.uri)" icon="tabler:code" class="w-full">
|
||||||
|
Copy Link
|
||||||
|
</ButtonsDropdownElement>
|
||||||
|
</HeadlessMenuItem>
|
||||||
|
</template>
|
||||||
|
</DropdownsAdaptiveDropdown>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -79,15 +98,53 @@ const props = defineProps<{
|
||||||
const isLoading = props.skeleton;
|
const isLoading = props.skeleton;
|
||||||
const timeAgo = useTimeAgo(props.note?.created_at ?? 0);
|
const timeAgo = useTimeAgo(props.note?.created_at ?? 0);
|
||||||
|
|
||||||
|
const { copy } = useClipboard();
|
||||||
const client = await useMegalodon();
|
const client = await useMegalodon();
|
||||||
const mentions = await useResolveMentions(props.note?.mentions ?? [], client);
|
const mentions = await useResolveMentions(props.note?.mentions ?? [], client);
|
||||||
const content = props.note ? await useParsedContent(props.note.content, props.note.emojis, mentions.value) : "";
|
const content = props.note
|
||||||
const numberFormat = (number = 0) => new Intl.NumberFormat(undefined, {
|
? await useParsedContent(
|
||||||
|
props.note.content,
|
||||||
|
props.note.emojis,
|
||||||
|
mentions.value,
|
||||||
|
)
|
||||||
|
: "";
|
||||||
|
const numberFormat = (number = 0) =>
|
||||||
|
new Intl.NumberFormat(undefined, {
|
||||||
notation: "compact",
|
notation: "compact",
|
||||||
compactDisplay: "short",
|
compactDisplay: "short",
|
||||||
maximumFractionDigits: 1,
|
maximumFractionDigits: 1,
|
||||||
}).format(number);
|
}).format(number);
|
||||||
const attachments = props.note?.media_attachments ?? [];
|
const attachments = props.note?.media_attachments ?? [];
|
||||||
const noteUrl = props.note && `/@${props.note.account.acct}/${props.note.id}`;
|
const noteUrl = props.note && `/@${props.note.account.acct}/${props.note.id}`;
|
||||||
const accountUrl = props.note && `/@${props.note.account.acct}`;
|
const accountUrl = props.note && `/@${props.note.account.acct}`;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.content pre:has(code) {
|
||||||
|
word-wrap: normal;
|
||||||
|
background: transparent;
|
||||||
|
background-color: #ffffff0d;
|
||||||
|
border-radius: .25rem;
|
||||||
|
-webkit-hyphens: none;
|
||||||
|
hyphens: none;
|
||||||
|
margin-top: 1rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding: .75rem 1rem;
|
||||||
|
-moz-tab-size: 4;
|
||||||
|
-o-tab-size: 4;
|
||||||
|
tab-size: 4;
|
||||||
|
white-space: pre;
|
||||||
|
word-break: normal;
|
||||||
|
word-spacing: normal;
|
||||||
|
--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);
|
||||||
|
--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);
|
||||||
|
box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), 0 0 #0000;
|
||||||
|
box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000);
|
||||||
|
--tw-ring-color: hsla(0, 0%, 100%, .1)
|
||||||
|
}
|
||||||
|
|
||||||
|
.content pre code {
|
||||||
|
display: block;
|
||||||
|
padding: 0
|
||||||
|
}
|
||||||
|
</style>
|
||||||
92
composables/AccountTimeline.ts
Normal file
92
composables/AccountTimeline.ts
Normal file
|
|
@ -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<Status[]>;
|
||||||
|
loadNext: () => Promise<void>;
|
||||||
|
loadPrev: () => Promise<void>;
|
||||||
|
} => {
|
||||||
|
if (!client || !id) {
|
||||||
|
return {
|
||||||
|
timeline: ref([]),
|
||||||
|
loadNext: async () => {},
|
||||||
|
loadPrev: async () => {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchedNotes = ref<Status[]>([]);
|
||||||
|
const fetchedNoteIds = new Set<string>();
|
||||||
|
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 };
|
||||||
|
};
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
|
import { renderToString } from "vue/server-renderer";
|
||||||
import type { Account } from "~/types/mastodon/account";
|
import type { Account } from "~/types/mastodon/account";
|
||||||
import type { Emoji } from "~/types/mastodon/emoji";
|
import type { Emoji } from "~/types/mastodon/emoji";
|
||||||
import MentionComponent from "../components/social-elements/notes/mention.vue";
|
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.
|
* 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);
|
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);
|
return ref(contentHtml.innerHTML);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ export default defineNuxtConfig({
|
||||||
"@nuxt/fonts",
|
"@nuxt/fonts",
|
||||||
"nuxt-icon",
|
"nuxt-icon",
|
||||||
"@vee-validate/nuxt",
|
"@vee-validate/nuxt",
|
||||||
|
"nuxt-shiki",
|
||||||
],
|
],
|
||||||
app: {
|
app: {
|
||||||
head: {
|
head: {
|
||||||
|
|
@ -21,6 +22,24 @@ export default defineNuxtConfig({
|
||||||
htmlAttrs: { lang: "en-us" },
|
htmlAttrs: { lang: "en-us" },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
shiki: {
|
||||||
|
defaultTheme: "rose-pine",
|
||||||
|
bundledLangs: [
|
||||||
|
"javascript",
|
||||||
|
"typescript",
|
||||||
|
"html",
|
||||||
|
"css",
|
||||||
|
"json",
|
||||||
|
"python",
|
||||||
|
"toml",
|
||||||
|
"rust",
|
||||||
|
"sql",
|
||||||
|
"scss",
|
||||||
|
"bash",
|
||||||
|
"shell",
|
||||||
|
"yaml",
|
||||||
|
],
|
||||||
|
},
|
||||||
nitro: {
|
nitro: {
|
||||||
preset: "bun",
|
preset: "bun",
|
||||||
minify: true,
|
minify: true,
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@
|
||||||
"nuxt": "^3.11.2",
|
"nuxt": "^3.11.2",
|
||||||
"nuxt-headlessui": "^1.2.0",
|
"nuxt-headlessui": "^1.2.0",
|
||||||
"nuxt-icon": "^0.6.10",
|
"nuxt-icon": "^0.6.10",
|
||||||
|
"nuxt-shiki": "^0.3.0",
|
||||||
"shiki": "^1.3.0",
|
"shiki": "^1.3.0",
|
||||||
"vue": "^3.4.21",
|
"vue": "^3.4.21",
|
||||||
"vue-router": "^4.3.0",
|
"vue-router": "^4.3.0",
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ import { useRoute } from "vue-router";
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const client = await useMegalodon();
|
const client = await useMegalodon();
|
||||||
const uuid = (route.params.uuid as string);
|
const uuid = route.params.uuid as string;
|
||||||
|
|
||||||
const note = await useNote(client, uuid);
|
const note = await useNote(client, uuid);
|
||||||
</script>
|
</script>
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="flex min-h-screen flex-col justify-center py-12 lg:px-8 relative">
|
<div
|
||||||
<div v-if="account" class="mx-auto max-w-lg w-full rounded ring-1 ring-white/10 pb-10">
|
class="flex mx-auto max-w-7xl min-h-screen flex-col gap-x-6 md:flex-row justify-center items-start md:py-12 lg:px-8 relative">
|
||||||
|
<div v-if="account" class="w-full rounded ring-1 md:max-w-lg ring-white/10 pb-10">
|
||||||
<div class="w-full aspect-[8/3] border-b border-white/10 bg-dark-700">
|
<div class="w-full aspect-[8/3] border-b border-white/10 bg-dark-700">
|
||||||
<img v-if="account.header" :src="account.header" class="object-cover w-full h-full" />
|
<img v-if="account.header" :src="account.header" class="object-cover w-full h-full" />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -22,7 +23,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-4 px-4">
|
<div class="mt-4 px-4">
|
||||||
<div class="prose prose-invert" v-html="account.note"></div>
|
<div class="prose prose-invert" v-html="parsedNote"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-3 flex items-center space-x-4 px-4">
|
<div class="mt-3 flex items-center space-x-4 px-4">
|
||||||
|
|
@ -46,8 +47,28 @@
|
||||||
<span class="text-gray-400">Followers</span>
|
<span class="text-gray-400">Followers</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="parsedFields.length > 0" class="mt-4 px-4 flex-col flex gap-y-3">
|
||||||
|
<div v-for="field of parsedFields" :key="field.name.value" class="flex flex-col gap-1">
|
||||||
|
<span class="text-pink-500 font-semibold" v-html="field.name.value"></span>
|
||||||
|
<span class="text-gray-200 prose prose-invert break-all" v-html="field.value.value"></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<ClientOnly>
|
||||||
|
<div class="w-full">
|
||||||
|
<SocialElementsNotesNote v-for="note of timeline" :key="note.id" :note="note" />
|
||||||
|
<span ref="skeleton"></span>
|
||||||
|
<SocialElementsNotesNote v-for="index of 5" v-if="!hasReachedEnd" :skeleton="true" />
|
||||||
|
|
||||||
|
<div v-if="hasReachedEnd"
|
||||||
|
class="text-center flex flex-row justify-center items-center py-10 text-gray-400 gap-3">
|
||||||
|
<Icon name="tabler:message-off" class="h-6 w-6" />
|
||||||
|
<span>No more posts, you've seen them all</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ClientOnly>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
|
@ -56,11 +77,55 @@ import { useRoute } from "vue-router";
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const client = await useMegalodon();
|
const client = await useMegalodon();
|
||||||
const username = (route.params.username as string).replace("@", "");
|
const username = (route.params.username as string).replace("@", "");
|
||||||
const id = await useAccountSearch(client, username);
|
const accounts = await useAccountSearch(client, username);
|
||||||
|
const account =
|
||||||
const account = id ? await useAccount(client, id[0].id) : null;
|
(await accounts?.find((account) => account.acct === username)) ?? null;
|
||||||
const formattedJoin = Intl.DateTimeFormat("en-US", {
|
const formattedJoin = Intl.DateTimeFormat("en-US", {
|
||||||
month: "long",
|
month: "long",
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
}).format(new Date(account?.created_at ?? 0));
|
}).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<HTMLSpanElement | null>(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;
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
@ -56,7 +56,7 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
const baseUrl = useBaseUrl()
|
const baseUrl = useBaseUrl();
|
||||||
|
|
||||||
useServerSeoMeta({
|
useServerSeoMeta({
|
||||||
title: "Welcome to Lysand!",
|
title: "Welcome to Lysand!",
|
||||||
|
|
|
||||||
|
|
@ -80,15 +80,17 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { toTypedSchema } from "@vee-validate/zod";
|
||||||
import { useRoute } from "vue-router";
|
import { useRoute } from "vue-router";
|
||||||
|
import { z } from "zod";
|
||||||
import LoginInput from "../../components/LoginInput.vue";
|
import LoginInput from "../../components/LoginInput.vue";
|
||||||
import { toTypedSchema } from '@vee-validate/zod';
|
|
||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
const schema = toTypedSchema(z.object({
|
const schema = toTypedSchema(
|
||||||
|
z.object({
|
||||||
email: z.string().email(),
|
email: z.string().email(),
|
||||||
password: z.string().min(3),
|
password: z.string().min(3),
|
||||||
}));
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
const query = useRoute().query;
|
const query = useRoute().query;
|
||||||
|
|
||||||
|
|
@ -101,5 +103,5 @@ const error_description = decodeURIComponent(query.error_description as string);
|
||||||
|
|
||||||
const validUrlParameters = redirect_uri && response_type && client_id && scope;
|
const validUrlParameters = redirect_uri && response_type && client_id && scope;
|
||||||
|
|
||||||
const oauthProviders = await useOAuthProviders()
|
const oauthProviders = await useOAuthProviders();
|
||||||
</script>
|
</script>
|
||||||
|
|
@ -97,7 +97,9 @@ const url = useRequestURL();
|
||||||
const query = useRoute().query;
|
const query = useRoute().query;
|
||||||
|
|
||||||
const application = "Soapbox"; //query.application;
|
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 redirect_uri = query.redirect_uri as string;
|
||||||
const client_id = query.client_id;
|
const client_id = query.client_id;
|
||||||
const scope = query.scope ? decodeURIComponent(query.scope as string) : "";
|
const scope = query.scope ? decodeURIComponent(query.scope as string) : "";
|
||||||
|
|
|
||||||
|
|
@ -21,17 +21,24 @@ const isLoading = ref(true);
|
||||||
|
|
||||||
const timelineParameters = ref({});
|
const timelineParameters = ref({});
|
||||||
const hasReachedEnd = ref(false);
|
const hasReachedEnd = ref(false);
|
||||||
const { timeline, loadNext, loadPrev } = usePublicTimeline(client, timelineParameters);
|
const { timeline, loadNext, loadPrev } = usePublicTimeline(
|
||||||
|
client,
|
||||||
|
timelineParameters,
|
||||||
|
);
|
||||||
const skeleton = ref<HTMLSpanElement | null>(null);
|
const skeleton = ref<HTMLSpanElement | null>(null);
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
useIntersectionObserver(skeleton, async (entries) => {
|
useIntersectionObserver(skeleton, async (entries) => {
|
||||||
if (entries[0].isIntersecting && !hasReachedEnd.value && !isLoading.value) {
|
if (
|
||||||
|
entries[0].isIntersecting &&
|
||||||
|
!hasReachedEnd.value &&
|
||||||
|
!isLoading.value
|
||||||
|
) {
|
||||||
isLoading.value = true;
|
isLoading.value = true;
|
||||||
await loadNext();
|
await loadNext();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
watch(timeline, (newTimeline, oldTimeline) => {
|
watch(timeline, (newTimeline, oldTimeline) => {
|
||||||
isLoading.value = false;
|
isLoading.value = false;
|
||||||
|
|
|
||||||
|
|
@ -84,28 +84,35 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import LoginInput from "../../components/LoginInput.vue";
|
import { toTypedSchema } from "@vee-validate/zod";
|
||||||
import { toTypedSchema } from '@vee-validate/zod';
|
|
||||||
import type { AxiosError } from "axios";
|
import type { AxiosError } from "axios";
|
||||||
import { z } from 'zod';
|
import { z } from "zod";
|
||||||
|
import LoginInput from "../../components/LoginInput.vue";
|
||||||
|
|
||||||
const schema = toTypedSchema(z.object({
|
const schema = toTypedSchema(
|
||||||
|
z
|
||||||
|
.object({
|
||||||
email: z.string().email(),
|
email: z.string().email(),
|
||||||
password: z.string().min(3),
|
password: z.string().min(3),
|
||||||
password2: z.string().min(3),
|
password2: z.string().min(3),
|
||||||
username: z.string().min(3).regex(/^[a-z0-9_]+$/),
|
username: z
|
||||||
|
.string()
|
||||||
|
.min(3)
|
||||||
|
.regex(/^[a-z0-9_]+$/),
|
||||||
reason: z.string().optional(),
|
reason: z.string().optional(),
|
||||||
tos: z.string(),
|
tos: z.string(),
|
||||||
}).superRefine((data, ctx) => {
|
})
|
||||||
|
.superRefine((data, ctx) => {
|
||||||
if (data.password !== data.password2) {
|
if (data.password !== data.password2) {
|
||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
path: [...ctx.path, 'password2'],
|
path: [...ctx.path, "password2"],
|
||||||
code: "custom",
|
code: "custom",
|
||||||
message: 'Passwords do not match',
|
message: "Passwords do not match",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return {};
|
return {};
|
||||||
}));
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
const client = await useMegalodon();
|
const client = await useMegalodon();
|
||||||
const instance = await useInstance(client);
|
const instance = await useInstance(client);
|
||||||
|
|
@ -126,7 +133,15 @@ const register = (result: {
|
||||||
reason: string;
|
reason: string;
|
||||||
}) => {
|
}) => {
|
||||||
isLoading.value = true;
|
isLoading.value = true;
|
||||||
client.registerAccount(result.username, result.email, result.password, true, "en", result.reason || "Empty reason")
|
client
|
||||||
|
.registerAccount(
|
||||||
|
result.username,
|
||||||
|
result.email,
|
||||||
|
result.password,
|
||||||
|
true,
|
||||||
|
"en",
|
||||||
|
result.reason || "Empty reason",
|
||||||
|
)
|
||||||
.then(async (res) => {
|
.then(async (res) => {
|
||||||
navigateTo("/register/success");
|
navigateTo("/register/success");
|
||||||
})
|
})
|
||||||
|
|
@ -135,8 +150,9 @@ const register = (result: {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
errors.value = error.response?.data || {};
|
errors.value = error.response?.data || {};
|
||||||
console.error(err);
|
console.error(err);
|
||||||
}).finally(() => {
|
})
|
||||||
|
.finally(() => {
|
||||||
isLoading.value = false;
|
isLoading.value = false;
|
||||||
});
|
});
|
||||||
}
|
};
|
||||||
</script>
|
</script>
|
||||||
Loading…
Reference in a new issue