mirror of
https://github.com/versia-pub/frontend.git
synced 2025-12-06 08:28:20 +01:00
refactor: 🔥 Remove old code
This commit is contained in:
parent
42e0b38fd8
commit
5b3e9ce8b3
4
app.vue
4
app.vue
|
|
@ -10,7 +10,6 @@
|
|||
<NuxtLayout>
|
||||
<NuxtPage />
|
||||
</NuxtLayout>
|
||||
<NotificationsRenderer />
|
||||
<ConfirmationModal />
|
||||
<!-- pointer-events-auto fixes https://github.com/unovue/shadcn-vue/issues/462 -->
|
||||
<Toaster class="pointer-events-auto" />
|
||||
|
|
@ -24,11 +23,8 @@ import "~/styles/index.css";
|
|||
import { convert } from "html-to-text";
|
||||
import "iconify-icon";
|
||||
import ConfirmationModal from "./components/modals/confirm.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());
|
||||
|
||||
const code = useRequestURL().searchParams.get("code");
|
||||
const appData = useAppData();
|
||||
|
|
|
|||
|
|
@ -1,42 +0,0 @@
|
|||
<template>
|
||||
<Teleport to="body">
|
||||
<Toaster :toaster="toaster" v-slot="toast">
|
||||
<Toast.Root
|
||||
class="rounded-lg w-[calc(100vw-2rem)] sm:w-80 bg-dark-500 duration-200 shadow-lg ring-1 ring-white/10 p-4 [&:nth-child(n+5)]:opacity-0 data-[stack]:!opacity-100 scale-[--scale,100%] translate-x-[--x] translate-y-[--y] z-[--z-index] will-change-transform">
|
||||
<div class="grid grid-cols-[auto_1fr_auto]">
|
||||
<div class="shrink-0 h-6 w-6">
|
||||
<iconify-icon v-if="toast.type === 'success'" icon="tabler:check" height="none"
|
||||
class="h-6 w-6 text-green-400" aria-hidden="true" />
|
||||
<iconify-icon v-else-if="toast.type === 'error'" icon="tabler:alert-triangle" height="none"
|
||||
class="h-6 w-6 text-red-400" aria-hidden="true" />
|
||||
<iconify-icon v-else-if="toast.type === 'loading'" icon="tabler:loader" height="none"
|
||||
class="h-6 w-6 text-primary2-500 animate-spin" aria-hidden="true" />
|
||||
<iconify-icon v-else-if="toast.type === 'info'" icon="tabler:info-circle" height="none"
|
||||
class="h-6 w-6 text-blue-500" aria-hidden="true" />
|
||||
</div>
|
||||
<div class="ml-3 flex-1 pt-0.5 shrink-0 min-w-48">
|
||||
<Toast.Title class="text-sm font-semibold text-gray-50">{{ toast.title }}</Toast.Title>
|
||||
<Toast.Description class="mt-1 text-sm text-gray-400">{{
|
||||
toast.description }}</Toast.Description>
|
||||
</div>
|
||||
<div class="ml-4 flex shrink-0">
|
||||
<Toast.CloseTrigger type="button" title="Close this notification"
|
||||
class="inline-flex rounded-md text-gray-400 hover:text-gray-300 duration-200">
|
||||
<iconify-icon icon="tabler:x" class="h-5 w-5" aria-hidden="true" />
|
||||
</Toast.CloseTrigger>
|
||||
</div>
|
||||
</div>
|
||||
</Toast.Root>
|
||||
</Toaster>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="tsx">
|
||||
import { Toast, Toaster, createToaster } from "@ark-ui/vue";
|
||||
|
||||
const toaster = createToaster({ placement: "top-end", overlap: true, gap: 24 });
|
||||
|
||||
useListen("notification:new", (notification) => {
|
||||
toaster.create(notification);
|
||||
});
|
||||
</script>
|
||||
|
|
@ -1,100 +0,0 @@
|
|||
<template>
|
||||
<AdaptiveDropdown>
|
||||
<template #button>
|
||||
<slot>
|
||||
<div class="rounded text-left flex flex-row gap-x-2 hover:scale-[95%] duration-100"
|
||||
v-if="identity">
|
||||
<div class="shrink-0">
|
||||
<Avatar class="size-12 rounded ring-1 ring-white/5" :src="identity.account.avatar"
|
||||
:alt="`${identity.account.acct}'s avatar'`" />
|
||||
</div>
|
||||
<div class="flex flex-col items-start p-1 justify-around grow overflow-hidden">
|
||||
<div class="flex flex-row items-center justify-between w-full">
|
||||
<div class="font-semibold text-gray-200 text-sm line-clamp-1 break-all">
|
||||
{{
|
||||
identity.account.display_name }}
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-gray-400 text-xs line-clamp-1 break-all w-full">
|
||||
Change account
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<ButtonBase theme="secondary" v-else class="w-full !justify-start overflow-hidden">
|
||||
<Icon icon="tabler:login" class="!size-6" />
|
||||
<span class="shrink-0 line-clamp-1">Sign In</span>
|
||||
</ButtonBase>
|
||||
</slot>
|
||||
</template>
|
||||
<template #items>
|
||||
<div class="p-2">
|
||||
<h3 class="text-gray-400 text-xs text-center md:text-left uppercase font-semibold">Switch to account
|
||||
</h3>
|
||||
</div>
|
||||
<div class="px-2 py-4 md:py-2 flex flex-col gap-3 max-w-[100vw]">
|
||||
<Menu.Item value="" v-for="identity of identities" class="hover:scale-[95%] duration-100">
|
||||
<div class="flex flex-row gap-x-4">
|
||||
<div class="shrink-0" data-part="item" @click="useEvent('identity:change', identity)">
|
||||
<Avatar class="h-12 w-12 rounded ring-1 ring-white/5" :src="identity.account.avatar"
|
||||
:alt="`${identity.account.acct}'s avatar'`" />
|
||||
</div>
|
||||
<div data-part="item" class="flex flex-col items-start justify-around grow overflow-hidden"
|
||||
@click="useEvent('identity:change', identity)">
|
||||
<div class="flex flex-row items-center justify-between w-full">
|
||||
<div class="font-semibold text-gray-200 line-clamp-1 break-all">
|
||||
{{
|
||||
identity.account.display_name }}
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-gray-400 text-sm line-clamp-1 break-all w-full">
|
||||
@{{
|
||||
identity.account.acct
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<button data-part="item"
|
||||
class="shrink-0 ml-6 size-12 ring-white/5 ring-1 flex items-center justify-center rounded"
|
||||
@click="$emit('signOut', identity.id)">
|
||||
<iconify-icon icon="tabler:logout" class="size-6 text-gray-200" width="none" />
|
||||
</button>
|
||||
</div>
|
||||
</Menu.Item>
|
||||
<Menu.Item value="" v-if="identity">
|
||||
<NuxtLink href="/settings" class="w-full">
|
||||
<ButtonBase theme="ghost" class="w-full !justify-start">
|
||||
<Icon icon="tabler:adjustments" class="!size-6" />
|
||||
<span class="shrink-0 line-clamp-1">Settings</span>
|
||||
</ButtonBase>
|
||||
</NuxtLink>
|
||||
</Menu.Item>
|
||||
<Menu.Item value="">
|
||||
<ButtonBase @click="$emit('signIn')" theme="ghost" class="w-full !justify-start">
|
||||
<Icon icon="tabler:user-plus" class="!size-6" />
|
||||
<span class="shrink-0 line-clamp-1">Add new account</span>
|
||||
</ButtonBase>
|
||||
</Menu.Item>
|
||||
<Menu.Item value="" v-if="!identity">
|
||||
<NuxtLink href="/register" class="w-full">
|
||||
<ButtonBase theme="outline" class="w-full !justify-start">
|
||||
<Icon icon="tabler:certificate" class="!size-6" />
|
||||
<span class="shrink-0 line-clamp-1">Create new account</span>
|
||||
</ButtonBase>
|
||||
</NuxtLink>
|
||||
</Menu.Item>
|
||||
</div>
|
||||
</template>
|
||||
</AdaptiveDropdown>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { Menu } from "@ark-ui/vue";
|
||||
import ButtonBase from "~/packages/ui/components/buttons/button.vue";
|
||||
import Icon from "~/packages/ui/components/icons/icon.vue";
|
||||
import Avatar from "../avatars/avatar.vue";
|
||||
import AdaptiveDropdown from "../dropdowns/AdaptiveDropdown.vue";
|
||||
|
||||
defineEmits<{
|
||||
signIn: [];
|
||||
signOut: [identityId: string];
|
||||
}>();
|
||||
</script>
|
||||
152
components/sidebars/account-switcher.vue
Normal file
152
components/sidebars/account-switcher.vue
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
<template>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<SidebarMenuButton size="lg"
|
||||
class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground">
|
||||
<Avatar v-if="identity" shape="square" class="size-8">
|
||||
<AvatarImage :src="identity?.account.avatar" alt="" />
|
||||
<AvatarFallback class="rounded-lg"> AA </AvatarFallback>
|
||||
</Avatar>
|
||||
<Avatar v-else shape="square" class="size-8">
|
||||
<AvatarFallback class="rounded-lg"> AB </AvatarFallback>
|
||||
</Avatar>
|
||||
<div class="grid flex-1 text-left text-sm leading-tight">
|
||||
<span class="truncate font-semibold" v-render-emojis="identity?.account.emojis">{{
|
||||
identity?.account.display_name ?? "Not signed in"
|
||||
}}</span>
|
||||
<span class="truncate text-xs" v-if="identity">@{{ identity?.account.acct }}</span>
|
||||
</div>
|
||||
<ChevronsUpDown class="ml-auto size-4" />
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent class="w-[--radix-dropdown-menu-trigger-width] min-w-56 rounded-lg" side="bottom"
|
||||
align="end" :side-offset="4">
|
||||
<DropdownMenuLabel class="p-0 font-normal">
|
||||
<div v-for="identity of identities" class="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
|
||||
<Avatar shape="square" class="size-8">
|
||||
<AvatarImage :src="identity.account.avatar" alt="" />
|
||||
<AvatarFallback class="rounded-lg"> AA </AvatarFallback>
|
||||
</Avatar>
|
||||
<div class="grid flex-1 text-left text-sm leading-tight">
|
||||
<span class="truncate font-semibold" v-render-emojis="identity.account.emojis">{{
|
||||
identity.account.display_name
|
||||
}}</span>
|
||||
<span class="truncate text-xs">@{{
|
||||
identity.account.acct
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
<DropdownMenuItem @click="signIn()">
|
||||
<UserPlus />
|
||||
Add account
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator v-if="identity" />
|
||||
<DropdownMenuGroup v-if="identity">
|
||||
<DropdownMenuItem>
|
||||
<BadgeCheck />
|
||||
Account
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem @click="signOut()">
|
||||
<LogOut />
|
||||
Log out
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { BadgeCheck, ChevronsUpDown, LogOut, UserPlus } from "lucide-vue-next";
|
||||
import { toast } from "vue-sonner";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "../ui/dropdown-menu";
|
||||
import { SidebarMenuButton } from "../ui/sidebar";
|
||||
|
||||
const appData = useAppData();
|
||||
|
||||
const signIn = async () => {
|
||||
const id = toast.loading("Signing in...");
|
||||
|
||||
const output = await client.value.createApp("Versia", {
|
||||
scopes: ["read", "write", "follow", "push"],
|
||||
redirect_uris: new URL("/", useRequestURL().origin).toString(),
|
||||
website: useBaseUrl().value,
|
||||
});
|
||||
|
||||
if (!output?.data) {
|
||||
toast.dismiss(id);
|
||||
toast.error("Failed to create app");
|
||||
return;
|
||||
}
|
||||
|
||||
appData.value = output.data;
|
||||
|
||||
const url = await client.value.generateAuthUrl(
|
||||
output.data.client_id,
|
||||
output.data.client_secret,
|
||||
{
|
||||
scopes: ["read", "write", "follow", "push"],
|
||||
redirect_uri: new URL("/", useRequestURL().origin).toString(),
|
||||
},
|
||||
);
|
||||
|
||||
if (!url) {
|
||||
toast.dismiss(id);
|
||||
toast.error("Failed to generate auth URL");
|
||||
return;
|
||||
}
|
||||
|
||||
window.location.href = url;
|
||||
};
|
||||
|
||||
const signOut = async (userId?: string) => {
|
||||
const id = toast.loading("Signing out...");
|
||||
|
||||
if (!(appData.value && identity.value)) {
|
||||
toast.dismiss(id);
|
||||
toast.error("No app or identity data to sign out");
|
||||
return;
|
||||
}
|
||||
|
||||
const identityToRevoke = userId
|
||||
? identities.value.find((i) => i.account.id === userId)
|
||||
: identity.value;
|
||||
|
||||
if (!identityToRevoke) {
|
||||
toast.dismiss(id);
|
||||
toast.error("No identity to revoke");
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't do anything on error, as Versia Server doesn't implement the revoke endpoint yet
|
||||
await client.value
|
||||
?.revokeToken(
|
||||
appData.value.client_id,
|
||||
identityToRevoke.tokens.access_token,
|
||||
identityToRevoke.tokens.access_token,
|
||||
)
|
||||
.catch(() => {
|
||||
// Do nothing
|
||||
});
|
||||
|
||||
if (!userId) {
|
||||
identity.value = null;
|
||||
await navigateTo("/");
|
||||
return;
|
||||
}
|
||||
|
||||
identities.value = identities.value.filter((i) => i.id !== userId);
|
||||
toast.dismiss(id);
|
||||
toast.success("Signed out");
|
||||
};
|
||||
</script>
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
<template>
|
||||
<aside v-bind="$props" role="complementary" :aria-expanded="open ? 'true' : 'false'"
|
||||
:class="['flex max-h-dvh overflow-hidden duration-200', open ? enterClass : leaveClass, direction === 'left' ? 'flex-row' : 'flex-row-reverse']">
|
||||
<OverlayScrollbarsComponent :defer="true"
|
||||
class="bg-dark-700 ring-1 ring-white/10 h-full overflow-y-auto w-full">
|
||||
<slot />
|
||||
</OverlayScrollbarsComponent>
|
||||
<button @click="open = !open" aria-label="Toggle sidebar"
|
||||
class="h-full bg-dark-700 hover:bg-dark-400 hover:cursor-pointer duration-200 py-4 px-0.5 flex items-center justify-center w-4 shrink-0">
|
||||
<iconify-icon icon="tabler:chevron-right"
|
||||
:class="['text-gray-200 duration-200', direction === 'left' ? open ? 'rotate-180' : 'rotate-0' : open ? 'rotate-0' : 'rotate-180']"
|
||||
aria-hidden="true" />
|
||||
</button>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { OverlayScrollbarsComponent } from "overlayscrollbars-vue";
|
||||
// slides in and out from the left or right
|
||||
import type { HTMLAttributes } from "vue";
|
||||
|
||||
interface Props extends /* @vue-ignore */ HTMLAttributes {
|
||||
direction?: "left" | "right";
|
||||
initial?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
direction: "left",
|
||||
initial: false,
|
||||
});
|
||||
|
||||
const leaveClass = computed(() =>
|
||||
props.direction === "left"
|
||||
? "-left-[calc(28rem-6rem)]"
|
||||
: "-right-[calc(28rem-1rem)]",
|
||||
);
|
||||
const enterClass = computed(() =>
|
||||
props.direction === "left" ? "left-0" : "right-0",
|
||||
);
|
||||
const open = ref(props.initial);
|
||||
</script>
|
||||
202
components/sidebars/left-sidebar.vue
Normal file
202
components/sidebars/left-sidebar.vue
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
<template>
|
||||
<Sidebar variant="inset" collapsible="icon">
|
||||
<SidebarHeader>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<NuxtLink href="/">
|
||||
<SidebarMenuButton size="lg"
|
||||
class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground">
|
||||
<Avatar shape="square" class="size-8">
|
||||
<AvatarImage :src="instance?.thumbnail.url ??
|
||||
'https://cdn.versia.pub/branding/icon.svg'
|
||||
" alt="" />
|
||||
</Avatar>
|
||||
<div class="grid flex-1 text-left text-sm leading-tight">
|
||||
<span class="truncate font-semibold">{{ instance?.title ?? 'Versia Server' }}</span>
|
||||
<span class="truncate text-xs">{{ "A Versia Server instance" }}</span>
|
||||
</div>
|
||||
<!-- <ChevronsUpDown class="ml-auto" /> -->
|
||||
</SidebarMenuButton>
|
||||
</NuxtLink>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Navigation</SidebarGroupLabel>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem v-for="item in data.other" :key="item.name">
|
||||
<SidebarMenuButton as-child>
|
||||
<NuxtLink :href="item.url">
|
||||
<component :is="item.icon" />
|
||||
<span>{{ item.name }}</span>
|
||||
</NuxtLink>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
<SidebarGroup class="mt-auto">
|
||||
<SidebarGroupLabel>More</SidebarGroupLabel>
|
||||
<SidebarMenu>
|
||||
<Collapsible v-for="item in data.navMain" :key="item.title" as-child
|
||||
class="group/collapsible">
|
||||
<SidebarMenuItem>
|
||||
<CollapsibleTrigger as-child>
|
||||
<SidebarMenuButton :tooltip="item.title">
|
||||
<component :is="item.icon" />
|
||||
<span>{{ item.title }}</span>
|
||||
<ChevronRight
|
||||
class="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
|
||||
</SidebarMenuButton>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<SidebarMenuSub>
|
||||
<SidebarMenuSubItem v-for="subItem in item.items" :key="subItem.title">
|
||||
<SidebarMenuSubButton as-child>
|
||||
<NuxtLink :href="subItem.url">
|
||||
<span>{{ subItem.title }}</span>
|
||||
</NuxtLink>
|
||||
</SidebarMenuSubButton>
|
||||
</SidebarMenuSubItem>
|
||||
</SidebarMenuSub>
|
||||
</CollapsibleContent>
|
||||
</SidebarMenuItem>
|
||||
</Collapsible>
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
<SidebarFooter>
|
||||
<SidebarMenu class="gap-3">
|
||||
<SidebarMenuItem>
|
||||
<ThemeSwitcher />
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<AccountSwitcher />
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem class="flex flex-col gap-2">
|
||||
<Button variant="default" size="lg" class="w-full group-data-[collapsible=icon]:px-4"
|
||||
v-if="identity"
|
||||
@click="useEvent('composer:open')">
|
||||
<Pen />
|
||||
<span class="group-data-[collapsible=icon]:hidden">Compose</span>
|
||||
</Button>
|
||||
<Button variant="destructive" size="lg" class="w-full group-data-[collapsible=icon]:px-4" v-if="$pwa?.needRefresh" @click="$pwa?.updateServiceWorker(true)">
|
||||
<DownloadCloud />
|
||||
<span class="group-data-[collapsible=icon]:hidden">Update</span>
|
||||
</Button>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarFooter>
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {
|
||||
BadgeCheck,
|
||||
BedSingle,
|
||||
Bell,
|
||||
ChevronRight,
|
||||
ChevronsUpDown,
|
||||
DownloadCloud,
|
||||
Globe,
|
||||
House,
|
||||
LogOut,
|
||||
MapIcon,
|
||||
Pen,
|
||||
RefreshCcw,
|
||||
Settings2,
|
||||
} from "lucide-vue-next";
|
||||
import { toast } from "vue-sonner";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "~/components/ui/collapsible";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "~/components/ui/dropdown-menu";
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem,
|
||||
SidebarRail,
|
||||
} from "~/components/ui/sidebar";
|
||||
import { Button } from "../ui/button";
|
||||
import AccountSwitcher from "./account-switcher.vue";
|
||||
import ThemeSwitcher from "./theme-switcher.vue";
|
||||
|
||||
const data = {
|
||||
navMain: [
|
||||
{
|
||||
title: "Preferences",
|
||||
url: "/preferences",
|
||||
icon: Settings2,
|
||||
items: [
|
||||
{
|
||||
title: "Appearance",
|
||||
url: "/preferences/appearance",
|
||||
},
|
||||
{
|
||||
title: "Behaviour",
|
||||
url: "/preferences/behaviour",
|
||||
},
|
||||
{
|
||||
title: "Emojis",
|
||||
url: "/preferences/emojis",
|
||||
},
|
||||
{
|
||||
title: "Roles",
|
||||
url: "/preferences/roles",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
other: [
|
||||
{
|
||||
name: "Home",
|
||||
url: "/home",
|
||||
icon: House,
|
||||
},
|
||||
{
|
||||
name: "Public",
|
||||
url: "/public",
|
||||
icon: MapIcon,
|
||||
},
|
||||
{
|
||||
name: "Local",
|
||||
url: "/local",
|
||||
icon: BedSingle,
|
||||
},
|
||||
{
|
||||
name: "Global",
|
||||
url: "/global",
|
||||
icon: Globe,
|
||||
},
|
||||
{
|
||||
name: "Notifications",
|
||||
url: "/notifications",
|
||||
icon: Bell,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const instance = useInstance();
|
||||
const { $pwa } = useNuxtApp();
|
||||
</script>
|
||||
|
|
@ -1,223 +0,0 @@
|
|||
<template>
|
||||
<aside
|
||||
class="fixed h-dvh z-10 md:flex hidden flex-col p-4 bg-dark-800 gap-10 max-w-[80px] hover:max-w-72 w-full duration-200 group ring-1 ring-dark-500"
|
||||
aria-label="Navigation" role="complementary">
|
||||
<NuxtLink href="/">
|
||||
<img crossorigin="anonymous" class="size-11 rounded ring-1 ring-white/10 hover:scale-105 duration-200"
|
||||
:src="instance?.thumbnail.url ?? 'https://cdn.versia.pub/branding/icon.svg'"
|
||||
alt="Logo of your instance" />
|
||||
</NuxtLink>
|
||||
|
||||
<div class="flex flex-col gap-3">
|
||||
<h3 class="font-semibold text-gray-300 text-xs uppercase opacity-0 group-hover:opacity-100 duration-200">
|
||||
Timelines</h3>
|
||||
|
||||
<NuxtLink v-for="timeline in visibleTimelines" :key="timeline.href" :to="timeline.href">
|
||||
<ButtonBase theme="ghost" class="w-full !justify-start overflow-hidden rounded-sm">
|
||||
<Icon :icon="timeline.icon" class="!size-6" />
|
||||
<span class="shrink-0 line-clamp-1">{{ timeline.name }}</span>
|
||||
</ButtonBase>
|
||||
</NuxtLink>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-3 mt-auto">
|
||||
<h3 class="font-semibold text-gray-300 text-xs uppercase opacity-0 group-hover:opacity-100 duration-200">
|
||||
Account</h3>
|
||||
|
||||
<AccountPicker @sign-in="signIn().finally(() => loadingAuth = false)"
|
||||
@sign-out="id => signOut(id).finally(() => loadingAuth = false)" />
|
||||
<NuxtLink href="/register" v-if="!identity">
|
||||
<ButtonBase theme="ghost" class="w-full !justify-start overflow-hidden rounded-sm">
|
||||
<Icon icon="tabler:certificate" class="!size-6" />
|
||||
<span class="shrink-0 line-clamp-1">Register</span>
|
||||
</ButtonBase>
|
||||
</NuxtLink>
|
||||
<NuxtLink href="/settings" v-if="identity">
|
||||
<ButtonBase @click="$emit('signIn')" theme="secondary" class="w-full !justify-start overflow-hidden">
|
||||
<Icon icon="tabler:adjustments" class="!size-6" />
|
||||
<span class="shrink-0 line-clamp-1">Settings</span>
|
||||
</ButtonBase>
|
||||
</NuxtLink>
|
||||
<h3 v-if="identity"
|
||||
class="font-semibold text-gray-300 text-xs uppercase opacity-0 group-hover:opacity-100 duration-200">
|
||||
Posts</h3>
|
||||
<ButtonBase v-if="identity" @click="compose" title="Open composer (shortcut: n)" theme="gradient"
|
||||
class="!justify-start overflow-hidden">
|
||||
<Icon icon="tabler:writing" class="!size-6" />
|
||||
<span class="shrink-0 line-clamp-1">Compose</span>
|
||||
<kbd class="text-xs font-semibold rounded bg-dark-500 font-mono px-1 flex flex-row ml-auto">
|
||||
<iconify-icon icon="tabler:keyboard" height="1rem" width="1rem" class="inline" aria-hidden="true" />
|
||||
<iconify-icon icon="tabler:letter-n-small" height="1rem" width="1rem" class="inline -mr-1"
|
||||
aria-hidden="true" />
|
||||
</kbd>
|
||||
</ButtonBase>
|
||||
<ButtonBase v-if="$pwa?.needRefresh" @click="$pwa?.updateServiceWorker()" title="Update service worker"
|
||||
theme="primary" class="w-full !justify-start overflow-hidden">
|
||||
<Icon icon="tabler:refresh" class="!size-6" />
|
||||
<span class="shrink-0 line-clamp-1">Update</span>
|
||||
</ButtonBase>
|
||||
|
||||
</div>
|
||||
</aside>
|
||||
<!-- Mobile bottom navbar -->
|
||||
<nav
|
||||
:class="['fixed bottom-0 left-0 right-0 z-20 h-16 md:hidden grid gap-3 p-2 *:shadow-xl bg-dark-900 ring-1 ring-white/10 text-gray-200', !!identity ? 'grid-cols-4' : 'grid-cols-3']">
|
||||
|
||||
<AdaptiveDropdown>
|
||||
<template #button>
|
||||
<ButtonMobileNavbar icon="tabler:home" text="Timelines" />
|
||||
</template>
|
||||
|
||||
<template #items>
|
||||
<Menu.Item value="" v-for="timeline in visibleTimelines" :key="timeline.href">
|
||||
<NuxtLink :href="timeline.href">
|
||||
<ButtonDropdown :icon="timeline.icon" class="w-full">
|
||||
{{ timeline.name }}
|
||||
</ButtonDropdown>
|
||||
</NuxtLink>
|
||||
</Menu.Item>
|
||||
</template>
|
||||
</AdaptiveDropdown>
|
||||
<NuxtLink href="/notifications" class="w-full">
|
||||
<ButtonMobileNavbar icon="tabler:bell" text="Notifications" />
|
||||
</NuxtLink>
|
||||
<ButtonMobileNavbar v-if="$pwa?.needRefresh" @click="$pwa?.updateServiceWorker(true)" icon="tabler:refresh"
|
||||
text="Update" />
|
||||
<AccountPicker v-else @sign-in="signIn().finally(() => loadingAuth = false)"
|
||||
@sign-out="id => signOut(id).finally(() => loadingAuth = false)">
|
||||
<ButtonMobileNavbar icon="tabler:user" text="Account" />
|
||||
</AccountPicker>
|
||||
<button @click="compose" v-if="identity"
|
||||
class="flex flex-col items-center justify-center p-2 rounded bg-gradient-to-tr from-[theme(colors.primary.300/70%)] via-purple-300/70 to-indigo-400/70">
|
||||
<iconify-icon icon="tabler:writing" class="text-2xl" />
|
||||
<span class="text-xs hidden md:inline">Compose</span>
|
||||
</button>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { Menu } from "@ark-ui/vue";
|
||||
import ButtonBase from "~/packages/ui/components/buttons/button.vue";
|
||||
import Icon from "~/packages/ui/components/icons/icon.vue";
|
||||
import ButtonDropdown from "../buttons/button-dropdown.vue";
|
||||
import ButtonMobileNavbar from "../buttons/button-mobile-navbar.vue";
|
||||
import AdaptiveDropdown from "../dropdowns/AdaptiveDropdown.vue";
|
||||
import AccountPicker from "./account-picker.vue";
|
||||
const { $pwa } = useNuxtApp();
|
||||
|
||||
const timelines = ref([
|
||||
{
|
||||
href: "/home",
|
||||
name: "Home",
|
||||
icon: "tabler:home",
|
||||
requiresAuth: true,
|
||||
},
|
||||
{
|
||||
href: "/public",
|
||||
name: "Public",
|
||||
icon: "tabler:world",
|
||||
},
|
||||
{
|
||||
href: "/local",
|
||||
name: "Local",
|
||||
icon: "tabler:home",
|
||||
},
|
||||
{
|
||||
href: "/notifications",
|
||||
name: "Notifications",
|
||||
icon: "tabler:bell",
|
||||
requiresAuth: true,
|
||||
},
|
||||
]);
|
||||
|
||||
const visibleTimelines = computed(() =>
|
||||
timelines.value.filter(
|
||||
(timeline) => !timeline.requiresAuth || identity.value,
|
||||
),
|
||||
);
|
||||
|
||||
const loadingAuth = ref(false);
|
||||
|
||||
const appData = useAppData();
|
||||
const instance = useInstance();
|
||||
|
||||
const compose = () => {
|
||||
useEvent("composer:open");
|
||||
};
|
||||
|
||||
const signIn = async () => {
|
||||
loadingAuth.value = true;
|
||||
|
||||
const output = await client.value.createApp("Versia", {
|
||||
scopes: ["read", "write", "follow", "push"],
|
||||
redirect_uris: new URL("/", useRequestURL().origin).toString(),
|
||||
website: useBaseUrl().value,
|
||||
});
|
||||
|
||||
if (!output?.data) {
|
||||
alert("Failed to create app");
|
||||
return;
|
||||
}
|
||||
|
||||
appData.value = output.data;
|
||||
|
||||
const url = await client.value.generateAuthUrl(
|
||||
output.data.client_id,
|
||||
output.data.client_secret,
|
||||
{
|
||||
scopes: ["read", "write", "follow", "push"],
|
||||
redirect_uri: new URL("/", useRequestURL().origin).toString(),
|
||||
},
|
||||
);
|
||||
|
||||
if (!url) {
|
||||
alert("Failed to generate auth URL");
|
||||
return;
|
||||
}
|
||||
|
||||
window.location.href = url;
|
||||
};
|
||||
|
||||
const signOut = async (id?: string) => {
|
||||
loadingAuth.value = true;
|
||||
|
||||
if (!(appData.value && identity.value)) {
|
||||
console.error("No app or identity data to sign out");
|
||||
return;
|
||||
}
|
||||
|
||||
const identityToRevoke = id
|
||||
? identities.value.find((i) => i.id === id)
|
||||
: identity.value;
|
||||
|
||||
if (!identityToRevoke) {
|
||||
console.error("No identity to revoke");
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't do anything on error, as Versia Server doesn't implement the revoke endpoint yet
|
||||
await client.value
|
||||
?.revokeToken(
|
||||
appData.value.client_id,
|
||||
identityToRevoke.tokens.access_token,
|
||||
identityToRevoke.tokens.access_token,
|
||||
)
|
||||
.catch(() => {
|
||||
// Do nothing
|
||||
});
|
||||
|
||||
if (id === identity.value.id) {
|
||||
identity.value = null;
|
||||
await navigateTo("/");
|
||||
return;
|
||||
}
|
||||
|
||||
identities.value = identities.value.filter((i) => i.id !== id);
|
||||
await useEvent("notification:new", {
|
||||
type: "success",
|
||||
title: "Signed out",
|
||||
description: "Account signed out successfully",
|
||||
});
|
||||
};
|
||||
</script>
|
||||
13
components/sidebars/right-sidebar.vue
Normal file
13
components/sidebars/right-sidebar.vue
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<template>
|
||||
<Sidebar variant="inset" collapsible="none" side="right" class="[--sidebar-width:24rem] hidden lg:flex">
|
||||
<SidebarContent class="p-2 overflow-y-auto">
|
||||
<NotificationsTimeline />
|
||||
</SidebarContent>
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import NotificationsTimeline from "../timelines/notifications.vue";
|
||||
import { Sidebar, SidebarContent, SidebarRail } from "../ui/sidebar";
|
||||
</script>
|
||||
|
|
@ -1,18 +1,4 @@
|
|||
<script setup lang="ts">
|
||||
import {
|
||||
BadgeCheck,
|
||||
BedSingle,
|
||||
Bell,
|
||||
ChevronRight,
|
||||
ChevronsUpDown,
|
||||
Globe,
|
||||
House,
|
||||
LogOut,
|
||||
MapIcon,
|
||||
Pen,
|
||||
Settings2,
|
||||
} from "lucide-vue-next";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar";
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
|
|
@ -21,235 +7,19 @@ import {
|
|||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from "~/components/ui/breadcrumb";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "~/components/ui/collapsible";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "~/components/ui/dropdown-menu";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarInset,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem,
|
||||
SidebarProvider,
|
||||
SidebarRail,
|
||||
SidebarTrigger,
|
||||
} from "~/components/ui/sidebar";
|
||||
import NotificationsTimeline from "../timelines/notifications.vue";
|
||||
import { Button } from "../ui/button";
|
||||
import ThemeSwitcher from "./theme-switcher.vue";
|
||||
|
||||
const data = {
|
||||
navMain: [
|
||||
{
|
||||
title: "Preferences",
|
||||
url: "/preferences",
|
||||
icon: Settings2,
|
||||
items: [
|
||||
{
|
||||
title: "Appearance",
|
||||
url: "/preferences/appearance",
|
||||
},
|
||||
{
|
||||
title: "Behaviour",
|
||||
url: "/preferences/behaviour",
|
||||
},
|
||||
{
|
||||
title: "Emojis",
|
||||
url: "/preferences/emojis",
|
||||
},
|
||||
{
|
||||
title: "Roles",
|
||||
url: "/preferences/roles",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
other: [
|
||||
{
|
||||
name: "Home",
|
||||
url: "/home",
|
||||
icon: House,
|
||||
},
|
||||
{
|
||||
name: "Public",
|
||||
url: "/public",
|
||||
icon: MapIcon,
|
||||
},
|
||||
{
|
||||
name: "Local",
|
||||
url: "/local",
|
||||
icon: BedSingle,
|
||||
},
|
||||
{
|
||||
name: "Global",
|
||||
url: "/global",
|
||||
icon: Globe,
|
||||
},
|
||||
{
|
||||
name: "Notifications",
|
||||
url: "/notifications",
|
||||
icon: Bell,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const instance = useInstance();
|
||||
import LeftSidebar from "./left-sidebar.vue";
|
||||
import RightSidebar from "./right-sidebar.vue";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SidebarProvider>
|
||||
<Sidebar variant="inset" collapsible="icon">
|
||||
<SidebarHeader>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<NuxtLink href="/">
|
||||
<SidebarMenuButton size="lg"
|
||||
class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground">
|
||||
<Avatar shape="square" class="size-8">
|
||||
<AvatarImage :src="instance?.thumbnail.url ??
|
||||
'https://cdn.versia.pub/branding/icon.svg'
|
||||
" alt="" />
|
||||
</Avatar>
|
||||
<div class="grid flex-1 text-left text-sm leading-tight">
|
||||
<span class="truncate font-semibold">{{ instance?.title ?? 'Versia Server' }}</span>
|
||||
<span class="truncate text-xs">{{ "A Versia Server instance" }}</span>
|
||||
</div>
|
||||
<!-- <ChevronsUpDown class="ml-auto" /> -->
|
||||
</SidebarMenuButton>
|
||||
</NuxtLink>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Navigation</SidebarGroupLabel>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem v-for="item in data.other" :key="item.name">
|
||||
<SidebarMenuButton as-child>
|
||||
<NuxtLink :href="item.url">
|
||||
<component :is="item.icon" />
|
||||
<span>{{ item.name }}</span>
|
||||
</NuxtLink>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
<SidebarGroup class="mt-auto">
|
||||
<SidebarGroupLabel>More</SidebarGroupLabel>
|
||||
<SidebarMenu>
|
||||
<Collapsible v-for="item in data.navMain" :key="item.title" as-child
|
||||
:default-open="item.isActive" class="group/collapsible">
|
||||
<SidebarMenuItem>
|
||||
<CollapsibleTrigger as-child>
|
||||
<SidebarMenuButton :tooltip="item.title">
|
||||
<component :is="item.icon" />
|
||||
<span>{{ item.title }}</span>
|
||||
<ChevronRight
|
||||
class="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
|
||||
</SidebarMenuButton>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<SidebarMenuSub>
|
||||
<SidebarMenuSubItem v-for="subItem in item.items" :key="subItem.title">
|
||||
<SidebarMenuSubButton as-child>
|
||||
<NuxtLink :href="subItem.url">
|
||||
<span>{{ subItem.title }}</span>
|
||||
</NuxtLink>
|
||||
</SidebarMenuSubButton>
|
||||
</SidebarMenuSubItem>
|
||||
</SidebarMenuSub>
|
||||
</CollapsibleContent>
|
||||
</SidebarMenuItem>
|
||||
</Collapsible>
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
<SidebarFooter>
|
||||
<SidebarMenu class="gap-3">
|
||||
<SidebarMenuItem>
|
||||
<ThemeSwitcher />
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<SidebarMenuButton size="lg"
|
||||
class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground">
|
||||
<Avatar shape="square" class="size-8">
|
||||
<AvatarImage :src="identity?.account.avatar" alt="" />
|
||||
<AvatarFallback class="rounded-lg"> AA </AvatarFallback>
|
||||
</Avatar>
|
||||
<div class="grid flex-1 text-left text-sm leading-tight">
|
||||
<span class="truncate font-semibold" v-render-emojis="identity?.account.emojis">{{
|
||||
identity?.account.display_name
|
||||
}}</span>
|
||||
<span class="truncate text-xs">@{{ identity?.account.acct }}</span>
|
||||
</div>
|
||||
<ChevronsUpDown class="ml-auto size-4" />
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent class="w-[--radix-dropdown-menu-trigger-width] min-w-56 rounded-lg"
|
||||
side="bottom" align="end" :side-offset="4">
|
||||
<DropdownMenuLabel class="p-0 font-normal">
|
||||
<div class="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
|
||||
<Avatar shape="square" class="size-8">
|
||||
<AvatarImage :src="identity?.account.avatar" alt="" />
|
||||
<AvatarFallback class="rounded-lg"> AA </AvatarFallback>
|
||||
</Avatar>
|
||||
<div class="grid flex-1 text-left text-sm leading-tight">
|
||||
<span class="truncate font-semibold" v-render-emojis="identity?.account.emojis">{{
|
||||
identity?.account.display_name
|
||||
}}</span>
|
||||
<span class="truncate text-xs">@{{
|
||||
identity?.account.acct
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem>
|
||||
<BadgeCheck />
|
||||
Account
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>
|
||||
<LogOut />
|
||||
Log out
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<Button variant="default" size="lg" class="w-full group-data-[collapsible=icon]:px-4" @click="useEvent('composer:open')">
|
||||
<Pen />
|
||||
<span class="group-data-[collapsible=icon]:hidden">Compose</span>
|
||||
</Button>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarFooter>
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
<LeftSidebar />
|
||||
<SidebarInset>
|
||||
<header
|
||||
class="flex h-16 shrink-0 items-center gap-2 transition-[width,height] ease-linear group-has-[[data-collapsible=icon]]/sidebar-wrapper:h-12 overflow-hidden">
|
||||
|
|
@ -275,11 +45,6 @@ const instance = useInstance();
|
|||
<slot />
|
||||
</div>
|
||||
</SidebarInset>
|
||||
<Sidebar variant="inset" collapsible="none" side="right" class="[--sidebar-width:24rem] hidden lg:flex">
|
||||
<SidebarContent class="p-2 overflow-y-auto">
|
||||
<NotificationsTimeline />
|
||||
</SidebarContent>
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
<RightSidebar />
|
||||
</SidebarProvider>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ export default defineNuxtConfig({
|
|||
modules: [
|
||||
"@nuxtjs/tailwindcss",
|
||||
"@vueuse/nuxt",
|
||||
"nuxt-headlessui",
|
||||
"@nuxt/fonts",
|
||||
"@vee-validate/nuxt",
|
||||
"nuxt-security",
|
||||
|
|
|
|||
|
|
@ -51,7 +51,6 @@
|
|||
"mitt": "^3.0.1",
|
||||
"nanoid": "^5.0.9",
|
||||
"nuxt": "^3.14.1592",
|
||||
"nuxt-headlessui": "^1.2.0",
|
||||
"nuxt-security": "^2.1.4",
|
||||
"nuxt-shiki": "^0.3.0",
|
||||
"overlayscrollbars": "^2.10.1",
|
||||
|
|
|
|||
Loading…
Reference in a new issue