feat: Give more functionality to note menu

This commit is contained in:
Jesse Wierzbinski 2024-11-30 18:21:40 +01:00
parent 97566289cd
commit 49d356e2ab
No known key found for this signature in database
12 changed files with 95 additions and 35 deletions

View file

@ -11,6 +11,8 @@
</NuxtLayout> </NuxtLayout>
<NotificationsRenderer /> <NotificationsRenderer />
<ConfirmationModal /> <ConfirmationModal />
<!-- pointer-events-auto fixes https://github.com/unovue/shadcn-vue/issues/462 -->
<Toaster class="pointer-events-auto" />
</ClientOnly> </ClientOnly>
</template> </template>
@ -21,6 +23,7 @@ import { convert } from "html-to-text";
import "iconify-icon"; import "iconify-icon";
import ConfirmationModal from "./components/modals/confirmation.vue"; import ConfirmationModal from "./components/modals/confirmation.vue";
import NotificationsRenderer from "./components/notifications/notifications-renderer.vue"; import NotificationsRenderer from "./components/notifications/notifications-renderer.vue";
import { Toaster } from "./components/ui/sonner";
import { SettingIds } from "./settings"; import { SettingIds } from "./settings";
// Use SSR-safe IDs for Headless UI // Use SSR-safe IDs for Headless UI
provideHeadlessUseId(() => useId()); provideHeadlessUseId(() => useId());

BIN
bun.lockb

Binary file not shown.

View file

@ -15,7 +15,7 @@
<Button variant="ghost"> <Button variant="ghost">
<Quote class="size-5 text-primary" /> <Quote class="size-5 text-primary" />
</Button> </Button>
<Menu> <Menu :api-note-string="apiNoteString" :url="url" :remote-url="remoteUrl" :is-remote="isRemote" :author-id="authorId">
<Button variant="ghost"> <Button variant="ghost">
<Ellipsis class="size-5 text-primary" /> <Ellipsis class="size-5 text-primary" />
</Button> </Button>
@ -32,6 +32,11 @@ defineProps<{
replyCount: number; replyCount: number;
likeCount: number; likeCount: number;
reblogCount: number; reblogCount: number;
apiNoteString: string;
isRemote: boolean;
url: string;
remoteUrl: string;
authorId: string;
}>(); }>();
const numberFormat = (number = 0) => const numberFormat = (number = 0) =>

View file

@ -4,36 +4,31 @@
<slot /> <slot />
</span> </span>
<span class="hidden group-hover:inline"> <span class="hidden group-hover:inline">
<span @click="copyText" v-if="!hasCopied" <span @click="copyText"
class="select-none cursor-pointer space-x-1"> class="select-none cursor-pointer space-x-1">
<Clipboard class="size-4 -translate-y-0.5 inline" /> <Clipboard class="size-4 -translate-y-0.5 inline" />
Click to copy Click to copy
</span> </span>
<span v-else class="select-none space-x-1">
<Check class="size-4 -translate-y-0.5 inline" />
Copied!
</span>
</span> </span>
</span> </span>
</template> </template>
<script lang="ts" setup> <script lang="tsx" setup>
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Check, Clipboard } from "lucide-vue-next"; import { Check, Clipboard } from "lucide-vue-next";
import type { HTMLAttributes } from "vue"; import type { HTMLAttributes } from "vue";
import { toast } from "vue-sonner";
const { text } = defineProps<{ const { text } = defineProps<{
text: string; text: string;
class?: HTMLAttributes["class"]; class?: HTMLAttributes["class"];
}>(); }>();
const hasCopied = ref(false);
const { copy } = useClipboard(); const { copy } = useClipboard();
const copyText = () => { const copyText = () => {
copy(text); copy(text);
hasCopied.value = true; toast("Copied to clipboard", {
setTimeout(() => { icon: <Check class="size-5 text-green-500" />,
hasCopied.value = false; });
}, 2000);
}; };
</script> </script>

View file

