Skip to content

Commit

Permalink
feat: ✨ Create new user profile view, refine components, add dropdown…
Browse files Browse the repository at this point in the history
… to notes
  • Loading branch information
CPlusPatch committed Apr 25, 2024
1 parent a0d0737 commit a17df9f
Show file tree
Hide file tree
Showing 21 changed files with 470 additions and 133 deletions.
Binary file modified bun.lockb
Binary file not shown.
2 changes: 1 addition & 1 deletion components/LoginInput.vue
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
</template>

<script setup lang="ts">
import type { InputHTMLAttributes } from 'vue';
import type { InputHTMLAttributes } from "vue";
interface Props extends /* @vue-ignore */ InputHTMLAttributes {
isInvalid?: boolean;
Expand Down
19 changes: 19 additions & 0 deletions components/buttons/DropdownElement.vue
Original file line number Diff line number Diff line change
@@ -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>
2 changes: 1 addition & 1 deletion components/buttons/base.vue
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
<script lang="ts" setup>
import type { ButtonHTMLAttributes } from "vue";
interface Props extends /* @vue-ignore */ ButtonHTMLAttributes { }
interface Props extends /* @vue-ignore */ ButtonHTMLAttributes {}
defineProps<Props>();
</script>
Expand Down
29 changes: 29 additions & 0 deletions components/dropdowns/AdaptiveDropdown.vue
Original file line number Diff line number Diff line change
@@ -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>
39 changes: 24 additions & 15 deletions components/skeleton/Skeleton.vue
Original file line number Diff line number Diff line change
Expand Up @@ -12,29 +12,38 @@
</template>

<script lang="ts" setup>
const props = withDefaults(defineProps<{
enabled: boolean;
shape?: "circle" | "rect";
type?: "text" | "content";
minWidth?: number;
maxWidth?: number;
widthUnit?: "px" | "%";
class?: string;
}>(), {
shape: "rect",
type: "text",
widthUnit: "px",
});
const props = withDefaults(
defineProps<{
enabled: boolean;
shape?: "circle" | "rect";
type?: "text" | "content";
minWidth?: number;
maxWidth?: number;
widthUnit?: "px" | "%";
class?: string;
}>(),
{
shape: "rect",
type: "text",
widthUnit: "px",
},
);
const isContent = computed(() => props.type === "content");
const isText = computed(() => props.type === "text");
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) => {
if (isWidthSpecified.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 undefined;
Expand Down
2 changes: 1 addition & 1 deletion components/social-elements/notes/attachment.vue
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@
</template>

<script lang="ts" setup>
import type { Attachment } from '~/types/mastodon/attachment';
import type { Attachment } from "~/types/mastodon/attachment";
const lightbox = ref(false);
Expand Down
8 changes: 4 additions & 4 deletions components/social-elements/notes/mention.vue
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
<template>
<span
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">
<a :href="`/@${account.acct}`"
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"
alt="" />
{{ account.display_name }}
</span>
</a>
</template>

<script lang="ts" setup>
import type { Account } from '~/types/mastodon/account';
import type { Account } from "~/types/mastodon/account";
const props = defineProps<{
account: Account;
Expand Down
81 changes: 69 additions & 12 deletions components/social-elements/notes/note.vue
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
<Skeleton :enabled="true" v-if="isLoading" :min-width="50" :max-width="100" width-unit="%" shape="rect"
type="content">
</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>
</NuxtLink>
<div v-if="attachments.length > 0" class="[&:not(:first-child)]:mt-6">
Expand All @@ -43,7 +43,7 @@
</div>
<Skeleton class="!h-10 w-full mt-6" :enabled="true" v-if="isLoading"></Skeleton>
<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>
<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>
Expand All @@ -60,9 +60,28 @@
<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>
</button>
<button>
<Icon name="tabler:dots" class="h-5 w-5 text-gray-200" aria-hidden="true" />
</button>
<DropdownsAdaptiveDropdown>
<template #button>
<HeadlessMenuButton>
<Icon name="tabler:dots" class="h-5 w-5 text-gray-200" aria-hidden="true" />
</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>
</template>
Expand All @@ -79,15 +98,53 @@ const props = defineProps<{
const isLoading = props.skeleton;
const timeAgo = useTimeAgo(props.note?.created_at ?? 0);
const { copy } = useClipboard();
const client = await useMegalodon();
const mentions = await useResolveMentions(props.note?.mentions ?? [], client);
const content = props.note ? await useParsedContent(props.note.content, props.note.emojis, mentions.value) : "";
const numberFormat = (number = 0) => new Intl.NumberFormat(undefined, {
notation: "compact",
compactDisplay: "short",
maximumFractionDigits: 1,
}).format(number);
const content = props.note
? await useParsedContent(
props.note.content,
props.note.emojis,
mentions.value,
)
: "";
const numberFormat = (number = 0) =>
new Intl.NumberFormat(undefined, {
notation: "compact",
compactDisplay: "short",
maximumFractionDigits: 1,
}).format(number);
const attachments = props.note?.media_attachments ?? [];
const noteUrl = props.note && `/@${props.note.account.acct}/${props.note.id}`;
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 changes: 92 additions & 0 deletions composables/AccountTimeline.ts
Original file line number Diff line number Diff line change
@@ -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 };
};
21 changes: 20 additions & 1 deletion composables/ParsedContent.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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);
};
File renamed without changes.
Loading

0 comments on commit a17df9f

Please sign in to comment.