mirror of
https://github.com/versia-pub/frontend.git
synced 2026-01-26 04:16:02 +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>
|
</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());
|
||||||
|
|
|
||||||
|
|
@ -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) =>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)"
|
)"
|
||||||
|
|
|
||||||
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",
|
"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
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