Basic implementation for anime page
@ -7,20 +7,19 @@ import { Primitive } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { badgeVariants } from "."
|
||||
|
||||
const props = defineProps<PrimitiveProps & {
|
||||
const props = withDefaults(defineProps<PrimitiveProps & {
|
||||
variant?: BadgeVariants["variant"]
|
||||
size?: BadgeVariants["size"]
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
}>(), {
|
||||
size: "default"
|
||||
});
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive
|
||||
data-slot="badge"
|
||||
:class="cn(badgeVariants({ variant }), props.class)"
|
||||
v-bind="delegatedProps"
|
||||
>
|
||||
<Primitive data-slot="badge" :class="cn(badgeVariants({ variant, size }), props.class)" v-bind="delegatedProps">
|
||||
<slot />
|
||||
</Primitive>
|
||||
</template>
|
||||
|
||||
@ -4,7 +4,7 @@ import { cva } from "class-variance-authority"
|
||||
export { default as Badge } from "./Badge.vue"
|
||||
|
||||
export const badgeVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
||||
"inline-flex items-center justify-center rounded-full border text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
@ -17,6 +17,11 @@ export const badgeVariants = cva(
|
||||
outline:
|
||||
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
},
|
||||
size: {
|
||||
default: "px-2 py-0.5",
|
||||
xs: "px-1 py-0.2",
|
||||
lg: "px-3 py-1"
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
|
||||
22
app/components/ui/card/Card.vue
Normal file
@ -0,0 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="card"
|
||||
:class="
|
||||
cn(
|
||||
'bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
17
app/components/ui/card/CardAction.vue
Normal file
@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="card-action"
|
||||
:class="cn('col-start-2 row-span-2 row-start-1 self-start justify-self-end', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
17
app/components/ui/card/CardContent.vue
Normal file
@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="card-content"
|
||||
:class="cn('px-6', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
17
app/components/ui/card/CardDescription.vue
Normal file
@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<p
|
||||
data-slot="card-description"
|
||||
:class="cn('text-muted-foreground text-sm', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</p>
|
||||
</template>
|
||||
17
app/components/ui/card/CardFooter.vue
Normal file
@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
:class="cn('flex items-center px-6 [.border-t]:pt-6', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
17
app/components/ui/card/CardHeader.vue
Normal file
@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="card-header"
|
||||
:class="cn('@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
17
app/components/ui/card/CardTitle.vue
Normal file
@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h3
|
||||
data-slot="card-title"
|
||||
:class="cn('leading-none font-semibold', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</h3>
|
||||
</template>
|
||||
7
app/components/ui/card/index.ts
Normal file
@ -0,0 +1,7 @@
|
||||
export { default as Card } from "./Card.vue"
|
||||
export { default as CardAction } from "./CardAction.vue"
|
||||
export { default as CardContent } from "./CardContent.vue"
|
||||
export { default as CardDescription } from "./CardDescription.vue"
|
||||
export { default as CardFooter } from "./CardFooter.vue"
|
||||
export { default as CardHeader } from "./CardHeader.vue"
|
||||
export { default as CardTitle } from "./CardTitle.vue"
|
||||
82
app/components/ui/hoverable-image/HoverableImage.vue
Normal file
@ -0,0 +1,82 @@
|
||||
<script setup lang="ts" generic="Provider extends keyof ConfiguredImageProviders = ProviderDefaults['provider']">
|
||||
import { Tooltip, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ConfiguredImageProviders, ProviderDefaults } from '@nuxt/image';
|
||||
import type { HTMLAttributes } from 'vue';
|
||||
import { imageVariants } from '.';
|
||||
|
||||
interface Props {
|
||||
src: string;
|
||||
alt?: string;
|
||||
title?: string;
|
||||
sizes?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
loading?: 'lazy' | 'eager';
|
||||
provider?: Provider;
|
||||
quality?: number;
|
||||
class?: HTMLAttributes['class'];
|
||||
overlayHeight?: string;
|
||||
overlayOpacity?: number;
|
||||
overlayClass?: string;
|
||||
imageClass?: string;
|
||||
variant?: 'default' | 'rounded' | 'elevated' | 'minimal';
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl' | 'full';
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
alt: '',
|
||||
title: '',
|
||||
sizes: '100vw sm:50vw md:33vw lg:25vw',
|
||||
width: 400,
|
||||
height: 300,
|
||||
loading: 'lazy',
|
||||
provider: undefined,
|
||||
quality: 80,
|
||||
overlayHeight: '4em',
|
||||
overlayOpacity: 1,
|
||||
overlayClass: '',
|
||||
imageClass: '',
|
||||
variant: 'default',
|
||||
size: 'md',
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
hover: [state: boolean];
|
||||
click: [event: MouseEvent];
|
||||
}>();
|
||||
|
||||
const handleHover = (state: boolean) => {
|
||||
emit('hover', state);
|
||||
};
|
||||
|
||||
const handleClick = (event: MouseEvent) => {
|
||||
emit('click', event);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<div :class="cn(imageVariants({ variant, size }), props.class)" @mouseenter="handleHover(true)"
|
||||
@mouseleave="handleHover(false)" @click="handleClick">
|
||||
<NuxtImg :src="src" :alt="alt" :sizes="sizes" :width="width" :height="height" :loading="loading"
|
||||
:provider="provider" :quality="quality" :class="cn('w-full h-auto object-cover', imageClass)" />
|
||||
|
||||
<div
|
||||
:class="cn('absolute bottom-0 left-0 right-0 overlay-gradient bg-linear-to-t group-hover:opacity-0 opacity-100 from-muted from-20% to-transparent flex items-end transition-opacity p-4', overlayClass)">
|
||||
<span v-if="title" class="text-white font-semibold text-sm md:text-base line-clamp-2">
|
||||
{{ title }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<slot />
|
||||
</Tooltip>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.overlay-gradient {
|
||||
height: v-bind(overlayHeight);
|
||||
}
|
||||
</style>
|
||||
31
app/components/ui/hoverable-image/index.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import type { VariantProps } from "class-variance-authority"
|
||||
import { cva } from "class-variance-authority"
|
||||
|
||||
export { default as HoverableImage } from "./HoverableImage.vue"
|
||||
|
||||
export const imageVariants = cva(
|
||||
"relative group cursor-pointer overflow-hidden rounded-lg",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border border-border",
|
||||
rounded: "rounded-full overflow-hidden",
|
||||
elevated: "shadow-lg hover:shadow-xl",
|
||||
minimal: "border-0",
|
||||
},
|
||||
size: {
|
||||
sm: "max-w-xs",
|
||||
md: "max-w-md",
|
||||
lg: "max-w-lg",
|
||||
xl: "max-w-xl",
|
||||
full: "w-full",
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "md",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export type HoverableImageVariants = VariantProps<typeof imageVariants>
|
||||
@ -17,10 +17,11 @@ watch(palette, () => {
|
||||
});
|
||||
|
||||
interface Props {
|
||||
id: number;
|
||||
image_url: string;
|
||||
image_alt?: string;
|
||||
title: string;
|
||||
type: 'anime' | 'manga' | 'character' | 'voice_actor';
|
||||
type: string;
|
||||
|
||||
subtitle?: string;
|
||||
description?: string;
|
||||
@ -46,22 +47,22 @@ const props = defineProps<Props>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NuxtLink :to="`anime/${id}`">
|
||||
<div class="h-full overflow-hidden max-w-64">
|
||||
<div class="flex flex-col gap-2 cursor-pointer text-muted-foreground vibrant-color">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<div>
|
||||
<NuxtImg class="rounded-lg" ref="imageElement" :src="props.image_url" sizes="128px md:256px"
|
||||
quality="100" />
|
||||
<p class="text-lg line-clamp-2 font-semibold">
|
||||
<NuxtImg class="rounded-lg" ref="imageElement" :src="props.image_url"
|
||||
sizes="128px md:256px" quality="100" />
|
||||
<p class="text-lg line-clamp-2 font-semibold max-w-[12ch] 2xl:max-w-full">
|
||||
{{ props.title }}
|
||||
</p>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent :sideOffset="16" :collisionBoundary="props.tooltipCollisionBoundary"
|
||||
:avoidCollisions="true" side="right" class="bg-muted text-muted-foreground"
|
||||
arrowClass="bg-muted fill-muted">
|
||||
:avoidCollisions="true" side="right" tooltipColor="muted" tooltipArrowColor="muted">
|
||||
<div class="flex flex-col gap-1 m-4">
|
||||
<div class="flex">
|
||||
<p class="text-xl font-bold truncate max-w-[24ch]">{{ props.title }}</p>
|
||||
@ -85,6 +86,7 @@ const props = defineProps<Props>();
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@ -9,7 +9,7 @@ const props = defineProps<Props>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="cn('rounded-lg h-full overflow-hidden w-full max-w-64', props.class)">
|
||||
<div :class="cn('rounded-lg h-full min-h-72 overflow-hidden w-full max-w-64', props.class)">
|
||||
<div class="flex flex-col gap-2 h-full pb-6">
|
||||
<Skeleton class="h-full w-full" />
|
||||
<Skeleton class="h-6 w-3/4" />
|
||||
|
||||
24
app/components/ui/tabs/Tabs.vue
Normal file
@ -0,0 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import type { TabsRootEmits, TabsRootProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { TabsRoot, useForwardPropsEmits } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<TabsRootProps & { class?: HTMLAttributes["class"] }>()
|
||||
const emits = defineEmits<TabsRootEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TabsRoot
|
||||
v-slot="slotProps"
|
||||
data-slot="tabs"
|
||||
v-bind="forwarded"
|
||||
:class="cn('flex flex-col gap-2', props.class)"
|
||||
>
|
||||
<slot v-bind="slotProps" />
|
||||
</TabsRoot>
|
||||
</template>
|
||||
21
app/components/ui/tabs/TabsContent.vue
Normal file
@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import type { TabsContentProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { TabsContent } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<TabsContentProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TabsContent
|
||||
data-slot="tabs-content"
|
||||
:class="cn('flex-1 outline-none', props.class)"
|
||||
v-bind="delegatedProps"
|
||||
>
|
||||
<slot />
|
||||
</TabsContent>
|
||||
</template>
|
||||
24
app/components/ui/tabs/TabsList.vue
Normal file
@ -0,0 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import type { TabsListProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { TabsList } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<TabsListProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TabsList
|
||||
data-slot="tabs-list"
|
||||
v-bind="delegatedProps"
|
||||
:class="cn(
|
||||
'bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<slot />
|
||||
</TabsList>
|
||||
</template>
|
||||
26
app/components/ui/tabs/TabsTrigger.vue
Normal file
@ -0,0 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
import type { TabsTriggerProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { TabsTrigger, useForwardProps } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<TabsTriggerProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TabsTrigger
|
||||
data-slot="tabs-trigger"
|
||||
:class="cn(
|
||||
'data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4',
|
||||
props.class,
|
||||
)"
|
||||
v-bind="forwardedProps"
|
||||
>
|
||||
<slot />
|
||||
</TabsTrigger>
|
||||
</template>
|
||||
4
app/components/ui/tabs/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export { default as Tabs } from "./Tabs.vue"
|
||||
export { default as TabsContent } from "./TabsContent.vue"
|
||||
export { default as TabsList } from "./TabsList.vue"
|
||||
export { default as TabsTrigger } from "./TabsTrigger.vue"
|
||||
@ -4,6 +4,7 @@ import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { TooltipArrow, TooltipContent, TooltipPortal, useForwardPropsEmits } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { tooltipArrowVariants, tooltipContentVariants, type TooltipArrowVariants, type TooltipContentVariants } from "."
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
@ -13,6 +14,8 @@ interface Props {
|
||||
includeArrow?: boolean
|
||||
class?: HTMLAttributes["class"]
|
||||
arrowClass?: HTMLAttributes["class"]
|
||||
tooltipColor?: TooltipContentVariants["color"]
|
||||
tooltipArrowColor?: TooltipArrowVariants["color"]
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<TooltipContentProps & Props>(), {
|
||||
@ -29,11 +32,11 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
<template>
|
||||
<TooltipPortal>
|
||||
<TooltipContent data-slot="tooltip-content" v-bind="{ ...forwarded, ...$attrs }"
|
||||
:class="cn('bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit rounded-md px-3 py-1.5 text-xs text-balance', props.class)">
|
||||
:class="cn(tooltipContentVariants({ variant: 'default', color: tooltipColor }), props.class)">
|
||||
<slot />
|
||||
|
||||
<TooltipArrow
|
||||
:class="cn('bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]', arrowClass)"
|
||||
:class="cn(tooltipArrowVariants({ variant: 'default', color: tooltipArrowColor }), arrowClass)"
|
||||
v-if="includeArrow" />
|
||||
</TooltipContent>
|
||||
</TooltipPortal>
|
||||
|
||||
@ -2,3 +2,48 @@ export { default as Tooltip } from "./Tooltip.vue"
|
||||
export { default as TooltipContent } from "./TooltipContent.vue"
|
||||
export { default as TooltipProvider } from "./TooltipProvider.vue"
|
||||
export { default as TooltipTrigger } from "./TooltipTrigger.vue"
|
||||
|
||||
import type { VariantProps } from "class-variance-authority"
|
||||
import { cva } from "class-variance-authority"
|
||||
|
||||
export const tooltipContentVariants = cva(
|
||||
"text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit rounded-md px-3 py-1.5 text-xs text-balance",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: ""
|
||||
},
|
||||
color: {
|
||||
default: "bg-foreground",
|
||||
muted: "bg-muted text-muted-foreground"
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
color: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export const tooltipArrowVariants = cva(
|
||||
"z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: ""
|
||||
},
|
||||
color: {
|
||||
default: "bg-foreground fill-foreground",
|
||||
muted: "bg-muted fill-muted"
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
color: "default"
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
export type TooltipContentVariants = VariantProps<typeof tooltipContentVariants>
|
||||
export type TooltipArrowVariants = VariantProps<typeof tooltipArrowVariants>
|
||||
|
||||
|
||||
@ -8,7 +8,7 @@
|
||||
<header ref="header">
|
||||
<AppHeader />
|
||||
</header>
|
||||
<main class="h-screen">
|
||||
<main class="h-screen pt-20">
|
||||
<slot />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
197
app/pages/anime/[id].vue
Normal file
@ -0,0 +1,197 @@
|
||||
<script setup lang="ts">
|
||||
import Badge from '~/components/ui/badge/Badge.vue';
|
||||
import HoverableImage from '~/components/ui/hoverable-image/HoverableImage.vue';
|
||||
import Tabs from '~/components/ui/tabs/Tabs.vue';
|
||||
import TabsContent from '~/components/ui/tabs/TabsContent.vue';
|
||||
import TabsList from '~/components/ui/tabs/TabsList.vue';
|
||||
import TabsTrigger from '~/components/ui/tabs/TabsTrigger.vue';
|
||||
|
||||
const data = {
|
||||
title: " .hack//G.U. Trilogy",
|
||||
poster: "example/hackguposter.jpg",
|
||||
description: `Based on the CyberConnect2 HIT GAME, now will be released in a CG Movie!
|
||||
|
||||
The Movie will be placed in the storyline of each .hack//G.U. games trilogy. The story follows Haseo, a player in the online MMORPG called The World:R2 at first depicted as a PKK (Player Killer Killer) known as the "Terror of Death", a former member of the disbanded Twilight Brigade guild. Haseo encounters Azure Kite (believing him to be Tri-Edge and blaming him for what happened to Shino) but is hopelessly outmatched. Azure Kite easily defeats Haseo and Data Drains him, reducing his level from 133 to 1 and leaving him without any items, weapons, or member addresses. He is left with a mystery on his hands as to the nature of the Data Drain and why Azure Kite is in possession of such a skill.
|
||||
|
||||
Eventually Haseo gains the "Avatar" of Skeith. Acquiring the ability to call Skeith and wield his abilities, such as Data Drain. With Skeith as his strength, Haseo begins the quest for a way to save Shino.
|
||||
|
||||
He is seen seeking out a PK (Player Killer) known as Tri-Edge, whose victims supposedly are unable to return to The World after he PKs them. Haseo's friend, Shino, was attacked six months prior to the events of the game by Tri-Edge, and the player herself, Shino Nanao, was left in a coma.
|
||||
|
||||
(Source: AniDB)
|
||||
`,
|
||||
format: "Movie",
|
||||
episodes: 1,
|
||||
duration: "1 hour, 33 mins",
|
||||
status: "Finished",
|
||||
release: "Mar 25, 2008",
|
||||
season: "Spring 2008",
|
||||
average: 67,
|
||||
mean: 67,
|
||||
popularity: 5481,
|
||||
favorites: 36,
|
||||
producers: ["Bandai Visual", "CyberConnect2", "Hanabee Entartainment", "Funimation"],
|
||||
source: "Video Game",
|
||||
genres: ["Action", "Fantasy", "Sci-Fi"],
|
||||
romaji: ".hack//G.U. Trilogy",
|
||||
native: ".hack//G.U. Trilogy",
|
||||
relations: [
|
||||
{
|
||||
url: "/example/relation1.png",
|
||||
type: "Prequel",
|
||||
},
|
||||
{
|
||||
url: "/example/relation2.jpg",
|
||||
type: "Sequel",
|
||||
},
|
||||
{
|
||||
url: "/example/relation3.jpg",
|
||||
type: "Alternative",
|
||||
},
|
||||
{
|
||||
url: "/example/relation4.jpg",
|
||||
type: "Alternative",
|
||||
},
|
||||
{
|
||||
url: "/example/relation5.jpg",
|
||||
type: "Side Story",
|
||||
},
|
||||
{
|
||||
url: "/example/relation6.jpg",
|
||||
type: "Summary",
|
||||
},
|
||||
]
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col lg:flex-row gap-4 lg:gap-8 xl:gap-16 bg-muted p-4 sm:p-6 md:p-8 lg:p-12 xl:min-h-3/4 xl:max-h-3/4">
|
||||
<div class="flex flex-col items-center lg:items-start gap-4 lg:gap-6 lg:w-auto lg:min-w-72 xl:min-w-96 ">
|
||||
<div class="max-w-xs lg:max-w-sm xl:max-w-md">
|
||||
<NuxtImg :src="data.poster" class="w-full object-cover aspect-3/4 rounded-lg shadow-lg"
|
||||
sizes="sm:100vw md:50vw lg:448px xl:512px" />
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-1 gap-2 w-full">
|
||||
<Button class="w-full">
|
||||
Watch Now
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1">
|
||||
<div class="space-y-4 xl:h-full">
|
||||
<h3 class="text-xl md:text-2xl xl:text-3xl font-bold tracking-tight">
|
||||
{{ data.title }}
|
||||
</h3>
|
||||
<div class="xl:h-full overflow-scroll">
|
||||
<p
|
||||
class="text-base lg:text-lg text-muted-foreground leading-relaxed md:leading-loose whitespace-pre-line transition-colors hover:text-foreground">
|
||||
{{ data.description }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col lg:flex-row gap-4 lg:gap-8 xl:gap-16 p-4 sm:p-6 md:p-8 lg:p-12">
|
||||
<div class="bg-muted text-muted-foreground text-xl space-y-4 min-w-96 p-8">
|
||||
<div>
|
||||
<p class="font-bold">Format</p>
|
||||
{{ data.format }}
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-bold">Episodes</p>
|
||||
{{ data.episodes }}
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-bold">Duration</p>
|
||||
{{ data.duration }}
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-bold">Status</p>
|
||||
<Badge>{{ data.status }}</Badge>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-bold">Release</p>
|
||||
{{ data.release }}
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-bold">Season</p>
|
||||
{{ data.season }}
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-bold">Genres</p>
|
||||
<Badge v-for="genre in data.genres" class="mx-2">{{ genre }}</Badge>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-bold">Average Score</p>
|
||||
{{ data.average }}%
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-bold">Mean Score</p>
|
||||
{{ data.mean }}%
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-bold">Popularity</p>
|
||||
{{ data.popularity }}
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-bold">Favorites</p>
|
||||
{{ data.favorites }}
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-bold">Producers</p>
|
||||
<p v-for="producer in data.producers">{{ producer }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-bold">Source</p>
|
||||
{{ data.source }}
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-bold">Romaji</p>
|
||||
{{ data.romaji }}
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-bold">Native</p>
|
||||
{{ data.native }}
|
||||
</div>
|
||||
</div>
|
||||
<Tabs default-value="overview" class="w-full h-fit">
|
||||
<TabsList class="w-full flex-1 mb-4">
|
||||
<TabsTrigger value="overview">
|
||||
Overview
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="characters">
|
||||
Characters
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="staff">
|
||||
Staff
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="overview">
|
||||
<span class="text-xl font-semibold">Relations</span>
|
||||
<HoverableImage src="/example/jigokuraku.jpg" alt="Description" title="Image Title" size="md"
|
||||
variant="default">
|
||||
<TooltipContent side="right" align="center" :side-offset="8" class="max-w-sm z-50"
|
||||
tooltipColor="muted" :includeArrow="false">
|
||||
<div class="space-y-2">
|
||||
<h4 class="font-semibold text-sm">
|
||||
{{ data.title }}
|
||||
</h4>
|
||||
|
||||
<div class="text-xs text-muted-foreground">
|
||||
<slot>
|
||||
<p>Hover over the image for more details</p>
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</HoverableImage>
|
||||
</TabsContent>
|
||||
<TabsContent value="characters">
|
||||
|
||||
</TabsContent>
|
||||
<TabsContent value="staff">
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</template>
|
||||
@ -5,6 +5,185 @@ import MediaImageCard from '~/components/ui/internal/MediaImageCard.vue';
|
||||
import SkeletonMediaImageCard from '~/components/ui/internal/SkeletonMediaImageCard.vue';
|
||||
|
||||
const tooltipCollisionBoundary = ref<Element | undefined>(undefined);
|
||||
|
||||
const trendingAnime = [
|
||||
{
|
||||
id: 1,
|
||||
image_url: "example/jigokuraku.jpg",
|
||||
title: "Jigokuraku 2nd Season",
|
||||
type: "anime",
|
||||
tooltipMetadata: {
|
||||
studio: "MAPPA",
|
||||
type: "TV Show",
|
||||
episodes: 12,
|
||||
rating: 78,
|
||||
tags: ["action", "adventure", "dark fantasy"],
|
||||
status: "Ongoing",
|
||||
season: "Spring 2024",
|
||||
duration: "23 min"
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
image_url: "example/jujutsukaisen.jpg",
|
||||
title: "Jujutsu Kaisen: Shimetsu Kaiyuu - Zenpen",
|
||||
type: "anime",
|
||||
tooltipMetadata: {
|
||||
studio: "MAPPA",
|
||||
type: "TV Show",
|
||||
episodes: 24,
|
||||
rating: 85,
|
||||
tags: ["action", "supernatural", "dark fantasy"],
|
||||
status: "Finished",
|
||||
season: "Fall 2023",
|
||||
duration: "23 min"
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
image_url: "example/okiraku.jpg",
|
||||
title: "Okiraku Ryoushu no Tanoshii Ryouchi Bouei",
|
||||
type: "anime",
|
||||
tooltipMetadata: {
|
||||
studio: "CloverWorks",
|
||||
type: "TV Show",
|
||||
episodes: 13,
|
||||
rating: 72,
|
||||
tags: ["comedy", "fantasy", "slice of life"],
|
||||
status: "Ongoing",
|
||||
season: "Winter 2024",
|
||||
duration: "24 min"
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
image_url: "example/oshi no ko.jpg",
|
||||
title: "[Oshi no Ko] 3rd Season",
|
||||
type: "anime",
|
||||
tooltipMetadata: {
|
||||
studio: "Doga Kobo",
|
||||
type: "TV Show",
|
||||
episodes: 11,
|
||||
rating: 88,
|
||||
tags: ["drama", "psychological", "showbiz"],
|
||||
status: "Airing",
|
||||
season: "Spring 2024",
|
||||
duration: "24 min"
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
image_url: "example/shibou.jpg",
|
||||
title: "Shibou Yuugi de Meshi wo Kuu.",
|
||||
type: "anime",
|
||||
tooltipMetadata: {
|
||||
studio: "Studio Bind",
|
||||
type: "TV Show",
|
||||
episodes: 12,
|
||||
rating: 75,
|
||||
tags: ["cooking", "fantasy", "isekai"],
|
||||
status: "Finished",
|
||||
season: "Fall 2023",
|
||||
duration: "23 min"
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
image_url: "example/sousounofrieren.jpg",
|
||||
title: "Sousou no Frieren 2nd Season",
|
||||
type: "anime",
|
||||
tooltipMetadata: {
|
||||
studio: "Madhouse",
|
||||
type: "TV Show",
|
||||
episodes: 28,
|
||||
rating: 92,
|
||||
tags: ["adventure", "drama", "fantasy"],
|
||||
status: "Airing",
|
||||
season: "Spring 2024",
|
||||
duration: "24 min"
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
image_url: "example/tamonkun.jpg",
|
||||
title: "Tamon-kun Ima Docchi!?",
|
||||
type: "anime",
|
||||
tooltipMetadata: {
|
||||
studio: "Kyoto Animation",
|
||||
type: "TV Show",
|
||||
episodes: 12,
|
||||
rating: 68,
|
||||
tags: ["comedy", "romance", "school"],
|
||||
status: "Ongoing",
|
||||
season: "Winter 2024",
|
||||
duration: "24 min"
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
image_url: "example/yuusha party.jpg",
|
||||
title: "Yuusha Party wo Oidasareta Kiyou Binbou",
|
||||
type: "anime",
|
||||
tooltipMetadata: {
|
||||
studio: "Wit Studio",
|
||||
type: "TV Show",
|
||||
episodes: 13,
|
||||
rating: 70,
|
||||
tags: ["action", "adventure", "fantasy"],
|
||||
status: "Finished",
|
||||
season: "Fall 2023",
|
||||
duration: "23 min"
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 9,
|
||||
image_url: "example/jujutsukaisen.jpg",
|
||||
title: "Jujutsu Kaisen: Shimetsu Kaiyuu - Zenpen soetjsot giwjiagjwiag wgi wajig wig gwi",
|
||||
type: "anime",
|
||||
tooltipMetadata: {
|
||||
studio: "MAPPA",
|
||||
type: "TV Show",
|
||||
episodes: 24,
|
||||
rating: 85,
|
||||
tags: ["action", "supernatural", "dark fantasy"],
|
||||
status: "Finished",
|
||||
season: "Fall 2023",
|
||||
duration: "23 min"
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 10,
|
||||
image_url: "example/okiraku.jpg",
|
||||
title: "Okiraku Ryoushu no Tanoshii Ryouchi Bouei teisotsoeitj wgjiwag wgj wigwji",
|
||||
type: "anime",
|
||||
tooltipMetadata: {
|
||||
studio: "CloverWorks",
|
||||
type: "TV Show",
|
||||
episodes: 13,
|
||||
rating: 72,
|
||||
tags: ["comedy", "fantasy", "slice of life"],
|
||||
status: "Ongoing",
|
||||
season: "Winter 2024",
|
||||
duration: "24 min"
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 11,
|
||||
image_url: "example/oshi no ko.jpg",
|
||||
title: "[Oshi no Ko] 3rd Season iaowjegiogjoiawgio waiogjawiog wag",
|
||||
type: "anime",
|
||||
tooltipMetadata: {
|
||||
studio: "Doga Kobo",
|
||||
type: "TV Show",
|
||||
episodes: 11,
|
||||
rating: 88,
|
||||
tags: ["drama", "psychological", "showbiz"],
|
||||
status: "Airing",
|
||||
season: "Spring 2024",
|
||||
duration: "24 min"
|
||||
}
|
||||
}
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@ -33,99 +212,10 @@ const tooltipCollisionBoundary = ref<Element | undefined>(undefined);
|
||||
</div>
|
||||
<div ref="tooltipCollisionBoundary"
|
||||
class="grid grid-cols-3 md:grid-cols-4 lg:grid-cols-5 2xl:grid-cols-6 gap-4 gap-y-12 justify-items-center w-full">
|
||||
<MediaImageCard image_url="example/jigokuraku.jpg" title="Jigokuraku 2nd Season" type="anime"
|
||||
:tooltipEnabled="true" :tooltipMetadata="{
|
||||
studio: 'MAPPA',
|
||||
type: 'TV Show',
|
||||
episodes: 12,
|
||||
rating: 78,
|
||||
tags: ['action', 'adventure']
|
||||
}" :tooltipCollisionBoundary="tooltipCollisionBoundary" />
|
||||
<MediaImageCard image_url="example/jujutsukaisen.jpg"
|
||||
title="Jujutsu Kaisen: Shimetsu Kaiyuu - Zenpen" type="anime" :tooltipEnabled="true"
|
||||
:tooltipMetadata="{
|
||||
studio: 'MAPPA',
|
||||
type: 'TV Show',
|
||||
episodes: 12,
|
||||
rating: 78,
|
||||
tags: ['action', 'adventure']
|
||||
}" :tooltipCollisionBoundary="tooltipCollisionBoundary" />
|
||||
<MediaImageCard image_url="example/okiraku.jpg" title="Okiraku Ryoushu no Tanoshii Ryouchi Bouei"
|
||||
type="anime" :tooltipEnabled="true" :tooltipMetadata="{
|
||||
studio: 'MAPPA',
|
||||
type: 'TV Show',
|
||||
episodes: 12,
|
||||
rating: 78,
|
||||
tags: ['action', 'adventure']
|
||||
}" :tooltipCollisionBoundary="tooltipCollisionBoundary" />
|
||||
<MediaImageCard image_url="example/oshi no ko.jpg" title="[Oshi no Ko] 3rd Season" type="anime"
|
||||
:tooltipEnabled="true" :tooltipMetadata="{
|
||||
studio: 'MAPPA',
|
||||
type: 'TV Show',
|
||||
episodes: 12,
|
||||
rating: 78,
|
||||
tags: ['action', 'adventure']
|
||||
}" :tooltipCollisionBoundary="tooltipCollisionBoundary" />
|
||||
<MediaImageCard image_url="example/shibou.jpg" title="Shibou Yuugi de Meshi wo Kuu." type="anime"
|
||||
:tooltipEnabled="true" :tooltipMetadata="{
|
||||
studio: 'MAPPA',
|
||||
type: 'TV Show',
|
||||
episodes: 12,
|
||||
rating: 78,
|
||||
tags: ['action', 'adventure']
|
||||
}" :tooltipCollisionBoundary="tooltipCollisionBoundary" />
|
||||
<MediaImageCard image_url="example/sousounofrieren.jpg" title="Sousou no Frieren 2nd Season"
|
||||
type="anime" :tooltipEnabled="true" :tooltipMetadata="{
|
||||
studio: 'MAPPA',
|
||||
type: 'TV Show',
|
||||
episodes: 12,
|
||||
rating: 78,
|
||||
tags: ['action', 'adventure']
|
||||
}" :tooltipCollisionBoundary="tooltipCollisionBoundary" />
|
||||
<MediaImageCard image_url="example/tamonkun.jpg" title="Tamon-kun Ima Docchi!?" type="anime"
|
||||
:tooltipEnabled="true" :tooltipMetadata="{
|
||||
studio: 'MAPPA',
|
||||
type: 'TV Show',
|
||||
episodes: 12,
|
||||
rating: 78,
|
||||
tags: ['action', 'adventure']
|
||||
}" :tooltipCollisionBoundary="tooltipCollisionBoundary" />
|
||||
<MediaImageCard image_url="example/yuusha party.jpg" title="Yuusha Party wo Oidasareta Kiyou Binbou"
|
||||
type="anime" :tooltipEnabled="true" :tooltipMetadata="{
|
||||
studio: 'MAPPA',
|
||||
type: 'TV Show',
|
||||
episodes: 12,
|
||||
rating: 78,
|
||||
tags: ['action', 'adventure']
|
||||
}" :tooltipCollisionBoundary="tooltipCollisionBoundary" />
|
||||
<MediaImageCard image_url="example/jujutsukaisen.jpg"
|
||||
title="Jujutsu Kaisen: Shimetsu Kaiyuu - Zenpen soetjsot giwjiagjwiag wgi wajig wig gwi"
|
||||
type="anime" :tooltipEnabled="true" :tooltipMetadata="{
|
||||
studio: 'MAPPA',
|
||||
type: 'TV Show',
|
||||
episodes: 12,
|
||||
rating: 78,
|
||||
tags: ['action', 'adventure']
|
||||
}" :tooltipCollisionBoundary="tooltipCollisionBoundary" />
|
||||
<MediaImageCard image_url="example/okiraku.jpg"
|
||||
title="Okiraku Ryoushu no Tanoshii Ryouchi Bouei teisotsoeitj wgjiwag wgj wigwji" type="anime"
|
||||
:tooltipEnabled="true" :tooltipMetadata="{
|
||||
studio: 'MAPPA',
|
||||
type: 'TV Show',
|
||||
episodes: 12,
|
||||
rating: 78,
|
||||
tags: ['action', 'adventure']
|
||||
}" :tooltipCollisionBoundary="tooltipCollisionBoundary" />
|
||||
<MediaImageCard image_url="example/oshi no ko.jpg"
|
||||
title="[Oshi no Ko] 3rd Season iaowjegiogjoiawgio waiogjawiog wag" type="anime"
|
||||
:tooltipEnabled="true" :tooltipMetadata="{
|
||||
studio: 'MAPPA',
|
||||
type: 'TV Show',
|
||||
episodes: 12,
|
||||
rating: 78,
|
||||
tags: ['action', 'adventure']
|
||||
}" :tooltipCollisionBoundary="tooltipCollisionBoundary" />
|
||||
<SkeletonMediaImageCard />
|
||||
<MediaImageCard v-for="anime in trendingAnime" :key="anime.id" :id="anime.id"
|
||||
:image_url="anime.image_url" :title="anime.title" :type="anime.type" :tooltipEnabled="true"
|
||||
:tooltipMetadata="anime.tooltipMetadata" :tooltipCollisionBoundary="tooltipCollisionBoundary" />
|
||||
<SkeletonMediaImageCard v-for="index in 10" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
BIN
public/example/hackguposter.jpg
Normal file
|
After Width: | Height: | Size: 120 KiB |
BIN
public/example/relation1.png
Normal file
|
After Width: | Height: | Size: 103 KiB |
BIN
public/example/relation2.jpg
Normal file
|
After Width: | Height: | Size: 45 KiB |
BIN
public/example/relation3.jpg
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
public/example/relation4.jpg
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
public/example/relation5.jpg
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
public/example/relation6.jpg
Normal file
|
After Width: | Height: | Size: 14 KiB |