refactor: ♻️ Redesign note UI

This commit is contained in:
Jesse Wierzbinski 2025-12-09 23:26:59 +01:00
parent 3627ac0ef8
commit 35f72e6197
No known key found for this signature in database
7 changed files with 77 additions and 127 deletions

View file

@ -1,5 +1,5 @@
<template> <template>
<Button variant="ghost" class="max-w-14 w-full" size="sm"> <Button variant="ghost" size="sm">
<component :is="icon" class="size-4"/> <component :is="icon" class="size-4"/>
<slot/> <slot/>
</Button> </Button>

View file

@ -1,5 +1,5 @@
<template> <template>
<div class="flex flex-row w-full max-w-sm items-stretch justify-between"> <div class="flex items-center gap-1">
<ActionButton <ActionButton
:icon="Reply" :icon="Reply"
@click="emit('reply')" @click="emit('reply')"
@ -39,21 +39,6 @@
:disabled="!authStore.isSignedIn" :disabled="!authStore.isSignedIn"
/> />
</Picker> </Picker>
<Menu
:api-note-string="apiNoteString"
:url="url"
:remote-url="remoteUrl"
:is-remote="isRemote"
:author-id="authorId"
@edit="emit('edit')"
:note-id="noteId"
@delete="emit('delete')"
>
<ActionButton
:icon="Ellipsis"
:title="m.busy_merry_cowfish_absorb()"
/>
</Menu>
</div> </div>
</template> </template>

View file

