Setup docker for production and development
Change ./init.sh to be an executable then run it. The default inner container ports are as followed: - Postgres: 5432:5432 - Backend: 3001:3000 - Frontend: 3000:3000 - Frontend dev: 8000 The backend image needs to be built: docker compose up -d --build
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
.env
|
||||||
12
backend/Dockerfile
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
FROM golang:alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
RUN go mod download
|
||||||
|
RUN go mod tidy
|
||||||
|
|
||||||
|
RUN go build main.go
|
||||||
|
|
||||||
|
CMD ["./main"]
|
||||||
14
backend/go.mod
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
module fr.latosa-escrima
|
||||||
|
|
||||||
|
go 1.23.4
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||||
|
github.com/joho/godotenv v1.5.1 // indirect
|
||||||
|
github.com/puzpuzpuz/xsync/v3 v3.4.0 // indirect
|
||||||
|
github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc // indirect
|
||||||
|
github.com/uptrace/bun v1.2.8 // indirect
|
||||||
|
github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect
|
||||||
|
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
|
||||||
|
golang.org/x/sys v0.29.0 // indirect
|
||||||
|
)
|
||||||
16
backend/go.sum
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||||
|
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||||
|
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||||
|
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||||
|
github.com/puzpuzpuz/xsync/v3 v3.4.0 h1:DuVBAdXuGFHv8adVXjWWZ63pJq+NRXOWVXlKDBZ+mJ4=
|
||||||
|
github.com/puzpuzpuz/xsync/v3 v3.4.0/go.mod h1:VjzYrABPabuM4KyBh1Ftq6u8nhwY5tBPKP9jpmh0nnA=
|
||||||
|
github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc h1:9lRDQMhESg+zvGYmW5DyG0UqvY96Bu5QYsTLvCHdrgo=
|
||||||
|
github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc/go.mod h1:bciPuU6GHm1iF1pBvUfxfsH0Wmnc2VbpgvbI9ZWuIRs=
|
||||||
|
github.com/uptrace/bun v1.2.8 h1:HEiLvy9wc7ehU5S02+O6NdV5BLz48lL4REPhTkMX3Dg=
|
||||||
|
github.com/uptrace/bun v1.2.8/go.mod h1:JBq0uBKsKqNT0Ccce1IAFZY337Wkf08c6F6qlmfOHE8=
|
||||||
|
github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8=
|
||||||
|
github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok=
|
||||||
|
github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
|
||||||
|
github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds=
|
||||||
|
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
|
||||||
|
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
28
backend/main.go
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/joho/godotenv"
|
||||||
|
)
|
||||||
|
|
||||||
|
func handler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
fmt.Fprintf(w, "<html><body><h1>Hello, World!</h1></body></html>")
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
err := godotenv.Load()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Error loading .env file: %v", err)
|
||||||
|
}
|
||||||
|
port := os.Getenv("BACKEND_PORT")
|
||||||
|
http.HandleFunc("/", handler)
|
||||||
|
fmt.Printf("Serving on port %s\n", port)
|
||||||
|
err = http.ListenAndServe(fmt.Sprintf(":%s", port), nil)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Error starting server: %s\n", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,44 @@
|
|||||||
services:
|
services:
|
||||||
latosa-escrima:
|
latosa-escrima.fr-frontend:
|
||||||
build:
|
container_name: latosa-frontend
|
||||||
context: .
|
image: cems.dev:5000/latosa-escrima.fr:latest
|
||||||
dockerfile: Dockerfile
|
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- 3000:${FRONTEND_PORT}
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
|
- FRONTEND_PORT=${FRONTEND_PORT}
|
||||||
restart: always # Ensures the container restarts on failure or Docker restart
|
restart: always # Ensures the container restarts on failure or Docker restart
|
||||||
|
networks:
|
||||||
|
- le-network
|
||||||
|
latosa-escrima.fr-backend:
|
||||||
|
container_name: latosa-backend
|
||||||
|
build:
|
||||||
|
context: ./backend/
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
env_file: .env
|
||||||
|
environment:
|
||||||
|
- PORT="3001"
|
||||||
|
ports:
|
||||||
|
- "3001:${BACKEND_PORT}"
|
||||||
|
networks:
|
||||||
|
- le-network
|
||||||
|
psql:
|
||||||
|
container_name: latosa-database
|
||||||
|
image: postgres:17.2-alpine
|
||||||
|
restart: always
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB}
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
volumes:
|
||||||
|
- le-data:/var/lib/postgresql/data
|
||||||
|
networks:
|
||||||
|
- le-network
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
le-data:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
le-network:
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ FROM denoland/deno:alpine
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy project files
|
# Copy project files
|
||||||
COPY ./latosa-frontend/ .
|
COPY . .
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
RUN deno install
|
RUN deno install
|
||||||
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
118
frontend/components/blog.tsx
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import { ArrowRight } from "lucide-react";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
export interface BlogInterface {
|
||||||
|
id: string;
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
label: string;
|
||||||
|
author: string;
|
||||||
|
published: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BlogSummaryInterface extends BlogInterface {
|
||||||
|
summary: string;
|
||||||
|
image: string;
|
||||||
|
href: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const posts: BlogSummaryInterface[] = [
|
||||||
|
{
|
||||||
|
id: "a1b2c3d4-e5f6-7g8h-9i0j-k1l2m3n4o5p6",
|
||||||
|
slug: "tech-advancements-2025",
|
||||||
|
title: "Tech Advancements in 2025",
|
||||||
|
content:
|
||||||
|
"The year 2025 promises groundbreaking technologies that will reshape industries. In this article, we explore the key advancements that could transform how we work, live, and communicate.",
|
||||||
|
label: "Technology",
|
||||||
|
author: "d3f5e6g7-h8i9j0k1-l2m3n4o5p6q7",
|
||||||
|
published: "2025-01-14",
|
||||||
|
summary:
|
||||||
|
"A look at the tech trends to expect in 2025 and beyond, from AI to quantum computing.",
|
||||||
|
image: "https://via.placeholder.com/600x400?text=Tech+2025",
|
||||||
|
href: "history/tech-advancements-2025",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "f7g8h9i0-j1k2l3m4-n5o6p7q8r9s0t1u2v3",
|
||||||
|
slug: "sustainable-fashion-2025",
|
||||||
|
title: "Sustainable Fashion in 2025",
|
||||||
|
content:
|
||||||
|
"Sustainability is no longer a trend, but a movement within the fashion industry. This article discusses how eco-friendly practices are influencing fashion designs and consumer behavior in 2025.",
|
||||||
|
label: "Fashion",
|
||||||
|
author: "w4x5y6z7-a8b9c0d1-e2f3g4h5i6j7",
|
||||||
|
published: "2025-01-12",
|
||||||
|
summary:
|
||||||
|
"Exploring how sustainable fashion is evolving in 2025 with innovative materials and ethical brands.",
|
||||||
|
image: "https://via.placeholder.com/600x400?text=Sustainable+Fashion",
|
||||||
|
href: "history/sustainable-fashion-2025",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "v1w2x3y4-z5a6b7c8-d9e0f1g2h3i4j5k6l7",
|
||||||
|
slug: "mental-health-awareness-2025",
|
||||||
|
title: "Mental Health Awareness in 2025",
|
||||||
|
content:
|
||||||
|
"As mental health awareness continues to grow, 2025 brings new challenges and opportunities to address psychological well-being. This article focuses on emerging trends in mental health support and public perception.",
|
||||||
|
label: "Health",
|
||||||
|
author: "m8n9o0p1-q2r3s4t5-u6v7w8x9y0z1a2b3",
|
||||||
|
published: "2025-01-10",
|
||||||
|
summary:
|
||||||
|
"Highlighting the importance of mental health awareness in 2025, focusing on new treatments and societal changes.",
|
||||||
|
image: "https://via.placeholder.com/600x400?text=Mental+Health+2025",
|
||||||
|
href: "/history/mental-health-awareness-2025",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const Blog = () => {
|
||||||
|
return (
|
||||||
|
<section className="self-center lg:md:py-24 sm:py-12">
|
||||||
|
<div className="container flex flex-col items-center gap-16 lg:px-16">
|
||||||
|
<div className="text-center">
|
||||||
|
<h2 className="mb-3 text-pretty text-3xl font-semibold md:mb-4 md:text-4xl lg:mb-6 lg:max-w-3xl lg:text-5xl">
|
||||||
|
En savoir plus sur ce sport
|
||||||
|
</h2>
|
||||||
|
<p className="mb-8 text-muted-foreground md:text-base lg:max-w-2xl lg:text-lg">
|
||||||
|
Lorem ipsum dolor sit amet consectetur adipisicing elit.
|
||||||
|
Elig doloremque mollitia fugiat omnis! Porro facilis quo
|
||||||
|
animi consequatur. Explicabo.
|
||||||
|
</p>
|
||||||
|
<Button variant="link" className="w-full sm:w-auto">
|
||||||
|
Explore all posts
|
||||||
|
<ArrowRight className="ml-2 size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3 lg:gap-8">
|
||||||
|
{posts.map((post) => (
|
||||||
|
<a
|
||||||
|
key={post.id}
|
||||||
|
href={post.href}
|
||||||
|
className="flex flex-col overflow-clip rounded-xl border border-border"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<img
|
||||||
|
src={post.image}
|
||||||
|
alt={post.title}
|
||||||
|
className="aspect-[16/9] h-full w-full object-cover object-center"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="px-6 py-8 md:px-8 md:py-10 lg:px-10 lg:py-12">
|
||||||
|
<h3 className="mb-3 text-lg font-semibold md:mb-4 md:text-xl lg:mb-6">
|
||||||
|
{post.title}
|
||||||
|
</h3>
|
||||||
|
<p className="mb-3 text-muted-foreground md:mb-4 lg:mb-6">
|
||||||
|
{post.summary}
|
||||||
|
</p>
|
||||||
|
<p className="flex items-center hover:underline">
|
||||||
|
Read more
|
||||||
|
<ArrowRight className="ml-2 size-4" />
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Blog;
|
||||||
@@ -1,17 +1,15 @@
|
|||||||
import { BlogInterface } from "@/components/blog";
|
import { BlogInterface } from "@/components/blog";
|
||||||
|
|
||||||
export interface BlogItemParams {
|
export interface BlogItemParams {
|
||||||
slug: string
|
slug: string;
|
||||||
title_style: string,
|
title_style: string;
|
||||||
subtitle_style: string,
|
subtitle_style: string;
|
||||||
p_style: string,
|
p_style: string;
|
||||||
default_img: string
|
default_img: string;
|
||||||
blog_content: BlogInterface
|
blog_content: BlogInterface;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function BlogItem({ params }
|
export default function BlogItem({ params }: { params: BlogItemParams }) {
|
||||||
: { params: BlogItemParams }
|
|
||||||
) {
|
|
||||||
return (
|
return (
|
||||||
<main className="flex flex-col w-full lg:md:py-24 sm:py-24">
|
<main className="flex flex-col w-full lg:md:py-24 sm:py-24">
|
||||||
<section className="container self-center max-w-2xl">
|
<section className="container self-center max-w-2xl">
|
||||||
@@ -24,7 +22,11 @@ export default function BlogItem({ params }
|
|||||||
<div>
|
<div>
|
||||||
<h2 className={params.subtitle_style}>Subtitle 1</h2>
|
<h2 className={params.subtitle_style}>Subtitle 1</h2>
|
||||||
<p className={params.p_style}>
|
<p className={params.p_style}>
|
||||||
Lorem ipsum dolor sit amet, consectetur adipisicing elit. Deleniti architecto incidunt, hic in consectetur eligendi nobis numquam tenetur sit repellat et unde, maxime ducimus autem esse temporibus omnis eum molestias!
|
Lorem ipsum dolor sit amet, consectetur adipisicing
|
||||||
|
elit. Deleniti architecto incidunt, hic in
|
||||||
|
consectetur eligendi nobis numquam tenetur sit
|
||||||
|
repellat et unde, maxime ducimus autem esse
|
||||||
|
temporibus omnis eum molestias!
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -33,12 +35,15 @@ export default function BlogItem({ params }
|
|||||||
alt={params.slug}
|
alt={params.slug}
|
||||||
className="aspect-[16/9] mb-5 rounded-sm h-full w-full object-cover object-center"
|
className="aspect-[16/9] mb-5 rounded-sm h-full w-full object-cover object-center"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h2 className={params.subtitle_style}>Subtitle 2</h2>
|
<h2 className={params.subtitle_style}>Subtitle 2</h2>
|
||||||
<p className={params.p_style}>
|
<p className={params.p_style}>
|
||||||
Lorem ipsm dolor sit amet, consectetur adipisicing elit. Deleniti architecto incidunt, hic in consectetur eligendi nobis numquam tenetur sit repellat et unde, maxime ducimus autem esse temporibus omnis eum molestias!
|
Lorem ipsm dolor sit amet, consectetur adipisicing
|
||||||
|
elit. Deleniti architecto incidunt, hic in
|
||||||
|
consectetur eligendi nobis numquam tenetur sit
|
||||||
|
repellat et unde, maxime ducimus autem esse
|
||||||
|
temporibus omnis eum molestias!
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -47,19 +52,26 @@ export default function BlogItem({ params }
|
|||||||
alt={params.slug}
|
alt={params.slug}
|
||||||
className="aspect-[16/9] mb-5 rounded-sm h-full w-full object-cover object-center"
|
className="aspect-[16/9] mb-5 rounded-sm h-full w-full object-cover object-center"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h2 className={params.subtitle_style}>Subtitle 3</h2>
|
<h2 className={params.subtitle_style}>Subtitle 3</h2>
|
||||||
<p className={params.p_style}>
|
<p className={params.p_style}>
|
||||||
Lorem ipsum dolor sit amet, consectetur adipisicing elit. Deleniti architecto incidunt, hic in consectetur eligendi nobis numquam tenetur sit repellat et unde, maxime ducimus autem esse temporibus omnis eum molestias!
|
Lorem ipsum dolor sit amet, consectetur adipisicing
|
||||||
|
elit. Deleniti architecto incidunt, hic in
|
||||||
|
consectetur eligendi nobis numquam tenetur sit
|
||||||
|
repellat et unde, maxime ducimus autem esse
|
||||||
|
temporibus omnis eum molestias!
|
||||||
</p>
|
</p>
|
||||||
<p className={params.p_style}>
|
<p className={params.p_style}>
|
||||||
Lorem ipsum dolor sit amet, consectetur adipisicing elit. Deleniti architecto incidunt, hic in consectetur eligendi nobis numquam tenetur sit repellat et unde, maxime ducimus autem esse temporibus omnis eum molestias!
|
Lorem ipsum dolor sit amet, consectetur adipisicing
|
||||||
|
elit. Deleniti architecto incidunt, hic in
|
||||||
|
consectetur eligendi nobis numquam tenetur sit
|
||||||
|
repellat et unde, maxime ducimus autem esse
|
||||||
|
temporibus omnis eum molestias!
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
263
frontend/components/ui/carousel.tsx
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import useEmblaCarousel, {
|
||||||
|
type UseEmblaCarouselType,
|
||||||
|
} from "embla-carousel-react";
|
||||||
|
import { ArrowLeft, ArrowRight } from "lucide-react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
type CarouselApi = UseEmblaCarouselType[1];
|
||||||
|
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
|
||||||
|
type CarouselOptions = UseCarouselParameters[0];
|
||||||
|
type CarouselPlugin = UseCarouselParameters[1];
|
||||||
|
|
||||||
|
type CarouselProps = {
|
||||||
|
opts?: CarouselOptions;
|
||||||
|
plugins?: CarouselPlugin;
|
||||||
|
orientation?: "horizontal" | "vertical";
|
||||||
|
setApi?: (api: CarouselApi) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CarouselContextProps = {
|
||||||
|
carouselRef: ReturnType<typeof useEmblaCarousel>[0];
|
||||||
|
api: ReturnType<typeof useEmblaCarousel>[1];
|
||||||
|
scrollPrev: () => void;
|
||||||
|
scrollNext: () => void;
|
||||||
|
canScrollPrev: boolean;
|
||||||
|
canScrollNext: boolean;
|
||||||
|
} & CarouselProps;
|
||||||
|
|
||||||
|
const CarouselContext = React.createContext<CarouselContextProps | null>(null);
|
||||||
|
|
||||||
|
function useCarousel() {
|
||||||
|
const context = React.useContext(CarouselContext);
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useCarousel must be used within a <Carousel />");
|
||||||
|
}
|
||||||
|
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Carousel = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement> & CarouselProps
|
||||||
|
>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
orientation = "horizontal",
|
||||||
|
opts,
|
||||||
|
setApi,
|
||||||
|
plugins,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref,
|
||||||
|
) => {
|
||||||
|
const [carouselRef, api] = useEmblaCarousel(
|
||||||
|
{
|
||||||
|
...opts,
|
||||||
|
axis: orientation === "horizontal" ? "x" : "y",
|
||||||
|
},
|
||||||
|
plugins,
|
||||||
|
);
|
||||||
|
const [canScrollPrev, setCanScrollPrev] = React.useState(false);
|
||||||
|
const [canScrollNext, setCanScrollNext] = React.useState(false);
|
||||||
|
|
||||||
|
const onSelect = React.useCallback((api: CarouselApi) => {
|
||||||
|
if (!api) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCanScrollPrev(api.canScrollPrev());
|
||||||
|
setCanScrollNext(api.canScrollNext());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const scrollPrev = React.useCallback(() => {
|
||||||
|
api?.scrollPrev();
|
||||||
|
}, [api]);
|
||||||
|
|
||||||
|
const scrollNext = React.useCallback(() => {
|
||||||
|
api?.scrollNext();
|
||||||
|
}, [api]);
|
||||||
|
|
||||||
|
const handleKeyDown = React.useCallback(
|
||||||
|
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||||
|
if (event.key === "ArrowLeft") {
|
||||||
|
event.preventDefault();
|
||||||
|
scrollPrev();
|
||||||
|
} else if (event.key === "ArrowRight") {
|
||||||
|
event.preventDefault();
|
||||||
|
scrollNext();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[scrollPrev, scrollNext],
|
||||||
|
);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!api || !setApi) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setApi(api);
|
||||||
|
}, [api, setApi]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!api) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onSelect(api);
|
||||||
|
api.on("reInit", onSelect);
|
||||||
|
api.on("select", onSelect);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
api?.off("select", onSelect);
|
||||||
|
};
|
||||||
|
}, [api, onSelect]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CarouselContext.Provider
|
||||||
|
value={{
|
||||||
|
carouselRef,
|
||||||
|
api: api,
|
||||||
|
opts,
|
||||||
|
orientation:
|
||||||
|
orientation ||
|
||||||
|
(opts?.axis === "y" ? "vertical" : "horizontal"),
|
||||||
|
scrollPrev,
|
||||||
|
scrollNext,
|
||||||
|
canScrollPrev,
|
||||||
|
canScrollNext,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
onKeyDownCapture={handleKeyDown}
|
||||||
|
className={cn("relative", className)}
|
||||||
|
role="region"
|
||||||
|
aria-roledescription="carousel"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</CarouselContext.Provider>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
Carousel.displayName = "Carousel";
|
||||||
|
|
||||||
|
const CarouselContent = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
const { carouselRef, orientation } = useCarousel();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={carouselRef} className="overflow-hidden">
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex",
|
||||||
|
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
CarouselContent.displayName = "CarouselContent";
|
||||||
|
|
||||||
|
const CarouselItem = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
const { orientation } = useCarousel();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
role="group"
|
||||||
|
aria-roledescription="slide"
|
||||||
|
className={cn(
|
||||||
|
"min-w-0 shrink-0 grow-0 basis-full",
|
||||||
|
orientation === "horizontal" ? "pl-4" : "pt-4",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
CarouselItem.displayName = "CarouselItem";
|
||||||
|
|
||||||
|
const CarouselPrevious = React.forwardRef<
|
||||||
|
HTMLButtonElement,
|
||||||
|
React.ComponentProps<typeof Button>
|
||||||
|
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
|
||||||
|
const { orientation, scrollPrev, canScrollPrev } = useCarousel();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
ref={ref}
|
||||||
|
variant={variant}
|
||||||
|
size={size}
|
||||||
|
className={cn(
|
||||||
|
"absolute h-8 w-8 rounded-full",
|
||||||
|
orientation === "horizontal"
|
||||||
|
? "-left-12 top-1/2 -translate-y-1/2"
|
||||||
|
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
disabled={!canScrollPrev}
|
||||||
|
onClick={scrollPrev}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Previous slide</span>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
CarouselPrevious.displayName = "CarouselPrevious";
|
||||||
|
|
||||||
|
const CarouselNext = React.forwardRef<
|
||||||
|
HTMLButtonElement,
|
||||||
|
React.ComponentProps<typeof Button>
|
||||||
|
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
|
||||||
|
const { orientation, scrollNext, canScrollNext } = useCarousel();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
ref={ref}
|
||||||
|
variant={variant}
|
||||||
|
size={size}
|
||||||
|
className={cn(
|
||||||
|
"absolute h-8 w-8 rounded-full",
|
||||||
|
orientation === "horizontal"
|
||||||
|
? "-right-12 top-1/2 -translate-y-1/2"
|
||||||
|
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
disabled={!canScrollNext}
|
||||||
|
onClick={scrollNext}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ArrowRight className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Next slide</span>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
CarouselNext.displayName = "CarouselNext";
|
||||||
|
|
||||||
|
export {
|
||||||
|
type CarouselApi,
|
||||||
|
Carousel,
|
||||||
|
CarouselContent,
|
||||||
|
CarouselItem,
|
||||||
|
CarouselPrevious,
|
||||||
|
CarouselNext,
|
||||||
|
};
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --turbopack",
|
"dev": "next dev --turbopack -p 8000",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint"
|
"lint": "next lint"
|
||||||
|
Before Width: | Height: | Size: 391 B After Width: | Height: | Size: 391 B |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 128 B After Width: | Height: | Size: 128 B |
|
Before Width: | Height: | Size: 385 B After Width: | Height: | Size: 385 B |
4
init.sh
Executable file
@@ -0,0 +1,4 @@
|
|||||||
|
#!/usr/bin/sh
|
||||||
|
printf "POSTGRES_USER=\nPOSTGRES_PASSWORD=\nPOSTGRES_DB=\n# Docker inner port container\nPOSTGRES_PORT=5432\nBACKEND_PORT=3000\nFRONTEND_PORT=3000\n" > .env
|
||||||
|
ln $(pwd)/.env $(pwd)/backend
|
||||||
|
ln $(pwd)/.env $(pwd)/frontend
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
import { ArrowRight } from "lucide-react";
|
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
|
|
||||||
export interface BlogInterface {
|
|
||||||
id: string,
|
|
||||||
slug: string,
|
|
||||||
title: string,
|
|
||||||
content: string,
|
|
||||||
label: string,
|
|
||||||
author: string,
|
|
||||||
published: string,
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BlogSummaryInterface extends BlogInterface {
|
|
||||||
summary: string,
|
|
||||||
image: string,
|
|
||||||
href: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export const posts: BlogSummaryInterface[] = [
|
|
||||||
{
|
|
||||||
id: 'a1b2c3d4-e5f6-7g8h-9i0j-k1l2m3n4o5p6',
|
|
||||||
slug: 'tech-advancements-2025',
|
|
||||||
title: 'Tech Advancements in 2025',
|
|
||||||
content: 'The year 2025 promises groundbreaking technologies that will reshape industries. In this article, we explore the key advancements that could transform how we work, live, and communicate.',
|
|
||||||
label: 'Technology',
|
|
||||||
author: 'd3f5e6g7-h8i9j0k1-l2m3n4o5p6q7',
|
|
||||||
published: '2025-01-14',
|
|
||||||
summary: 'A look at the tech trends to expect in 2025 and beyond, from AI to quantum computing.',
|
|
||||||
image: 'https://via.placeholder.com/600x400?text=Tech+2025',
|
|
||||||
href: 'history/tech-advancements-2025'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'f7g8h9i0-j1k2l3m4-n5o6p7q8r9s0t1u2v3',
|
|
||||||
slug: 'sustainable-fashion-2025',
|
|
||||||
title: 'Sustainable Fashion in 2025',
|
|
||||||
content: 'Sustainability is no longer a trend, but a movement within the fashion industry. This article discusses how eco-friendly practices are influencing fashion designs and consumer behavior in 2025.',
|
|
||||||
label: 'Fashion',
|
|
||||||
author: 'w4x5y6z7-a8b9c0d1-e2f3g4h5i6j7',
|
|
||||||
published: '2025-01-12',
|
|
||||||
summary: 'Exploring how sustainable fashion is evolving in 2025 with innovative materials and ethical brands.',
|
|
||||||
image: 'https://via.placeholder.com/600x400?text=Sustainable+Fashion',
|
|
||||||
href: 'history/sustainable-fashion-2025'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'v1w2x3y4-z5a6b7c8-d9e0f1g2h3i4j5k6l7',
|
|
||||||
slug: 'mental-health-awareness-2025',
|
|
||||||
title: 'Mental Health Awareness in 2025',
|
|
||||||
content: 'As mental health awareness continues to grow, 2025 brings new challenges and opportunities to address psychological well-being. This article focuses on emerging trends in mental health support and public perception.',
|
|
||||||
label: 'Health',
|
|
||||||
author: 'm8n9o0p1-q2r3s4t5-u6v7w8x9y0z1a2b3',
|
|
||||||
published: '2025-01-10',
|
|
||||||
summary: 'Highlighting the importance of mental health awareness in 2025, focusing on new treatments and societal changes.',
|
|
||||||
image: 'https://via.placeholder.com/600x400?text=Mental+Health+2025',
|
|
||||||
href: '/history/mental-health-awareness-2025'
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
const Blog = () => {
|
|
||||||
return (
|
|
||||||
<section className="self-center lg:md:py-24 sm:py-12">
|
|
||||||
<div className="container flex flex-col items-center gap-16 lg:px-16">
|
|
||||||
<div className="text-center">
|
|
||||||
<h2 className="mb-3 text-pretty text-3xl font-semibold md:mb-4 md:text-4xl lg:mb-6 lg:max-w-3xl lg:text-5xl">
|
|
||||||
En savoir plus sur ce sport
|
|
||||||
</h2>
|
|
||||||
<p className="mb-8 text-muted-foreground md:text-base lg:max-w-2xl lg:text-lg">
|
|
||||||
Lorem ipsum dolor sit amet consectetur adipisicing elit. Elig
|
|
||||||
doloremque mollitia fugiat omnis! Porro facilis quo animi
|
|
||||||
consequatur. Explicabo.
|
|
||||||
</p>
|
|
||||||
<Button variant="link" className="w-full sm:w-auto">
|
|
||||||
Explore all posts
|
|
||||||
<ArrowRight className="ml-2 size-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3 lg:gap-8">
|
|
||||||
{posts.map((post) => (
|
|
||||||
<a
|
|
||||||
key={post.id}
|
|
||||||
href={post.href}
|
|
||||||
className="flex flex-col overflow-clip rounded-xl border border-border"
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<img
|
|
||||||
src={post.image}
|
|
||||||
alt={post.title}
|
|
||||||
className="aspect-[16/9] h-full w-full object-cover object-center"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="px-6 py-8 md:px-8 md:py-10 lg:px-10 lg:py-12">
|
|
||||||
<h3 className="mb-3 text-lg font-semibold md:mb-4 md:text-xl lg:mb-6">
|
|
||||||
{post.title}
|
|
||||||
</h3>
|
|
||||||
<p className="mb-3 text-muted-foreground md:mb-4 lg:mb-6">
|
|
||||||
{post.summary}
|
|
||||||
</p>
|
|
||||||
<p className="flex items-center hover:underline">
|
|
||||||
Read more
|
|
||||||
<ArrowRight className="ml-2 size-4" />
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Blog;
|
|
||||||
@@ -1,262 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import * as React from "react"
|
|
||||||
import useEmblaCarousel, {
|
|
||||||
type UseEmblaCarouselType,
|
|
||||||
} from "embla-carousel-react"
|
|
||||||
import { ArrowLeft, ArrowRight } from "lucide-react"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
|
|
||||||
type CarouselApi = UseEmblaCarouselType[1]
|
|
||||||
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
|
|
||||||
type CarouselOptions = UseCarouselParameters[0]
|
|
||||||
type CarouselPlugin = UseCarouselParameters[1]
|
|
||||||
|
|
||||||
type CarouselProps = {
|
|
||||||
opts?: CarouselOptions
|
|
||||||
plugins?: CarouselPlugin
|
|
||||||
orientation?: "horizontal" | "vertical"
|
|
||||||
setApi?: (api: CarouselApi) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
type CarouselContextProps = {
|
|
||||||
carouselRef: ReturnType<typeof useEmblaCarousel>[0]
|
|
||||||
api: ReturnType<typeof useEmblaCarousel>[1]
|
|
||||||
scrollPrev: () => void
|
|
||||||
scrollNext: () => void
|
|
||||||
canScrollPrev: boolean
|
|
||||||
canScrollNext: boolean
|
|
||||||
} & CarouselProps
|
|
||||||
|
|
||||||
const CarouselContext = React.createContext<CarouselContextProps | null>(null)
|
|
||||||
|
|
||||||
function useCarousel() {
|
|
||||||
const context = React.useContext(CarouselContext)
|
|
||||||
|
|
||||||
if (!context) {
|
|
||||||
throw new Error("useCarousel must be used within a <Carousel />")
|
|
||||||
}
|
|
||||||
|
|
||||||
return context
|
|
||||||
}
|
|
||||||
|
|
||||||
const Carousel = React.forwardRef<
|
|
||||||
HTMLDivElement,
|
|
||||||
React.HTMLAttributes<HTMLDivElement> & CarouselProps
|
|
||||||
>(
|
|
||||||
(
|
|
||||||
{
|
|
||||||
orientation = "horizontal",
|
|
||||||
opts,
|
|
||||||
setApi,
|
|
||||||
plugins,
|
|
||||||
className,
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
},
|
|
||||||
ref
|
|
||||||
) => {
|
|
||||||
const [carouselRef, api] = useEmblaCarousel(
|
|
||||||
{
|
|
||||||
...opts,
|
|
||||||
axis: orientation === "horizontal" ? "x" : "y",
|
|
||||||
},
|
|
||||||
plugins
|
|
||||||
)
|
|
||||||
const [canScrollPrev, setCanScrollPrev] = React.useState(false)
|
|
||||||
const [canScrollNext, setCanScrollNext] = React.useState(false)
|
|
||||||
|
|
||||||
const onSelect = React.useCallback((api: CarouselApi) => {
|
|
||||||
if (!api) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setCanScrollPrev(api.canScrollPrev())
|
|
||||||
setCanScrollNext(api.canScrollNext())
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const scrollPrev = React.useCallback(() => {
|
|
||||||
api?.scrollPrev()
|
|
||||||
}, [api])
|
|
||||||
|
|
||||||
const scrollNext = React.useCallback(() => {
|
|
||||||
api?.scrollNext()
|
|
||||||
}, [api])
|
|
||||||
|
|
||||||
const handleKeyDown = React.useCallback(
|
|
||||||
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
|
||||||
if (event.key === "ArrowLeft") {
|
|
||||||
event.preventDefault()
|
|
||||||
scrollPrev()
|
|
||||||
} else if (event.key === "ArrowRight") {
|
|
||||||
event.preventDefault()
|
|
||||||
scrollNext()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[scrollPrev, scrollNext]
|
|
||||||
)
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (!api || !setApi) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setApi(api)
|
|
||||||
}, [api, setApi])
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (!api) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
onSelect(api)
|
|
||||||
api.on("reInit", onSelect)
|
|
||||||
api.on("select", onSelect)
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
api?.off("select", onSelect)
|
|
||||||
}
|
|
||||||
}, [api, onSelect])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<CarouselContext.Provider
|
|
||||||
value={{
|
|
||||||
carouselRef,
|
|
||||||
api: api,
|
|
||||||
opts,
|
|
||||||
orientation:
|
|
||||||
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
|
|
||||||
scrollPrev,
|
|
||||||
scrollNext,
|
|
||||||
canScrollPrev,
|
|
||||||
canScrollNext,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
ref={ref}
|
|
||||||
onKeyDownCapture={handleKeyDown}
|
|
||||||
className={cn("relative", className)}
|
|
||||||
role="region"
|
|
||||||
aria-roledescription="carousel"
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</CarouselContext.Provider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
Carousel.displayName = "Carousel"
|
|
||||||
|
|
||||||
const CarouselContent = React.forwardRef<
|
|
||||||
HTMLDivElement,
|
|
||||||
React.HTMLAttributes<HTMLDivElement>
|
|
||||||
>(({ className, ...props }, ref) => {
|
|
||||||
const { carouselRef, orientation } = useCarousel()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div ref={carouselRef} className="overflow-hidden">
|
|
||||||
<div
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"flex",
|
|
||||||
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
CarouselContent.displayName = "CarouselContent"
|
|
||||||
|
|
||||||
const CarouselItem = React.forwardRef<
|
|
||||||
HTMLDivElement,
|
|
||||||
React.HTMLAttributes<HTMLDivElement>
|
|
||||||
>(({ className, ...props }, ref) => {
|
|
||||||
const { orientation } = useCarousel()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={ref}
|
|
||||||
role="group"
|
|
||||||
aria-roledescription="slide"
|
|
||||||
className={cn(
|
|
||||||
"min-w-0 shrink-0 grow-0 basis-full",
|
|
||||||
orientation === "horizontal" ? "pl-4" : "pt-4",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
CarouselItem.displayName = "CarouselItem"
|
|
||||||
|
|
||||||
const CarouselPrevious = React.forwardRef<
|
|
||||||
HTMLButtonElement,
|
|
||||||
React.ComponentProps<typeof Button>
|
|
||||||
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
|
|
||||||
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
ref={ref}
|
|
||||||
variant={variant}
|
|
||||||
size={size}
|
|
||||||
className={cn(
|
|
||||||
"absolute h-8 w-8 rounded-full",
|
|
||||||
orientation === "horizontal"
|
|
||||||
? "-left-12 top-1/2 -translate-y-1/2"
|
|
||||||
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
disabled={!canScrollPrev}
|
|
||||||
onClick={scrollPrev}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<ArrowLeft className="h-4 w-4" />
|
|
||||||
<span className="sr-only">Previous slide</span>
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
CarouselPrevious.displayName = "CarouselPrevious"
|
|
||||||
|
|
||||||
const CarouselNext = React.forwardRef<
|
|
||||||
HTMLButtonElement,
|
|
||||||
React.ComponentProps<typeof Button>
|
|
||||||
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
|
|
||||||
const { orientation, scrollNext, canScrollNext } = useCarousel()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
ref={ref}
|
|
||||||
variant={variant}
|
|
||||||
size={size}
|
|
||||||
className={cn(
|
|
||||||
"absolute h-8 w-8 rounded-full",
|
|
||||||
orientation === "horizontal"
|
|
||||||
? "-right-12 top-1/2 -translate-y-1/2"
|
|
||||||
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
disabled={!canScrollNext}
|
|
||||||
onClick={scrollNext}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<ArrowRight className="h-4 w-4" />
|
|
||||||
<span className="sr-only">Next slide</span>
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
CarouselNext.displayName = "CarouselNext"
|
|
||||||
|
|
||||||
export {
|
|
||||||
type CarouselApi,
|
|
||||||
Carousel,
|
|
||||||
CarouselContent,
|
|
||||||
CarouselItem,
|
|
||||||
CarouselPrevious,
|
|
||||||
CarouselNext,
|
|
||||||
}
|
|
||||||