@ -1,6 +1,6 @@
<template> <template>
<NuxtLink :href="url" class="rounded flex flex-row items-center gap-3"> <div class="rounded flex flex-row items-center gap-3">
<div :class="cn('relative size-14', smallLayout && 'size-6')"> <NuxtLink :href="url" :class="cn('relative size-14', smallLayout && 'size-6')">
<Avatar :class="cn('size-14 rounded-md border border-card', smallLayout && 'size-6')"> <Avatar :class="cn('size-14 rounded-md border border-card', smallLayout && 'size-6')">
<AvatarImage :src="avatar" alt="" /> <AvatarImage :src="avatar" alt="" />
<AvatarFallback class="rounded-lg"> AA </AvatarFallback> <AvatarFallback class="rounded-lg"> AA </AvatarFallback>
@ -9,7 +9,7 @@
<AvatarImage :src="cornerAvatar" alt="" /> <AvatarImage :src="cornerAvatar" alt="" />
<AvatarFallback class="rounded-lg"> AA </AvatarFallback> <AvatarFallback class="rounded-lg"> AA </AvatarFallback>
</Avatar> </Avatar>
</div> </NuxtLink>
<div :class="cn('flex flex-col gap-0.5 justify-center flex-1 text-left leading-tight', smallLayout && 'flex-row justify-start items-center gap-2')"> <div :class="cn('flex flex-col gap-0.5 justify-center flex-1 text-left leading-tight', smallLayout && 'flex-row justify-start items-center gap-2')">
<span class="truncate font-semibold">{{ <span class="truncate font-semibold">{{
displayName displayName
@ -31,7 +31,7 @@
<component :is="visibilities[visibility].icon" class="size-5" /> <component :is="visibilities[visibility].icon" class="size-5" />
</span> </span>
</div> </div>
</NuxtLink> </div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>

View file

@ -1,4 +1,4 @@
<script setup lang="ts"> <script setup lang="tsx">
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@ -11,6 +11,7 @@ import {
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { import {
Ban, Ban,
Check,
Code, Code,
Delete, Delete,
ExternalLink, ExternalLink,
@ -19,6 +20,32 @@ import {
Pencil, Pencil,
Trash, Trash,
} from "lucide-vue-next"; } from "lucide-vue-next";
import { toast } from "vue-sonner";
defineProps<{
apiNoteString: string;
isRemote: boolean;
url: string;
remoteUrl: string;
authorId: string;
}>();
const { copy } = useClipboard();
const copyText = (text: string) => {
copy(text);
toast("Copied to clipboard", {
icon: <Check class="size-5 text-green-500" />,
});
};
const blockUser = async (id: string) => {
await client.value.blockAccount(id);
toast("User blocked", {
icon: <Ban class="size-5 text-destructive" />,
});
};
</script> </script>
<template> <template>
@ -30,27 +57,27 @@ import {
<DropdownMenuLabel>Note Actions</DropdownMenuLabel> <DropdownMenuLabel>Note Actions</DropdownMenuLabel>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuGroup> <DropdownMenuGroup>
<DropdownMenuItem> <DropdownMenuItem as="button">
<Pencil class="mr-2 size-4" /> <Pencil class="mr-2 size-4" />
<span>Edit</span> <span>Edit</span>
<DropdownMenuShortcut>E</DropdownMenuShortcut> <DropdownMenuShortcut>E</DropdownMenuShortcut>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem> <DropdownMenuItem as="button" @click="copyText(apiNoteString)">
<Code class="mr-2 size-4" /> <Code class="mr-2 size-4" />
<span>Copy API data</span> <span>Copy API data</span>
<DropdownMenuShortcut>B</DropdownMenuShortcut> <DropdownMenuShortcut>B</DropdownMenuShortcut>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem> <DropdownMenuItem as="button" @click="copyText(url)">
<Link class="mr-2 size-4" /> <Link class="mr-2 size-4" />
<span>Copy link</span> <span>Copy link</span>
<DropdownMenuShortcut>S</DropdownMenuShortcut> <DropdownMenuShortcut>S</DropdownMenuShortcut>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem> <DropdownMenuItem as="button" v-if="isRemote" @click="copyText(remoteUrl)">
<Link class="mr-2 size-4" /> <Link class="mr-2 size-4" />
<span>Copy link (origin)</span> <span>Copy link (origin)</span>
<DropdownMenuShortcut>K</DropdownMenuShortcut> <DropdownMenuShortcut>K</DropdownMenuShortcut>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem> <DropdownMenuItem as="a" v-if="isRemote" target="_blank" rel="noopener noreferrer" :href="remoteUrl">
<ExternalLink class="mr-2 size-4" /> <ExternalLink class="mr-2 size-4" />
<span>Open on remote</span> <span>Open on remote</span>
<DropdownMenuShortcut>F</DropdownMenuShortcut> <DropdownMenuShortcut>F</DropdownMenuShortcut>
@ -58,11 +85,11 @@ import {
</DropdownMenuGroup> </DropdownMenuGroup>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuGroup> <DropdownMenuGroup>
<DropdownMenuItem> <DropdownMenuItem as="button">
<Delete class="mr-2 size-4" /> <Delete class="mr-2 size-4" />
<span>Delete and redraft</span> <span>Delete and redraft</span>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem> <DropdownMenuItem as="button">
<Trash class="mr-2 size-4" /> <Trash class="mr-2 size-4" />
<span>Delete</span> <span>Delete</span>
<DropdownMenuShortcut>D</DropdownMenuShortcut> <DropdownMenuShortcut>D</DropdownMenuShortcut>
@ -70,11 +97,11 @@ import {
</DropdownMenuGroup> </DropdownMenuGroup>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuGroup> <DropdownMenuGroup>
<DropdownMenuItem> <DropdownMenuItem as="button" :disabled="true">
<MessageSquare class="mr-2 size-4" /> <MessageSquare class="mr-2 size-4" />
<span>Report</span> <span>Report</span>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem> <DropdownMenuItem as="button" @click="blockUser(authorId)">
<Ban class="mr-2 size-4" /> <Ban class="mr-2 size-4" />
<span>Block user</span> <span>Block user</span>
</DropdownMenuItem> </DropdownMenuItem>

View file

@ -1,18 +1,19 @@
<template> <template>
<Card as="article" class="rounded-none border-0 duration-200 shadow-none"> <Card as="article" class="rounded-none border-0 duration-200 shadow-none">
<CardHeader class="pb-4" as="header"> <CardHeader class="pb-4" as="header">
<ReblogHeader v-if="note.reblog" :avatar="note.account.avatar" <ReblogHeader v-if="note.reblog" :avatar="note.account.avatar" :display-name="note.account.display_name"
:display-name="note.account.display_name" :url="reblogAccountUrl" /> :url="reblogAccountUrl" />
<Header :avatar="noteToUse.account.avatar" :corner-avatar="note.reblog ? note.account.avatar : undefined" <Header :avatar="noteToUse.account.avatar" :corner-avatar="note.reblog ? note.account.avatar : undefined"
:acct="noteToUse.account.acct" :display-name="noteToUse.account.display_name" :acct="noteToUse.account.acct" :display-name="noteToUse.account.display_name"
:visibility="noteToUse.visibility" :url="accountUrl" :created-at="new Date(noteToUse.created_at)" :small-layout="smallLayout" /> :visibility="noteToUse.visibility" :url="accountUrl" :created-at="new Date(noteToUse.created_at)"
:small-layout="smallLayout" />
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<Content :content="noteToUse.content" :quote="note.quote ?? undefined" /> <Content :content="noteToUse.content" :quote="note.quote ?? undefined" />
</CardContent> </CardContent>
<CardFooter v-if="!hideActions"> <CardFooter v-if="!hideActions">
<Actions :reply-count="noteToUse.replies_count" :like-count="noteToUse.favourites_count" <Actions :reply-count="noteToUse.replies_count" :like-count="noteToUse.favourites_count" :url="url"
:reblog-count="noteToUse.reblogs_count" /> :api-note-string="JSON.stringify(note, null, 4)" :reblog-count="noteToUse.reblogs_count" :remote-url="noteToUse.url" :is-remote="isRemote" :author-id="noteToUse.account.id" />
</CardFooter> </CardFooter>
</Card> </Card>
</template> </template>
@ -34,7 +35,8 @@ const { note } = defineProps<{
// 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
const noteToUse = note.reblog ? note.reblog : note; const noteToUse = note.reblog ? note.reblog : note;
const url = `/@${noteToUse.account.acct}/${noteToUse.id}`; const url = wrapUrl(`/@${noteToUse.account.acct}/${noteToUse.id}`);
const accountUrl = `/@${noteToUse.account.acct}`; const accountUrl = wrapUrl(`/@${noteToUse.account.acct}`);
const reblogAccountUrl = `/@${note.account.acct}`; const reblogAccountUrl = wrapUrl(`/@${note.account.acct}`);
const isRemote = noteToUse.account.acct.includes("@");
</script> </script>

View file

@ -24,7 +24,7 @@ const forwardedProps = useForwardProps(delegatedProps);
<DropdownMenuItem <DropdownMenuItem
v-bind="forwardedProps" v-bind="forwardedProps"
:class="cn( :class="cn(
'relative flex cursor-default select-none items-center rounded-sm gap-2 px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0', 'relative flex cursor-default select-none items-center rounded-sm gap-2 px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0 w-full',
inset && 'pl-8', inset && 'pl-8',
props.class, props.class,
)" )"

View file

@ -0,0 +1,18 @@
<script lang="ts" setup>
import { Toaster as Sonner, type ToasterProps } from "vue-sonner";
const props = defineProps<ToasterProps>();
</script>
<template>
<Sonner class="toaster group" v-bind="props" :toast-options="{
classes: {
toast: 'group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg',
description: 'group-[.toast]:text-muted-foreground',
actionButton:
'group-[.toast]:bg-primary group-[.toast]:text-primary-foreground',
cancelButton:
'group-[.toast]:bg-muted group-[.toast]:text-muted-foreground',
},
}" :position="'top-right'" />
</template>

View file

@ -0,0 +1 @@
export { default as Toaster } from "./Sonner.vue";

View file

@ -63,6 +63,7 @@
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"vue": "^3.5.13", "vue": "^3.5.13",
"vue-router": "^4.5.0", "vue-router": "^4.5.0",
"vue-sonner": "^1.3.0",
"zod": "^3.23.8" "zod": "^3.23.8"
}, },
"devDependencies": { "devDependencies": {

8
utils/urls.ts Normal file
View file

@ -0,0 +1,8 @@
export const wrapUrl = (path: string) => {
return new URL(
path,
identity.value
? `https://${identity.value.instance.domain}`
: window.location.origin,
).toString();
};