mirror of
https://github.com/versia-pub/frontend.git
synced 2025-12-06 08:28:20 +01:00
refactor: ♻️ More work on rewriting notes
This commit is contained in:
parent
d29f181000
commit
8cc4ff1348
|
|
@ -1,12 +1,12 @@
|
||||||
<template>
|
<template>
|
||||||
<svg class="absolute inset-0 h-full w-full stroke-white/10 [mask-image:radial-gradient(100%_100%_at_top_right,white,transparent)]"
|
<svg class="absolute inset-0 h-full w-full stroke-primary/[0.07] [mask-image:radial-gradient(100%_100%_at_top_right,hsl(var(--primary-foreground)),transparent)] pointer-events-none"
|
||||||
aria-hidden="true">
|
aria-hidden="true">
|
||||||
<defs>
|
<defs>
|
||||||
<pattern id="983e3e4c-de6d-4c3f-8d64-b9761d1534cc" width="200" height="200" x="50%" y="-1"
|
<pattern id="983e3e4c-de6d-4c3f-8d64-b9761d1534cc" width="200" height="200" x="50%" y="-1"
|
||||||
patternUnits="userSpaceOnUse">
|
patternUnits="userSpaceOnUse">
|
||||||
<path d="M.5 200V.5H200" fill="none"></path>
|
<path d="M.5 200V.5H200" fill="none"></path>
|
||||||
</pattern>
|
</pattern>
|
||||||
</defs><svg x="50%" y="-1" class="overflow-visible fill-gray-800/20">
|
</defs><svg x="50%" y="-1" class="overflow-visible fill-primary/[0.03]">
|
||||||
<path d="M-200 0h201v201h-201Z M600 0h201v201h-201Z M-400 600h201v201h-201Z M200 800h201v201h-201Z"
|
<path d="M-200 0h201v201h-201Z M600 0h201v201h-201Z M-400 600h201v201h-201Z M200 800h201v201h-201Z"
|
||||||
stroke-width="0"></path>
|
stroke-width="0"></path>
|
||||||
</svg>
|
</svg>
|
||||||
|
|
|
||||||
45
components/notes/actions.vue
Normal file
45
components/notes/actions.vue
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-row w-full items-stretch justify-around text-sm *:max-w-28 *:w-full *:text-muted-foreground">
|
||||||
|
<Button variant="ghost">
|
||||||
|
<Reply class="size-5 text-primary" />
|
||||||
|
{{ numberFormat(replyCount) }}
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost">
|
||||||
|
<Heart class="size-5 text-primary" />
|
||||||
|
{{ numberFormat(likeCount) }}
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost">
|
||||||
|
<Repeat class="size-5 text-primary" />
|
||||||
|
{{ numberFormat(reblogCount) }}
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost">
|
||||||
|
<Quote class="size-5 text-primary" />
|
||||||
|
</Button>
|
||||||
|
<Menu>
|
||||||
|
<Button variant="ghost">
|
||||||
|
<Ellipsis class="size-5 text-primary" />
|
||||||
|
</Button>
|
||||||
|
</Menu>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { Ellipsis, Heart, Quote, Repeat, Reply } from "lucide-vue-next";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import Menu from "./menu.vue";
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
replyCount: number;
|
||||||
|
likeCount: number;
|
||||||
|
reblogCount: number;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const numberFormat = (number = 0) =>
|
||||||
|
number !== 0
|
||||||
|
? new Intl.NumberFormat(undefined, {
|
||||||
|
notation: "compact",
|
||||||
|
compactDisplay: "short",
|
||||||
|
maximumFractionDigits: 1,
|
||||||
|
}).format(number)
|
||||||
|
: undefined;
|
||||||
|
</script>
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<template>
|
<template>
|
||||||
<div :class="['prose block relative dark:prose-invert duration-200 !max-w-full break-words', $style.content]" v-html="content">
|
<div :class="['prose block relative dark:prose-invert duration-200 !max-w-full break-words prose-a:no-underline prose-a:hover:underline', $style.content]" v-html="content">
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,29 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="rounded flex flex-row gap-4">
|
<div class="rounded flex flex-row gap-3">
|
||||||
<Avatar class="size-14 rounded border border-card">
|
<div class="relative">
|
||||||
<AvatarImage :src="avatar" alt="" />
|
<Avatar class="size-14 rounded-md border border-card">
|
||||||
<AvatarFallback class="rounded-lg"> AA </AvatarFallback>
|
<AvatarImage :src="avatar" alt="" />
|
||||||
</Avatar>
|
<AvatarFallback class="rounded-lg"> AA </AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<Avatar v-if="cornerAvatar" class="size-6 rounded border absolute -bottom-1 -right-1">
|
||||||
|
<AvatarImage :src="cornerAvatar" alt="" />
|
||||||
|
<AvatarFallback class="rounded-lg"> AA </AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
</div>
|
||||||
<div class="flex flex-col gap-0.5 justify-center flex-1 text-left leading-tight">
|
<div class="flex flex-col gap-0.5 justify-center flex-1 text-left leading-tight">
|
||||||
<span class="truncate font-semibold">{{
|
<span class="truncate font-semibold">{{
|
||||||
displayName
|
displayName
|
||||||
}}</span>
|
}}</span>
|
||||||
<span class="truncate text-sm">
|
<span class="truncate text-sm tracking-tight">
|
||||||
<CopyableText :text="acct">
|
<CopyableText :text="acct">
|
||||||
<span
|
<span
|
||||||
class="font-semibold bg-gradient-to-tr from-pink-300 via-purple-300 to-indigo-400 text-transparent bg-clip-text">
|
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 }}
|
@{{ username }}
|
||||||
</span>
|
</span>
|
||||||
<span class="text-muted-foreground">{{ instance && "@" }}{{ instance }}</span>
|
<span class="text-muted-foreground">{{ instance && "@" }}{{ instance }}</span>
|
||||||
</CopyableText>
|
</CopyableText>
|
||||||
·
|
·
|
||||||
<span class="text-muted-foreground ml-auto" :title="fullTime">{{ timeAgo }}</span>
|
<span class="text-muted-foreground ml-auto tracking-normal" :title="fullTime">{{ timeAgo }}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col gap-1 justify-center items-end">
|
<div class="flex flex-col gap-1 justify-center items-end">
|
||||||
|
|
@ -35,6 +41,7 @@ import CopyableText from "./copyable-text.vue";
|
||||||
|
|
||||||
const { acct, createdAt } = defineProps<{
|
const { acct, createdAt } = defineProps<{
|
||||||
avatar: string;
|
avatar: string;
|
||||||
|
cornerAvatar?: string;
|
||||||
acct: string;
|
acct: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
visibility: StatusVisibility;
|
visibility: StatusVisibility;
|
||||||
|
|
|
||||||
84
components/notes/menu.vue
Normal file
84
components/notes/menu.vue
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuShortcut,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import {
|
||||||
|
Ban,
|
||||||
|
Code,
|
||||||
|
Delete,
|
||||||
|
ExternalLink,
|
||||||
|
Link,
|
||||||
|
MessageSquare,
|
||||||
|
Pencil,
|
||||||
|
Trash,
|
||||||
|
} from "lucide-vue-next";
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger as-child>
|
||||||
|
<slot />
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent class="w-56">
|
||||||
|
<DropdownMenuLabel>Note Actions</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuGroup>
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<Pencil class="mr-2 size-4" />
|
||||||
|
<span>Edit</span>
|
||||||
|
<DropdownMenuShortcut>⇧⌘E</DropdownMenuShortcut>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<Code class="mr-2 size-4" />
|
||||||
|
<span>Copy API data</span>
|
||||||
|
<DropdownMenuShortcut>⌘B</DropdownMenuShortcut>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<Link class="mr-2 size-4" />
|
||||||
|
<span>Copy link</span>
|
||||||
|
<DropdownMenuShortcut>⌘S</DropdownMenuShortcut>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<Link class="mr-2 size-4" />
|
||||||
|
<span>Copy link (origin)</span>
|
||||||
|
<DropdownMenuShortcut>⌘K</DropdownMenuShortcut>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<ExternalLink class="mr-2 size-4" />
|
||||||
|
<span>Open on remote</span>
|
||||||
|
<DropdownMenuShortcut>⌘F</DropdownMenuShortcut>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuGroup>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuGroup>
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<Delete class="mr-2 size-4" />
|
||||||
|
<span>Delete and redraft</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<Trash class="mr-2 size-4" />
|
||||||
|
<span>Delete</span>
|
||||||
|
<DropdownMenuShortcut>⌘D</DropdownMenuShortcut>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuGroup>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuGroup>
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<MessageSquare class="mr-2 size-4" />
|
||||||
|
<span>Report</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<Ban class="mr-2 size-4" />
|
||||||
|
<span>Block user</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuGroup>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</template>
|
||||||
|
|
@ -1,26 +1,38 @@
|
||||||
<template>
|
<template>
|
||||||
<Card as="article" class="rounded-none border-0 hover:bg-muted/50 duration-200">
|
<Card as="article" class="rounded-none border-0 duration-200 shadow-none">
|
||||||
<CardHeader class="pb-4">
|
<CardHeader class="pb-4">
|
||||||
<Header :avatar="note.account.avatar" :acct="note.account.acct" :display-name="note.account.display_name"
|
<ReblogHeader v-if="!!note.reblog" :avatar="note.account.avatar"
|
||||||
:visibility="note.visibility" :url="accountUrl" :created-at="new Date(note.created_at)" />
|
:display-name="note.account.display_name" />
|
||||||
|
<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)" />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Content :content="note.content" />
|
<Content :content="noteToUse.content" />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
<CardFooter>
|
||||||
|
<Actions :reply-count="noteToUse.replies_count" :like-count="noteToUse.favourites_count"
|
||||||
|
:reblog-count="noteToUse.reblogs_count" />
|
||||||
|
</CardFooter>
|
||||||
</Card>
|
</Card>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Status } from "@versia/client/types";
|
import type { Status } from "@versia/client/types";
|
||||||
import { Card, CardHeader } from "../ui/card";
|
import { Card, CardFooter, CardHeader } from "../ui/card";
|
||||||
import { Separator } from "../ui/separator";
|
import Actions from "./actions.vue";
|
||||||
import Content from "./content.vue";
|
import Content from "./content.vue";
|
||||||
import Header from "./header.vue";
|
import Header from "./header.vue";
|
||||||
|
import ReblogHeader from "./reblog-header.vue";
|
||||||
|
|
||||||
const { note } = defineProps<{
|
const { note } = defineProps<{
|
||||||
note: Status;
|
note: Status;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const url = `/@${note.account.acct}/${note.id}`;
|
// Notes can be reblogs or quotes, in which case
|
||||||
const accountUrl = `/@${note.account.acct}`;
|
// the actual thing to render is inside the reblog or quote
|
||||||
|
const noteToUse = note.reblog ? note.reblog : note.quote ? note.quote : note;
|
||||||
|
|
||||||
|
const url = `/@${noteToUse.account.acct}/${noteToUse.id}`;
|
||||||
|
const accountUrl = `/@${noteToUse.account.acct}`;
|
||||||
</script>
|
</script>
|
||||||
21
components/notes/reblog-header.vue
Normal file
21
components/notes/reblog-header.vue
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
<template>
|
||||||
|
<div class="rounded border hover:bg-muted duration-100 text-sm flex flex-row items-center gap-2 px-2 py-1 mb-2">
|
||||||
|
<Repeat class="size-4 text-primary" />
|
||||||
|
<Avatar class="size-6 rounded border">
|
||||||
|
<AvatarImage :src="avatar" alt="" />
|
||||||
|
<AvatarFallback class="rounded-lg"> AA </AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<span class="font-semibold">{{ displayName }}</span>
|
||||||
|
reblogged
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
|
import { Repeat } from "lucide-vue-next";
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
avatar: string;
|
||||||
|
displayName: string;
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
@ -1,12 +1,14 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {
|
import {
|
||||||
BadgeCheck,
|
BadgeCheck,
|
||||||
|
BedSingle,
|
||||||
Bell,
|
Bell,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
ChevronsUpDown,
|
ChevronsUpDown,
|
||||||
|
Globe,
|
||||||
House,
|
House,
|
||||||
LogOut,
|
LogOut,
|
||||||
MoreHorizontal,
|
MapIcon,
|
||||||
Settings2,
|
Settings2,
|
||||||
} from "lucide-vue-next";
|
} from "lucide-vue-next";
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar";
|
import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar";
|
||||||
|
|
@ -55,30 +57,6 @@ import ThemeSwitcher from "./theme-switcher.vue";
|
||||||
|
|
||||||
const data = {
|
const data = {
|
||||||
navMain: [
|
navMain: [
|
||||||
{
|
|
||||||
title: "Timelines",
|
|
||||||
url: "#",
|
|
||||||
icon: House,
|
|
||||||
isActive: true,
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
title: "Home",
|
|
||||||
url: "/home",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Public",
|
|
||||||
url: "/public",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Local",
|
|
||||||
url: "/local",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Global",
|
|
||||||
url: "/global",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
title: "Settings",
|
title: "Settings",
|
||||||
url: "#",
|
url: "#",
|
||||||
|
|
@ -104,6 +82,26 @@ const data = {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
other: [
|
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",
|
name: "Notifications",
|
||||||
url: "/notifications",
|
url: "/notifications",
|
||||||
|
|
@ -140,8 +138,21 @@ const instance = useInstance();
|
||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
</SidebarHeader>
|
</SidebarHeader>
|
||||||
<SidebarContent>
|
<SidebarContent>
|
||||||
<SidebarGroup>
|
<SidebarGroup class="group-data-[collapsible=icon]:hidden">
|
||||||
<SidebarGroupLabel>Navigation</SidebarGroupLabel>
|
<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>
|
<SidebarMenu>
|
||||||
<Collapsible v-for="item in data.navMain" :key="item.title" as-child
|
<Collapsible v-for="item in data.navMain" :key="item.title" as-child
|
||||||
:default-open="item.isActive" class="group/collapsible">
|
:default-open="item.isActive" class="group/collapsible">
|
||||||
|
|
@ -169,19 +180,6 @@ const instance = useInstance();
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
</SidebarGroup>
|
</SidebarGroup>
|
||||||
<SidebarGroup class="group-data-[collapsible=icon]:hidden">
|
|
||||||
<SidebarGroupLabel>Other</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>
|
|
||||||
</SidebarContent>
|
</SidebarContent>
|
||||||
<SidebarFooter>
|
<SidebarFooter>
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
|
|
@ -264,7 +262,7 @@ const instance = useInstance();
|
||||||
</Breadcrumb>
|
</Breadcrumb>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div class="flex flex-1 flex-col gap-4 md:p-1 overflow-auto">
|
<div class="flex flex-1 flex-col gap-4 md:p-1 overflow-auto *:z-10">
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
</SidebarInset>
|
</SidebarInset>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
<!-- Timeline.vue -->
|
<!-- Timeline.vue -->
|
||||||
<template>
|
<template>
|
||||||
<div class="timeline rounded overflow-hidden ring-1 ring-ring/15">
|
<div class="timeline rounded overflow-hidden ring-1 ring-ring/10">
|
||||||
<TransitionGroup name="timeline-item" tag="div" class="timeline-items *:!border-b *:last:border-0">
|
<TransitionGroup name="timeline-item" tag="div" class="timeline-items *:!border-b-[0.5px] *:last:border-0">
|
||||||
<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>
|
||||||
|
|
|
||||||
65
components/ui/carousel/Carousel.vue
Normal file
65
components/ui/carousel/Carousel.vue
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type {
|
||||||
|
CarouselEmits,
|
||||||
|
CarouselProps,
|
||||||
|
WithClassAsProps,
|
||||||
|
} from "./interface";
|
||||||
|
import { useProvideCarousel } from "./useCarousel";
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<CarouselProps & WithClassAsProps>(), {
|
||||||
|
orientation: "horizontal",
|
||||||
|
});
|
||||||
|
|
||||||
|
const emits = defineEmits<CarouselEmits>();
|
||||||
|
|
||||||
|
const {
|
||||||
|
canScrollNext,
|
||||||
|
canScrollPrev,
|
||||||
|
carouselApi,
|
||||||
|
carouselRef,
|
||||||
|
orientation,
|
||||||
|
scrollNext,
|
||||||
|
scrollPrev,
|
||||||
|
} = useProvideCarousel(props, emits);
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
canScrollNext,
|
||||||
|
canScrollPrev,
|
||||||
|
carouselApi,
|
||||||
|
carouselRef,
|
||||||
|
orientation,
|
||||||
|
scrollNext,
|
||||||
|
scrollPrev,
|
||||||
|
});
|
||||||
|
|
||||||
|
function onKeyDown(event: KeyboardEvent) {
|
||||||
|
const prevKey = props.orientation === "vertical" ? "ArrowUp" : "ArrowLeft";
|
||||||
|
const nextKey =
|
||||||
|
props.orientation === "vertical" ? "ArrowDown" : "ArrowRight";
|
||||||
|
|
||||||
|
if (event.key === prevKey) {
|
||||||
|
event.preventDefault();
|
||||||
|
scrollPrev();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === nextKey) {
|
||||||
|
event.preventDefault();
|
||||||
|
scrollNext();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
:class="cn('relative', props.class)"
|
||||||
|
role="region"
|
||||||
|
aria-roledescription="carousel"
|
||||||
|
tabindex="0"
|
||||||
|
@keydown="onKeyDown"
|
||||||
|
>
|
||||||
|
<slot :can-scroll-next :can-scroll-prev :carousel-api :carousel-ref :orientation :scroll-next :scroll-prev />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
29
components/ui/carousel/CarouselContent.vue
Normal file
29
components/ui/carousel/CarouselContent.vue
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { WithClassAsProps } from "./interface";
|
||||||
|
import { useCarousel } from "./useCarousel";
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
inheritAttrs: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const props = defineProps<WithClassAsProps>();
|
||||||
|
|
||||||
|
const { carouselRef, orientation } = useCarousel();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div ref="carouselRef" class="overflow-hidden">
|
||||||
|
<div
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'flex',
|
||||||
|
orientation === 'horizontal' ? '-ml-4' : '-mt-4 flex-col',
|
||||||
|
props.class,
|
||||||
|
)"
|
||||||
|
v-bind="$attrs"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
23
components/ui/carousel/CarouselItem.vue
Normal file
23
components/ui/carousel/CarouselItem.vue
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { WithClassAsProps } from "./interface";
|
||||||
|
import { useCarousel } from "./useCarousel";
|
||||||
|
|
||||||
|
const props = defineProps<WithClassAsProps>();
|
||||||
|
|
||||||
|
const { orientation } = useCarousel();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
role="group"
|
||||||
|
aria-roledescription="slide"
|
||||||
|
:class="cn(
|
||||||
|
'min-w-0 shrink-0 grow-0 basis-full',
|
||||||
|
orientation === 'horizontal' ? 'pl-4' : 'pt-4',
|
||||||
|
props.class,
|
||||||
|
)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
31
components/ui/carousel/CarouselNext.vue
Normal file
31
components/ui/carousel/CarouselNext.vue
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { ArrowRight } from "lucide-vue-next";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import type { WithClassAsProps } from "./interface";
|
||||||
|
import { useCarousel } from "./useCarousel";
|
||||||
|
|
||||||
|
const props = defineProps<WithClassAsProps>();
|
||||||
|
|
||||||
|
const { orientation, canScrollNext, scrollNext } = useCarousel();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Button
|
||||||
|
:disabled="!canScrollNext"
|
||||||
|
:class="cn(
|
||||||
|
'touch-manipulation absolute h-8 w-8 rounded-full p-0',
|
||||||
|
orientation === 'horizontal'
|
||||||
|
? '-right-12 top-1/2 -translate-y-1/2'
|
||||||
|
: '-bottom-12 left-1/2 -translate-x-1/2 rotate-90',
|
||||||
|
props.class,
|
||||||
|
)"
|
||||||
|
variant="outline"
|
||||||
|
@click="scrollNext"
|
||||||
|
>
|
||||||
|
<slot>
|
||||||
|
<ArrowRight class="h-4 w-4 text-current" />
|
||||||
|
<span class="sr-only">Next Slide</span>
|
||||||
|
</slot>
|
||||||
|
</Button>
|
||||||
|
</template>
|
||||||
31
components/ui/carousel/CarouselPrevious.vue
Normal file
31
components/ui/carousel/CarouselPrevious.vue
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { ArrowLeft } from "lucide-vue-next";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import type { WithClassAsProps } from "./interface";
|
||||||
|
import { useCarousel } from "./useCarousel";
|
||||||
|
|
||||||
|
const props = defineProps<WithClassAsProps>();
|
||||||
|
|
||||||
|
const { orientation, canScrollPrev, scrollPrev } = useCarousel();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Button
|
||||||
|
:disabled="!canScrollPrev"
|
||||||
|
:class="cn(
|
||||||
|
'touch-manipulation absolute h-8 w-8 rounded-full p-0',
|
||||||
|
orientation === 'horizontal'
|
||||||
|
? '-left-12 top-1/2 -translate-y-1/2'
|
||||||
|
: '-top-12 left-1/2 -translate-x-1/2 rotate-90',
|
||||||
|
props.class,
|
||||||
|
)"
|
||||||
|
variant="outline"
|
||||||
|
@click="scrollPrev"
|
||||||
|
>
|
||||||
|
<slot>
|
||||||
|
<ArrowLeft class="h-4 w-4 text-current" />
|
||||||
|
<span class="sr-only">Previous Slide</span>
|
||||||
|
</slot>
|
||||||
|
</Button>
|
||||||
|
</template>
|
||||||
8
components/ui/carousel/index.ts
Normal file
8
components/ui/carousel/index.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
export { default as Carousel } from "./Carousel.vue";
|
||||||
|
export { default as CarouselContent } from "./CarouselContent.vue";
|
||||||
|
export { default as CarouselItem } from "./CarouselItem.vue";
|
||||||
|
export { default as CarouselNext } from "./CarouselNext.vue";
|
||||||
|
export { default as CarouselPrevious } from "./CarouselPrevious.vue";
|
||||||
|
export type { UnwrapRefCarouselApi as CarouselApi } from "./interface";
|
||||||
|
|
||||||
|
export { useCarousel } from "./useCarousel";
|
||||||
25
components/ui/carousel/interface.ts
Normal file
25
components/ui/carousel/interface.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
import type useEmblaCarousel from "embla-carousel-vue";
|
||||||
|
import type { EmblaCarouselVueType } from "embla-carousel-vue";
|
||||||
|
import type { HTMLAttributes, UnwrapRef } from "vue";
|
||||||
|
|
||||||
|
type CarouselApi = EmblaCarouselVueType[1];
|
||||||
|
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
|
||||||
|
type CarouselOptions = UseCarouselParameters[0];
|
||||||
|
type CarouselPlugin = UseCarouselParameters[1];
|
||||||
|
|
||||||
|
export type UnwrapRefCarouselApi = UnwrapRef<CarouselApi>;
|
||||||
|
|
||||||
|
export interface CarouselProps {
|
||||||
|
opts?: CarouselOptions;
|
||||||
|
plugins?: CarouselPlugin;
|
||||||
|
orientation?: "horizontal" | "vertical";
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CarouselEmits = (
|
||||||
|
e: "init-api",
|
||||||
|
payload: UnwrapRefCarouselApi,
|
||||||
|
) => void;
|
||||||
|
|
||||||
|
export interface WithClassAsProps {
|
||||||
|
class?: HTMLAttributes["class"];
|
||||||
|
}
|
||||||
69
components/ui/carousel/useCarousel.ts
Normal file
69
components/ui/carousel/useCarousel.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
import { createInjectionState } from "@vueuse/core";
|
||||||
|
import emblaCarouselVue from "embla-carousel-vue";
|
||||||
|
import { onMounted, ref } from "vue";
|
||||||
|
import type {
|
||||||
|
UnwrapRefCarouselApi as CarouselApi,
|
||||||
|
CarouselEmits,
|
||||||
|
CarouselProps,
|
||||||
|
} from "./interface";
|
||||||
|
|
||||||
|
const [useProvideCarousel, useInjectCarousel] = createInjectionState(
|
||||||
|
({ opts, orientation, plugins }: CarouselProps, emits: CarouselEmits) => {
|
||||||
|
const [emblaNode, emblaApi] = emblaCarouselVue(
|
||||||
|
{
|
||||||
|
...opts,
|
||||||
|
axis: orientation === "horizontal" ? "x" : "y",
|
||||||
|
},
|
||||||
|
plugins,
|
||||||
|
);
|
||||||
|
|
||||||
|
function scrollPrev() {
|
||||||
|
emblaApi.value?.scrollPrev();
|
||||||
|
}
|
||||||
|
function scrollNext() {
|
||||||
|
emblaApi.value?.scrollNext();
|
||||||
|
}
|
||||||
|
|
||||||
|
const canScrollNext = ref(false);
|
||||||
|
const canScrollPrev = ref(false);
|
||||||
|
|
||||||
|
function onSelect(api: CarouselApi) {
|
||||||
|
canScrollNext.value = !!api?.canScrollNext();
|
||||||
|
canScrollPrev.value = !!api?.canScrollPrev();
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (!emblaApi.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
emblaApi.value?.on("init", onSelect);
|
||||||
|
emblaApi.value?.on("reInit", onSelect);
|
||||||
|
emblaApi.value?.on("select", onSelect);
|
||||||
|
|
||||||
|
emits("init-api", emblaApi.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
carouselRef: emblaNode,
|
||||||
|
carouselApi: emblaApi,
|
||||||
|
canScrollPrev,
|
||||||
|
canScrollNext,
|
||||||
|
scrollPrev,
|
||||||
|
scrollNext,
|
||||||
|
orientation,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
function useCarousel() {
|
||||||
|
const carouselState = useInjectCarousel();
|
||||||
|
|
||||||
|
if (!carouselState) {
|
||||||
|
throw new Error("useCarousel must be used within a <Carousel />");
|
||||||
|
}
|
||||||
|
|
||||||
|
return carouselState;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { useCarousel, useProvideCarousel };
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
<template>
|
<template>
|
||||||
<Sidebar>
|
<Sidebar>
|
||||||
|
<SquarePattern />
|
||||||
<slot />
|
<slot />
|
||||||
</Sidebar>
|
</Sidebar>
|
||||||
<ComposerModal />
|
<ComposerModal />
|
||||||
|
|
@ -8,6 +9,7 @@
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import ComposerModal from "~/components/composer/modal.client.vue";
|
import ComposerModal from "~/components/composer/modal.client.vue";
|
||||||
|
import SquarePattern from "~/components/graphics/square-pattern.vue";
|
||||||
import Sidebar from "~/components/sidebars/sidebar.vue";
|
import Sidebar from "~/components/sidebars/sidebar.vue";
|
||||||
import AttachmentDialog from "~/components/social-elements/notes/attachment-dialog.vue";
|
import AttachmentDialog from "~/components/social-elements/notes/attachment-dialog.vue";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@
|
||||||
"c12": "^2.0.1",
|
"c12": "^2.0.1",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"embla-carousel-vue": "^8.5.1",
|
||||||
"fastest-levenshtein": "^1.0.16",
|
"fastest-levenshtein": "^1.0.16",
|
||||||
"html-to-text": "^9.0.5",
|
"html-to-text": "^9.0.5",
|
||||||
"iconify-icon": "^2.1.0",
|
"iconify-icon": "^2.1.0",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue