diff --git a/backend/api/core/paginated.go b/backend/api/core/paginated.go new file mode 100644 index 0000000..22d5eb2 --- /dev/null +++ b/backend/api/core/paginated.go @@ -0,0 +1,8 @@ +package core + +type Paginated[T any] struct { + Page int `json:"page"` + Limit int `json:"limit"` + TotalPages int `json:"totalPages"` + Items []T `json:"items"` +} diff --git a/backend/api/core/schemas.go b/backend/api/core/schemas.go index 7473199..464bb55 100644 --- a/backend/api/core/schemas.go +++ b/backend/api/core/schemas.go @@ -136,6 +136,17 @@ type WebsiteSettings struct { AutoAcceptDemand bool `bun:"auto_accept_demand,default:false" json:"autoAcceptDemand"` } +type Media struct { + ID uuid.UUID `bun:"type:uuid,pk,default:gen_random_uuid()" json:"id"` + AuthorID uuid.UUID `bun:"author_id,type:uuid,notnull" json:"authorID"` + Author *User `bun:"rel:belongs-to,join:author_id=user_id" json:"author,omitempty"` + Type string `bun:"media_type" json:"type"` // Image, Video, GIF etc. Add support for PDFs? + Alt string `bun:"media_alt" json:"alt"` + Path string `bun:"media_path" json:"path"` + Size int64 `bun:"media_size" json:"size"` + URL string `bun:"-" json:"url"` +} + func InitDatabase(dsn DSN) (*bun.DB, error) { sqldb := sql.OpenDB(pgdriver.NewConnector(pgdriver.WithDSN(dsn.ToString()))) db := bun.NewDB(sqldb, pgdialect.New()) @@ -152,6 +163,7 @@ func InitDatabase(dsn DSN) (*bun.DB, error) { _, err = db.NewCreateTable().Model((*EventToUser)(nil)).IfNotExists().Exec(ctx) _, err = db.NewCreateTable().Model((*Blog)(nil)).IfNotExists().Exec(ctx) _, err = db.NewCreateTable().Model((*WebsiteSettings)(nil)).IfNotExists().Exec(ctx) + _, err = db.NewCreateTable().Model((*Media)(nil)).IfNotExists().Exec(ctx) if err != nil { return nil, err } diff --git a/backend/api/delete_media.go b/backend/api/delete_media.go new file mode 100644 index 0000000..cdf66da --- /dev/null +++ b/backend/api/delete_media.go @@ -0,0 +1,41 @@ +package api + +import ( + "context" + "log" + "net/http" + "os" + + "fr.latosa-escrima/api/core" +) + +func HandleDeleteMedia(w http.ResponseWriter, r *http.Request) { + uuid := r.PathValue("media_uuid") + var media core.Media + res, err := core.DB.NewDelete(). + Model(&media). + Where("id = ?", uuid). + Returning("*"). + Exec(context.Background()) + if err != nil { + core.JSONError{ + Status: core.Error, + Message: err.Error(), + }.Respond(w, http.StatusInternalServerError) + return + } + log.Println(res) + err = os.Remove(media.Path) + if err != nil { + core.JSONError{ + Status: core.Error, + Message: err.Error(), + }.Respond(w, http.StatusInternalServerError) + return + } + + core.JSONSuccess{ + Status: core.Success, + Message: "Image successfully deleted.", + }.Respond(w, http.StatusOK) +} diff --git a/backend/api/get_media.go b/backend/api/get_media.go new file mode 100644 index 0000000..42f0012 --- /dev/null +++ b/backend/api/get_media.go @@ -0,0 +1,117 @@ +package api + +import ( + "context" + "fmt" + "math" + "net/http" + "strconv" + + "fr.latosa-escrima/api/core" + "fr.latosa-escrima/utils" +) + +func HandleGetMedia(w http.ResponseWriter, r *http.Request) { + queryParams := r.URL.Query() + page, err := strconv.Atoi(queryParams.Get("page")) + limit, err := strconv.Atoi(queryParams.Get("limit")) + if page < 0 { + page = 0 + } + if limit <= 0 { + limit = 10 + } + if err != nil { + core.JSONError{ + Status: core.Error, + Message: err.Error(), + }.Respond(w, http.StatusBadRequest) + return + } + + offset := (page - 1) * limit + + total, err := core.DB.NewSelect(). + Model((*core.Media)(nil)). + Count(context.Background()) + + totalPages := int(math.Max(1, float64(total/limit))) + + var media []core.Media + err = core.DB.NewSelect(). + Model(&media). + Limit(limit). + Offset(offset). + Scan(context.Background()) + if err != nil { + core.JSONError{ + Status: core.Error, + Message: err.Error(), + }.Respond(w, http.StatusInternalServerError) + return + } + baseURL := utils.GetURL(r) + media = utils.Map(media, func(m core.Media) core.Media { + m.Author = nil + m.URL = fmt.Sprintf("%s%s/file", baseURL, m.ID) + return m + }) + + core.JSONSuccess{ + Status: core.Success, + Message: "Media successfully retrieved", + Data: core.Paginated[core.Media]{ + Page: page, + Limit: limit, + TotalPages: totalPages, + Items: media, + }, + }.Respond(w, http.StatusOK) +} + +func HandleGetMediaDetails(w http.ResponseWriter, r *http.Request) { + uuid := r.PathValue("media_uuid") + var media core.Media + err := core.DB.NewSelect(). + Model(&media). + Where("id = ?", uuid). + Limit(1). + Scan(context.Background()) + if err != nil { + core.JSONError{ + Status: core.Error, + Message: err.Error(), + }.Respond(w, http.StatusInternalServerError) + return + } + + baseURL := utils.GetURL(r) + media.URL = fmt.Sprintf("%s/file", baseURL) + + media.Author = nil + core.JSONSuccess{ + Status: core.Success, + Message: "Media retrieved", + Data: media, + }.Respond(w, http.StatusOK) +} + +func HandleGetMediaFile(w http.ResponseWriter, r *http.Request) { + uuid := r.PathValue("media_uuid") + var media core.Media + err := core.DB.NewSelect(). + Model(&media). + Where("id = ?", uuid). + Limit(1). + Scan(context.Background()) + if err != nil { + core.JSONError{ + Status: core.Error, + Message: err.Error(), + }.Respond(w, http.StatusInternalServerError) + return + } + + http.ServeFile(w, r, media.Path) + +} diff --git a/backend/api/upload_media.go b/backend/api/upload_media.go index 78aaf68..bbdfd45 100644 --- a/backend/api/upload_media.go +++ b/backend/api/upload_media.go @@ -1,6 +1,7 @@ package api import ( + "context" "fmt" "io" "net/http" @@ -9,6 +10,8 @@ import ( "fr.latosa-escrima/api/core" "fr.latosa-escrima/utils" + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" ) func HandleUploadMedia(w http.ResponseWriter, r *http.Request) { @@ -66,6 +69,49 @@ func HandleUploadMedia(w http.ResponseWriter, r *http.Request) { return } + token, ok := r.Context().Value("token").(*jwt.Token) + if !ok { + core.JSONError{ + Status: core.Error, + Message: "Couldn't retrieve your JWT.", + }.Respond(w, http.StatusInternalServerError) + return + } + + claims, ok := token.Claims.(jwt.MapClaims) + if !ok { + core.JSONError{ + Status: core.Error, + Message: "Invalid token claims.", + }.Respond(w, http.StatusInternalServerError) + return + } + + id, err := uuid.Parse(claims["user_id"].(string)) + if err != nil { + core.JSONError{ + Status: core.Error, + Message: err.Error(), + }.Respond(w, http.StatusInternalServerError) + return + } + media := &core.Media{ + AuthorID: id, + Type: fileHeader.Header.Get("Content-Type"), + Alt: "To be implemented", + Path: p, + Size: fileHeader.Size, + } + + _, err = core.DB.NewInsert().Model(media).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: "File uploaded successfully.", diff --git a/backend/main.go b/backend/main.go index 01a42c5..e27b64e 100644 --- a/backend/main.go +++ b/backend/main.go @@ -120,6 +120,29 @@ func main() { Handler: api.HandleVerifyMedia, Middlewares: []core.Middleware{api.Methods("POST"), api.AuthJWT}, }, + // Paginated media response + "/media/": { + Handler: api.HandleGetMedia, + Middlewares: []core.Middleware{api.Methods("GET")}, + }, + // Unique element + "/media/{media_uuid}": { + Handler: api.HandleGetMediaDetails, + Middlewares: []core.Middleware{api.Methods("GET")}, + }, + // Get the image, video, GIF etc. + "/media/{media_uuid}/file": { + Handler: api.HandleGetMediaFile, + Middlewares: []core.Middleware{api.Methods("GET")}, + }, + // "/media/{media_uuid}/update": { + // Handler: api.HandleGetMediaFile, + // Middlewares: []core.Middleware{api.Methods("PATCH"), api.AuthJWT}, + // }, + "/media/{media_uuid}/delete": { + Handler: api.HandleDeleteMedia, + Middlewares: []core.Middleware{api.Methods("DELETE"), api.AuthJWT}, + }, "/contact": { Handler: api.HandleContact, Middlewares: []core.Middleware{api.Methods("POST"), CSRFMiddleware}, diff --git a/backend/utils/get_url.go b/backend/utils/get_url.go new file mode 100644 index 0000000..d6bf868 --- /dev/null +++ b/backend/utils/get_url.go @@ -0,0 +1,23 @@ +package utils + +import ( + "fmt" + "net/http" +) + +func GetURL(r *http.Request) string { + scheme := "http" + if r.TLS != nil { // Check if the request is over HTTPS + scheme = "https" + } + + // Extract the host + host := r.Host + + // Get the full request URI (path + query string) + fullPath := r.URL.Path + + // Build the full request URL + return fmt.Sprintf("%s://%s%s", scheme, host, fullPath) +} + diff --git a/frontend/app/(auth)/dashboard/media/old.tsx b/frontend/app/(auth)/dashboard/media/old.tsx new file mode 100644 index 0000000..8d4a453 --- /dev/null +++ b/frontend/app/(auth)/dashboard/media/old.tsx @@ -0,0 +1,30 @@ +"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) => { + const file = event.target.files?.[0]; + if (file) { + uploadFile(file, "/media/upload", (response) => { + console.log("Upload success:", response); + }); + } + }; + + return ( +
+ + {isUploading &&

Uploading... {progress}%

} + {error &&

Error: {error}

} + +
+ ); +}; + +export default MyComponent; diff --git a/frontend/app/(auth)/dashboard/media/page.tsx b/frontend/app/(auth)/dashboard/media/page.tsx index 8d4a453..e332e63 100644 --- a/frontend/app/(auth)/dashboard/media/page.tsx +++ b/frontend/app/(auth)/dashboard/media/page.tsx @@ -1,30 +1,154 @@ "use client"; -import useFileUpload from "@/hooks/use-file-upload"; -import { ChangeEvent } from "react"; -const MyComponent = () => { +import { useState } from "react"; +import Image from "next/image"; +import { Plus } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + Pagination, + PaginationContent, + PaginationItem, + PaginationLink, + PaginationNext, + PaginationPrevious, +} from "@/components/ui/pagination"; +import { PhotoDialog } from "@/components/photo-dialog"; +import useFileUpload from "@/hooks/use-file-upload"; +import useMedia from "@/hooks/use-media"; +import Media from "@/interfaces/Media"; +import useApiMutation, { request } from "@/hooks/use-api"; + +export default function PhotoGallery() { + const { + data, + error: mediaError, + isLoading, + success, + setPage, + setLimit, + mutate, + } = useMedia(); + console.log(data); + const [selectedPhoto, setSelectedPhoto] = useState(null); + const [isAddDialogOpen, setIsAddDialogOpen] = useState(false); const { progress, isUploading, error, uploadFile, cancelUpload } = useFileUpload(); - const handleFileUpload = (event: ChangeEvent) => { - const file = event.target.files?.[0]; - if (file) { - uploadFile(file, "/media/upload", (response) => { - console.log("Upload success:", response); - }); + const handleAddPhoto = (newPhoto: Omit, file: File) => { + uploadFile(file, "/media/upload", (response) => { + mutate(); + }); + }; + + const handleUpdatePhoto = (updatedPhoto: Omit) => { + if (selectedPhoto) { } + setSelectedPhoto(null); + }; + + const handleDeletePhoto = async (id: Media["id"]) => { + try { + const res = await request(`/media/${id}/delete`, { + method: "DELETE", + requiresAuth: true, + }); + if (res.status === "Success") mutate(); + } catch (e) { + console.log(e); + } + setSelectedPhoto(null); }; return ( -
- - {isUploading &&

Uploading... {progress}%

} - {error &&

Error: {error}

} - +
+
+

Gallerie Photo

+ +
+
+ {data?.items.map((photo) => ( +
setSelectedPhoto(photo)} + > + {photo.alt} +
+ ))} +
+ + + + { + e.preventDefault(); + setPage((prev) => Math.max(prev - 1, 1)); + }} + className={ + data?.page === 1 + ? "pointer-events-none opacity-50" + : "" + } + /> + + {[...Array(data?.totalPages)].map((_, i) => ( + + { + e.preventDefault(); + setPage(i + 1); + }} + isActive={data?.page === i + 1} + > + {i + 1} + + + ))} + + { + e.preventDefault(); + setPage((prev) => + Math.min(prev + 1, data?.totalPages ?? 1), + ); + }} + className={ + data?.page === data?.totalPages + ? "pointer-events-none opacity-50" + : "" + } + /> + + + + + setSelectedPhoto((p) => (isUploading ? p : null)) + } + onDelete={handleDeletePhoto} + onSave={handleUpdatePhoto} + /> + setIsAddDialogOpen(false)} + onSave={handleAddPhoto} + />
); -}; - -export default MyComponent; +} diff --git a/frontend/components/photo-dialog.tsx b/frontend/components/photo-dialog.tsx new file mode 100644 index 0000000..7015f2a --- /dev/null +++ b/frontend/components/photo-dialog.tsx @@ -0,0 +1,197 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import Image from "next/image"; +import { useDropzone } from "react-dropzone"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import Media from "@/interfaces/Media"; +import Link from "next/link"; + +interface PhotoDialogProps { + photo?: Media; + isOpen: boolean; + onClose: () => void; + onDelete?: (id: Media["id"]) => void; + onSave: (photo: Omit, file: File) => void; +} + +export function PhotoDialog({ + photo, + isOpen, + onClose, + onDelete, + onSave, +}: PhotoDialogProps) { + const [file, setFile] = useState(null); + const [newPhoto, setNewPhoto] = useState>({ + url: "", + alt: "", + path: "", + type: "", + size: 0, + }); + const [activeTab, setActiveTab] = useState("url"); + + useEffect(() => { + setNewPhoto({ + url: photo?.url ?? "", + alt: photo?.alt ?? "", + type: photo?.type ?? "", + path: photo?.path ?? "", + size: photo?.size ?? 0, + }); + }, [photo]); + + const onDrop = useCallback((acceptedFiles: File[]) => { + const file = acceptedFiles[0]; + if (file) { + setFile(file); + const reader = new FileReader(); + reader.onload = (e) => { + setNewPhoto((prev) => ({ + ...prev, + url: e.target?.result as string, + })); + }; + reader.readAsDataURL(file); + } + }, []); + + const { getRootProps, getInputProps, isDragActive } = useDropzone({ + onDrop, + accept: { "image/*": [] }, + }); + + const handleSave = () => { + if (file) onSave(newPhoto, file); + onClose(); + }; + + const handleFileChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) { + setFile(file); + const reader = new FileReader(); + reader.onload = (e) => { + setNewPhoto((prev) => ({ + ...prev, + url: e.target?.result as string, + })); + }; + reader.readAsDataURL(file); + } + }; + + const isAddMode = !photo; + + return ( + + + + + {isAddMode + ? "Ajouter une nouvelle photo" + : "Éditer la photo"} + + + {isAddMode + ? "Ajouter une nouvelle photo dans la gallerie" + : "Mettre à jour les informations"} + + + + + URL + Drag & Drop + + +
+ + + setNewPhoto({ + ...newPhoto, + url: e.target.value, + }) + } + placeholder="Enter URL for the image" + /> +
+
+ +
+ + {isDragActive ? ( +

Déposez une image ici...

+ ) : ( +

+ Déposez une image directement ou cliquer + pour sélectionner. +

+ )} +
+
+
+
+ + {newPhoto.alt} + +
+
+ + + setNewPhoto({ ...newPhoto, alt: e.target.value }) + } + placeholder="Entrer une description pour l'image" + /> +
+ + {!isAddMode && onDelete && ( + + )} +
+ + +
+
+
+
+ ); +} diff --git a/frontend/components/ui/form.tsx b/frontend/components/ui/form.tsx index b6daa65..48a0b5a 100644 --- a/frontend/components/ui/form.tsx +++ b/frontend/components/ui/form.tsx @@ -1,178 +1,182 @@ -"use client" +"use client"; -import * as React from "react" -import * as LabelPrimitive from "@radix-ui/react-label" -import { Slot } from "@radix-ui/react-slot" +import * as React from "react"; +import * as LabelPrimitive from "@radix-ui/react-label"; +import { Slot } from "@radix-ui/react-slot"; import { - Controller, - ControllerProps, - FieldPath, - FieldValues, - FormProvider, - useFormContext, -} from "react-hook-form" + Controller, + ControllerProps, + FieldPath, + FieldValues, + FormProvider, + useFormContext, +} from "react-hook-form"; -import { cn } from "@/lib/utils" -import { Label } from "@/components/ui/label" +import { cn } from "@/lib/utils"; +import { Label } from "@/components/ui/label"; -const Form = FormProvider +const Form = FormProvider; type FormFieldContextValue< - TFieldValues extends FieldValues = FieldValues, - TName extends FieldPath = FieldPath + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, > = { - name: TName -} + name: TName; +}; const FormFieldContext = React.createContext( - {} as FormFieldContextValue -) + {} as FormFieldContextValue, +); const FormField = < - TFieldValues extends FieldValues = FieldValues, - TName extends FieldPath = FieldPath + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, >({ - ...props + ...props }: ControllerProps) => { - return ( - - - - ) -} + return ( + + + + ); +}; const useFormField = () => { - const fieldContext = React.useContext(FormFieldContext) - const itemContext = React.useContext(FormItemContext) - const { getFieldState, formState } = useFormContext() + const fieldContext = React.useContext(FormFieldContext); + const itemContext = React.useContext(FormItemContext); + const { getFieldState, formState } = useFormContext(); - const fieldState = getFieldState(fieldContext.name, formState) + const fieldState = getFieldState(fieldContext.name, formState); - if (!fieldContext) { - throw new Error("useFormField should be used within ") - } + if (!fieldContext) { + throw new Error("useFormField should be used within "); + } - const { id } = itemContext + const { id } = itemContext; - return { - id, - name: fieldContext.name, - formItemId: `${id}-form-item`, - formDescriptionId: `${id}-form-item-description`, - formMessageId: `${id}-form-item-message`, - ...fieldState, - } -} + return { + id, + name: fieldContext.name, + formItemId: `${id}-form-item`, + formDescriptionId: `${id}-form-item-description`, + formMessageId: `${id}-form-item-message`, + ...fieldState, + }; +}; type FormItemContextValue = { - id: string -} + id: string; +}; const FormItemContext = React.createContext( - {} as FormItemContextValue -) + {} as FormItemContextValue, +); const FormItem = React.forwardRef< - HTMLDivElement, - React.HTMLAttributes + HTMLDivElement, + React.HTMLAttributes >(({ className, ...props }, ref) => { - const id = React.useId() + const id = React.useId(); - return ( - -
- - ) -}) -FormItem.displayName = "FormItem" + return ( + +
+ + ); +}); +FormItem.displayName = "FormItem"; const FormLabel = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef + React.ComponentRef, + React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => { - const { error, formItemId } = useFormField() + const { error, formItemId } = useFormField(); - return ( -