mirror of
https://github.com/versia-pub/frontend.git
synced 2025-12-06 08:28:20 +01:00
feat: ✨ Refactor notifications
This commit is contained in:
parent
d32f4d6899
commit
a6c5093cf5
32
app.vue
32
app.vue
|
|
@ -1,19 +1,21 @@
|
||||||
<template>
|
<template>
|
||||||
<ClientOnly>
|
<TooltipProvider>
|
||||||
<Component is="style">
|
<ClientOnly>
|
||||||
{{ customCss.value }}
|
<Component is="style">
|
||||||
</Component>
|
{{ customCss.value }}
|
||||||
</ClientOnly>
|
</Component>
|
||||||
<NuxtPwaAssets />
|
</ClientOnly>
|
||||||
<ClientOnly>
|
<NuxtPwaAssets />
|
||||||
<NuxtLayout>
|
<ClientOnly>
|
||||||
<NuxtPage />
|
<NuxtLayout>
|
||||||
</NuxtLayout>
|
<NuxtPage />
|
||||||
<NotificationsRenderer />
|
</NuxtLayout>
|
||||||
<ConfirmationModal />
|
<NotificationsRenderer />
|
||||||
<!-- pointer-events-auto fixes https://github.com/unovue/shadcn-vue/issues/462 -->
|
<ConfirmationModal />
|
||||||
<Toaster class="pointer-events-auto" />
|
<!-- pointer-events-auto fixes https://github.com/unovue/shadcn-vue/issues/462 -->
|
||||||
</ClientOnly>
|
<Toaster class="pointer-events-auto" />
|
||||||
|
</ClientOnly>
|
||||||
|
</TooltipProvider>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
|
|
||||||
|
|
@ -16,31 +16,27 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter class="items-center flex-row">
|
<DialogFooter class="items-center flex-row">
|
||||||
<TooltipProvider>
|
<Tooltip>
|
||||||
<Tooltip>
|
<TooltipTrigger as="div">
|
||||||
<TooltipTrigger as="div">
|
<Button variant="ghost" size="icon">
|
||||||
<Button variant="ghost" size="icon">
|
<AtSign class="!size-5" />
|
||||||
<AtSign class="!size-5" />
|
</Button>
|
||||||
</Button>
|
</TooltipTrigger>
|
||||||
</TooltipTrigger>
|
<TooltipContent>
|
||||||
<TooltipContent>
|
<p>Mention someone</p>
|
||||||
<p>Mention someone</p>
|
</TooltipContent>
|
||||||
</TooltipContent>
|
</Tooltip>
|
||||||
</Tooltip>
|
<Tooltip>
|
||||||
</TooltipProvider>
|
<TooltipTrigger as="div">
|
||||||
<TooltipProvider>
|
<Toggle variant="default" size="sm" :pressed="state.contentType === 'text/markdown'"
|
||||||
<Tooltip>
|
@update:pressed="i => state.contentType = i ? 'text/plain' : 'text/markdown'">
|
||||||
<TooltipTrigger as="div">
|
<LetterText class="!size-5" />
|
||||||
<Toggle variant="default" size="sm" :pressed="state.contentType === 'text/markdown'"
|
</Toggle>
|
||||||
@update:pressed="i => state.contentType = i ? 'text/plain' : 'text/markdown'">
|
</TooltipTrigger>
|
||||||
<LetterText class="!size-5" />
|
<TooltipContent>
|
||||||
</Toggle>
|
<p>Enable Markdown</p>
|
||||||
</TooltipTrigger>
|
</TooltipContent>
|
||||||
<TooltipContent>
|
</Tooltip>
|
||||||
<p>Enable Markdown</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
<Select v-model:model-value="state.visibility">
|
<Select v-model:model-value="state.visibility">
|
||||||
<SelectTrigger :as-child="true" :disabled="relation?.type === 'edit'">
|
<SelectTrigger :as-child="true" :disabled="relation?.type === 'edit'">
|
||||||
<Button variant="ghost" size="icon">
|
<Button variant="ghost" size="icon">
|
||||||
|
|
@ -59,42 +55,36 @@
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<TooltipProvider>
|
<Tooltip>
|
||||||
<Tooltip>
|
<TooltipTrigger as="div">
|
||||||
<TooltipTrigger as="div">
|
<Button variant="ghost" size="icon">
|
||||||
<Button variant="ghost" size="icon">
|
<Smile class="!size-5" />
|
||||||
<Smile class="!size-5" />
|
</Button>
|
||||||
</Button>
|
</TooltipTrigger>
|
||||||
</TooltipTrigger>
|
<TooltipContent>
|
||||||
<TooltipContent>
|
<p>Insert emoji</p>
|
||||||
<p>Insert emoji</p>
|
</TooltipContent>
|
||||||
</TooltipContent>
|
</Tooltip>
|
||||||
</Tooltip>
|
<Tooltip>
|
||||||
</TooltipProvider>
|
<TooltipTrigger as="div">
|
||||||
<TooltipProvider>
|
<Button variant="ghost" size="icon" @click="fileInput?.click()">
|
||||||
<Tooltip>
|
<FilePlus2 class="!size-5" />
|
||||||
<TooltipTrigger as="div">
|
</Button>
|
||||||
<Button variant="ghost" size="icon" @click="fileInput?.click()">
|
</TooltipTrigger>
|
||||||
<FilePlus2 class="!size-5" />
|
<TooltipContent>
|
||||||
</Button>
|
<p>Attach a file</p>
|
||||||
</TooltipTrigger>
|
</TooltipContent>
|
||||||
<TooltipContent>
|
</Tooltip>
|
||||||
<p>Attach a file</p>
|
<Tooltip>
|
||||||
</TooltipContent>
|
<TooltipTrigger as="div">
|
||||||
</Tooltip>
|
<Toggle variant="default" size="sm" v-model:pressed="state.sensitive">
|
||||||
</TooltipProvider>
|
<TriangleAlert class="!size-5" />
|
||||||
<TooltipProvider>
|
</Toggle>
|
||||||
<Tooltip>
|
</TooltipTrigger>
|
||||||
<TooltipTrigger as="div">
|
<TooltipContent>
|
||||||
<Toggle variant="default" size="sm" v-model:pressed="state.sensitive">
|
<p>Mark as sensitive</p>
|
||||||
<TriangleAlert class="!size-5" />
|
</TooltipContent>
|
||||||
</Toggle>
|
</Tooltip>
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p>Mark as sensitive</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
<Button type="submit" size="lg" class="ml-auto" :disabled="sending" @click="submit">
|
<Button type="submit" size="lg" class="ml-auto" :disabled="sending" @click="submit">
|
||||||
<Loader v-if="sending" class="!size-5 animate-spin" />
|
<Loader v-if="sending" class="!size-5 animate-spin" />
|
||||||
{{ relation?.type === "edit" ? "Save" : "Send" }}
|
{{ relation?.type === "edit" ? "Save" : "Send" }}
|
||||||
|
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
<template>
|
|
||||||
<div v-if="identity" class="bg-dark-800 z-0 p-6 my-5 relative overflow-hidden rounded ring-1 ring-white/5">
|
|
||||||
<div class="sm:flex sm:items-center sm:justify-between gap-3">
|
|
||||||
<div class="sm:flex sm:space-x-5 grow">
|
|
||||||
<Avatar :src="identity.account.avatar"
|
|
||||||
class="mx-auto shrink-0 size-20 rounded overflow-hidden ring-1 ring-white/10"
|
|
||||||
:alt="'Your avatar'" />
|
|
||||||
<div
|
|
||||||
class="mt-4 text-center flex flex-col justify-center sm:mt-0 sm:text-left bg-dark-800 py-2 px-4 rounded grow ring-1 ring-white/10">
|
|
||||||
<p class="text-sm font-medium text-gray-300">Welcome back,</p>
|
|
||||||
<p class="text-xl font-bold text-gray-50 sm:text-2xl line-clamp-1" v-html="display_name"></p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script lang="ts" setup>
|
|
||||||
import Avatar from "../avatars/avatar.vue";
|
|
||||||
|
|
||||||
const { display_name } = useParsedAccount(
|
|
||||||
computed(() => identity.value?.account),
|
|
||||||
settings,
|
|
||||||
);
|
|
||||||
</script>
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
import {
|
|
||||||
confirmModalService,
|
|
||||||
confirmModalWithInputService,
|
|
||||||
} from "./service.ts";
|
|
||||||
import type { ConfirmModalOptions, ConfirmModalResult } from "./types.ts";
|
|
||||||
|
|
||||||
export function useConfirmModal() {
|
|
||||||
const confirm = (
|
|
||||||
options: ConfirmModalOptions,
|
|
||||||
): Promise<ConfirmModalResult> => {
|
|
||||||
return confirmModalService.confirm(options);
|
|
||||||
};
|
|
||||||
|
|
||||||
const confirmWithInput = (
|
|
||||||
options: ConfirmModalOptions,
|
|
||||||
placeholder?: string,
|
|
||||||
): Promise<ConfirmModalResult> => {
|
|
||||||
return confirmModalWithInputService.confirm(options, placeholder);
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
confirm,
|
|
||||||
confirmWithInput,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,124 +0,0 @@
|
||||||
<template>
|
|
||||||
<HeadlessTransitionRoot as="template" :show="isOpen">
|
|
||||||
<Dialog.Root :open="isOpen" @update:open="handleOpenChange" :close-on-escape="true"
|
|
||||||
:close-on-interact-outside="true">
|
|
||||||
<Teleport to="body">
|
|
||||||
<Dialog.Positioner class="fixed inset-0 z-50 flex items-end md:items-center justify-center md:p-4">
|
|
||||||
<HeadlessTransitionChild as="template" enter="ease-out duration-300" enter-from="opacity-0"
|
|
||||||
enter-to="opacity-100" leave="ease-in duration-300" leave-from="opacity-100"
|
|
||||||
leave-to="opacity-0">
|
|
||||||
<Dialog.Backdrop class="fixed inset-0 bg-black/70 backdrop-blur-sm" />
|
|
||||||
</HeadlessTransitionChild>
|
|
||||||
|
|
||||||
<HeadlessTransitionChild as="template" enter="ease-out duration-300" enter-from="opacity-0 scale-95"
|
|
||||||
enter-to="opacity-100 scale-100" leave="ease-in duration-300" leave-from="opacity-100 scale-100"
|
|
||||||
leave-to="opacity-0 scale-95">
|
|
||||||
<Dialog.Content class="relative w-full md:max-w-md p-6 rounded bg-dark-800 ring-1 ring-white/10 shadow-xl">
|
|
||||||
<Dialog.Title class="mb-4 text-lg font-bold tracking-tight text-gray-100 sm:text-xl">
|
|
||||||
{{ modalOptions.title || 'Confirm Action' }}
|
|
||||||
</Dialog.Title>
|
|
||||||
|
|
||||||
<div class="mb-6 text-gray-300">
|
|
||||||
{{ modalOptions.message }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="withInput" class="mb-4">
|
|
||||||
<input v-model="inputValue" type="text"
|
|
||||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
||||||
:placeholder="inputPlaceholder" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-10 grid grid-cols-1 md:grid-cols-2 gap-3 *:!py-2">
|
|
||||||
<Button @click="handleCancel"
|
|
||||||
theme="outline">
|
|
||||||
{{ modalOptions.cancelText || 'Cancel' }}
|
|
||||||
</button>
|
|
||||||
<Button @click="handleConfirm"
|
|
||||||
theme="primary">
|
|
||||||
{{ modalOptions.confirmText || 'Confirm' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</Dialog.Content>
|
|
||||||
</HeadlessTransitionChild>
|
|
||||||
</Dialog.Positioner>
|
|
||||||
</Teleport>
|
|
||||||
</Dialog.Root>
|
|
||||||
</HeadlessTransitionRoot>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { Dialog } from "@ark-ui/vue";
|
|
||||||
import Button from "~/packages/ui/components/buttons/button.vue";
|
|
||||||
import {
|
|
||||||
confirmModalService,
|
|
||||||
confirmModalWithInputService,
|
|
||||||
} from "./service.ts";
|
|
||||||
import type { ConfirmModalOptions, ConfirmModalResult } from "./types.ts";
|
|
||||||
|
|
||||||
const isOpen = ref(false);
|
|
||||||
const modalOptions = ref<ConfirmModalOptions>({ message: "" });
|
|
||||||
const resolvePromise = ref<((result: ConfirmModalResult) => void) | null>(null);
|
|
||||||
const inputValue = ref("");
|
|
||||||
const withInput = ref(false);
|
|
||||||
const inputPlaceholder = ref("");
|
|
||||||
|
|
||||||
const open = async (
|
|
||||||
options: ConfirmModalOptions,
|
|
||||||
): Promise<ConfirmModalResult> => {
|
|
||||||
modalOptions.value = options;
|
|
||||||
isOpen.value = true;
|
|
||||||
withInput.value = false;
|
|
||||||
inputValue.value = "";
|
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
resolvePromise.value = resolve;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const openWithInput = async (
|
|
||||||
options: ConfirmModalOptions,
|
|
||||||
placeholder = "Enter value",
|
|
||||||
): Promise<ConfirmModalResult> => {
|
|
||||||
modalOptions.value = options;
|
|
||||||
isOpen.value = true;
|
|
||||||
withInput.value = true;
|
|
||||||
inputValue.value = "";
|
|
||||||
inputPlaceholder.value = placeholder;
|
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
resolvePromise.value = resolve;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleConfirm = () => {
|
|
||||||
if (resolvePromise.value) {
|
|
||||||
resolvePromise.value({
|
|
||||||
confirmed: true,
|
|
||||||
value: withInput.value ? inputValue.value : undefined,
|
|
||||||
});
|
|
||||||
isOpen.value = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCancel = () => {
|
|
||||||
if (resolvePromise.value) {
|
|
||||||
resolvePromise.value({ confirmed: false });
|
|
||||||
isOpen.value = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleOpenChange = (open: boolean) => {
|
|
||||||
if (!open && resolvePromise.value) {
|
|
||||||
resolvePromise.value({ confirmed: false });
|
|
||||||
isOpen.value = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Register the component with the service
|
|
||||||
confirmModalService.register({
|
|
||||||
open,
|
|
||||||
});
|
|
||||||
confirmModalWithInputService.register({
|
|
||||||
open: openWithInput,
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
@ -1,52 +0,0 @@
|
||||||
import { ref } from "vue";
|
|
||||||
import type { ConfirmModalOptions, ConfirmModalResult } from "./types.ts";
|
|
||||||
|
|
||||||
class ConfirmModalService {
|
|
||||||
private modalRef = ref<{
|
|
||||||
open: (options: ConfirmModalOptions) => Promise<ConfirmModalResult>;
|
|
||||||
} | null>(null);
|
|
||||||
|
|
||||||
register(modal: {
|
|
||||||
open: (options: ConfirmModalOptions) => Promise<ConfirmModalResult>;
|
|
||||||
}) {
|
|
||||||
this.modalRef.value = modal;
|
|
||||||
}
|
|
||||||
|
|
||||||
confirm(options: ConfirmModalOptions): Promise<ConfirmModalResult> {
|
|
||||||
if (!this.modalRef.value) {
|
|
||||||
throw new Error("Confirmation modal not initialized");
|
|
||||||
}
|
|
||||||
return this.modalRef.value.open(options);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class ConfirmModalWithInputService {
|
|
||||||
private modalRef = ref<{
|
|
||||||
open: (
|
|
||||||
options: ConfirmModalOptions,
|
|
||||||
placeholder?: string,
|
|
||||||
) => Promise<ConfirmModalResult>;
|
|
||||||
} | null>(null);
|
|
||||||
|
|
||||||
register(modal: {
|
|
||||||
open: (
|
|
||||||
options: ConfirmModalOptions,
|
|
||||||
placeholder?: string,
|
|
||||||
) => Promise<ConfirmModalResult>;
|
|
||||||
}) {
|
|
||||||
this.modalRef.value = modal;
|
|
||||||
}
|
|
||||||
|
|
||||||
confirm(
|
|
||||||
options: ConfirmModalOptions,
|
|
||||||
placeholder?: string,
|
|
||||||
): Promise<ConfirmModalResult> {
|
|
||||||
if (!this.modalRef.value) {
|
|
||||||
throw new Error("Confirmation modal not initialized");
|
|
||||||
}
|
|
||||||
return this.modalRef.value.open(options, placeholder);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const confirmModalService = new ConfirmModalService();
|
|
||||||
export const confirmModalWithInputService = new ConfirmModalWithInputService();
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
export interface ConfirmModalOptions {
|
|
||||||
title?: string;
|
|
||||||
message: string;
|
|
||||||
confirmText?: string;
|
|
||||||
cancelText?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ConfirmModalResult {
|
|
||||||
confirmed: boolean;
|
|
||||||
value?: string;
|
|
||||||
}
|
|
||||||
87
components/notifications/follow-request.vue
Normal file
87
components/notifications/follow-request.vue
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
<template>
|
||||||
|
<div v-if="relationship?.requested_by !== false" class="flex flex-row items-center gap-3 p-4">
|
||||||
|
<NuxtLink class="relative size-10">
|
||||||
|
<Avatar class="size-10 rounded border border-border">
|
||||||
|
<AvatarImage :src="follower.avatar" alt="" />
|
||||||
|
<AvatarFallback class="rounded-lg"> AA </AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
</NuxtLink>
|
||||||
|
<div class="flex flex-col gap-0.5 justify-center flex-1 text-left leading-tight text-sm">
|
||||||
|
<span class="truncate font-semibold">{{
|
||||||
|
follower.display_name
|
||||||
|
}}</span>
|
||||||
|
<span class="truncate tracking-tight">
|
||||||
|
<CopyableText :text="follower.acct">
|
||||||
|
<span
|
||||||
|
class="font-semibold bg-gradient-to-tr from-pink-700 dark:from-indigo-400 via-purple-700 dark:via-purple-400 to-indigo-700 dark:to-indigo-400 text-transparent bg-clip-text">
|
||||||
|
@{{ username }}
|
||||||
|
</span>
|
||||||
|
<span class="text-muted-foreground">{{ instance && "@" }}{{ instance }}</span>
|
||||||
|
</CopyableText>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="loading" class="flex p-2 items-center justify-center h-12">
|
||||||
|
<Loader class="size-4 animate-spin" />
|
||||||
|
</div>
|
||||||
|
<div v-else-if="relationship?.requested_by === false" class="flex p-2 items-center justify-center h-12">
|
||||||
|
<Check class="size-4" />
|
||||||
|
</div>
|
||||||
|
<div v-else class="grid grid-cols-2 p-2 gap-2">
|
||||||
|
<Button variant="outline" size="sm" @click="accept">
|
||||||
|
<Check />
|
||||||
|
Accept
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" @click="reject">
|
||||||
|
<X />
|
||||||
|
Reject
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import type { Account } from "@versia/client/types";
|
||||||
|
import { Check, Loader, X } from "lucide-vue-next";
|
||||||
|
import { toast } from "vue-sonner";
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
|
||||||
|
const { follower } = defineProps<{
|
||||||
|
follower: Account;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const loading = ref(true);
|
||||||
|
const [username, instance] = follower.acct.split("@");
|
||||||
|
const { relationship } = useRelationship(client, follower.id);
|
||||||
|
|
||||||
|
// TODO: Add "followed" notification
|
||||||
|
watch(relationship, () => {
|
||||||
|
if (relationship.value) {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const accept = async () => {
|
||||||
|
const id = toast.loading("Accepting follow request...");
|
||||||
|
loading.value = true;
|
||||||
|
|
||||||
|
const { data } = await client.value.acceptFollowRequest(follower.id);
|
||||||
|
|
||||||
|
toast.dismiss(id);
|
||||||
|
toast.success("Follow request accepted.");
|
||||||
|
relationship.value = data;
|
||||||
|
loading.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const reject = async () => {
|
||||||
|
const id = toast.loading("Rejecting follow request...");
|
||||||
|
loading.value = true;
|
||||||
|
|
||||||
|
const { data } = await client.value.rejectFollowRequest(follower.id);
|
||||||
|
|
||||||
|
toast.dismiss(id);
|
||||||
|
toast.success("Follow request rejected.");
|
||||||
|
relationship.value = data;
|
||||||
|
loading.value = false;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
@ -1,32 +1,45 @@
|
||||||
<template>
|
<template>
|
||||||
<TooltipProvider>
|
<Card>
|
||||||
<Card>
|
<Collapsible>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger :as-child="true">
|
<TooltipTrigger :as-child="true">
|
||||||
<CardHeader v-if="notification.account" class="flex-row items-center gap-2 p-4">
|
<CardHeader v-if="notification.account"
|
||||||
<component :is="icon" class="size-5" />
|
class="flex-row items-center gap-2 px-4 py-2 border-b border-border">
|
||||||
|
<component :is="icon" class="size-5 shrink-0" />
|
||||||
<Avatar class="size-6 rounded-md border border-card">
|
<Avatar class="size-6 rounded-md border border-card">
|
||||||
<AvatarImage :src="notification.account.avatar" alt="" />
|
<AvatarImage :src="notification.account.avatar" alt="" />
|
||||||
<AvatarFallback class="rounded-lg"> AA </AvatarFallback>
|
<AvatarFallback class="rounded-lg"> AA </AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<span class="font-semibold">{{ notification.type === 'mention' ? text.toLowerCase() : notification.account.display_name }}</span>
|
<span class="font-semibold">{{
|
||||||
|
notification.account.display_name
|
||||||
|
}}</span>
|
||||||
|
<CollapsibleTrigger :as-child="true">
|
||||||
|
<Button variant="ghost" size="icon" class="ml-auto [&_svg]:data-[state=open]:-rotate-180">
|
||||||
|
<ChevronDown class="duration-200" />
|
||||||
|
</Button>
|
||||||
|
</CollapsibleTrigger>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<p>{{ text }}</p>
|
<p>{{ text }}</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<CardContent class="p-0">
|
<CollapsibleContent :as-child="true">
|
||||||
<Note v-if="notification.status" :note="notification.status" :small-layout="true" :hide-actions="true" />
|
<CardContent class="p-0">
|
||||||
</CardContent>
|
<Note v-if="notification.status" :note="notification.status" :small-layout="true"
|
||||||
</Card>
|
:hide-actions="true" />
|
||||||
</TooltipProvider>
|
<FollowRequest v-else-if="notification.type === 'follow_request' && notification.account" :follower="notification.account" />
|
||||||
|
</CardContent>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
</Card>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import type { Notification } from "@versia/client/types";
|
import type { Notification } from "@versia/client/types";
|
||||||
import {
|
import {
|
||||||
AtSign,
|
AtSign,
|
||||||
|
ChevronDown,
|
||||||
Heart,
|
Heart,
|
||||||
Repeat,
|
Repeat,
|
||||||
User,
|
User,
|
||||||
|
|
@ -34,14 +47,20 @@ import {
|
||||||
UserPlus,
|
UserPlus,
|
||||||
} from "lucide-vue-next";
|
} from "lucide-vue-next";
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar";
|
import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader } from "~/components/ui/card";
|
import { Card, CardContent, CardHeader } from "~/components/ui/card";
|
||||||
|
import {
|
||||||
|
Collapsible,
|
||||||
|
CollapsibleContent,
|
||||||
|
CollapsibleTrigger,
|
||||||
|
} from "~/components/ui/collapsible";
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
TooltipProvider,
|
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "~/components/ui/tooltip";
|
} from "~/components/ui/tooltip";
|
||||||
import Note from "../notes/note.vue";
|
import Note from "../notes/note.vue";
|
||||||
|
import FollowRequest from "./follow-request.vue";
|
||||||
|
|
||||||
const { notification } = defineProps<{
|
const { notification } = defineProps<{
|
||||||
notification: Notification;
|
notification: Notification;
|
||||||
|
|
@ -84,4 +103,4 @@ const text = computed(() => {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -275,7 +275,7 @@ const instance = useInstance();
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
</SidebarInset>
|
</SidebarInset>
|
||||||
<Sidebar variant="inset" collapsible="none" side="right" class="[--sidebar-width:24rem]">
|
<Sidebar variant="inset" collapsible="none" side="right" class="[--sidebar-width:24rem] hidden lg:flex">
|
||||||
<SidebarContent class="p-2 overflow-y-auto">
|
<SidebarContent class="p-2 overflow-y-auto">
|
||||||
<NotificationsTimeline />
|
<NotificationsTimeline />
|
||||||
</SidebarContent>
|
</SidebarContent>
|
||||||
|
|
|
||||||
|
|
@ -1,116 +0,0 @@
|
||||||
<template>
|
|
||||||
<div class="flex flex-col p-1 bg-dark-400">
|
|
||||||
<div class="px-4 pt-2 pb-3 flex flex-row gap-2 items-center">
|
|
||||||
<Skeleton :enabled="!element" shape="rect" class="!h-6" :min-width="40" :max-width="100" width-unit="%">
|
|
||||||
<iconify-icon :icon="icon" width="1.5rem" height="1.5rem" class="text-gray-200" aria-hidden="true" />
|
|
||||||
<Avatar v-if="element?.account?.avatar" :src="element?.account.avatar"
|
|
||||||
:alt="`${element?.account.acct}'s avatar'`" class="h-6 w-6 shrink-0 rounded ring-1 ring-white/10" />
|
|
||||||
<span class="text-gray-200 line-clamp-1"><strong v-html="display_name"></strong> {{ text
|
|
||||||
}}</span>
|
|
||||||
</Skeleton>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Note v-if="element?.status || !element" :element="element?.status" :small="true" />
|
|
||||||
<div v-else-if="element.account" class="p-6 ring-1 ring-white/5 bg-dark-800">
|
|
||||||
<SmallCard :account="element.account" />
|
|
||||||
</div>
|
|
||||||
<div v-if="element?.type === 'follow_request' && relationship?.requested_by"
|
|
||||||
class="w-full grid grid-cols-2 gap-4 p-2 ">
|
|
||||||
<Button theme="primary" :loading="isWorkingOnFollowRequest"
|
|
||||||
@click="acceptFollowRequest"><span>Accept</span>
|
|
||||||
</Button>
|
|
||||||
<Button theme="secondary" :loading="isWorkingOnFollowRequest"
|
|
||||||
@click="rejectFollowRequest"><span>Reject</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script lang="ts" setup>
|
|
||||||
import type { Notification } from "@versia/client/types";
|
|
||||||
import Avatar from "~/components/avatars/avatar.vue";
|
|
||||||
import Skeleton from "~/components/skeleton/Skeleton.vue";
|
|
||||||
import Button from "~/packages/ui/components/buttons/button.vue";
|
|
||||||
//import Note from "../notes/note.vue";
|
|
||||||
import SmallCard from "../users/SmallCard.vue";
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
element?: Notification;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const isWorkingOnFollowRequest = ref(false);
|
|
||||||
const { relationship } = useRelationship(
|
|
||||||
client,
|
|
||||||
props.element?.account?.id ?? null,
|
|
||||||
);
|
|
||||||
|
|
||||||
const acceptFollowRequest = async () => {
|
|
||||||
if (!props.element?.account) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
isWorkingOnFollowRequest.value = true;
|
|
||||||
const { data } = await client.value.acceptFollowRequest(
|
|
||||||
props.element.account.id,
|
|
||||||
);
|
|
||||||
relationship.value = data;
|
|
||||||
isWorkingOnFollowRequest.value = false;
|
|
||||||
};
|
|
||||||
|
|
||||||
const rejectFollowRequest = async () => {
|
|
||||||
if (!props.element?.account) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
isWorkingOnFollowRequest.value = true;
|
|
||||||
const { data } = await client.value.rejectFollowRequest(
|
|
||||||
props.element.account.id,
|
|
||||||
);
|
|
||||||
relationship.value = data;
|
|
||||||
isWorkingOnFollowRequest.value = false;
|
|
||||||
};
|
|
||||||
|
|
||||||
const { display_name } = useParsedAccount(props.element?.account, settings);
|
|
||||||
|
|
||||||
const text = computed(() => {
|
|
||||||
if (!props.element) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (props.element.type) {
|
|
||||||
case "mention":
|
|
||||||
return "mentioned you";
|
|
||||||
case "reblog":
|
|
||||||
return "reblogged your note";
|
|
||||||
case "favourite":
|
|
||||||
return "liked your note";
|
|
||||||
case "follow":
|
|
||||||
return "followed you";
|
|
||||||
case "follow_request":
|
|
||||||
return "requested to follow you";
|
|
||||||
default: {
|
|
||||||
console.error("Unknown notification type", props.element.type);
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
const icon = computed(() => {
|
|
||||||
if (!props.element) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (props.element.type) {
|
|
||||||
case "mention":
|
|
||||||
return "tabler:at";
|
|
||||||
case "reblog":
|
|
||||||
return "tabler:repeat";
|
|
||||||
case "favourite":
|
|
||||||
return "tabler:heart";
|
|
||||||
case "follow":
|
|
||||||
return "tabler:plus";
|
|
||||||
case "follow_request":
|
|
||||||
return "tabler:plus";
|
|
||||||
default:
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
<!-- Timeline.vue -->
|
<!-- Timeline.vue -->
|
||||||
<template>
|
<template>
|
||||||
<div class="timeline rounded">
|
<div class="timeline rounded">
|
||||||
<TransitionGroup name="timeline-item" tag="div" class="timeline-items *:rounded space-y-4 *:border *:border-border/50">
|
<TransitionGroup name="timeline-item" tag="div"
|
||||||
|
class="timeline-items *:rounded space-y-4 *:border *:border-border/50">
|
||||||
<TimelineItem :type="type" v-for="item in items" :key="item.id" :item="item" @update="updateItem"
|
<TimelineItem :type="type" v-for="item in items" :key="item.id" :item="item" @update="updateItem"
|
||||||
@delete="removeItem" />
|
@delete="removeItem" />
|
||||||
</TransitionGroup>
|
</TransitionGroup>
|
||||||
|
|
@ -12,17 +13,25 @@
|
||||||
{{ error.message }}
|
{{ error.message }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="hasReachedEnd && items.length > 0"
|
<!-- If there are some posts, but the user scrolled to the end -->
|
||||||
class="flex flex-col items-center justify-center gap-2 text-gray-200 text-center p-10">
|
<Card v-if="hasReachedEnd && items.length > 0" class="shadow-none bg-transparent border-none p-4">
|
||||||
<span class="text-lg font-semibold">You've scrolled so far, there's nothing left to show.</span>
|
<CardHeader class="text-center gap-y-4">
|
||||||
<span class="text-sm">You can always go back and see what you missed.</span>
|
<CardTitle class="text-">No more data.</CardTitle>
|
||||||
</div>
|
<CardDescription>
|
||||||
|
You've scrolled so far, there's nothing left to show.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<div v-else-if="hasReachedEnd && items.length === 0"
|
<!-- If there are no posts at all -->
|
||||||
class="flex flex-col items-center justify-center gap-2 text-gray-200 text-center p-10">
|
<Card v-else-if="hasReachedEnd && items.length === 0" class="shadow-none bg-transparent border-none p-4">
|
||||||
<span class="text-lg font-semibold">There's nothing to show here.</span>
|
<CardHeader class="text-center gap-y-4">
|
||||||
<span class="text-sm">Either you're all caught up or there's nothing to show.</span>
|
<CardTitle class="text-">There's nothing to show here.</CardTitle>
|
||||||
</div>
|
<CardDescription>
|
||||||
|
Either you're all caught up or there's nothing to show.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<div v-else-if="!infiniteScroll.value" class="py-10 px-4">
|
<div v-else-if="!infiniteScroll.value" class="py-10 px-4">
|
||||||
<Button theme="secondary" @click="loadNext" :disabled="isLoading" class="w-full">
|
<Button theme="secondary" @click="loadNext" :disabled="isLoading" class="w-full">
|
||||||
|
|
@ -38,6 +47,12 @@
|
||||||
import type { Notification, Status } from "@versia/client/types";
|
import type { Notification, Status } from "@versia/client/types";
|
||||||
import { useIntersectionObserver } from "@vueuse/core";
|
import { useIntersectionObserver } from "@vueuse/core";
|
||||||
import { onMounted, watch } from "vue";
|
import { onMounted, watch } from "vue";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "~/components/ui/card";
|
||||||
import Button from "~/packages/ui/components/buttons/button.vue";
|
import Button from "~/packages/ui/components/buttons/button.vue";
|
||||||
import { SettingIds } from "~/settings";
|
import { SettingIds } from "~/settings";
|
||||||
import TimelineItem from "./timeline-item.vue";
|
import TimelineItem from "./timeline-item.vue";
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue