Basic implementation for anime page

This commit is contained in:
2026-01-18 00:46:32 +05:00
parent da573b6b33
commit 7390c631d5
31 changed files with 846 additions and 162 deletions

View File

@ -7,20 +7,19 @@ import { Primitive } from "reka-ui"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { badgeVariants } from "." import { badgeVariants } from "."
const props = defineProps<PrimitiveProps & { const props = withDefaults(defineProps<PrimitiveProps & {
variant?: BadgeVariants["variant"] variant?: BadgeVariants["variant"]
class?: HTMLAttributes["class"] size?: BadgeVariants["size"]
}>() class?: HTMLAttributes["class"]
}>(), {
size: "default"
});
const delegatedProps = reactiveOmit(props, "class") const delegatedProps = reactiveOmit(props, "class")
</script> </script>
<template> <template>
<Primitive <Primitive data-slot="badge" :class="cn(badgeVariants({ variant, size }), props.class)" v-bind="delegatedProps">
data-slot="badge" <slot />
:class="cn(badgeVariants({ variant }), props.class)" </Primitive>
v-bind="delegatedProps"
>
<slot />
</Primitive>
</template> </template>

View File

@ -4,23 +4,28 @@ import { cva } from "class-variance-authority"
export { default as Badge } from "./Badge.vue" export { default as Badge } from "./Badge.vue"
export const badgeVariants = cva( 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: { variants: {
variant: { variant: {
default: default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90", "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary: secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90", "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive: destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline: outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", "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",
},
}, },
defaultVariants: {
variant: "default",
},
},
) )
export type BadgeVariants = VariantProps<typeof badgeVariants> export type BadgeVariants = VariantProps<typeof badgeVariants>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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"

View 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>

View 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>

View File

