Merge branch 'dev/cedric' into dev/guerby

This commit is contained in:
gom-by
2025-02-19 12:30:43 +01:00
63 changed files with 3024 additions and 1063 deletions

View File

@@ -1 +0,0 @@
.env

View File

@@ -1,12 +1,19 @@
FROM golang:alpine FROM golang:alpine AS build
WORKDIR /app WORKDIR /app
COPY . . COPY . .
RUN go mod download RUN go mod download
RUN go mod tidy
RUN go build main.go RUN go build -o /app/main .
RUN go build -o /app/migrator ./cmd/migrate
CMD ["./main"] FROM alpine AS final
WORKDIR /app
COPY .env /app/
COPY --from=build /app/main /app/migrator /app/cmd /app/
CMD ["/app/main"]

View File

@@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"math" "math"
"net/http" "net/http"
"os"
"strconv" "strconv"
"fr.latosa-escrima/core" "fr.latosa-escrima/core"
@@ -36,7 +37,8 @@ func HandleMedia(w http.ResponseWriter, r *http.Request) {
Model((*models.Media)(nil)). Model((*models.Media)(nil)).
Count(context.Background()) Count(context.Background())
totalPages := int(math.Max(1, float64(total/limit))) upperBound := float64(total) / float64(limit)
totalPages := int(math.Max(1, math.Ceil(upperBound)))
var media []models.Media var media []models.Media
err = core.DB.NewSelect(). err = core.DB.NewSelect().
@@ -51,10 +53,20 @@ func HandleMedia(w http.ResponseWriter, r *http.Request) {
}.Respond(w, http.StatusInternalServerError) }.Respond(w, http.StatusInternalServerError)
return return
} }
baseURL := utils.GetURL(r) scheme := "http"
if r.TLS != nil || os.Getenv("ENVIRONMENT") != "DEV" { // Check if the request is over HTTPS
scheme = "https"
}
// Extract the host
host := r.Host
baseURL := fmt.Sprintf("%s://%s", scheme, host)
if os.Getenv("ENVIRONMENT") != "DEV" {
baseURL += "/api"
}
media = utils.Map(media, func(m models.Media) models.Media { media = utils.Map(media, func(m models.Media) models.Media {
m.Author = nil m.Author = nil
m.URL = fmt.Sprintf("%s%s/file", baseURL, m.ID) m.URL = fmt.Sprintf("%s/media/%s/file", baseURL, m.ID)
return m return m
}) })

View File

@@ -1,3 +1,52 @@
package media package media
// TODO import (
"context"
"encoding/json"
"net/http"
"fr.latosa-escrima/core"
"fr.latosa-escrima/core/models"
"github.com/google/uuid"
)
func HandleUpdate(w http.ResponseWriter, r *http.Request) {
var media models.Media
err := json.NewDecoder(r.Body).Decode(&media)
if err != nil {
core.JSONError{
Status: core.Error,
Message: err.Error(),
}.Respond(w, http.StatusBadRequest)
return
}
media_uuid := r.PathValue("media_uuid")
media.ID, err = uuid.Parse(media_uuid)
if err != nil {
core.JSONError{
Status: core.Error,
Message: err.Error(),
}.Respond(w, http.StatusBadRequest)
return
}
_, err = core.DB.NewUpdate().
Model(&media).
OmitZero().
WherePK().
Exec(context.Background())
if err != nil {
core.JSONError{
Status: core.Error,
Message: err.Error(),
}.Respond(w, http.StatusInternalServerError)
return
}
core.JSONSuccess{
Status: core.Success,
Message: "Media updated",
Data: media,
}.Respond(w, http.StatusOK)
}

View File

@@ -14,7 +14,7 @@ var MediaRoutes = map[string]core.Handler{
Middlewares: []core.Middleware{Methods("POST"), AuthJWT}, Middlewares: []core.Middleware{Methods("POST"), AuthJWT},
}, },
// Paginated media response // Paginated media response
"/media/": { "/media": {
Handler: media.HandleMedia, Handler: media.HandleMedia,
Middlewares: []core.Middleware{Methods("GET")}, Middlewares: []core.Middleware{Methods("GET")},
}, },
@@ -28,10 +28,10 @@ var MediaRoutes = map[string]core.Handler{
Handler: media.HandleMediaFile, Handler: media.HandleMediaFile,
Middlewares: []core.Middleware{Methods("GET")}, Middlewares: []core.Middleware{Methods("GET")},
}, },
// "/media/{media_uuid}/update": { "/media/{media_uuid}/update": {
// Handler: HandleGetMediaFile, Handler: media.HandleUpdate,
// Middlewares: []core.Middleware{Methods("PATCH"), AuthJWT}, Middlewares: []core.Middleware{Methods("PATCH"), AuthJWT},
// }, },
"/media/{media_uuid}/delete": { "/media/{media_uuid}/delete": {
Handler: media.HandleDelete, Handler: media.HandleDelete,
Middlewares: []core.Middleware{Methods("DELETE"), AuthJWT}, Middlewares: []core.Middleware{Methods("DELETE"), AuthJWT},

View File

@@ -6,7 +6,7 @@ import (
) )
var RolesRoutes = map[string]core.Handler{ var RolesRoutes = map[string]core.Handler{
"/roles/": { "/roles": {
Handler: roles.HandleRoles, Handler: roles.HandleRoles,
Middlewares: []core.Middleware{Methods("GET"), AuthJWT}, Middlewares: []core.Middleware{Methods("GET"), AuthJWT},
}, },

View File

@@ -2,7 +2,9 @@ package shortcodes
import ( import (
"context" "context"
"fmt"
"net/http" "net/http"
"os"
"fr.latosa-escrima/core" "fr.latosa-escrima/core"
"fr.latosa-escrima/core/models" "fr.latosa-escrima/core/models"
@@ -11,11 +13,21 @@ import (
func HandleShortcode(w http.ResponseWriter, r *http.Request) { func HandleShortcode(w http.ResponseWriter, r *http.Request) {
code := r.PathValue("shortcode") code := r.PathValue("shortcode")
var shortcode models.Shortcode var shortcode models.Shortcode
err := core.DB.NewSelect(). count, err := core.DB.NewSelect().
Model(&shortcode). Model(&shortcode).
Where("code = ?", code). Where("code = ?", code).
Relation("Media").
Limit(1). Limit(1).
Scan(context.Background()) ScanAndCount(context.Background())
if count == 0 {
core.JSONSuccess{
Status: core.Success,
Message: "Shortcode has not been found",
}.Respond(w, http.StatusNotFound)
return
}
if err != nil { if err != nil {
core.JSONError{ core.JSONError{
Status: core.Error, Status: core.Error,
@@ -24,6 +36,20 @@ func HandleShortcode(w http.ResponseWriter, r *http.Request) {
return return
} }
scheme := "http"
if r.TLS != nil { // Check if the request is over HTTPS
scheme = "https"
}
// Extract the host
host := r.Host
baseURL := fmt.Sprintf("%s://%s", scheme, host)
if os.Getenv("ENVIRONMENT") != "DEV" {
baseURL += "/api"
}
if shortcode.Media != nil {
shortcode.Media.URL = fmt.Sprintf("%s/media/%s/file", baseURL, shortcode.Media.ID)
}
core.JSONSuccess{ core.JSONSuccess{
Status: core.Success, Status: core.Success,
Message: "Shortcode found", Message: "Shortcode found",

View File

@@ -10,7 +10,7 @@ var ShortcodesRoutes = map[string]core.Handler{
Handler: shortcodes.HandleNew, Handler: shortcodes.HandleNew,
Middlewares: []core.Middleware{Methods("POST"), AuthJWT}, Middlewares: []core.Middleware{Methods("POST"), AuthJWT},
}, },
"/shortcodes/": { "/shortcodes": {
Handler: shortcodes.HandleShortcodes, Handler: shortcodes.HandleShortcodes,
Middlewares: []core.Middleware{Methods("GET"), AuthJWT}, Middlewares: []core.Middleware{Methods("GET"), AuthJWT},
}, },

View File

@@ -0,0 +1 @@
ALTER TABLE events DROP COLUMN title;

View File

@@ -0,0 +1 @@
ALTER TABLE events ADD COLUMN title text not null default '';

View File

@@ -0,0 +1,61 @@
package migrations
import (
"context"
"fmt"
"fr.latosa-escrima/core/models"
"github.com/uptrace/bun"
)
func init() {
Migrations.MustRegister(func(ctx context.Context, db *bun.DB) error {
fmt.Print(" [up migration] ")
_, err := db.NewAddColumn().
Model((*models.Event)(nil)).
ColumnExpr("full_day BOOLEAN NOT NULL DEFAULT FALSE").
Exec(ctx)
if err != nil {
return err
}
// Add "is_visible" column
_, err = db.NewAddColumn().
Model((*models.Event)(nil)).
ColumnExpr("is_visible BOOLEAN NOT NULL DEFAULT TRUE").
Exec(ctx)
if err != nil {
return err
}
// Add "rrule" column
_, err = db.NewAddColumn().
Model((*models.Event)(nil)).
ColumnExpr("rrule TEXT").
Exec(ctx)
return err
}, func(ctx context.Context, db *bun.DB) error {
fmt.Print(" [down migration] ")
_, err := db.NewDropColumn().
Model((*models.Event)(nil)).
Column("full_day").
Exec(ctx)
if err != nil {
return err
}
_, err = db.NewDropColumn().
Model((*models.Event)(nil)).
Column("is_visible").
Exec(ctx)
if err != nil {
return err
}
_, err = db.NewDropColumn().
Model((*models.Event)(nil)).
Column("rrule").
Exec(ctx)
return err
})
}

View File

@@ -0,0 +1,23 @@
package migrations
import (
"context"
"fmt"
"fr.latosa-escrima/core/models"
"github.com/uptrace/bun"
)
func init() {
Migrations.MustRegister(func(ctx context.Context, db *bun.DB) error {
fmt.Print(" [up migration] ")
_, err := db.NewDropColumn().
Model((*models.Event)(nil)).
ColumnExpr("status").
Exec(ctx)
return err
}, func(ctx context.Context, db *bun.DB) error {
fmt.Print(" [down migration] ")
return nil
})
}

View File

@@ -3,6 +3,7 @@ package utils
import ( import (
"fmt" "fmt"
"net/http" "net/http"
"os"
) )
func GetURL(r *http.Request) string { func GetURL(r *http.Request) string {
@@ -17,7 +18,10 @@ func GetURL(r *http.Request) string {
// Get the full request URI (path + query string) // Get the full request URI (path + query string)
fullPath := r.URL.Path fullPath := r.URL.Path
if os.Getenv("ENVIRONMENT") != "DEVELOPMENT" {
fullPath += "/api/"
}
// Build the full request URL // Build the full request URL
return fmt.Sprintf("%s://%s%s", scheme, host, fullPath) return fmt.Sprintf("%s://%s%s", scheme, host, fullPath)
} }

View File

@@ -1,10 +1,11 @@
services: services:
latosa-escrima.fr-frontend: latosa-escrima.fr-frontend:
container_name: latosa-frontend container_name: latosa-frontend
# image: cems.dev:5000/latosa-escrima.fr:latest image: cems.dev:5000/latosa-escrima.fr:latest
build: #build:
context: ./frontend/ # context: ./frontend/
dockerfile: Dockerfile # dockerfile: Dockerfile
# target: final
depends_on: depends_on:
- latosa-escrima.fr-backend - latosa-escrima.fr-backend
env_file: .env env_file: .env

View File

@@ -12,7 +12,7 @@ WORKDIR /app
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* .npmrc* ./ COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* .npmrc* ./
RUN \ RUN \
if [ -f yarn.lock ]; then yarn --frozen-lockfile; \ if [ -f yarn.lock ]; then yarn --frozen-lockfile; \
elif [ -f package-lock.json ]; then npm ci; \ elif [ -f package-lock.json ]; then npm ci --force; \
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \ elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \
else echo "Lockfile not found." && exit 1; \ else echo "Lockfile not found." && exit 1; \
fi fi

View File

@@ -0,0 +1,144 @@
"use client";
import { Textarea } from "@/components/ui/textarea";
import { useEffect, useRef, useState } from "react";
import { marked } from "marked";
import DOMPurify from "isomorphic-dompurify";
import { Button } from "@/components/ui/button";
import { Bold, Italic, Strikethrough, Underline } from "lucide-react";
enum Command {
Italic = "*",
Bold = "**",
Strikethrough = "~~",
Underline = "__",
}
export default function NewBlog() {
const ref = useRef<HTMLTextAreaElement>(null);
const [text, setText] = useState("");
const [cursor, setCursor] = useState<{ line: number; column: number }>({
line: 0,
column: 0,
});
const [selection, setSelection] = useState<{
start: number;
end: number;
} | null>(null);
const getCursorPosition = (
event: React.ChangeEvent<HTMLTextAreaElement>,
) => {
const textarea = event.target;
const text = textarea.value;
const cursorPos = textarea.selectionStart;
const lines = text.substring(0, cursorPos).split("\n");
const line = lines.length; // Current line number (1-based)
const column = lines[lines.length - 1].length + 1; // Current column (1-based)
setCursor({ line, column });
};
const onSelect = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
const { selectionStart, selectionEnd } = event.currentTarget;
if (selectionStart === selectionEnd) return;
setSelection({ start: selectionStart, end: selectionEnd });
};
useEffect(() => {
setSelection(null);
}, [text]);
const moveCursor = (newPos: number) => {
if (!ref.current) return;
ref.current.selectionEnd = newPos;
ref.current.focus();
};
const execCommand = (command: Command, symetry: boolean = true) => {
if (selection) {
const selectedText = text.substring(selection.start, selection.end);
const pre = text.slice(0, selection.start);
const post = text.slice(selection.end);
const newSelectedText = `${command}${selectedText}${symetry ? command : ""}`;
setText(pre + newSelectedText + post);
return;
}
const pre = text.slice(0, cursor.column);
const post = text.slice(cursor.column);
if (!symetry) setText(pre + command + post);
else {
const t = pre + command + command + post;
setText(t);
}
console.log(pre.length + command.length);
moveCursor(cursor.column + 2);
};
const sanitized = DOMPurify.sanitize(marked(text, { async: false }));
return (
<section className="flex">
<div>
<div className="flex gap-2 mb-2 border-b pb-2">
<Button
variant="outline"
size="icon"
onClick={() => execCommand(Command.Bold)}
>
<Bold size={16} />
</Button>
<Button
variant="outline"
size="icon"
onClick={() => execCommand(Command.Italic)}
>
<Italic size={16} />
</Button>
<Button
variant="outline"
size="icon"
onClick={() => execCommand(Command.Underline)}
>
<Underline size={16} />
</Button>
<Button
variant="outline"
size="icon"
onClick={() => execCommand(Command.Strikethrough)}
>
<Strikethrough size={16} />
</Button>
{/*<Button variant="outline" size="icon" onClick={handleLink}>
<Link size={16} />
</Button> */}
</div>
<Textarea
ref={ref}
value={text}
onSelect={(e: React.ChangeEvent<HTMLTextAreaElement>) => {
getCursorPosition(e);
onSelect(e);
}}
onChange={(e) => setText(e.currentTarget.value)}
/>
<div>
Line: {cursor.line}; Column: {cursor.column}
<br />
Selection: Start {selection?.start} End {selection?.end}
</div>
</div>
<div
className="mt-4 p-2 bg-gray-100 border rounded-md text-sm text-black"
dangerouslySetInnerHTML={{
// @ts-ignore
__html: sanitized,
}}
></div>
</section>
);
}

View File

@@ -0,0 +1,221 @@
"use client";
import { UserIcon, Building, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Role, User } from "@/types/types";
import { useParams } from "next/navigation";
import { useApi } from "@/hooks/use-api";
import { useState } from "react";
import request from "@/lib/request";
export default function UserDetailsPage() {
const { uuid } = useParams<{ uuid: string }>();
const user = useApi<User>(`/users/${uuid}`, {}, true);
const availableRoles = useApi<Role[]>("/roles", {}, true);
availableRoles.data ??= [];
const [selectedRole, setSelectedRole] = useState<Role | null>(null);
// const [selectedOrg, setSelectedOrg] = useState("");
const addRole = async (role: Role) => {
const res = await request(
`/users/${user.data?.userId}/roles/${role.id}/add`,
{ method: "PATCH", requiresAuth: true },
);
if (res.status === "Success") {
setSelectedRole(null);
user.mutate();
}
};
const removeRole = async (role: Role) => {
const res = await request(
`/users/${user.data?.userId}/roles/${role.id}/remove`,
{ method: "PATCH", requiresAuth: true },
);
if (res.status === "Success") user.mutate();
};
const addOrganization = () => {
// if (selectedOrg && !user.organizations.includes(selectedOrg)) {
// setUser((prevUser) => ({
// ...prevUser,
// organizations: [...prevUser.organizations, selectedOrg],
// }));
// setSelectedOrg("");
// }
};
const removeOrganization = (orgToRemove: string) => {
// setUser((prevUser) => ({
// ...prevUser,
// organizations: prevUser.organizations.filter(
// (org) => org !== orgToRemove,
// ),
// }));
};
if (!user.data || !user.success) return <p>Error</p>;
return (
<div className="container mx-auto py-10">
<Card>
<CardContent className="pt-6">
<div className="grid gap-6">
<div className="flex items-center space-x-4">
<UserIcon className="h-12 w-12 text-gray-400" />
<div>
<h2 className="text-xl font-semibold">
{user.data.firstname} {user.data.lastname}
</h2>
<p className="text-sm text-gray-500">
{user.data.email}
</p>
</div>
</div>
<div className="grid gap-6 md:grid-cols-2">
<div>
<h3 className="text-lg font-semibold mb-2">
Rôles
</h3>
<div className="flex flex-wrap gap-2">
{user.data.roles?.map((role) => (
<Badge
key={role.id}
variant="secondary"
className="text-sm py-1 px-2"
>
{role.name}
<button
onClick={() => removeRole(role)}
className="ml-2 text-gray-500 hover:text-gray-700"
>
<X className="h-3 w-3" />
</button>
</Badge>
))}
</div>
<div className="mt-2 flex space-x-2">
<Select
value={
selectedRole
? selectedRole.name
: ""
}
onValueChange={(s) => {
const r = availableRoles.data?.find(
(r) => r.name === s,
);
console.log(r);
if (r) setSelectedRole(r);
}}
>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Sélectionner un rôle" />
</SelectTrigger>
<SelectContent>
{availableRoles.data
.filter(
(org) =>
!user.data?.roles?.includes(
org,
),
)
.map((role) => (
<SelectItem
key={role.id}
value={role.name}
>
{role.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
disabled={!user.data || !selectedRole}
onClick={() => addRole(selectedRole!)}
className="flex items-center"
>
<Building className="mr-2 h-4 w-4" />
Ajouter le rôle
</Button>
</div>
</div>
{/*<div>
<h3 className="text-lg font-semibold mb-2">
Organizations
</h3>
<div className="flex flex-wrap gap-2">
{user.data.organizations.map((org) => (
<Badge
key={org}
variant="outline"
className="text-sm py-1 px-2"
>
{org}
<button
onClick={() =>
removeOrganization(org)
}
className="ml-2 text-gray-500 hover:text-gray-700"
>
<X className="h-3 w-3" />
</button>
</Badge>
))}
</div>
<div className="mt-2 flex space-x-2">
<Select
value={selectedOrg}
onValueChange={setSelectedOrg}
>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Select an organization" />
</SelectTrigger>
<SelectContent>
{availableOrganizations
.filter(
(org) =>
!user.organizations.includes(
org,
),
)
.map((org) => (
<SelectItem
key={org}
value={org}
>
{org}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
onClick={addOrganization}
className="flex items-center"
>
<Building className="mr-2 h-4 w-4" />
Add Org
</Button>
</div>
</div> */}
</div>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,25 @@
"use client";
import Planning from "@/components/planning";
import { useApi } from "@/hooks/use-api";
import ICalendarEvent from "@/interfaces/ICalendarEvent";
import { Loader2 } from "lucide-react";
export default function Page() {
const {
data: requestedEvents,
isLoading,
success,
mutate,
} = useApi<ICalendarEvent[]>("/events", undefined, false, false);
if (isLoading) return <Loader2 className="animate-spin" />;
if (success)
return (
<Planning
modifiable
events={requestedEvents ?? []}
mutate={mutate}
/>
);
}

View File

@@ -1,30 +0,0 @@
"use client";
import useFileUpload from "@/hooks/use-file-upload";
import { ChangeEvent } from "react";
const MyComponent = () => {
const { progress, isUploading, error, uploadFile, cancelUpload } =
useFileUpload();
const handleFileUpload = (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
uploadFile(file, "/media/upload", (response) => {
console.log("Upload success:", response);
});
}
};
return (
<div>
<input type="file" onChange={handleFileUpload} />
{isUploading && <p>Uploading... {progress}%</p>}
{error && <p>Error: {error}</p>}
<button onClick={cancelUpload} disabled={!isUploading}>
Cancel Upload
</button>
</div>
);
};
export default MyComponent;

View File

@@ -16,7 +16,7 @@ import { PhotoDialog } from "@/components/photo-dialog";
import useFileUpload from "@/hooks/use-file-upload"; import useFileUpload from "@/hooks/use-file-upload";
import useMedia from "@/hooks/use-media"; import useMedia from "@/hooks/use-media";
import Media from "@/interfaces/Media"; import Media from "@/interfaces/Media";
import useApiMutation, { request } from "@/hooks/use-api"; import request from "@/lib/request";
export default function PhotoGallery() { export default function PhotoGallery() {
const { const {
@@ -40,8 +40,22 @@ export default function PhotoGallery() {
}); });
}; };
const handleUpdatePhoto = (updatedPhoto: Omit<Media, "id">) => { const handleUpdatePhoto = async (
body: Media | Omit<Media, "id">,
file: File,
) => {
if (selectedPhoto) { if (selectedPhoto) {
const res = await request<Media>(
`/media/${selectedPhoto.id}/update`,
{
method: "PATCH",
requiresAuth: true,
body,
},
);
if (res.status === "Success") {
mutate();
}
} }
setSelectedPhoto(null); setSelectedPhoto(null);
}; };

View File

@@ -15,7 +15,8 @@ import {
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { ChevronDown, ChevronRight, Plus, Trash2 } from "lucide-react"; import { ChevronDown, ChevronRight, Plus, Trash2 } from "lucide-react";
import { toTitleCase } from "@/lib/utils"; import { toTitleCase } from "@/lib/utils";
import { request, useApi } from "@/hooks/use-api"; import { useApi } from "@/hooks/use-api";
import request from "@/lib/request";
type Action = string; type Action = string;

View File

@@ -3,7 +3,8 @@
import { useState } from "react"; import { useState } from "react";
import { ShortcodeTable } from "@/components/shortcodes-table"; import { ShortcodeTable } from "@/components/shortcodes-table";
import type IShortcode from "@/interfaces/IShortcode"; import type IShortcode from "@/interfaces/IShortcode";
import { request, useApi } from "@/hooks/use-api"; import { useApi } from "@/hooks/use-api";
import request from "@/lib/request";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
export default function ShortcodesPage() { export default function ShortcodesPage() {
@@ -15,6 +16,8 @@ export default function ShortcodesPage() {
success, success,
} = useApi<IShortcode[]>("/shortcodes", undefined, true); } = useApi<IShortcode[]>("/shortcodes", undefined, true);
console.log(shortcodes);
const handleUpdate = async (updatedShortcode: IShortcode) => { const handleUpdate = async (updatedShortcode: IShortcode) => {
const res = await request<IShortcode>( const res = await request<IShortcode>(
`/shortcodes/${updatedShortcode.code}/update`, `/shortcodes/${updatedShortcode.code}/update`,

View File

@@ -1,4 +1,4 @@
"use server"; export const dynamic = "force-dynamic"; // Prevents static rendering
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -10,7 +10,7 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "@/components/ui/card"; } from "@/components/ui/card";
import { Avatar, AvatarFallback, AvatarImage } from "@radix-ui/react-avatar"; import getShortcode from "@/lib/getShortcode";
import { CheckIcon } from "lucide-react"; import { CheckIcon } from "lucide-react";
export default async function About() { export default async function About() {
@@ -21,13 +21,17 @@ export default async function About() {
</Button> </Button>
</a> </a>
); );
const profileImage = await getShortcode("profile_image");
return ( return (
<> <>
<div className=""> <div className="">
<div className="flex flex-col lg:flex-row gap-4 justify-between w-full p-12"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-4 w-full p-12 items-stretch">
<div className="flex flex-col lg:w-1/2 xl:w-full gap-4 w-full justify-center"> {/* Text Section - Takes 2/3 on large screens */}
<Card className="py-5 max-h-fit"> <div className="lg:col-span-2 flex flex-col justify-center">
<CardHeader className="text-center p-2"> <Card className="h-full">
<CardHeader className="text-center p-4">
<CardTitle className="text-5xl"> <CardTitle className="text-5xl">
Nicolas GORUK Nicolas GORUK
</CardTitle> </CardTitle>
@@ -38,10 +42,10 @@ export default async function About() {
</CardHeader> </CardHeader>
<CardContent className="px-8 sm:px-10 py-14"> <CardContent className="px-8 sm:px-10 py-14">
<div className="flex flex-col gap-4 justify-center"> <div className="flex flex-col gap-4 justify-center">
<h2 className="text-pretty text-center text-xl font-semibold md:mb-0.5 lg:mb-1 lg:max-w-3xl sm:text-3xl"> <h2 className="text-center text-xl font-semibold sm:text-3xl">
Lorem ipsum, dolor sit amet Lorem ipsum, dolor sit amet
</h2> </h2>
<p className="blog-paragraph text-muted-foreground"> <p className="text-muted-foreground">
Lorem ipsum dolor sit amet consectetur Lorem ipsum dolor sit amet consectetur
adipisicing elit. Debitis accusamus adipisicing elit. Debitis accusamus
illum, nam nemo quod delectus velit illum, nam nemo quod delectus velit
@@ -49,10 +53,10 @@ export default async function About() {
aliquam atque praesentium ea placeat ad, aliquam atque praesentium ea placeat ad,
neque eveniet adipisci? neque eveniet adipisci?
</p> </p>
<h2 className="text-pretty text-center text-xl font-semibold md:mb-0.5 lg:mb-1 lg:max-w-3xl sm:text-3xl"> <h2 className="text-center text-xl font-semibold sm:text-3xl">
Lorem ipsum, dolor sit amet Lorem ipsum, dolor sit amet
</h2> </h2>
<p className="blog-paragraph text-muted-foreground"> <p className="text-muted-foreground">
Lorem ipsum dolor sit amet consectetur Lorem ipsum dolor sit amet consectetur
adipisicing elit. Debitis accusamus adipisicing elit. Debitis accusamus
illum, nam nemo quod delectus velit illum, nam nemo quod delectus velit
@@ -64,10 +68,15 @@ export default async function About() {
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
<div className="w-full lg:w-1/2 border rounded">
{/* Image Section - Takes 1/3 on large screens */}
<div className="lg:col-span-1 flex items-center">
<img <img
className="w-full aspect-square" className="w-full h-full object-cover rounded"
src="https://shadcnblocks.com/images/block/placeholder-dark-1.svg" src={
profileImage?.media?.url ??
"https://shadcnblocks.com/images/block/placeholder-dark-1.svg"
}
alt="president profile image" alt="president profile image"
/> />
</div> </div>

View File

@@ -0,0 +1,118 @@
"use client";
import Image from "next/image";
import {
Pagination,
PaginationContent,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from "@/components/ui/pagination";
import useMedia from "@/hooks/use-media";
import { Loader2 } from "lucide-react";
import Lightbox from "yet-another-react-lightbox";
import "yet-another-react-lightbox/styles.css";
import Zoom from "yet-another-react-lightbox/plugins/zoom";
import { useState } from "react";
export default function PhotoGallery() {
const {
data,
error: mediaError,
isLoading,
success,
setPage,
setLimit,
mutate,
} = useMedia();
const [index, setIndex] = useState<number | null>(null);
return (
<div className="container mx-auto px-4 py-8">
<div className="flex justify-between items-center mb-8">
<h1 className="text-3xl font-bold">Gallerie Photo</h1>
</div>
{isLoading ? (
<div className="flex w-full h-full justify-center">
<Loader2 className="animate-spin" />
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{data?.items.map((photo, idx) => (
<div
key={photo.id}
className="aspect-square overflow-hidden rounded-lg shadow-md cursor-pointer"
onClick={() => setIndex(idx)}
>
<Image
src={photo.url || "/placeholder.svg"}
alt={photo.alt}
width={300}
height={300}
unoptimized
className="w-full h-full object-cover"
/>
</div>
))}
<Lightbox
open={index !== null}
close={() => setIndex(null)}
slides={data?.items.map((i) => ({ src: i.url }))}
index={index ?? 0}
plugins={[Zoom]}
/>
</div>
)}
<Pagination className="mt-8">
<PaginationContent>
<PaginationItem>
<PaginationPrevious
href="#"
onClick={(e) => {
e.preventDefault();
setPage((prev) => Math.max(prev - 1, 1));
}}
className={
data?.page === 1
? "pointer-events-none opacity-50"
: ""
}
/>
</PaginationItem>
{[...Array(data?.totalPages)].map((_, i) => (
<PaginationItem key={i + 1}>
<PaginationLink
href="#"
onClick={(e) => {
e.preventDefault();
setPage(i + 1);
}}
isActive={data?.page === i + 1}
>
{i + 1}
</PaginationLink>
</PaginationItem>
))}
<PaginationItem>
<PaginationNext
href="#"
onClick={(e) => {
e.preventDefault();
setPage((prev) =>
Math.min(prev + 1, data?.totalPages ?? 1),
);
}}
className={
data?.page === data?.totalPages
? "pointer-events-none opacity-50"
: ""
}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
</div>
);
}

View File

@@ -1,23 +1,36 @@
"use server"; export const dynamic = "force-dynamic"; // Prevents static rendering
import Features, { FeatureItem } from "@/components/features"; import Features, { FeatureItem } from "@/components/features";
import Gallery from "@/components/gallery"; import Gallery from "@/components/gallery";
import Hero from "@/components/hero"; import Hero from "@/components/hero";
import HomepageGalleryItems from "@/components/homepage-gallery";
import Testimonial from "@/components/testimonial"; import Testimonial from "@/components/testimonial";
import { CarouselItem } from "@/components/ui/carousel"; import { CarouselItem } from "@/components/ui/carousel";
import YouTubeEmbed from "@/components/youtube-embed"; import YouTubeEmbed from "@/components/youtube-embed";
import { IYoutube } from "@/interfaces/youtube"; import { IYoutube } from "@/interfaces/youtube";
import getShortcode from "@/lib/getShortcode";
const PLAYLIST_ID = "PLh8PxbpRguvNlmarfGkCTAd-UVAG4QpE9";
export default async function Home() { export default async function Home() {
let videos: IYoutube | null = null; let videos: IYoutube | null = null;
if (process.env.YOUTUBE_API_KEY) { if (process.env.YOUTUBE_API_KEY) {
const query = `https://www.googleapis.com/youtube/v3/search?key=${process.env.YOUTUBE_API_KEY}&channelId=UCzuFLl5I0WxSMqbeMaiq_FQ&part=snippet,id&order=date&maxResults=50`; const query = `https://www.googleapis.com/youtube/v3/playlistItems?key=${process.env.YOUTUBE_API_KEY}&playlistId=${PLAYLIST_ID}&part=snippet,id&maxResults=50`;
const res = await fetch(query); const res = await fetch(query);
videos = await res.json(); videos = await res.json();
} }
console.log(videos);
const hero = await getShortcode("hero_image");
const systemEvolution = await getShortcode("evolution_systeme");
const fondations = await getShortcode("fondements");
const todaysPrinciples = await getShortcode("aujourdhui");
return ( return (
<main> <main>
<Hero /> <Hero
background={
hero?.media?.url ??
"https://shadcnblocks.com/images/block/placeholder-2.svg"
}
/>
<div className="p-12"> <div className="p-12">
<YouTubeEmbed <YouTubeEmbed
loadIframe loadIframe
@@ -36,7 +49,10 @@ export default async function Home() {
<FeatureItem <FeatureItem
title="Les Fondements de Latosa Escrima Concepts" title="Les Fondements de Latosa Escrima Concepts"
position="left" position="left"
image="https://shadcnblocks.com/images/block/placeholder-2.svg" image={
fondations?.media?.url ??
"https://shadcnblocks.com/images/block/placeholder-2.svg"
}
> >
<ol className="flex list-decimal flex-col gap-4 text-justify"> <ol className="flex list-decimal flex-col gap-4 text-justify">
<li> <li>
@@ -72,7 +88,10 @@ export default async function Home() {
<FeatureItem <FeatureItem
title="LÉvolution du Système" title="LÉvolution du Système"
position="right" position="right"
image="https://shadcnblocks.com/images/block/placeholder-2.svg" image={
systemEvolution?.media?.url ??
"https://shadcnblocks.com/images/block/placeholder-2.svg"
}
> >
<ol className="flex list-none flex-col gap-4 text-justify"> <ol className="flex list-none flex-col gap-4 text-justify">
<li> <li>
@@ -117,7 +136,10 @@ export default async function Home() {
<FeatureItem <FeatureItem
title="Les Principes du Système Aujourdhui" title="Les Principes du Système Aujourdhui"
position="left" position="left"
image="https://shadcnblocks.com/images/block/placeholder-2.svg" image={
todaysPrinciples?.media?.url ??
"https://shadcnblocks.com/images/block/placeholder-2.svg"
}
> >
Latosa Escrima Concepts repose sur cinq concepts Latosa Escrima Concepts repose sur cinq concepts
fondamentaux : fondamentaux :
@@ -131,11 +153,13 @@ export default async function Home() {
</FeatureItem> </FeatureItem>
</Features> </Features>
<Gallery <Gallery
tagLine="Tag Line" tagLine=""
cta="Book a demo" cta="Voir toutes les photos"
ctaHref="#" ctaHref="/gallery"
title="Gallery" title="Gallerie"
/> >
<HomepageGalleryItems />
</Gallery>
{videos && ( {videos && (
<Gallery <Gallery
tagLine="" tagLine=""
@@ -144,9 +168,13 @@ export default async function Home() {
title="Vidéos YouTube" title="Vidéos YouTube"
> >
{videos.items.map((video) => { {videos.items.map((video) => {
const id =
typeof video.id !== "string"
? video.id.videoId
: video.snippet.resourceId.videoId;
return ( return (
<CarouselItem <CarouselItem
key={video.id.videoId} key={id}
className="pl-[20px] md:max-w-[452px]" className="pl-[20px] md:max-w-[452px]"
> >
<YouTubeEmbed video={video} /> <YouTubeEmbed video={video} />

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import Planning from "@/components/planning"; import Planning from "@/components/planning";
import { useApi } from "@/hooks/use-api"; import { useApi } from "@/hooks/use-api";
import { type CalendarEventExternal } from "@schedule-x/calendar"; import ICalendarEvent from "@/interfaces/ICalendarEvent";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
const Page = () => { const Page = () => {
@@ -10,7 +10,7 @@ const Page = () => {
isLoading, isLoading,
success, success,
mutate, mutate,
} = useApi<CalendarEventExternal[]>("/events", undefined, false, false); } = useApi<ICalendarEvent[]>("/events", undefined, false, false);
if (isLoading) return <Loader2 className="animate-spin" />; if (isLoading) return <Loader2 className="animate-spin" />;
if (success) if (success)

View File

@@ -3,6 +3,7 @@ import { Geist, Geist_Mono } from "next/font/google";
import "@/app/globals.css"; import "@/app/globals.css";
import SWRLayout from "@/components/layouts/swr-layout"; import SWRLayout from "@/components/layouts/swr-layout";
import { ThemeProvider } from "@/components/ThemeProvider"; import { ThemeProvider } from "@/components/ThemeProvider";
import { Toaster } from "@/components/ui/toaster";
const geistSans = Geist({ const geistSans = Geist({
variable: "--font-geist-sans", variable: "--font-geist-sans",
@@ -47,6 +48,7 @@ export default function RootLayout({
disableTransitionOnChange disableTransitionOnChange
> >
<SWRLayout>{children}</SWRLayout> <SWRLayout>{children}</SWRLayout>
<Toaster />
</ThemeProvider> </ThemeProvider>
</body> </body>
</html> </html>

View File

@@ -63,8 +63,14 @@ const data = {
}, },
{ {
title: "Planning", title: "Planning",
url: "/dashboard/planning",
icon: Calendar, icon: Calendar,
url: "/dashboard/planning",
items: [
{
title: "Planning",
url: "/dashboard/planning",
},
],
}, },
{ {
title: "Blogs", title: "Blogs",

View File

@@ -3,8 +3,8 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { ApiResponse } from "@/hooks/use-api";
import { API_URL } from "@/lib/constants"; import { API_URL } from "@/lib/constants";
import { ApiResponse } from "@/types/types";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
interface FormData { interface FormData {

View File

@@ -1,23 +1,44 @@
"use client" "use client";
import * as React from "react" import * as React from "react";
import { CalendarIcon } from "lucide-react" import { CalendarIcon } from "lucide-react";
import { format } from "date-fns" import { format } from "date-fns";
import { zodResolver } from "@hookform/resolvers/zod" import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod" import * as z from "zod";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar" import { Calendar } from "@/components/ui/calendar";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"
import { Input } from "@/components/ui/input"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
import { Checkbox } from "@/components/ui/checkbox"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { useForm } from "react-hook-form"
import { import {
CalendarEventExternal, Form,
} from "@schedule-x/calendar"; FormControl,
import ICalendarEvent from "@/interfaces/ICalendarEvent" FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Checkbox } from "@/components/ui/checkbox";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
SubmitErrorHandler,
SubmitHandler,
useForm,
UseFormReturn,
} from "react-hook-form";
import { CalendarEventExternal } from "@schedule-x/calendar";
import ICalendarEvent from "@/interfaces/ICalendarEvent";
import { useEffect } from "react";
export const eventFormSchema = z.object({ export const eventFormSchema = z.object({
title: z.string().min(1, "Titre requis"), title: z.string().min(1, "Titre requis"),
@@ -33,59 +54,54 @@ export const eventFormSchema = z.object({
frequency: z.enum(["unique", "quotidien", "hebdomadaire", "mensuel"]), frequency: z.enum(["unique", "quotidien", "hebdomadaire", "mensuel"]),
frequencyEndDate: z.date().optional(), frequencyEndDate: z.date().optional(),
isVisible: z.boolean().default(true), isVisible: z.boolean().default(true),
}) });
export type EventFormValues = z.infer<typeof eventFormSchema> export type EventFormValues = z.infer<typeof eventFormSchema>;
const frequencies = [ const frequencies = [
{ label: "Unique", value: "unique" }, { label: "Unique", value: "unique" },
{ label: "Quotidien", value: "quotidien" }, { label: "Quotidien", value: "quotidien" },
{ label: "Hebdomadaire", value: "hebdomadaire" }, { label: "Hebdomadaire", value: "hebdomadaire" },
{ label: "Mensuel", value: "mensuel" }, { label: "Mensuel", value: "mensuel" },
] ];
const isCalendarEventExternal = (event: CalendarEventExternal | Omit<CalendarEventExternal, "id">): event is CalendarEventExternal => { const isCalendarEventExternal = (
event: CalendarEventExternal | Omit<CalendarEventExternal, "id">,
): event is CalendarEventExternal => {
return (event as CalendarEventExternal).id !== undefined; return (event as CalendarEventExternal).id !== undefined;
}; };
export const EventForm: React.FC< export const EventForm: React.FC<{
{
event: ICalendarEvent | Omit<ICalendarEvent, "id">; event: ICalendarEvent | Omit<ICalendarEvent, "id">;
onSubmitEvent: (eventFormValues: EventFormValues) => void; setForm: React.Dispatch<
} React.SetStateAction<UseFormReturn<EventFormValues> | undefined>
> = ({ >;
event, }> = ({ event, setForm }) => {
onSubmitEvent, const _start = new Date(event.start ?? Date.now());
}) => { const _end = new Date(event.end ?? Date.now());
const form = useForm<EventFormValues>({ const form = useForm<EventFormValues>({
resolver: zodResolver(eventFormSchema), resolver: zodResolver(eventFormSchema),
defaultValues: { defaultValues: {
title: event.title ? event.title : "", title: event.title ? event.title : "",
startDate: new Date(), // event.start), startDate: _start, // event.start),
startTime: `${new Date(event.start).getHours()}:${new Date(event.start).getMinutes()}`, startTime: format(_start, "HH:mm"),
endDate: new Date(), // event.end), endDate: _end, // event.end),
endTime: `${new Date(event.end).getHours()}:${new Date(event.end).getMinutes()}`, endTime: format(_end, "HH:mm"),
fullDay: event.fullday, fullDay: event.fullday,
frequency: "unique", frequency: "unique",
isVisible: event.isVisible, isVisible: event.isVisible,
}, },
}) });
const frequency = form.watch("frequency") useEffect(() => {
setForm(form);
}, []);
async function onSubmit(data: EventFormValues) { const frequency = form.watch("frequency");
try {
const validatedData = eventFormSchema.parse(data)
onSubmitEvent(validatedData)
} catch (error) {
console.error("On submit error : ", error)
}
}
return ( return (
<Form {...form}> <Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="w-full max-w-md space-y-4"> <form className="w-full max-w-md space-y-4">
<FormField <FormField
control={form.control} control={form.control}
name="title" name="title"
@@ -93,7 +109,10 @@ export const EventForm: React.FC<
<FormItem> <FormItem>
<FormLabel>Titre</FormLabel> <FormLabel>Titre</FormLabel>
<FormControl> <FormControl>
<Input placeholder="Ajouter un titre" {...field} /> <Input
placeholder="Ajouter un titre"
{...field}
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -112,15 +131,36 @@ export const EventForm: React.FC<
<FormControl> <FormControl>
<Button <Button
variant="outline" variant="outline"
className={cn("w-full pl-3 text-left font-normal", !field.value && "text-muted-foreground")} className={cn(
"w-full pl-3 text-left font-normal",
!field.value &&
"text-muted-foreground",
)}
> >
{field.value ? format(field.value, "yyyy-mm-dd hh:mm") : <span>Choisis une date</span>} {field.value ? (
format(
field.value,
"dd/MM/yyyy",
)
) : (
<span>
Choisis une date
</span>
)}
<CalendarIcon className="ml-auto h-4 w-4 opacity-50" /> <CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
</Button> </Button>
</FormControl> </FormControl>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start"> <PopoverContent
<Calendar mode="single" selected={field.value} onSelect={field.onChange} initialFocus /> className="w-auto p-0"
align="start"
>
<Calendar
mode="single"
selected={field.value}
onSelect={field.onChange}
initialFocus
/>
</PopoverContent> </PopoverContent>
</Popover> </Popover>
<FormMessage /> <FormMessage />
@@ -134,7 +174,11 @@ export const EventForm: React.FC<
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormControl> <FormControl>
<Input type="time" {...field} className="w-[120px]" /> <Input
type="time"
{...field}
className="w-[120px]"
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -154,15 +198,36 @@ export const EventForm: React.FC<
<FormControl> <FormControl>
<Button <Button
variant="outline" variant="outline"
className={cn("w-full pl-3 text-left font-normal", !field.value && "text-muted-foreground")} className={cn(
"w-full pl-3 text-left font-normal",
!field.value &&
"text-muted-foreground",
)}
> >
{field.value ? format(field.value, "yyyy-mm-dd hh:mm") : <span>Choisis une date</span>} {field.value ? (
format(
field.value,
"dd/MM/yyyy",
)
) : (
<span>
Choisis une date
</span>
)}
<CalendarIcon className="ml-auto h-4 w-4 opacity-50" /> <CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
</Button> </Button>
</FormControl> </FormControl>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start"> <PopoverContent
<Calendar mode="single" selected={field.value} onSelect={field.onChange} initialFocus /> className="w-auto p-0"
align="start"
>
<Calendar
mode="single"
selected={field.value}
onSelect={field.onChange}
initialFocus
/>
</PopoverContent> </PopoverContent>
</Popover> </Popover>
<FormMessage /> <FormMessage />
@@ -176,7 +241,11 @@ export const EventForm: React.FC<
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormControl> <FormControl>
<Input type="time" {...field} className="w-[120px]" /> <Input
type="time"
{...field}
className="w-[120px]"
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -190,7 +259,10 @@ export const EventForm: React.FC<
render={({ field }) => ( render={({ field }) => (
<FormItem className="flex flex-row items-start space-x-3 space-y-0"> <FormItem className="flex flex-row items-start space-x-3 space-y-0">
<FormControl> <FormControl>
<Checkbox checked={field.value} onCheckedChange={field.onChange} /> <Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl> </FormControl>
<FormLabel>Journée complète</FormLabel> <FormLabel>Journée complète</FormLabel>
<FormMessage /> <FormMessage />
@@ -205,7 +277,10 @@ export const EventForm: React.FC<
render={({ field }) => ( render={({ field }) => (
<FormItem className="flex-1"> <FormItem className="flex-1">
<FormLabel>Fréquence</FormLabel> <FormLabel>Fréquence</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}> <Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<FormControl> <FormControl>
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Selectionner Fréquence" /> <SelectValue placeholder="Selectionner Fréquence" />
@@ -213,7 +288,10 @@ export const EventForm: React.FC<
</FormControl> </FormControl>
<SelectContent> <SelectContent>
{frequencies.map((frequency) => ( {frequencies.map((frequency) => (
<SelectItem key={frequency.value} value={frequency.value}> <SelectItem
key={frequency.value}
value={frequency.value}
>
{frequency.label} {frequency.label}
</SelectItem> </SelectItem>
))} ))}
@@ -236,15 +314,36 @@ export const EventForm: React.FC<
<FormControl> <FormControl>
<Button <Button
variant="outline" variant="outline"
className={cn("w-full pl-3 text-left font-normal", !field.value && "text-muted-foreground")} className={cn(
"w-full pl-3 text-left font-normal",
!field.value &&
"text-muted-foreground",
)}
> >
{field.value ? format(field.value, "MM/dd/yyyy") : <span>Choisis une date</span>} {field.value ? (
format(
field.value,
"dd/MM/yyyy",
)
) : (
<span>
Choisis une date
</span>
)}
<CalendarIcon className="ml-auto h-4 w-4 opacity-50" /> <CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
</Button> </Button>
</FormControl> </FormControl>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start"> <PopoverContent
<Calendar mode="single" selected={field.value} onSelect={field.onChange} initialFocus /> className="w-auto p-0"
align="start"
>
<Calendar
mode="single"
selected={field.value}
onSelect={field.onChange}
initialFocus
/>
</PopoverContent> </PopoverContent>
</Popover> </Popover>
<FormMessage /> <FormMessage />
@@ -259,9 +358,12 @@ export const EventForm: React.FC<
name="isVisible" name="isVisible"
render={({ field }) => ( render={({ field }) => (
<FormItem className="flex-1"> <FormItem className="flex-1">
<FormLabel className="align-sub">Evènement visible ?</FormLabel> <FormLabel className="align-sub">
Evènement visible ?
</FormLabel>
<FormControl> <FormControl>
<Checkbox className="m-3 align-top justify-center" <Checkbox
className="m-3 align-top justify-center"
checked={field.value} checked={field.value}
onCheckedChange={field.onChange} onCheckedChange={field.onChange}
/> />
@@ -270,17 +372,7 @@ export const EventForm: React.FC<
</FormItem> </FormItem>
)} )}
/> />
<div className="flex justify-end space-x-2">
<Button variant="outline" type="button">
Abandonner
</Button>
<Button type="submit" className="bg-[#6B4EFF] hover:bg-[#5B3FEF]">
Sauvegarder
</Button>
</div>
</form> </form>
</Form> </Form>
) );
} };

View File

@@ -2,12 +2,24 @@ import { ExternalLink } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import Link from "next/link"; import Link from "next/link";
import { API_URL } from "@/lib/constants";
import Image from "next/image";
const Hero = () => { const Hero: React.FC<{ background: string }> = ({ background }) => {
return ( return (
<section className="relative flex h-[calc(100vh-68px)] items-center justify-center overflow-hidden py-32"> <section className="relative flex h-[calc(100vh-68px)] items-center justify-center overflow-hidden py-32">
<div className=""> <div className="">
<div className="magicpattern absolute inset-x-0 top-0 -z-10 flex h-full w-full items-center justify-center bg-blue-50 opacity-100" /> <Image
src={background}
layout="fill"
// objectFit="cover"
priority
alt="Hero image"
unoptimized
className="grayscale object-cover "
/>
{/* Gradient and Blur Overlay */}
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-transparent to-transparent bg-opacity-30 backdrop-blur-sm"></div>
<div className="mx-auto flex max-w-5xl flex-col items-center"> <div className="mx-auto flex max-w-5xl flex-col items-center">
<div className="z-10 flex flex-col items-center gap-6 text-center"> <div className="z-10 flex flex-col items-center gap-6 text-center">
<img <img
@@ -16,10 +28,12 @@ const Hero = () => {
className="h-16" className="h-16"
/> />
<div> <div>
<h1 className="mb-6 text-pretty text-2xl font-bold text-primary lg:text-5xl"> <h1 className="mb-6 text-pretty text-2xl font-bold text-primary lg:text-5xl font-times">
Trouvez votre équilibre Trouvez votre <em>équilibre</em> avec
<br /> <br />
avec Latosa-Escrima <span className="font-extrabold text-3xl lg:text-6xl">
Latosa Escrima
</span>
</h1> </h1>
<p className="text-muted-foreground lg:text-xl"> <p className="text-muted-foreground lg:text-xl">
Une évolution des arts martiaux Philippins Une évolution des arts martiaux Philippins

View File

@@ -0,0 +1,59 @@
"use client";
import useMedia from "@/hooks/use-media";
import { CarouselItem } from "./ui/carousel";
import { Loader2 } from "lucide-react";
import { useState } from "react";
import Lightbox, { SlideImage } from "yet-another-react-lightbox";
import "yet-another-react-lightbox/styles.css";
import Zoom from "yet-another-react-lightbox/plugins/zoom";
export default function HomepageGalleryItems() {
const {
data,
error,
mutate,
setPage,
success,
setLimit,
isLoading,
isValidating,
} = useMedia(20);
const [index, setIndex] = useState<number | null>(null);
if (isLoading) {
return (
<div className="flex justify-center w-full h-full">
<Loader2 className="animate-spin" />
</div>
);
}
return (
<>
{data?.items.map((i, idx) => (
<CarouselItem
key={i.id}
onClick={() => setIndex(idx)}
className="pl-[20px] md:max-w-[452px] cursor-pointer"
>
<div className="w-full aspect-square">
<img
src={i.url}
alt={i.alt}
className="inset-0 border rounded-sm w-full h-full object-cover"
/>
</div>
</CarouselItem>
))}
<Lightbox
open={index !== null}
close={() => setIndex(null)}
slides={data?.items.map((i) => ({ src: i.url }))}
index={index ?? 0}
plugins={[Zoom]}
/>
</>
);
}

View File

@@ -3,12 +3,10 @@ import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { useEffect, useState } from "react"; import { useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import useLogin from "@/hooks/use-login"; import useLogin from "@/hooks/use-login";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import { API_URL } from "@/lib/constants";
import { ApiResponse } from "@/hooks/use-api";
export function LoginForm({ export function LoginForm({
className, className,

View File

@@ -13,8 +13,8 @@ import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
import MemberDialog, { Member } from "./member-dialog"; import MemberDialog, { Member } from "./member-dialog";
import * as z from "zod"; import { useApi } from "@/hooks/use-api";
import { request, useApi } from "@/hooks/use-api"; import request from "@/lib/request";
import { import {
CircleX, CircleX,
Loader2, Loader2,
@@ -22,6 +22,7 @@ import {
UserRoundPen, UserRoundPen,
UserRoundPlus, UserRoundPlus,
} from "lucide-react"; } from "lucide-react";
import Link from "next/link";
export default function MembersTable() { export default function MembersTable() {
const { const {
@@ -107,7 +108,7 @@ export default function MembersTable() {
<TableRow> <TableRow>
{selectMode && ( {selectMode && (
<TableHead className="w-[50px]"> <TableHead className="w-[50px]">
Selectionner Sélectionner
</TableHead> </TableHead>
)} )}
<TableHead>Prénom</TableHead> <TableHead>Prénom</TableHead>
@@ -140,9 +141,23 @@ export default function MembersTable() {
</TableCell> </TableCell>
)} )}
<TableCell> <TableCell>
<Link
href={`/dashboard/members/${member.userId}`}
>
<span className="underline">
{member.firstname} {member.firstname}
</span>
</Link>
</TableCell>
<TableCell>
<Link
href={`/dashboard/members/${member.userId}`}
>
<span className="underline">
{member.lastname}
</span>
</Link>
</TableCell> </TableCell>
<TableCell>{member.lastname}</TableCell>
<TableCell>{member.email}</TableCell> <TableCell>{member.email}</TableCell>
<TableCell>{member.phone}</TableCell> <TableCell>{member.phone}</TableCell>
<TableCell>{member.role}</TableCell> <TableCell>{member.role}</TableCell>

View File

@@ -0,0 +1,15 @@
.animate-header-slide-down-fade {
animation: header-slide-down-fade 1s ease-in-out;
}
@keyframes header-slide-down-fade {
0% {
opacity: 0;
transform: translateY(-16px);
}
to {
opacity: 1;
transform: translateY(0);
}
}

View File

@@ -1,14 +1,8 @@
"use client"; "use client";
import { Book, Menu, Sunset, Trees, Zap } from "lucide-react"; import { Menu } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Button, buttonVariants } from "@/components/ui/button"; import { Button, buttonVariants } from "@/components/ui/button";
import { navigationMenuTriggerStyle } from "@/components/ui/navigation-menu"; import { navigationMenuTriggerStyle } from "@/components/ui/navigation-menu";
import { import {
@@ -22,53 +16,27 @@ import Link from "next/link";
import { deleteCookie, getCookie } from "cookies-next"; import { deleteCookie, getCookie } from "cookies-next";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { ThemeSwitcher } from "./theme-switcher"; import { ThemeSwitcher } from "./theme-switcher";
import "./nav-bar.css";
const subMenuItemsOne = [ const Href: React.FC<React.PropsWithChildren<{ href: string }>> = ({
{ href,
title: "Blog", children,
description: "The latest industry news, updates, and info", }) => {
icon: <Book className="size-5 shrink-0" />, return (
}, <Link
{ className={cn(
title: "Compnay", "text-foreground font-bold",
description: "Our mission is to innovate and empower the world", navigationMenuTriggerStyle,
icon: <Trees className="size-5 shrink-0" />, buttonVariants({
}, variant: "ghost",
{ }),
title: "Careers", )}
description: "Browse job listing and discover our workspace", href={href}
icon: <Sunset className="size-5 shrink-0" />, >
}, {children}
{ </Link>
title: "Support", );
description: };
"Get in touch with our support team or visit our community forums",
icon: <Zap className="size-5 shrink-0" />,
},
];
const subMenuItemsTwo = [
{
title: "Help Center",
description: "Get all the answers you need right here",
icon: <Zap className="size-5 shrink-0" />,
},
{
title: "Contact Us",
description: "We are here to help you with any questions you have",
icon: <Sunset className="size-5 shrink-0" />,
},
{
title: "Status",
description: "Check the current status of our services and APIs",
icon: <Trees className="size-5 shrink-0" />,
},
{
title: "Terms of Service",
description: "Our terms and conditions for using our services",
icon: <Book className="size-5 shrink-0" />,
},
];
const Navbar = () => { const Navbar = () => {
const [cookie, setCookie] = useState<string | null>(null); const [cookie, setCookie] = useState<string | null>(null);
@@ -77,7 +45,7 @@ const Navbar = () => {
setCookie(_cookie?.toString() ?? null); setCookie(_cookie?.toString() ?? null);
}, []); }, []);
return ( return (
<section className="sticky top-0 z-50 bg-background p-4"> <section className="sticky top-0 z-50 bg-background/50 border-b border-b-white/10 backdrop-blur-md p-4 transition duration-200 ease-in-out animate-header-slide-down-fade">
<div> <div>
<nav className="hidden justify-between lg:flex"> <nav className="hidden justify-between lg:flex">
<div className="flex items-center gap-6"> <div className="flex items-center gap-6">
@@ -92,65 +60,42 @@ const Navbar = () => {
</span> </span>
</div> </div>
<div className="flex items-center"> <div className="flex items-center">
<Href href="/">Accueil</Href>
<Href href="/planning">Planning</Href>
<Href href="/about">À propos</Href>
<Href href="/gallery">Gallerie</Href>
<Href href="/blogs">Blog</Href>
</div>
</div>
<div className="flex gap-2 animate-in ease-in-out">
<ThemeSwitcher />
{cookie ? (
<Link <Link
className={cn( className={cn(
"text-muted-foreground", "text-muted-foreground",
navigationMenuTriggerStyle, navigationMenuTriggerStyle,
buttonVariants({ buttonVariants({
variant: "ghost", variant: "outline",
}), }),
)} )}
href="/" href="/dashboard"
> >
Accueil Compte
</Link> </Link>
<a
className={cn(
"text-muted-foreground",
navigationMenuTriggerStyle,
buttonVariants({
variant: "ghost",
}),
)}
href="/planning"
>
Planning
</a>
<a
className={cn(
"text-muted-foreground",
navigationMenuTriggerStyle,
buttonVariants({
variant: "ghost",
}),
)}
href="/about"
>
A propos
</a>
<a
className={cn(
"text-muted-foreground",
navigationMenuTriggerStyle,
buttonVariants({
variant: "ghost",
}),
)}
href="/blogs"
>
Blog
</a>
</div>
</div>
<div className="flex gap-2 animate-in ease-in-out">
<ThemeSwitcher />
<Button variant="outline">
{cookie ? (
<Link href="/dashboard">Compte</Link>
) : ( ) : (
<Link href="/login">Se connecter</Link> <Link
className={cn(
"text-muted-foreground",
navigationMenuTriggerStyle,
buttonVariants({
variant: "outline",
}),
)}
href="/login"
>
Se connecter
</Link>
)} )}
</Button>
{cookie ? ( {cookie ? (
<Button <Button
onClick={() => { onClick={() => {
@@ -219,6 +164,12 @@ const Navbar = () => {
> >
À propos À propos
</Link> </Link>
<Link
href="/gallery"
className="font-semibold"
>
Gallerie
</Link>
<Link <Link
href="/blog" href="/blog"
className="font-semibold" className="font-semibold"

View File

@@ -23,7 +23,7 @@ interface PhotoDialogProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
onDelete?: (id: Media["id"]) => void; onDelete?: (id: Media["id"]) => void;
onSave: (photo: Omit<Media, "id">, file: File) => void; onSave: (photo: Omit<Media, "id"> | Media, file: File) => void;
} }
export function PhotoDialog({ export function PhotoDialog({
@@ -34,7 +34,7 @@ export function PhotoDialog({
onSave, onSave,
}: PhotoDialogProps) { }: PhotoDialogProps) {
const [file, setFile] = useState<File | null>(null); const [file, setFile] = useState<File | null>(null);
const [newPhoto, setNewPhoto] = useState<Omit<Media, "id">>({ const [newPhoto, setNewPhoto] = useState<Omit<Media, "id"> | Media>({
url: "", url: "",
alt: "", alt: "",
path: "", path: "",

View File

@@ -0,0 +1,27 @@
"use client";
import {
Dialog,
DialogContent,
DialogTitle,
DialogTrigger,
} from "@radix-ui/react-dialog";
import Image, { ImageProps } from "next/image";
import React, { useState } from "react";
const PhotoViewer: React.FC<ImageProps> = ({ ...props }) => {
const [selected, setSelected] = useState(false);
return (
<Dialog open={selected} onOpenChange={setSelected}>
<DialogTitle>{props.alt}</DialogTitle>
<DialogTrigger asChild>
<Image onClick={() => setSelected(true)} {...props} />
</DialogTrigger>
<DialogContent>
<Image {...props} unoptimized />
</DialogContent>
</Dialog>
);
};
export default PhotoViewer;

View File

@@ -1,16 +1,14 @@
"use client"; "use client";
import { ApiResponse, request } from "@/hooks/use-api"; import { ApiResponse } from "@/types/types";
import request from "@/lib/request";
import "@schedule-x/theme-shadcn/dist/index.css"; import "@schedule-x/theme-shadcn/dist/index.css";
import { useNextCalendarApp, ScheduleXCalendar } from "@schedule-x/react"; import { useNextCalendarApp, ScheduleXCalendar } from "@schedule-x/react";
import { createEventsServicePlugin } from "@schedule-x/events-service"; import { createEventsServicePlugin } from "@schedule-x/events-service";
import { createDragAndDropPlugin } from "@schedule-x/drag-and-drop"; import { createDragAndDropPlugin } from "@schedule-x/drag-and-drop";
import { createResizePlugin } from "@schedule-x/resize"; import { createResizePlugin } from "@schedule-x/resize";
import { createEventRecurrencePlugin } from "@schedule-x/event-recurrence"; import { createEventRecurrencePlugin } from "@schedule-x/event-recurrence";
import { import { createViewDay, createViewWeek } from "@schedule-x/calendar";
createViewDay,
createViewWeek,
} from "@schedule-x/calendar";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { format } from "date-fns"; import { format } from "date-fns";
import { Dialog, DialogProps } from "@radix-ui/react-dialog"; import { Dialog, DialogProps } from "@radix-ui/react-dialog";
@@ -27,42 +25,20 @@ import { getCookie } from "cookies-next";
import { useTheme } from "next-themes"; import { useTheme } from "next-themes";
import { EventForm, EventFormValues } from "./event-dialog"; import { EventForm, EventFormValues } from "./event-dialog";
import ICalendarEvent from "@/interfaces/ICalendarEvent"; import ICalendarEvent from "@/interfaces/ICalendarEvent";
import { UseFormReturn } from "react-hook-form";
const mapFrequencyToRrule = (frequency: "unique" | "quotidien" | "hebdomadaire" | "mensuel", frequencyEndDate?: Date): string => { import mapFrequencyToRrule from "@/lib/mapFrequencyToRrule";
let rrule = ""; import { useToast } from "@/hooks/use-toast";
switch (frequency) {
case "quotidien":
rrule = "FREQ=DAILY";
break;
case "hebdomadaire":
rrule = "FREQ=WEEKLY";
break;
case "mensuel":
rrule = "FREQ=MONTHLY";
break;
default:
return "";
}
if (frequencyEndDate) {
const until = frequencyEndDate.getTime();
const untilDate = new Date(until);
const epochDateString = untilDate.toISOString().replace(/[-:]/g, "").split(".")[0]; // Format as YYYYMMDDTHHmmss
rrule += `;UNTIL=${epochDateString}`;
}
return rrule;
};
const Planning: React.FC<{ const Planning: React.FC<{
events: ICalendarEvent[]; events: ICalendarEvent[];
mutate?: KeyedMutator<ApiResponse<ICalendarEvent[]>>; mutate?: KeyedMutator<ApiResponse<ICalendarEvent[]>>;
}> = ({ events, mutate }) => { modifiable?: boolean;
}> = ({ events, mutate, modifiable = false }) => {
const { toast } = useToast();
const { resolvedTheme } = useTheme(); const { resolvedTheme } = useTheme();
console.log(resolvedTheme);
const isConnected = getCookie("auth_token"); const isConnected = getCookie("auth_token");
const plugins = isConnected const plugins =
isConnected && modifiable
? [ ? [
createEventsServicePlugin(), createEventsServicePlugin(),
createDragAndDropPlugin(), createDragAndDropPlugin(),
@@ -70,19 +46,22 @@ const Planning: React.FC<{
createEventRecurrencePlugin(), createEventRecurrencePlugin(),
] ]
: []; : [];
const [eventSelected, setEventSelected] = const [eventSelected, setEventSelected] = useState<ICalendarEvent | null>(
useState<ICalendarEvent | null>(null); null,
const [newEvent, setNewEvent] = useState<Omit< );
ICalendarEvent, const [newEvent, setNewEvent] = useState<Omit<ICalendarEvent, "id"> | null>(
"id" null,
> | null>(null); );
const handleEventUpdate = async (eventSelected: ICalendarEvent) => { const handleEventUpdate = async (
const event: ICalendarEvent = { eventSelected: ICalendarEvent | Omit<ICalendarEvent, "id">,
) => {
if (!isConnected || !modifiable) return;
const event = {
...eventSelected, ...eventSelected,
start: `${new Date(eventSelected.start).toISOString()}`, start: `${new Date(eventSelected.start).toISOString()}`,
end: `${new Date(eventSelected.end).toISOString()}`, end: `${new Date(eventSelected.end).toISOString()}`,
}; } as ICalendarEvent;
try { try {
const res = await request<undefined>(`/events/${event.id}/update`, { const res = await request<undefined>(`/events/${event.id}/update`, {
method: "PATCH", method: "PATCH",
@@ -91,10 +70,19 @@ const Planning: React.FC<{
csrfToken: false, csrfToken: false,
}); });
if (res.status === "Error") { if (res.status === "Error") {
// calendar?.events?.update(oldEvent); toast({
title: "Une erreur est survenue.",
description: res.message,
});
} else {
mutate?.();
} }
} catch (e) { } catch (e) {
console.log(e); if (e instanceof Error)
toast({
title: "Une erreur est survenue.",
description: e.message,
});
} }
}; };
@@ -120,7 +108,6 @@ const Planning: React.FC<{
setEventSelected(event as ICalendarEvent); setEventSelected(event as ICalendarEvent);
}, },
async onEventUpdate(newEvent) { async onEventUpdate(newEvent) {
await handleEventUpdate(newEvent as ICalendarEvent); await handleEventUpdate(newEvent as ICalendarEvent);
}, },
}, },
@@ -136,11 +123,14 @@ const Planning: React.FC<{
calendar?.setTheme(resolvedTheme === "dark" ? "dark" : "light"); calendar?.setTheme(resolvedTheme === "dark" ? "dark" : "light");
}, [resolvedTheme]); }, [resolvedTheme]);
const AddButton: React.FC = () => ( const AddButton: React.FC = () => {
if (!isConnected || !modifiable) return <></>;
return (
<Button onClick={() => setNewEvent({})} variant="outline"> <Button onClick={() => setNewEvent({})} variant="outline">
Nouveau Nouveau
</Button> </Button>
); );
};
return ( return (
<div> <div>
@@ -148,85 +138,125 @@ const Planning: React.FC<{
<AddButton /> <AddButton />
<ScheduleXCalendar calendarApp={calendar} /> <ScheduleXCalendar calendarApp={calendar} />
</div> </div>
{newEvent && ( {newEvent && isConnected && modifiable && (
<EventDialog <EventDialog
open={newEvent !== null || false} open={newEvent !== null || false}
onOpenChange={(open) => { onOpenChange={(open) => {
setNewEvent((e) => (open ? e : null)); setNewEvent((e) => (open ? e : null));
}} }}
event={newEvent} onAdd={async (formValues) => {
onSubmitEvent={async (eventFormValues) => { if (!isConnected || !modifiable) return;
const rrule = mapFrequencyToRrule( const rrule = mapFrequencyToRrule(
eventFormValues.frequency, formValues.frequency,
eventFormValues.frequencyEndDate formValues.frequencyEndDate,
) );
try { const [sHours, sMinutes] =
formValues.startTime.split(":");
formValues.startDate.setHours(
parseInt(sHours),
parseInt(sMinutes),
);
const [eHours, eMinutes] =
formValues.endTime.split(":");
formValues.endDate.setHours(
parseInt(eHours),
parseInt(eMinutes),
);
console.log(formValues.endDate);
const event: Omit<ICalendarEvent, "id"> = { const event: Omit<ICalendarEvent, "id"> = {
...newEvent, ...newEvent,
start: `${eventFormValues.startDate} ${eventFormValues.startTime}`, start: formValues.startDate.toISOString(),
end: `${eventFormValues.endDate} ${eventFormValues.endTime}`, end: formValues.endDate.toISOString(),
title: `${eventFormValues.title}`, title: `${formValues.title}`,
fullDay: eventFormValues.fullDay, fullday: formValues.fullDay,
rrule: rrule, rrule: rrule,
isVisible: eventFormValues.isVisible isVisible: formValues.isVisible,
} };
const res = await request<undefined>( const res = await request<undefined>(`/events/new`, {
`/events/new`,
{
method: "POST", method: "POST",
body: event, body: event,
requiresAuth: true, requiresAuth: true,
csrfToken: false, csrfToken: false,
}, });
);
if (res.status === "Error") { if (res.status === "Error") {
console.log("Error"); toast({
title: "Une erreur est survenue.",
description: res.message,
});
} }
if (res.status === "Success") { if (res.status === "Success") {
mutate?.(); mutate?.();
console.log("Success"); console.log("Success");
} }
} catch (e) {
console.log(e);
}
}} }}
event={newEvent}
/> />
)} )}
{eventSelected && ( {eventSelected && modifiable && isConnected && (
<EventDialog <EventDialog
open={eventSelected !== null || false} open={eventSelected !== null || false}
onOpenChange={(open) => { onOpenChange={(open) => {
setEventSelected((e) => (open ? e : null)); setEventSelected((e) => (open ? e : null));
}} }}
event={eventSelected} event={eventSelected}
onSubmitEvent={ (eventForm) => { onDelete={async (id) => {
console.log("Event form: " + eventForm) if (!isConnected || !modifiable) return;
}} calendar?.events?.remove(id);
onDelete={async () => {
calendar?.events?.remove(eventSelected.id);
try { try {
const res = await request<undefined>( const res = await request<undefined>(
`/events/${eventSelected.id}/delete`, `/events/${id}/delete`,
{ {
method: "DELETE", method: "DELETE",
body: eventSelected, requiresAuth: true,
requiresAuth: false,
csrfToken: false, csrfToken: false,
}, },
); );
if (res.status === "Error") { if (res.status === "Error") {
console.log("Error"); toast({
title: "Une erreur est survenue.",
description: res.message,
});
} }
if (res.status === "Success") { if (res.status === "Success") {
console.log("Success"); console.log("Success");
} }
} catch (e) { } catch (e: unknown) {
console.log(e); if (e instanceof Error)
toast({
title: "Une erreur est survenue.",
description: e.message,
});
} }
setEventSelected(null); setEventSelected(null);
}} }}
onUpdate={async () => { onUpdate={async (formValues) => {
await handleEventUpdate(eventSelected); if (!isConnected || !modifiable) return;
const rrule = mapFrequencyToRrule(
formValues.frequency,
formValues.frequencyEndDate,
);
const [sHours, sMinutes] =
formValues.startTime.split(":");
formValues.startDate.setHours(
parseInt(sHours),
parseInt(sMinutes),
);
const [eHours, eMinutes] =
formValues.endTime.split(":");
formValues.endDate.setHours(
parseInt(eHours),
parseInt(eMinutes),
);
const event: ICalendarEvent = {
...eventSelected,
start: formValues.startDate.toISOString(),
end: formValues.endDate.toISOString(),
title: `${formValues.title}`,
fullday: formValues.fullDay,
rrule: rrule,
isVisible: formValues.isVisible,
};
await handleEventUpdate(event);
setEventSelected(null); setEventSelected(null);
}} }}
/> />
@@ -237,21 +267,19 @@ const Planning: React.FC<{
const EventDialog: React.FC< const EventDialog: React.FC<
{ {
onSubmitEvent: (eventFormValues: EventFormValues) => void; onDelete?: (id: string) => void;
onDelete?: () => void; onUpdate?: (formValues: EventFormValues) => void;
onUpdate?: () => void; onAdd?: (formValues: EventFormValues) => void;
onAdd?: () => void;
event: ICalendarEvent | Omit<ICalendarEvent, "id">; event: ICalendarEvent | Omit<ICalendarEvent, "id">;
} & DialogProps } & DialogProps
> = ({ > = ({ open, onOpenChange, onDelete, onUpdate, onAdd, event }) => {
open, const [form, setForm] = useState<UseFormReturn<EventFormValues>>();
onOpenChange,
onSubmitEvent, const submitForm = (event: "add" | "update") => {
onDelete, const callback = event === "add" ? onAdd : onUpdate;
onUpdate, if (callback) form?.handleSubmit(callback)();
onAdd, };
event,
}) => {
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md"> <DialogContent className="sm:max-w-md">
@@ -259,14 +287,12 @@ const EventDialog: React.FC<
<DialogTitle>{event.title}</DialogTitle> <DialogTitle>{event.title}</DialogTitle>
<DialogDescription>{event.description}</DialogDescription> <DialogDescription>{event.description}</DialogDescription>
</DialogHeader> </DialogHeader>
<EventForm <EventForm event={event} setForm={setForm} />
event={event}
onSubmitEvent={onSubmitEvent}/>
<DialogFooter className="flex flex-row justify-end"> <DialogFooter className="flex flex-row justify-end">
{onUpdate && ( {onUpdate && (
<Button <Button
variant="outline" variant="outline"
onClick={() => onUpdate()} onClick={() => submitForm("update")}
type="submit" type="submit"
> >
Actualiser Actualiser
@@ -275,14 +301,14 @@ const EventDialog: React.FC<
{onDelete && ( {onDelete && (
<Button <Button
variant="destructive" variant="destructive"
onClick={() => onDelete()} onClick={() => onDelete(event.id)}
type="submit" type="submit"
> >
Supprimer Supprimer
</Button> </Button>
)} )}
{onAdd && !onUpdate && !onDelete && ( {onAdd && !onUpdate && !onDelete && (
<Button onClick={() => onAdd()} type="submit"> <Button onClick={() => submitForm("add")} type="submit">
Créer Créer
</Button> </Button>
)} )}

View File

@@ -14,6 +14,18 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import type IShortcode from "@/interfaces/IShortcode"; import type IShortcode from "@/interfaces/IShortcode";
import Image from "next/image";
import Media from "@/interfaces/Media";
import {
Pagination,
PaginationContent,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from "./ui/pagination";
import useMedia from "@/hooks/use-media";
import { Loader2 } from "lucide-react";
interface ShortcodeDialogProps { interface ShortcodeDialogProps {
onSave: (shortcode: IShortcode) => void; onSave: (shortcode: IShortcode) => void;
@@ -33,6 +45,8 @@ export default function ShortcodeDialog({
); );
const handleSave = () => { const handleSave = () => {
if (!(_shortcode?.code && (_shortcode.media_id || _shortcode.value)))
return;
onSave(_shortcode); onSave(_shortcode);
setOpen(); setOpen();
resetForm(); resetForm();
@@ -92,6 +106,7 @@ export default function ShortcodeDialog({
setShortcode((p) => ({ setShortcode((p) => ({
...p, ...p,
value: e.target.value, value: e.target.value,
media_id: undefined,
})) }))
} }
className="col-span-3" className="col-span-3"
@@ -99,27 +114,29 @@ export default function ShortcodeDialog({
</div> </div>
</TabsContent> </TabsContent>
<TabsContent value="media"> <TabsContent value="media">
<div className="grid grid-cols-4 items-center gap-4"> <PhotoGrid
<Label htmlFor="mediaId" className="text-right"> onSelect={(photo) => {
Media ID
</Label>
<Input
id="mediaId"
value={_shortcode.media_id}
onChange={(e) =>
setShortcode((p) => ({ setShortcode((p) => ({
...p, ...p,
media_id: e.target.value, media_id: photo.id,
})) value: undefined,
} }));
className="col-span-3" }}
/> />
</div>
</TabsContent> </TabsContent>
</Tabs> </Tabs>
</div> </div>
<DialogFooter> <DialogFooter>
<Button type="submit" onClick={handleSave}> <Button
disabled={
!(
_shortcode?.code &&
(_shortcode.media_id || _shortcode.value)
)
}
type="submit"
onClick={handleSave}
>
Enregistrer Enregistrer
</Button> </Button>
</DialogFooter> </DialogFooter>
@@ -127,3 +144,144 @@ export default function ShortcodeDialog({
</Dialog> </Dialog>
); );
} }
const LazyImage = ({
photo,
onClick,
isSelected,
}: {
photo: Media;
onClick: () => void;
isSelected: boolean;
}) => {
return (
<div
className={`aspect-square ${isSelected ? "ring-4 ring-primary" : ""}`}
>
<Image
src={photo.url || "/placeholder.svg"}
alt={photo.alt}
width={300}
height={300}
className="object-cover w-full h-full cursor-pointer transition-transform hover:scale-105"
onClick={onClick}
/>
</div>
);
};
const PhotoGrid: React.FC<{ onSelect: (photo: Media) => void }> = ({
onSelect,
}) => {
const { data, error, isLoading, success, setPage, setLimit, mutate } =
useMedia(5);
const [selectedPhoto, setSelectedPhoto] = useState<Media | null>(null);
const handlePhotoClick = (photo: Media) => {
setSelectedPhoto(photo);
onSelect(photo);
};
const handleChangeSelection = () => {
setSelectedPhoto(null);
// if (!photos) return;
// const currentIndex = photos.findIndex(
// (photo) => photo.id === selectedPhoto?.id,
// );
// const nextIndex = (currentIndex + 1) % photos.length;
// setSelectedPhoto(photos[nextIndex]);
};
if (isLoading) return <Loader2 className="animate-spin" />;
return (
<div className="container mx-auto p-4">
<h1 className="text-2xl font-bold mb-4">Gallerie Photo</h1>
{selectedPhoto ? (
<div className="flex flex-col items-center">
<Image
src={selectedPhoto.url || "/placeholder.svg"}
alt={selectedPhoto.alt}
width={600}
height={600}
className="w-full max-w-2xl h-auto mb-4 rounded-lg"
/>
<div className="flex gap-4 mt-4">
<Button onClick={handleChangeSelection}>
Changer de sélection
</Button>
</div>
</div>
) : (
<>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
{data?.items?.map((photo) => {
return (
<LazyImage
photo={photo}
key={photo.id}
onClick={() => handlePhotoClick(photo)}
isSelected={false}
/>
);
})}
</div>
<Pagination className="mt-8">
<PaginationContent>
<PaginationItem>
<PaginationPrevious
href="#"
onClick={(e) => {
e.preventDefault();
setPage((prev) =>
Math.max(prev - 1, 1),
);
}}
className={
data?.page === 1
? "pointer-events-none opacity-50"
: ""
}
/>
</PaginationItem>
{[...Array(data?.totalPages)].map((_, i) => (
<PaginationItem key={i + 1}>
<PaginationLink
href="#"
onClick={(e) => {
e.preventDefault();
setPage(i + 1);
}}
isActive={data?.page === i + 1}
>
{i + 1}
</PaginationLink>
</PaginationItem>
))}
<PaginationItem>
<PaginationNext
href="#"
onClick={(e) => {
e.preventDefault();
setPage((prev) =>
Math.min(
prev + 1,
data?.totalPages ?? 1,
),
);
}}
className={
data?.page === data?.totalPages
? "pointer-events-none opacity-50"
: ""
}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
</>
)}
</div>
);
};

View File

@@ -1,20 +1,20 @@
"use client" "use client";
import * as React from "react" import * as React from "react";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox" import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { Check } from "lucide-react" import { Check } from "lucide-react";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
const Checkbox = React.forwardRef< const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>, React.ComponentRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root> React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root <CheckboxPrimitive.Root
ref={ref} ref={ref}
className={cn( className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground", "peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className className,
)} )}
{...props} {...props}
> >
@@ -24,7 +24,7 @@ const Checkbox = React.forwardRef<
<Check className="h-4 w-4" /> <Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator> </CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root> </CheckboxPrimitive.Root>
)) ));
Checkbox.displayName = CheckboxPrimitive.Root.displayName Checkbox.displayName = CheckboxPrimitive.Root.displayName;
export { Checkbox } export { Checkbox };

View File

@@ -104,7 +104,7 @@ const FormLabel = React.forwardRef<
FormLabel.displayName = "FormLabel"; FormLabel.displayName = "FormLabel";
const FormControl = React.forwardRef< const FormControl = React.forwardRef<
React.ElementRef<typeof Slot>, React.ComponentRef<typeof Slot>,
React.ComponentPropsWithoutRef<typeof Slot> React.ComponentPropsWithoutRef<typeof Slot>
>(({ ...props }, ref) => { >(({ ...props }, ref) => {
const { error, formItemId, formDescriptionId, formMessageId } = const { error, formItemId, formDescriptionId, formMessageId } =

View File

@@ -6,7 +6,7 @@ import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const ScrollArea = React.forwardRef< const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>, React.ComponentRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root> React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => ( >(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root <ScrollAreaPrimitive.Root

View File

@@ -0,0 +1,129 @@
"use client";
import * as React from "react";
import * as ToastPrimitives from "@radix-ui/react-toast";
import { cva, type VariantProps } from "class-variance-authority";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
const ToastProvider = ToastPrimitives.Provider;
const ToastViewport = React.forwardRef<
React.ComponentRef<typeof ToastPrimitives.Viewport>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Viewport
ref={ref}
className={cn(
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
className,
)}
{...props}
/>
));
ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
const toastVariants = cva(
"group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
{
variants: {
variant: {
default: "border bg-background text-foreground",
destructive:
"destructive group border-destructive bg-destructive text-destructive-foreground",
},
},
defaultVariants: {
variant: "default",
},
},
);
const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
VariantProps<typeof toastVariants>
>(({ className, variant, ...props }, ref) => {
return (
<ToastPrimitives.Root
ref={ref}
className={cn(toastVariants({ variant }), className)}
{...props}
/>
);
});
Toast.displayName = ToastPrimitives.Root.displayName;
const ToastAction = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Action>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Action
ref={ref}
className={cn(
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium transition-colors hover:bg-secondary focus:outline-none focus:ring-1 focus:ring-ring disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
className,
)}
{...props}
/>
));
ToastAction.displayName = ToastPrimitives.Action.displayName;
const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Close>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Close
ref={ref}
className={cn(
"absolute right-1 top-1 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-1 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
className,
)}
toast-close=""
{...props}
>
<X className="h-4 w-4" />
</ToastPrimitives.Close>
));
ToastClose.displayName = ToastPrimitives.Close.displayName;
const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Title
ref={ref}
className={cn("text-sm font-semibold [&+div]:text-xs", className)}
{...props}
/>
));
ToastTitle.displayName = ToastPrimitives.Title.displayName;
const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Description
ref={ref}
className={cn("text-sm opacity-90", className)}
{...props}
/>
));
ToastDescription.displayName = ToastPrimitives.Description.displayName;
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>;
type ToastActionElement = React.ReactElement<typeof ToastAction>;
export {
type ToastProps,
type ToastActionElement,
ToastProvider,
ToastViewport,
Toast,
ToastTitle,
ToastDescription,
ToastClose,
ToastAction,
};

View File

@@ -0,0 +1,43 @@
"use client";
import { useToast } from "@/hooks/use-toast";
import {
Toast,
ToastClose,
ToastDescription,
ToastProvider,
ToastTitle,
ToastViewport,
} from "@/components/ui/toast";
export function Toaster() {
const { toasts } = useToast();
return (
<ToastProvider>
{toasts.map(function ({
id,
title,
description,
action,
...props
}) {
return (
<Toast key={id} {...props}>
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
{description && (
<ToastDescription>
{description}
</ToastDescription>
)}
</div>
{action}
<ToastClose />
</Toast>
);
})}
<ToastViewport />
</ToastProvider>
);
}

View File

@@ -18,6 +18,13 @@ export default function YouTubeEmbed({
}) { }) {
const [isIframeLoaded, setIframeLoaded] = useState(loadIframe); const [isIframeLoaded, setIframeLoaded] = useState(loadIframe);
const id =
typeof video === "string"
? video
: typeof video.id === "string"
? video.snippet.resourceId.videoId
: video.id.videoId;
const isString = typeof video === "string"; const isString = typeof video === "string";
const _loadIframe = () => setIframeLoaded(true); const _loadIframe = () => setIframeLoaded(true);
@@ -36,7 +43,7 @@ export default function YouTubeEmbed({
className="rounded-md shadow-current aspect-video" className="rounded-md shadow-current aspect-video"
width={width === "full" ? "100%" : width} width={width === "full" ? "100%" : width}
height={height === "full" ? "100%" : height} height={height === "full" ? "100%" : height}
src={`https://www.youtube-nocookie.com/embed/${isString ? video : video.id.videoId}?rel=0&modestbranding=1&autoplay=${autoPlay ? 1 : 0}`} src={`https://www.youtube-nocookie.com/embed/${id}?rel=0&modestbranding=1&autoplay=${autoPlay ? 1 : 0}`}
title={ title={
isString ? "YouTube video player" : video.snippet.title isString ? "YouTube video player" : video.snippet.title
} }
@@ -50,7 +57,7 @@ export default function YouTubeEmbed({
width="100%" width="100%"
height="100%" height="100%"
className="w-full h-full object-cover rounded-md shadow-current" className="w-full h-full object-cover rounded-md shadow-current"
src={`https://img.youtube.com/vi/${isString ? video : video.id.videoId}/hqdefault.jpg`} src={`https://img.youtube.com/vi/${id}/hqdefault.jpg`}
alt={ alt={
isString isString
? "YouTube video player" ? "YouTube video player"
@@ -62,7 +69,7 @@ export default function YouTubeEmbed({
width={width} width={width}
height={height} height={height}
className="w-full h-full object-cover rounded-md shadow-current" className="w-full h-full object-cover rounded-md shadow-current"
src={`https://img.youtube.com/vi/${isString ? video : video.id.videoId}/hqdefault.jpg`} src={`https://img.youtube.com/vi/${id}/hqdefault.jpg`}
alt={ alt={
isString isString
? "YouTube video player" ? "YouTube video player"

View File

@@ -1,60 +1,9 @@
"use client"; "use client";
import { API_URL } from "@/lib/constants"; import request from "@/lib/request";
import { getCookie } from "cookies-next"; import { ApiResponse } from "@/types/types";
import useSWR, { SWRConfiguration } from "swr"; import useSWR, { SWRConfiguration } from "swr";
import useSWRMutation, { type SWRMutationConfiguration } from "swr/mutation"; import useSWRMutation, { type SWRMutationConfiguration } from "swr/mutation";
export interface ApiResponse<T> {
status: "Error" | "Success";
message: string;
data?: T;
}
export async function request<T>(
endpoint: string,
options: {
method?: "GET" | "POST" | "PATCH" | "DELETE";
body?: any;
requiresAuth?: boolean;
csrfToken?: boolean;
} = {},
): Promise<ApiResponse<T>> {
const { method = "GET", body, requiresAuth = true } = options;
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
if (options.csrfToken) {
const res: ApiResponse<{ csrf: string }> = await (
await fetch(`${API_URL}/csrf-token`)
).json();
if (res.data) headers["X-CSRF-Token"] = res.data.csrf;
}
if (requiresAuth) {
const authToken = getCookie("auth_token");
if (!authToken) {
throw new Error("User is not authenticated");
}
headers.Authorization = `Bearer ${authToken}`;
}
const response = await fetch(`${API_URL}${endpoint}`, {
method,
headers,
body: body ? JSON.stringify(body) : undefined,
credentials: options.csrfToken ? "include" : "omit",
});
const apiResponse: ApiResponse<T> = await response.json();
if (apiResponse.status === "Error") {
throw new Error(apiResponse.message || "An unexpected error occurred");
}
return apiResponse;
}
async function fetcher<T>( async function fetcher<T>(
url: string, url: string,
requiresAuth: boolean = true, requiresAuth: boolean = true,

View File

@@ -1,8 +1,8 @@
"use client"; "use client";
import { API_URL } from "@/lib/constants"; import { API_URL } from "@/lib/constants";
import { ApiResponse } from "@/types/types";
import { getCookie } from "cookies-next"; import { getCookie } from "cookies-next";
import { useState, useRef, useCallback } from "react"; import { useState, useRef, useCallback } from "react";
import { ApiResponse, useApi } from "./use-api";
interface UseFileUploadReturn { interface UseFileUploadReturn {
progress: number; progress: number;

View File

@@ -1,9 +1,7 @@
"use client"; "use client";
import { setCookie } from "cookies-next"; import { setCookie } from "cookies-next";
import useApiMutation, { ApiResponse } from "./use-api"; import useApiMutation from "./use-api";
import { useEffect, useState } from "react";
import { API_URL } from "@/lib/constants";
export interface LoginArgs { export interface LoginArgs {
email: string; email: string;

View File

@@ -17,6 +17,9 @@ export default function useMedia(_limit: number = 20) {
const [limit, setLimit] = useState(_limit); const [limit, setLimit] = useState(_limit);
const res = useApi<IPaginatedResponse<Media>>( const res = useApi<IPaginatedResponse<Media>>(
`/media?page=${page}&limit=${limit}`, `/media?page=${page}&limit=${limit}`,
{},
false,
false,
); );
return { return {
...res, ...res,

192
frontend/hooks/use-toast.ts Normal file
View File

@@ -0,0 +1,192 @@
"use client";
// Inspired by react-hot-toast library
import * as React from "react";
import type { ToastActionElement, ToastProps } from "@/components/ui/toast";
const TOAST_LIMIT = 1;
const TOAST_REMOVE_DELAY = 1000000;
type ToasterToast = ToastProps & {
id: string;
title?: React.ReactNode;
description?: React.ReactNode;
action?: ToastActionElement;
};
const actionTypes = {
ADD_TOAST: "ADD_TOAST",
UPDATE_TOAST: "UPDATE_TOAST",
DISMISS_TOAST: "DISMISS_TOAST",
REMOVE_TOAST: "REMOVE_TOAST",
} as const;
let count = 0;
function genId() {
count = (count + 1) % Number.MAX_SAFE_INTEGER;
return count.toString();
}
type ActionType = typeof actionTypes;
type Action =
| {
type: ActionType["ADD_TOAST"];
toast: ToasterToast;
}
| {
type: ActionType["UPDATE_TOAST"];
toast: Partial<ToasterToast>;
}
| {
type: ActionType["DISMISS_TOAST"];
toastId?: ToasterToast["id"];
}
| {
type: ActionType["REMOVE_TOAST"];
toastId?: ToasterToast["id"];
};
interface State {
toasts: ToasterToast[];
}
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) {
return;
}
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId);
dispatch({
type: "REMOVE_TOAST",
toastId: toastId,
});
}, TOAST_REMOVE_DELAY);
toastTimeouts.set(toastId, timeout);
};
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case "ADD_TOAST":
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
};
case "UPDATE_TOAST":
return {
...state,
toasts: state.toasts.map((t) =>
t.id === action.toast.id ? { ...t, ...action.toast } : t,
),
};
case "DISMISS_TOAST": {
const { toastId } = action;
// ! Side effects ! - This could be extracted into a dismissToast() action,
// but I'll keep it here for simplicity
if (toastId) {
addToRemoveQueue(toastId);
} else {
state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id);
});
}
return {
...state,
toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined
? {
...t,
open: false,
}
: t,
),
};
}
case "REMOVE_TOAST":
if (action.toastId === undefined) {
return {
...state,
toasts: [],
};
}
return {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId),
};
}
};
const listeners: Array<(state: State) => void> = [];
let memoryState: State = { toasts: [] };
function dispatch(action: Action) {
memoryState = reducer(memoryState, action);
listeners.forEach((listener) => {
listener(memoryState);
});
}
type Toast = Omit<ToasterToast, "id">;
function toast({ ...props }: Toast) {
const id = genId();
const update = (props: ToasterToast) =>
dispatch({
type: "UPDATE_TOAST",
toast: { ...props, id },
});
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id });
dispatch({
type: "ADD_TOAST",
toast: {
...props,
id,
open: true,
onOpenChange: (open) => {
if (!open) dismiss();
},
},
});
return {
id: id,
dismiss,
update,
};
}
function useToast() {
const [state, setState] = React.useState<State>(memoryState);
React.useEffect(() => {
listeners.push(setState);
return () => {
const index = listeners.indexOf(setState);
if (index > -1) {
listeners.splice(index, 1);
}
};
}, [state]);
return {
...state,
toast,
dismiss: (toastId?: string) =>
dispatch({ type: "DISMISS_TOAST", toastId }),
};
}
export { useToast, toast };

View File

@@ -10,7 +10,7 @@ export interface IYoutube {
export interface IYoutubeItem { export interface IYoutubeItem {
kind: string; kind: string;
etag: string; etag: string;
id: IYoutubeID; id: IYoutubeID | string;
snippet: IYoutubeSnippet; snippet: IYoutubeSnippet;
} }
@@ -28,6 +28,7 @@ export interface IYoutubeSnippet {
channelTitle: string; channelTitle: string;
liveBroadcastContent: string; liveBroadcastContent: string;
publishTime: Date; publishTime: Date;
resourceId: IYoutubeID;
} }
export interface IYoutubeThumbnails { export interface IYoutubeThumbnails {

View File

@@ -0,0 +1,14 @@
import IShortcode from "@/interfaces/IShortcode";
import request from "./request";
export default async function getShortcode(
code: string,
): Promise<IShortcode | null> {
const res = await request<IShortcode>(`/shortcodes/${code}`, {
method: "GET",
requiresAuth: false,
});
if (res.status === "Error") throw new Error("Shortcode doesn't exist.");
return res.data ?? null;
}

View File

@@ -0,0 +1,51 @@
// const mapRruleToFrequency = (rrule: string) => {
//
// switch (frequency) {
// case "quotidien":
// rrule = "FREQ=DAILY";
// break;
// case "hebdomadaire":
// rrule = "FREQ=WEEKLY";
// break;
// case "mensuel":
// rrule = "FREQ=MONTHLY";
// break;
// default:
// return "";
// }
// }
const mapFrequencyToRrule = (
frequency: "unique" | "quotidien" | "hebdomadaire" | "mensuel",
frequencyEndDate?: Date,
): string => {
let rrule = "";
switch (frequency) {
case "quotidien":
rrule = "FREQ=DAILY";
break;
case "hebdomadaire":
rrule = "FREQ=WEEKLY";
break;
case "mensuel":
rrule = "FREQ=MONTHLY";
break;
default:
return "";
}
if (frequencyEndDate) {
const until = frequencyEndDate.getTime();
const untilDate = new Date(until);
const epochDateString = untilDate
.toISOString()
.replace(/[-:]/g, "")
.split(".")[0]; // Format as YYYYMMDDTHHmmss
rrule += `;UNTIL=${epochDateString}`;
}
return rrule;
};
export default mapFrequencyToRrule;

56
frontend/lib/request.ts Normal file
View File

@@ -0,0 +1,56 @@
import { API_URL } from "@/lib/constants";
import { ApiResponse } from "@/types/types";
import { getCookie } from "cookies-next";
import { ReadonlyRequestCookies } from "next/dist/server/web/spec-extension/adapters/request-cookies";
export default async function request<T>(
endpoint: string,
options: {
method?: "GET" | "POST" | "PATCH" | "DELETE";
body?: any;
requiresAuth?: boolean;
csrfToken?: boolean;
cookies?: () => Promise<ReadonlyRequestCookies>;
} = {},
): Promise<ApiResponse<T>> {
console.log(API_URL, endpoint);
const { method = "GET", body, requiresAuth = true } = options;
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
if (options.csrfToken) {
const res: ApiResponse<{ csrf: string }> = await (
await fetch(`${API_URL}/csrf-token`)
).json();
if (res.data) headers["X-CSRF-Token"] = res.data.csrf;
}
if (requiresAuth) {
let authToken;
if (!options.cookies) {
authToken = getCookie("auth_token");
} else {
authToken = (await options.cookies()).get("auth_token")?.value;
}
if (!authToken) {
throw new Error("User is not authenticated");
}
headers.Authorization = `Bearer ${authToken}`;
}
const response = await fetch(`${API_URL}${endpoint}`, {
method,
headers,
body: body ? JSON.stringify(body) : undefined,
credentials: options.csrfToken ? "include" : "omit",
});
const apiResponse: ApiResponse<T> = await response.json();
if (apiResponse.status === "Error") {
throw new Error(apiResponse.message || "An unexpected error occurred");
}
return apiResponse;
}

View File

@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { ApiResponse } from "./hooks/use-api"; import { ApiResponse } from "./types/types";
import { API_URL } from "./lib/constants"; import { API_URL } from "./lib/constants";
import IUser from "./interfaces/IUser"; import IUser from "./interfaces/IUser";

View File

@@ -25,6 +25,10 @@ const nextConfig: NextConfig = {
protocol: "http", protocol: "http",
hostname: "localhost", hostname: "localhost",
}, },
{
protocol: "https",
hostname: "latosa.cems.dev",
},
], ],
}, },
env: { env: {

File diff suppressed because it is too large Load Diff

View File

@@ -25,6 +25,7 @@
"@radix-ui/react-slot": "^1.1.2", "@radix-ui/react-slot": "^1.1.2",
"@radix-ui/react-switch": "^1.1.2", "@radix-ui/react-switch": "^1.1.2",
"@radix-ui/react-tabs": "^1.1.2", "@radix-ui/react-tabs": "^1.1.2",
"@radix-ui/react-toast": "^1.2.6",
"@radix-ui/react-tooltip": "^1.1.6", "@radix-ui/react-tooltip": "^1.1.6",
"@schedule-x/drag-and-drop": "^2.15.1", "@schedule-x/drag-and-drop": "^2.15.1",
"@schedule-x/event-modal": "^2.15.1", "@schedule-x/event-modal": "^2.15.1",
@@ -39,7 +40,9 @@
"cookies-next": "^5.1.0", "cookies-next": "^5.1.0",
"date-fns": "^3.6.0", "date-fns": "^3.6.0",
"embla-carousel-react": "^8.5.2", "embla-carousel-react": "^8.5.2",
"isomorphic-dompurify": "^2.21.0",
"lucide-react": "^0.471.1", "lucide-react": "^0.471.1",
"marked": "^15.0.6",
"next": "15.1.4", "next": "15.1.4",
"next-themes": "^0.4.4", "next-themes": "^0.4.4",
"react": "^19.0.0", "react": "^19.0.0",
@@ -51,6 +54,7 @@
"swr": "^2.3.0", "swr": "^2.3.0",
"tailwind-merge": "^2.6.0", "tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"yet-another-react-lightbox": "^3.21.7",
"zod": "^3.24.1" "zod": "^3.24.1"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -9,6 +9,9 @@ export default {
], ],
theme: { theme: {
extend: { extend: {
fontFamily: {
times: ["Times New Roman", "Times", "serif"],
},
colors: { colors: {
background: "hsl(var(--background))", background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))", foreground: "hsl(var(--foreground))",

View File

@@ -1,8 +1,16 @@
export interface Permission {
resource: string;
action: string;
}
// Role type as a string literal // Role type as a string literal
export type Role = 'admin' | 'user'; export interface Role {
id: string;
name: string;
permissions: Permission[];
}
// Status type as a string literal // Status type as a string literal
export type Status = 'Active' | 'Inactive'; export type Status = "Active" | "Inactive";
// Event type (you can expand this type as needed based on your schema) // Event type (you can expand this type as needed based on your schema)
export interface Event { export interface Event {
@@ -28,9 +36,9 @@ export interface Blog {
// User type definition // User type definition
export interface User { export interface User {
userID: string; // UUID represented as a string userId: string; // UUID represented as a string
firstName: string; firstname: string;
lastName: string; lastname: string;
email: string; email: string;
password?: string; // Optional field, since it's omitted in the JSON password?: string; // Optional field, since it's omitted in the JSON
phone: string; phone: string;
@@ -40,4 +48,11 @@ export interface User {
events?: Event[]; // Many-to-many relation with Event (optional) events?: Event[]; // Many-to-many relation with Event (optional)
articles?: Blog[]; // One-to-many relation with Blog (optional) articles?: Blog[]; // One-to-many relation with Blog (optional)
roles?: Role[];
}
export interface ApiResponse<T> {
status: "Error" | "Success";
message: string;
data?: T;
} }

6
package-lock.json generated Normal file
View File

@@ -0,0 +1,6 @@
{
"name": "latosa-escrima.fr",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}