@ -1,64 +1,43 @@
<template> <template>
<div class="rounded grid grid-cols-[auto_1fr_auto] items-center gap-3"> <div class="flex items-start justify-between">
<HoverCard <div class="flex items-center gap-3">
v-model:open="popupOpen" <NuxtLink :href="urlAsPath">
@update:open="() => { <Avatar :src="author.avatar" :name="author.display_name"/>
if (!preferences.popup_avatar_hover) {
popupOpen = false;
}
}"
:open-delay="2000"
>
<HoverCardTrigger :as-child="true">
<NuxtLink
:href="urlAsPath"
:class="cn('relative size-12', smallLayout && 'size-8')"
>
<Avatar
:class="cn('size-12 border border-card', smallLayout && 'size-8')"
:src="author.avatar"
:name="author.display_name"
/>
<Avatar
v-if="cornerAvatar"
class="size-6 border absolute -bottom-1 -right-1"
:src="cornerAvatar"
/>
</NuxtLink> </NuxtLink>
</HoverCardTrigger> <div class="flex flex-col gap-0.5">
<HoverCardContent class="w-96"> <div class="flex items-center gap-1">
<SmallCard :account="author"/> <span
</HoverCardContent> class="text-sm font-semibold"
</HoverCard> v-render-emojis="author.emojis"
<Column :class="smallLayout && 'text-sm'"> >{{ author.display_name }}</span
<Text class="font-semibold" v-render-emojis="author.emojis">
{{
author.display_name
}}
</Text>
<div class="-mt-1">
<Address as="span" :username="username" :domain="instance"/>
&middot;
<Text
as="span"
muted
class="ml-auto tracking-normal"
:title="fullTime"
> >
{{ timeAgo }}
</Text>
</div> </div>
</Column> <div
<div v-if="!smallLayout"> class="flex items-center gap-1 text-muted-foreground text-xs"
<NuxtLink
:href="noteUrlAsPath"
class="text-xs text-muted-foreground"
:title="visibilities[visibility].text"
> >
<component :is="visibilities[visibility].icon" class="size-4"/> <span>
</NuxtLink> @{{ `${username}${instance ? `@${instance}` : ""}` }}
</span>
<span>&middot;</span>
<span>{{ timeAgo }}</span>
</div> </div>
</div> </div>
</div>
<Menu
:api-note-string="apiNoteString"
:url="noteUrl"
:remote-url="remoteUrl"
:is-remote="isRemote"
:author-id="author.id"
@edit="emit('edit')"
:note-id="noteId"
@delete="emit('delete')"
>
<Button variant="ghost" size="icon">
<Ellipsis/>
</Button>
</Menu>
</div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
@ -67,29 +46,24 @@ import type {
UseTimeAgoMessages, UseTimeAgoMessages,
UseTimeAgoUnitNamesDefault, UseTimeAgoUnitNamesDefault,
} from "@vueuse/core"; } from "@vueuse/core";
import { AtSign, Globe, Lock, LockOpen } from "lucide-vue-next"; import { AtSign, Ellipsis, Globe, Lock, LockOpen } from "lucide-vue-next";
import type { z } from "zod"; import type { z } from "zod";
import { cn } from "@/lib/utils";
import { getLocale } from "~~/paraglide/runtime"; import { getLocale } from "~~/paraglide/runtime";
import Address from "../profiles/address.vue";
import Avatar from "../profiles/avatar.vue"; import Avatar from "../profiles/avatar.vue";
import SmallCard from "../profiles/small-card.vue"; import { Button } from "../ui/button";
import Column from "../typography/layout/col.vue"; import Menu from "./menu.vue";
import Text from "../typography/text.vue";
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from "../ui/hover-card";
const { createdAt, noteUrl, author, authorUrl } = defineProps<{ const { createdAt, noteUrl, author, authorUrl } = defineProps<{
cornerAvatar?: string; cornerAvatar?: string;
visibility: z.infer<typeof Status.shape.visibility>; visibility: z.infer<typeof Status.shape.visibility>;
noteUrl: string; noteUrl: string;
createdAt: Date; createdAt: Date;
smallLayout?: boolean;
author: z.infer<typeof Account>; author: z.infer<typeof Account>;
authorUrl: string; authorUrl: string;
remoteUrl?: string;
apiNoteString: string;
isRemote: boolean;
noteId: string;
}>(); }>();
const [username, instance] = author.acct.split("@"); const [username, instance] = author.acct.split("@");
@ -117,6 +91,11 @@ const fullTime = new Intl.DateTimeFormat(getLocale(), {
}).format(createdAt); }).format(createdAt);
const popupOpen = ref(false); const popupOpen = ref(false);
const emit = defineEmits<{
edit: [];
delete: [];
}>();
const visibilities = { const visibilities = {
public: { public: {
icon: Globe, icon: Globe,

View file

@ -1,5 +1,12 @@
<template> <template>
<Card as="article" class="relative gap-3 items-stretch"> <Card
as="article"
:class="['relative gap-1.5 items-stretch bg-background', replyBar && 'pl-6']"
>
<div
v-if="replyBar"
class="absolute left-0 top-0 bottom-0 w-2 bg-border rounded-tl-md"
/>
<CardHeader as="header" class="space-y-2"> <CardHeader as="header" class="space-y-2">
<ReblogHeader <ReblogHeader
v-if="note.reblog" v-if="note.reblog"
@ -13,34 +20,18 @@
:author-url="accountUrl" :author-url="accountUrl"
:corner-avatar="note.reblog ? note.account.avatar : undefined" :corner-avatar="note.reblog ? note.account.avatar : undefined"
:note-url="url" :note-url="url"
:is-remote="isRemote"
:remote-url="noteToUse.url ?? undefined"
:api-note-string="JSON.stringify(noteToUse, null, 4)"
:visibility="noteToUse.visibility" :visibility="noteToUse.visibility"
:created-at="new Date(noteToUse.created_at)" :created-at="new Date(noteToUse.created_at)"
:small-layout="smallLayout" @edit="useEvent('composer:edit', noteToUse)"
@delete="useEvent('note:delete', noteToUse)"
:note-id="noteToUse.id"
class="z-1" class="z-1"
/> />
<div
v-if="topAvatarBar"
:class="
cn(
'shrink-0 bg-border w-0.5 absolute top-0 h-7 left-12'
)
"
></div>
<div
v-if="bottomAvatarBar"
:class="
cn(
'shrink-0 bg-border w-0.5 absolute bottom-0 h-[calc(100%-1.5rem)] left-12'
)
"
></div>
</CardHeader> </CardHeader>
<!-- Simply offset by the size of avatar + 0.75rem (the gap) --> <CardContent class="space-y-2">
<CardContent
:class="
['space-y-4', contentUnderUsername && (smallLayout ? 'ml-11' : 'ml-17')]
"
>
<Content <Content
:content="noteToUse.content" :content="noteToUse.content"
:quote="note.quote ?? undefined" :quote="note.quote ?? undefined"
@ -82,7 +73,6 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Status } from "@versia/client/schemas"; import type { Status } from "@versia/client/schemas";
import type { z } from "zod"; import type { z } from "zod";
import { cn } from "@/lib/utils";
import { Card, CardContent, CardFooter, CardHeader } from "../ui/card"; import { Card, CardContent, CardFooter, CardHeader } from "../ui/card";
import Actions from "./actions.vue"; import Actions from "./actions.vue";
import Content from "./content.vue"; import Content from "./content.vue";
@ -92,13 +82,14 @@ import ReblogHeader from "./reblog-header.vue";
type PartialBy<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>; type PartialBy<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
const { note } = defineProps<{ const {
note,
hideActions,
replyBar = false,
} = defineProps<{
note: PartialBy<z.infer<typeof Status>, "reblog" | "quote">; note: PartialBy<z.infer<typeof Status>, "reblog" | "quote">;
hideActions?: boolean; hideActions?: boolean;
smallLayout?: boolean; replyBar?: boolean;
contentUnderUsername?: boolean;
topAvatarBar?: boolean;
bottomAvatarBar?: boolean;
}>(); }>();
// Notes can be reblogs, in which case the actual thing to render is inside the reblog property // Notes can be reblogs, in which case the actual thing to render is inside the reblog property

View file

@ -1,5 +1,5 @@
<template> <template>
<div class="flex flex-row gap-2 flex-wrap"> <div class="flex flex-row gap-1 flex-wrap">
<Reaction <Reaction
v-for="reaction in reactions" v-for="reaction in reactions"
:key="reaction.name" :key="reaction.name"

View file

@ -13,9 +13,9 @@
v-if="emoji" v-if="emoji"
:src="emoji.url" :src="emoji.url"
:alt="emoji.shortcode" :alt="emoji.shortcode"
class="h-[1lh] align-middle inline not-prose" class="h-lh align-middle inline not-prose"
> >
<span v-else> {{ reaction.name }}</span> <span v-else>{{ reaction.name }}</span>
{{ formatNumber(reaction.count) }} {{ formatNumber(reaction.count) }}
</Button> </Button>
</HoverCardTrigger> </HoverCardTrigger>

View file

@ -4,15 +4,10 @@
v-if="parent" v-if="parent"
:note="parent" :note="parent"
:hide-actions="true" :hide-actions="true"
:content-under-username="true" :reply-bar="true"
:bottom-avatar-bar="true" class="rounded-b-none"
class="border-b-0 rounded-b-none"
/>
<Note
:note="note"
:class="parent && 'border-t-0 rounded-t-none'"
:top-avatar-bar="!!parent"
/> />
<Note :note="note" :class="parent && 'border-t-0 rounded-t-none'"/>
</div> </div>
</template> </template>