mirror of
https://github.com/versia-pub/frontend.git
synced 2025-12-06 08:28:20 +01:00
feat: ✨ Give more functionality to note menu
This commit is contained in:
parent
97566289cd
commit
49d356e2ab
3
app.vue
3
app.vue
|
|
@ -11,6 +11,8 @@
|
|||
</NuxtLayout>
|
||||
<NotificationsRenderer />
|
||||
<ConfirmationModal />
|
||||
<!-- pointer-events-auto fixes https://github.com/unovue/shadcn-vue/issues/462 -->
|
||||
<Toaster class="pointer-events-auto" />
|
||||
</ClientOnly>
|
||||
</template>
|
||||
|
||||
|
|
@ -21,6 +23,7 @@ import { convert } from "html-to-text";
|
|||
import "iconify-icon";
|
||||
import ConfirmationModal from "./components/modals/confirmation.vue";
|
||||
import NotificationsRenderer from "./components/notifications/notifications-renderer.vue";
|
||||
import { Toaster } from "./components/ui/sonner";
|
||||
import { SettingIds } from "./settings";
|
||||
// Use SSR-safe IDs for Headless UI
|
||||
provideHeadlessUseId(() => useId());
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@
|
|||
<Button variant="ghost">
|
||||
<Quote class="size-5 text-primary" />
|
||||
</Button>
|
||||
<Menu>
|
||||
<Menu :api-note-string="apiNoteString" :url="url" :remote-url="remoteUrl" :is-remote="isRemote" :author-id="authorId">
|
||||
<Button variant="ghost">
|
||||
<Ellipsis class="size-5 text-primary" />
|
||||
</Button>
|
||||
|
|
@ -32,6 +32,11 @@ defineProps<{
|
|||
replyCount: number;
|
||||
likeCount: number;
|
||||
reblogCount: number;
|
||||
apiNoteString: string;
|
||||
isRemote: boolean;
|
||||
url: string;
|
||||
remoteUrl: string;
|
||||
authorId: string;
|
||||
}>();
|
||||
|
||||
const numberFormat = (number = 0) =>
|
||||
|
|
|
|||
|
|
@ -4,36 +4,31 @@
|
|||
<slot />
|
||||
</span>
|
||||
<span class="hidden group-hover:inline">
|
||||
<span @click="copyText" v-if="!hasCopied"
|
||||
<span @click="copyText"
|
||||
class="select-none cursor-pointer space-x-1">
|
||||
<Clipboard class="size-4 -translate-y-0.5 inline" />
|
||||
Click to copy
|
||||
</span>
|
||||
<span v-else class="select-none space-x-1">
|
||||
<Check class="size-4 -translate-y-0.5 inline" />
|
||||
Copied!
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
<script lang="tsx" setup>
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Check, Clipboard } from "lucide-vue-next";
|
||||
import type { HTMLAttributes } from "vue";
|
||||
import { toast } from "vue-sonner";
|
||||
|
||||
const { text } = defineProps<{
|
||||
text: string;
|
||||
class?: HTMLAttributes["class"];
|
||||
}>();
|
||||
|
||||
const hasCopied = ref(false);
|
||||
const { copy } = useClipboard();
|
||||
const copyText = () => {
|
||||
copy(text);
|
||||
hasCopied.value = true;
|
||||
setTimeout(() => {
|
||||
hasCopied.value = false;
|
||||
}, 2000);
|
||||
toast("Copied to clipboard", {
|
||||
icon: <Check class="size-5 text-green-500" />,
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
<template>
|
||||
<NuxtLink :href="url" class="rounded flex flex-row items-center gap-3">
|
||||
<div :class="cn('relative size-14', smallLayout && 'size-6')">
|
||||
<div class="rounded flex flex-row items-center gap-3">
|
||||
<NuxtLink :href="url" :class="cn('relative size-14', smallLayout && 'size-6')">
|
||||
<Avatar :class="cn('size-14 rounded-md border border-card', smallLayout && 'size-6')">
|
||||
<AvatarImage :src="avatar" alt="" />
|
||||
<AvatarFallback class="rounded-lg"> AA </AvatarFallback>
|
||||
|
|
@ -9,7 +9,7 @@
|
|||
<AvatarImage :src="cornerAvatar" alt="" />
|
||||
<AvatarFallback class="rounded-lg"> AA </AvatarFallback>
|
||||
</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')">
|
||||
<span class="truncate font-semibold">{{
|
||||
displayName
|
||||
|
|
@ -31,7 +31,7 @@
|
|||
<component :is="visibilities[visibility].icon" class="size-5" />
|
||||
</span>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
<script setup lang="ts">
|
||||
<script setup lang="tsx">
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
|
|
@ -11,6 +11,7 @@ import {
|
|||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Ban,
|
||||
Check,
|
||||
Code,
|
||||
Delete,
|
||||
ExternalLink,
|
||||
|
|
@ -19,6 +20,32 @@ import {
|
|||
Pencil,
|
||||
Trash,
|
||||
} 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>
|
||||
|
||||
<template>
|
||||
|
|
@ -30,27 +57,27 @@ import {
|
|||
<DropdownMenuLabel>Note Actions</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem>
|
||||
<DropdownMenuItem as="button">
|
||||
<Pencil class="mr-2 size-4" />
|
||||
<span>Edit</span>
|
||||
<DropdownMenuShortcut>⇧⌘E</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<DropdownMenuItem as="button" @click="copyText(apiNoteString)">
|
||||
<Code class="mr-2 size-4" />
|
||||
<span>Copy API data</span>
|
||||
<DropdownMenuShortcut>⌘B</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<DropdownMenuItem as="button" @click="copyText(url)">
|
||||
<Link class="mr-2 size-4" />
|
||||
<span>Copy link</span>
|
||||
<DropdownMenuShortcut>⌘S</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<DropdownMenuItem as="button" v-if="isRemote" @click="copyText(remoteUrl)">
|
||||
<Link class="mr-2 size-4" />
|
||||
<span>Copy link (origin)</span>
|
||||
<DropdownMenuShortcut>⌘K</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<DropdownMenuItem as="a" v-if="isRemote" target="_blank" rel="noopener noreferrer" :href="remoteUrl">
|
||||
<ExternalLink class="mr-2 size-4" />
|
||||
<span>Open on remote</span>
|
||||
<DropdownMenuShortcut>⌘F</DropdownMenuShortcut>
|
||||
|
|
@ -58,11 +85,11 @@ import {
|
|||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem>
|
||||
<DropdownMenuItem as="button">
|
||||
<Delete class="mr-2 size-4" />
|
||||
<span>Delete and redraft</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<DropdownMenuItem as="button">
|
||||
<Trash class="mr-2 size-4" />
|
||||
<span>Delete</span>
|
||||
<DropdownMenuShortcut>⌘D</DropdownMenuShortcut>
|
||||
|
|
@ -70,11 +97,11 @@ import {
|
|||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem>
|
||||
<DropdownMenuItem as="button" :disabled="true">
|
||||
<MessageSquare class="mr-2 size-4" />
|
||||
<span>Report</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<DropdownMenuItem as="button" @click="blockUser(authorId)">
|
||||
<Ban class="mr-2 size-4" />
|
||||
<span>Block user</span>
|
||||
</DropdownMenuItem>
|
||||
|
|
|
|||
|
|
@ -1,18 +1,19 @@
|
|||
<template>
|
||||
<Card as="article" class="rounded-none border-0 duration-200 shadow-none">
|
||||
<CardHeader class="pb-4" as="header">
|
||||
<ReblogHeader v-if="note.reblog" :avatar="note.account.avatar"
|
||||
:display-name="note.account.display_name" :url="reblogAccountUrl" />
|
||||
<ReblogHeader v-if="note.reblog" :avatar="note.account.avatar" :display-name="note.account.display_name"
|
||||
:url="reblogAccountUrl" />
|
||||
<Header :avatar="noteToUse.account.avatar" :corner-avatar="note.reblog ? note.account.avatar : undefined"
|
||||
: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>
|
||||
<CardContent>
|
||||
<Content :content="noteToUse.content" :quote="note.quote ?? undefined" />
|
||||
</CardContent>
|
||||
<CardFooter v-if="!hideActions">
|
||||
<Actions :reply-count="noteToUse.replies_count" :like-count="noteToUse.favourites_count"
|
||||
:reblog-count="noteToUse.reblogs_count" />
|
||||
<Actions :reply-count="noteToUse.replies_count" :like-count="noteToUse.favourites_count" :url="url"
|
||||
: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>
|
||||
</Card>
|
||||
</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
|
||||
const noteToUse = note.reblog ? note.reblog : note;
|
||||
|
||||
const url = `/@${noteToUse.account.acct}/${noteToUse.id}`;
|
||||
const accountUrl = `/@${noteToUse.account.acct}`;
|
||||
const reblogAccountUrl = `/@${note.account.acct}`;
|
||||
const url = wrapUrl(`/@${noteToUse.account.acct}/${noteToUse.id}`);
|
||||
const accountUrl = wrapUrl(`/@${noteToUse.account.acct}`);
|
||||
const reblogAccountUrl = wrapUrl(`/@${note.account.acct}`);
|
||||
const isRemote = noteToUse.account.acct.includes("@");
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ const forwardedProps = useForwardProps(delegatedProps);
|
|||
<DropdownMenuItem
|
||||
v-bind="forwardedProps"
|
||||
: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',
|
||||
props.class,
|
||||
)"
|
||||
|
|
|
|||
18
components/ui/sonner/Sonner.vue
Normal file
18
components/ui/sonner/Sonner.vue
Normal 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>
|
||||
1
components/ui/sonner/index.ts
Normal file
1
components/ui/sonner/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default as Toaster } from "./Sonner.vue";
|
||||
|
|
@ -63,6 +63,7 @@
|
|||
"tailwindcss-animate": "^1.0.7",
|
||||
"vue": "^3.5.13",
|
||||
"vue-router": "^4.5.0",
|
||||
"vue-sonner": "^1.3.0",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
8
utils/urls.ts
Normal file
8
utils/urls.ts
Normal 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();
|
||||
};
|
||||
Loading…
Reference in a new issue