mirror of
https://github.com/maxdorninger/MediaManager.git
synced 2026-04-21 16:25:36 +02:00
feat: add recommended shows carousel and integrate TV recommendations API
This commit is contained in:
@@ -1,14 +1,16 @@
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import {Button} from '$lib/components/ui/button/index.js';
|
||||
import {env} from '$env/dynamic/public';
|
||||
import * as Card from '$lib/components/ui/card/index.js';
|
||||
import {ImageOff} from 'lucide-svelte';
|
||||
import {goto} from '$app/navigation';
|
||||
import {base} from '$app/paths';
|
||||
import type {MetaDataProviderShowSearchResult} from '$lib/types.js';
|
||||
|
||||
let loading = $state(false);
|
||||
let errorMessage = $state(null);
|
||||
let {result} = $props();
|
||||
let {result}: { result: MetaDataProviderShowSearchResult } = $props();
|
||||
console.log('Add Show Card Result: ', result);
|
||||
|
||||
async function addShow() {
|
||||
loading = true;
|
||||
@@ -32,35 +34,51 @@
|
||||
|
||||
<Card.Root class="h-full max-w-sm">
|
||||
<Card.Header>
|
||||
<Card.Title>
|
||||
<Card.Title class="h-12 overflow-hidden leading-tight flex items-center">
|
||||
{result.name}
|
||||
{#if result.year != null}
|
||||
({result.year})
|
||||
{/if}
|
||||
</Card.Title>
|
||||
<Card.Description class="truncate">{result?.overview}</Card.Description>
|
||||
<Card.Description
|
||||
class="truncate">{result.overview !== "" ? result.overview : "No overview available"}</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<Card.Content class="w-full h-96 flex items-center justify-center">
|
||||
{#if result.poster_path != null}
|
||||
<img
|
||||
class="h-auto max-w-full rounded-lg object-cover"
|
||||
class="max-h-full max-w-full object-contain rounded-lg"
|
||||
src={result.poster_path}
|
||||
alt="{result.name}'s Poster Image"
|
||||
/>
|
||||
{:else}
|
||||
<ImageOff/>
|
||||
<div class="w-full h-full flex items-center justify-center">
|
||||
<ImageOff class="w-12 h-12 text-gray-400"/>
|
||||
</div>
|
||||
{/if}
|
||||
</Card.Content>
|
||||
<Card.Footer>
|
||||
<Button disabled={result.added || loading} onclick={() => addShow(result)}>
|
||||
<Card.Footer class="flex flex-col gap-2 items-start p-4 bg-card rounded-b-lg border-t">
|
||||
<Button
|
||||
class="w-full font-semibold"
|
||||
disabled={result.added || loading}
|
||||
onclick={() => addShow(result)}
|
||||
>
|
||||
{#if loading}
|
||||
Loading...
|
||||
<span class="animate-pulse">Loading...</span>
|
||||
{:else}
|
||||
{result.added ? 'Show already exists' : 'Add Show'}
|
||||
{/if}
|
||||
</Button>
|
||||
<div class="flex items-center gap-2 w-full">
|
||||
{#if result.vote_average != null}
|
||||
<span class="text-sm text-yellow-600 font-medium flex items-center">
|
||||
<svg class="w-4 h-4 mr-1 text-yellow-400" fill="currentColor" viewBox="0 0 20 20"><path
|
||||
d="M10 15l-5.878 3.09 1.122-6.545L.488 6.91l6.561-.955L10 0l2.951 5.955 6.561.955-4.756 4.635 1.122 6.545z"/></svg>
|
||||
Rating: {result.vote_average}/10
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if errorMessage}
|
||||
<p class="text-sm text-red-500">{errorMessage}</p>
|
||||
<p class="text-xs text-red-500 bg-red-50 rounded px-2 py-1 w-full">{errorMessage}</p>
|
||||
{/if}
|
||||
</Card.Footer>
|
||||
</Card.Root>
|
||||
|
||||
36
web/src/lib/components/recommended-shows-carousel.svelte
Normal file
36
web/src/lib/components/recommended-shows-carousel.svelte
Normal file
@@ -0,0 +1,36 @@
|
||||
<script lang="ts">
|
||||
import * as Card from "$lib/components/ui/card/index.js";
|
||||
import Autoplay from "embla-carousel-autoplay";
|
||||
import * as Carousel from "$lib/components/ui/carousel/index.js";
|
||||
import type {MetaDataProviderShowSearchResult} from "$lib/types";
|
||||
import AddShowCard from "$lib/components/add-show-card.svelte";
|
||||
|
||||
let {shows}: { shows: MetaDataProviderShowSearchResult } = $props();
|
||||
</script>
|
||||
<Carousel.Root
|
||||
opts={{
|
||||
align: "start",
|
||||
loop: true,
|
||||
}}
|
||||
plugins={[
|
||||
Autoplay({
|
||||
delay: 2000,
|
||||
stopOnInteraction: false,
|
||||
stopOnMouseEnter: true,
|
||||
playOnInit: true,
|
||||
|
||||
}),
|
||||
]}
|
||||
>
|
||||
<Carousel.Content class="-ml-1">
|
||||
{#each shows as show}
|
||||
<Carousel.Item class="pl-1 md:basis-1/2 lg:basis-1/3">
|
||||
<div class="p-1">
|
||||
<AddShowCard result={show}/>
|
||||
</div>
|
||||
</Carousel.Item>
|
||||
{/each}
|
||||
</Carousel.Content>
|
||||
<Carousel.Previous/>
|
||||
<Carousel.Next/>
|
||||
</Carousel.Root>
|
||||
@@ -1,35 +1,36 @@
|
||||
<script lang="ts" module>
|
||||
import type {WithElementRef} from 'bits-ui';
|
||||
import type {HTMLAnchorAttributes, HTMLButtonAttributes} from 'svelte/elements';
|
||||
import {type VariantProps, tv} from 'tailwind-variants';
|
||||
import type {WithElementRef} from "bits-ui";
|
||||
import type {HTMLAnchorAttributes, HTMLButtonAttributes} from "svelte/elements";
|
||||
import {type VariantProps, tv} from "tailwind-variants";
|
||||
|
||||
export const buttonVariants = tv({
|
||||
base: 'focus-visible:ring-ring inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
|
||||
base: "focus-visible:ring-ring inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground hover:bg-primary/90 shadow',
|
||||
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90 shadow-sm',
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90 shadow",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90 shadow-sm",
|
||||
outline:
|
||||
'border-input bg-background hover:bg-accent hover:text-accent-foreground border shadow-sm',
|
||||
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80 shadow-sm',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
link: 'text-primary underline-offset-4 hover:underline'
|
||||
"border-input bg-background hover:bg-accent hover:text-accent-foreground border shadow-sm",
|
||||
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80 shadow-sm",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: 'h-9 px-4 py-2',
|
||||
sm: 'h-8 rounded-md px-3 text-xs',
|
||||
lg: 'h-10 rounded-md px-8',
|
||||
icon: 'h-9 w-9'
|
||||
}
|
||||
default: "h-9 px-4 py-2",
|
||||
sm: "h-8 rounded-md px-3 text-xs",
|
||||
lg: "h-10 rounded-md px-8",
|
||||
icon: "h-9 w-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default'
|
||||
}
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
});
|
||||
|
||||
export type ButtonVariant = VariantProps<typeof buttonVariants>['variant'];
|
||||
export type ButtonSize = VariantProps<typeof buttonVariants>['size'];
|
||||
export type ButtonVariant = VariantProps<typeof buttonVariants>["variant"];
|
||||
export type ButtonSize = VariantProps<typeof buttonVariants>["size"];
|
||||
|
||||
export type ButtonProps = WithElementRef<HTMLButtonAttributes> &
|
||||
WithElementRef<HTMLAnchorAttributes> & {
|
||||
@@ -39,22 +40,27 @@
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import {cn} from '$lib/utils.js';
|
||||
import {cn} from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
class: className,
|
||||
variant = 'default',
|
||||
size = 'default',
|
||||
variant = "default",
|
||||
size = "default",
|
||||
ref = $bindable(null),
|
||||
href = undefined,
|
||||
type = 'button',
|
||||
type = "button",
|
||||
children,
|
||||
...restProps
|
||||
}: ButtonProps = $props();
|
||||
</script>
|
||||
|
||||
{#if href}
|
||||
<a bind:this={ref} class={cn(buttonVariants({ variant, size }), className)} {href} {...restProps}>
|
||||
<a
|
||||
bind:this={ref}
|
||||
class={cn(buttonVariants({ variant, size }), className)}
|
||||
{href}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</a>
|
||||
{:else}
|
||||
|
||||
@@ -2,8 +2,8 @@ import Root, {
|
||||
type ButtonProps,
|
||||
type ButtonSize,
|
||||
type ButtonVariant,
|
||||
buttonVariants
|
||||
} from './button.svelte';
|
||||
buttonVariants,
|
||||
} from "./button.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
@@ -13,5 +13,5 @@ export {
|
||||
buttonVariants,
|
||||
type ButtonProps,
|
||||
type ButtonSize,
|
||||
type ButtonVariant
|
||||
type ButtonVariant,
|
||||
};
|
||||
|
||||
44
web/src/lib/components/ui/carousel/carousel-content.svelte
Normal file
44
web/src/lib/components/ui/carousel/carousel-content.svelte
Normal file
@@ -0,0 +1,44 @@
|
||||
<script lang="ts">
|
||||
import emblaCarouselSvelte from "embla-carousel-svelte";
|
||||
import type {WithElementRef} from "bits-ui";
|
||||
import type {HTMLAttributes} from "svelte/elements";
|
||||
import {getEmblaContext} from "./context.js";
|
||||
import {cn} from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
|
||||
const emblaCtx = getEmblaContext("<Carousel.Content/>");
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore event_directive_deprecated -->
|
||||
<div
|
||||
class="overflow-hidden"
|
||||
on:emblaInit={emblaCtx.onInit}
|
||||
use:emblaCarouselSvelte={{
|
||||
options: {
|
||||
container: "[data-embla-container]",
|
||||
slides: "[data-embla-slide]",
|
||||
...emblaCtx.options,
|
||||
axis: emblaCtx.orientation === "horizontal" ? "x" : "y",
|
||||
},
|
||||
plugins: emblaCtx.plugins,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
{...restProps}
|
||||
bind:this={ref}
|
||||
class={cn(
|
||||
"flex",
|
||||
emblaCtx.orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
|
||||
className
|
||||
)}
|
||||
data-embla-container=""
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
</div>
|
||||
30
web/src/lib/components/ui/carousel/carousel-item.svelte
Normal file
30
web/src/lib/components/ui/carousel/carousel-item.svelte
Normal file
@@ -0,0 +1,30 @@
|
||||
<script lang="ts">
|
||||
import type {WithElementRef} from "bits-ui";
|
||||
import type {HTMLAttributes} from "svelte/elements";
|
||||
import {getEmblaContext} from "./context.js";
|
||||
import {cn} from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
|
||||
const emblaCtx = getEmblaContext("<Carousel.Item/>");
|
||||
</script>
|
||||
|
||||
<div
|
||||
{...restProps}
|
||||
aria-roledescription="slide"
|
||||
bind:this={ref}
|
||||
class={cn(
|
||||
"min-w-0 shrink-0 grow-0 basis-full",
|
||||
emblaCtx.orientation === "horizontal" ? "pl-4" : "pt-4",
|
||||
className
|
||||
)}
|
||||
data-embla-slide=""
|
||||
role="group"
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
37
web/src/lib/components/ui/carousel/carousel-next.svelte
Normal file
37
web/src/lib/components/ui/carousel/carousel-next.svelte
Normal file
@@ -0,0 +1,37 @@
|
||||
<script lang="ts">
|
||||
import ArrowRight from "@lucide/svelte/icons/arrow-right";
|
||||
import type {WithoutChildren} from "bits-ui";
|
||||
import {getEmblaContext} from "./context.js";
|
||||
import {cn} from "$lib/utils.js";
|
||||
import {Button, type Props} from "$lib/components/ui/button/index.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
variant = "outline",
|
||||
size = "icon",
|
||||
...restProps
|
||||
}: WithoutChildren<Props> = $props();
|
||||
|
||||
const emblaCtx = getEmblaContext("<Carousel.Next/>");
|
||||
</script>
|
||||
|
||||
<Button
|
||||
{...restProps}
|
||||
bind:ref
|
||||
class={cn(
|
||||
"absolute size-8 touch-manipulation rounded-full",
|
||||
emblaCtx.orientation === "horizontal"
|
||||
? "-right-12 top-1/2 -translate-y-1/2"
|
||||
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
|
||||
className
|
||||
)}
|
||||
disabled={!emblaCtx.canScrollNext}
|
||||
onclick={emblaCtx.scrollNext}
|
||||
onkeydown={emblaCtx.handleKeyDown}
|
||||
{size}
|
||||
{variant}
|
||||
>
|
||||
<ArrowRight/>
|
||||
<span class="sr-only">Next slide</span>
|
||||
</Button>
|
||||
37
web/src/lib/components/ui/carousel/carousel-previous.svelte
Normal file
37
web/src/lib/components/ui/carousel/carousel-previous.svelte
Normal file
@@ -0,0 +1,37 @@
|
||||
<script lang="ts">
|
||||
import ArrowLeft from "@lucide/svelte/icons/arrow-left";
|
||||
import type {WithoutChildren} from "bits-ui";
|
||||
import {getEmblaContext} from "./context.js";
|
||||
import {cn} from "$lib/utils.js";
|
||||
import {Button, type Props} from "$lib/components/ui/button/index.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
variant = "outline",
|
||||
size = "icon",
|
||||
...restProps
|
||||
}: WithoutChildren<Props> = $props();
|
||||
|
||||
const emblaCtx = getEmblaContext("<Carousel.Previous/>");
|
||||
</script>
|
||||
|
||||
<Button
|
||||
{...restProps}
|
||||
bind:ref
|
||||
class={cn(
|
||||
"absolute size-8 touch-manipulation rounded-full",
|
||||
emblaCtx.orientation === "horizontal"
|
||||
? "-left-12 top-1/2 -translate-y-1/2"
|
||||
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
|
||||
className
|
||||
)}
|
||||
disabled={!emblaCtx.canScrollPrev}
|
||||
onclick={emblaCtx.scrollPrev}
|
||||
onkeydown={emblaCtx.handleKeyDown}
|
||||
{size}
|
||||
{variant}
|
||||
>
|
||||
<ArrowLeft/>
|
||||
<span class="sr-only">Previous slide</span>
|
||||
</Button>
|
||||
95
web/src/lib/components/ui/carousel/carousel.svelte
Normal file
95
web/src/lib/components/ui/carousel/carousel.svelte
Normal file
@@ -0,0 +1,95 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
type CarouselAPI,
|
||||
type CarouselProps,
|
||||
type EmblaContext,
|
||||
setEmblaContext,
|
||||
} from "./context.js";
|
||||
import {cn} from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
opts = {},
|
||||
plugins = [],
|
||||
setApi = () => {
|
||||
},
|
||||
orientation = "horizontal",
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: CarouselProps = $props();
|
||||
|
||||
let carouselState = $state<EmblaContext>({
|
||||
api: undefined,
|
||||
scrollPrev,
|
||||
scrollNext,
|
||||
orientation,
|
||||
canScrollNext: false,
|
||||
canScrollPrev: false,
|
||||
handleKeyDown,
|
||||
options: opts,
|
||||
plugins,
|
||||
onInit,
|
||||
scrollSnaps: [],
|
||||
selectedIndex: 0,
|
||||
scrollTo,
|
||||
});
|
||||
|
||||
setEmblaContext(carouselState);
|
||||
|
||||
function scrollPrev() {
|
||||
carouselState.api?.scrollPrev();
|
||||
}
|
||||
|
||||
function scrollNext() {
|
||||
carouselState.api?.scrollNext();
|
||||
}
|
||||
|
||||
function scrollTo(index: number, jump?: boolean) {
|
||||
carouselState.api?.scrollTo(index, jump);
|
||||
}
|
||||
|
||||
function onSelect(api: CarouselAPI) {
|
||||
if (!api) return;
|
||||
carouselState.canScrollPrev = api.canScrollPrev();
|
||||
carouselState.canScrollNext = api.canScrollNext();
|
||||
carouselState.selectedIndex = api.selectedScrollSnap();
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (carouselState.api) {
|
||||
onSelect(carouselState.api);
|
||||
carouselState.api.on("select", onSelect);
|
||||
carouselState.api.on("reInit", onSelect);
|
||||
}
|
||||
});
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === "ArrowLeft") {
|
||||
e.preventDefault();
|
||||
scrollPrev();
|
||||
} else if (e.key === "ArrowRight") {
|
||||
e.preventDefault();
|
||||
scrollNext();
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
setApi(carouselState.api);
|
||||
});
|
||||
|
||||
function onInit(event: CustomEvent<CarouselAPI>) {
|
||||
carouselState.api = event.detail;
|
||||
|
||||
carouselState.scrollSnaps = carouselState.api.scrollSnapList();
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
return () => {
|
||||
carouselState.api?.off("select", onSelect);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<div {...restProps} aria-roledescription="carousel" class={cn("relative", className)} role="region">
|
||||
{@render children?.()}
|
||||
</div>
|
||||
56
web/src/lib/components/ui/carousel/context.ts
Normal file
56
web/src/lib/components/ui/carousel/context.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import type {EmblaCarouselSvelteType} from "embla-carousel-svelte";
|
||||
import type emblaCarouselSvelte from "embla-carousel-svelte";
|
||||
import {getContext, hasContext, setContext} from "svelte";
|
||||
import type {WithElementRef} from "bits-ui";
|
||||
import type {HTMLAttributes} from "svelte/elements";
|
||||
|
||||
export type CarouselAPI =
|
||||
NonNullable<NonNullable<EmblaCarouselSvelteType["$$_attributes"]>["on:emblaInit"]> extends (
|
||||
evt: CustomEvent<infer CarouselAPI>
|
||||
) => void
|
||||
? CarouselAPI
|
||||
: never;
|
||||
|
||||
type EmblaCarouselConfig = NonNullable<Parameters<typeof emblaCarouselSvelte>[1]>;
|
||||
|
||||
export type CarouselOptions = EmblaCarouselConfig["options"];
|
||||
export type CarouselPlugins = EmblaCarouselConfig["plugins"];
|
||||
|
||||
////
|
||||
|
||||
export type CarouselProps = {
|
||||
opts?: CarouselOptions;
|
||||
plugins?: CarouselPlugins;
|
||||
setApi?: (api: CarouselAPI | undefined) => void;
|
||||
orientation?: "horizontal" | "vertical";
|
||||
} & WithElementRef<HTMLAttributes<HTMLDivElement>>;
|
||||
|
||||
const EMBLA_CAROUSEL_CONTEXT = Symbol("EMBLA_CAROUSEL_CONTEXT");
|
||||
|
||||
export type EmblaContext = {
|
||||
api: CarouselAPI | undefined;
|
||||
orientation: "horizontal" | "vertical";
|
||||
scrollNext: () => void;
|
||||
scrollPrev: () => void;
|
||||
canScrollNext: boolean;
|
||||
canScrollPrev: boolean;
|
||||
handleKeyDown: (e: KeyboardEvent) => void;
|
||||
options: CarouselOptions;
|
||||
plugins: CarouselPlugins;
|
||||
onInit: (e: CustomEvent<CarouselAPI>) => void;
|
||||
scrollTo: (index: number, jump?: boolean) => void;
|
||||
scrollSnaps: number[];
|
||||
selectedIndex: number;
|
||||
};
|
||||
|
||||
export function setEmblaContext(config: EmblaContext): EmblaContext {
|
||||
setContext(EMBLA_CAROUSEL_CONTEXT, config);
|
||||
return config;
|
||||
}
|
||||
|
||||
export function getEmblaContext(name = "This component") {
|
||||
if (!hasContext(EMBLA_CAROUSEL_CONTEXT)) {
|
||||
throw new Error(`${name} must be used within a <Carousel.Root> component`);
|
||||
}
|
||||
return getContext<ReturnType<typeof setEmblaContext>>(EMBLA_CAROUSEL_CONTEXT);
|
||||
}
|
||||
19
web/src/lib/components/ui/carousel/index.ts
Normal file
19
web/src/lib/components/ui/carousel/index.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import Root from "./carousel.svelte";
|
||||
import Content from "./carousel-content.svelte";
|
||||
import Item from "./carousel-item.svelte";
|
||||
import Previous from "./carousel-previous.svelte";
|
||||
import Next from "./carousel-next.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
Content,
|
||||
Item,
|
||||
Previous,
|
||||
Next,
|
||||
//
|
||||
Root as Carousel,
|
||||
Content as CarouselContent,
|
||||
Item as CarouselItem,
|
||||
Previous as CarouselPrevious,
|
||||
Next as CarouselNext,
|
||||
};
|
||||
Reference in New Issue
Block a user