@ -17,10 +17,11 @@ watch(palette, () => {
}); });
interface Props { interface Props {
id: number;
image_url: string; image_url: string;
image_alt?: string; image_alt?: string;
title: string; title: string;
type: 'anime' | 'manga' | 'character' | 'voice_actor'; type: string;
subtitle?: string; subtitle?: string;
description?: string; description?: string;
@ -46,45 +47,46 @@ const props = defineProps<Props>();
</script> </script>
<template> <template>
<div class="h-full overflow-hidden max-w-64"> <NuxtLink :to="`anime/${id}`">
<div class="flex flex-col gap-2 cursor-pointer text-muted-foreground vibrant-color"> <div class="h-full overflow-hidden max-w-64">
<TooltipProvider> <div class="flex flex-col gap-2 cursor-pointer text-muted-foreground vibrant-color">
<Tooltip> <TooltipProvider>
<TooltipTrigger as-child> <Tooltip>
<div> <TooltipTrigger as-child>
<NuxtImg class="rounded-lg" ref="imageElement" :src="props.image_url" sizes="128px md:256px" <div>
quality="100" /> <NuxtImg class="rounded-lg" ref="imageElement" :src="props.image_url"
<p class="text-lg line-clamp-2 font-semibold"> sizes="128px md:256px" quality="100" />
{{ props.title }} <p class="text-lg line-clamp-2 font-semibold max-w-[12ch] 2xl:max-w-full">
</p> {{ props.title }}
</div> </p>
</TooltipTrigger>
<TooltipContent :sideOffset="16" :collisionBoundary="props.tooltipCollisionBoundary"
:avoidCollisions="true" side="right" class="bg-muted text-muted-foreground"
arrowClass="bg-muted fill-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>
</div> </div>
<p class="text-lg font-semibold tracking-wider" :style="{ color: paletteVibrantHsl }">{{ </TooltipTrigger>
props.tooltipMetadata?.studio }} <TooltipContent :sideOffset="16" :collisionBoundary="props.tooltipCollisionBoundary"
</p> :avoidCollisions="true" side="right" tooltipColor="muted" tooltipArrowColor="muted">
<div class="flex items-center text-base font-semibold"> <div class="flex flex-col gap-1 m-4">
<p>{{ props.tooltipMetadata?.type }}</p> <div class="flex">
<Dot /> <p class="text-xl font-bold truncate max-w-[24ch]">{{ props.title }}</p>
<p>{{ props.tooltipMetadata?.rating }} episodes</p> </div>
<p class="text-lg font-semibold tracking-wider" :style="{ color: paletteVibrantHsl }">{{
props.tooltipMetadata?.studio }}
</p>
<div class="flex items-center text-base font-semibold">
<p>{{ props.tooltipMetadata?.type }}</p>
<Dot />
<p>{{ props.tooltipMetadata?.rating }} episodes</p>
</div>
<div class="flex gap-2">
<Badge v-for="tag in props.tooltipMetadata?.tags">
{{ tag }}
</Badge>
</div>
</div> </div>
<div class="flex gap-2"> </TooltipContent>
<Badge v-for="tag in props.tooltipMetadata?.tags"> </Tooltip>
{{ tag }} </TooltipProvider>
</Badge> </div>
</div>
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div> </div>
</div> </NuxtLink>
</template> </template>
<style scoped> <style scoped>

View File

@ -9,7 +9,7 @@ const props = defineProps<Props>();
</script> </script>
<template> <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"> <div class="flex flex-col gap-2 h-full pb-6">
<Skeleton class="h-full w-full" /> <Skeleton class="h-full w-full" />
<Skeleton class="h-6 w-3/4" /> <Skeleton class="h-6 w-3/4" />

View 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>

View 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>

View 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>

View 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>

View 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"

View File

@ -4,6 +4,7 @@ import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core" import { reactiveOmit } from "@vueuse/core"
import { TooltipArrow, TooltipContent, TooltipPortal, useForwardPropsEmits } from "reka-ui" import { TooltipArrow, TooltipContent, TooltipPortal, useForwardPropsEmits } from "reka-ui"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { tooltipArrowVariants, tooltipContentVariants, type TooltipArrowVariants, type TooltipContentVariants } from "."
defineOptions({ defineOptions({
inheritAttrs: false, inheritAttrs: false,
@ -13,6 +14,8 @@ interface Props {
includeArrow?: boolean includeArrow?: boolean
class?: HTMLAttributes["class"] class?: HTMLAttributes["class"]
arrowClass?: HTMLAttributes["class"] arrowClass?: HTMLAttributes["class"]
tooltipColor?: TooltipContentVariants["color"]
tooltipArrowColor?: TooltipArrowVariants["color"]
} }
const props = withDefaults(defineProps<TooltipContentProps & Props>(), { const props = withDefaults(defineProps<TooltipContentProps & Props>(), {
@ -29,11 +32,11 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
<template> <template>
<TooltipPortal> <TooltipPortal>
<TooltipContent data-slot="tooltip-content" v-bind="{ ...forwarded, ...$attrs }" <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 /> <slot />
<TooltipArrow <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" /> v-if="includeArrow" />
</TooltipContent> </TooltipContent>
</TooltipPortal> </TooltipPortal>

View File

@ -2,3 +2,48 @@ export { default as Tooltip } from "./Tooltip.vue"
export { default as TooltipContent } from "./TooltipContent.vue" export { default as TooltipContent } from "./TooltipContent.vue"
export { default as TooltipProvider } from "./TooltipProvider.vue" export { default as TooltipProvider } from "./TooltipProvider.vue"
export { default as TooltipTrigger } from "./TooltipTrigger.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>

View File

@ -8,7 +8,7 @@
<header ref="header"> <header ref="header">
<AppHeader /> <AppHeader />
</header> </header>
<main class="h-screen"> <main class="h-screen pt-20">
<slot /> <slot />
</main> </main>
</div> </div>

197
app/pages/anime/[id].vue Normal file
View 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>

View File

@ -5,6 +5,185 @@ import MediaImageCard from '~/components/ui/internal/MediaImageCard.vue';
import SkeletonMediaImageCard from '~/components/ui/internal/SkeletonMediaImageCard.vue'; import SkeletonMediaImageCard from '~/components/ui/internal/SkeletonMediaImageCard.vue';
const tooltipCollisionBoundary = ref<Element | undefined>(undefined); 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> </script>
<template> <template>
@ -33,99 +212,10 @@ const tooltipCollisionBoundary = ref<Element | undefined>(undefined);
</div> </div>
<div ref="tooltipCollisionBoundary" <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"> 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" <MediaImageCard v-for="anime in trendingAnime" :key="anime.id" :id="anime.id"
:tooltipEnabled="true" :tooltipMetadata="{ :image_url="anime.image_url" :title="anime.title" :type="anime.type" :tooltipEnabled="true"
studio: 'MAPPA', :tooltipMetadata="anime.tooltipMetadata" :tooltipCollisionBoundary="tooltipCollisionBoundary" />
type: 'TV Show', <SkeletonMediaImageCard v-for="index in 10" />
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 />
</div> </div>
</div> </div>
</div> </div>

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB