From 7cb633b4c6f650c93150f7201b528b2e7a4a5307 Mon Sep 17 00:00:00 2001 From: cdricms <36056008+cdricms@users.noreply.github.com> Date: Thu, 6 Mar 2025 17:34:52 +0100 Subject: [PATCH] Fixed creation of users + better frontend handling of permissions --- backend/api/shortcodes/shortcodes.go | 28 +- backend/api/users/new.go | 29 +- backend/api/users/update.go | 88 ++++- backend/api/users/users.go | 1 + backend/utils/get_diff.go | 42 ++ frontend/app/(auth)/dashboard/blogs/blogs.tsx | 16 +- .../app/(auth)/dashboard/blogs/new/_new.tsx | 16 + .../app/(auth)/dashboard/blogs/new/page.tsx | 31 +- frontend/app/(auth)/dashboard/blogs/page.tsx | 4 +- .../(auth)/dashboard/members/[uuid]/_user.tsx | 238 ------------ .../(auth)/dashboard/members/[uuid]/page.tsx | 20 - .../app/(auth)/dashboard/members/page.tsx | 2 +- .../(auth)/dashboard/planning/_planning.tsx | 8 +- .../app/(auth)/dashboard/planning/page.tsx | 2 +- .../(auth)/dashboard/settings/media/page.tsx | 2 +- .../dashboard/settings/roles/_roles.tsx | 24 +- .../(auth)/dashboard/settings/roles/page.tsx | 2 +- .../dashboard/settings/shortcodes/page.tsx | 2 +- frontend/app/(main)/page.tsx | 2 +- frontend/app/globals.css | 2 + frontend/app/layout.tsx | 1 + frontend/app/robots.ts | 13 + frontend/app/sitemap.ts | 43 +++ frontend/components/article.tsx | 4 +- frontend/components/article/delete-button.tsx | 10 +- .../components/{ => editor}/editor-menu.tsx | 148 +++++--- .../components/editor/extensions/marks.ts | 222 +++++++++++ .../{editor.tsx => editor/index.tsx} | 3 + frontend/components/event-dialog.tsx | 359 +++++++++--------- frontend/components/footer.tsx | 53 +-- frontend/components/login-form.tsx | 43 +-- frontend/components/member-dialog.tsx | 337 ++++++++++------ frontend/components/members-table.tsx | 161 ++++---- frontend/components/shortcodes-table.tsx | 36 +- frontend/components/ui/calendar.tsx | 142 +++---- frontend/components/ui/hover-card.tsx | 29 ++ frontend/components/ui/scroll-area.tsx | 2 +- frontend/components/ui/toast.tsx | 2 +- frontend/components/ui/toggle-group.tsx | 2 +- frontend/components/ui/toggle.tsx | 2 +- frontend/lib/constants.ts | 1 + frontend/lib/hasPermissions.ts | 40 +- frontend/lib/utils.ts | 30 ++ frontend/package-lock.json | 161 ++++++++ frontend/package.json | 1 + frontend/types/global.d.ts | 16 + 46 files changed, 1511 insertions(+), 909 deletions(-) create mode 100644 backend/utils/get_diff.go create mode 100644 frontend/app/(auth)/dashboard/blogs/new/_new.tsx delete mode 100644 frontend/app/(auth)/dashboard/members/[uuid]/_user.tsx delete mode 100644 frontend/app/(auth)/dashboard/members/[uuid]/page.tsx create mode 100644 frontend/app/robots.ts create mode 100644 frontend/app/sitemap.ts rename frontend/components/{ => editor}/editor-menu.tsx (83%) create mode 100644 frontend/components/editor/extensions/marks.ts rename frontend/components/{editor.tsx => editor/index.tsx} (96%) create mode 100644 frontend/components/ui/hover-card.tsx create mode 100644 frontend/types/global.d.ts diff --git a/backend/api/shortcodes/shortcodes.go b/backend/api/shortcodes/shortcodes.go index fb30284..9905c4e 100644 --- a/backend/api/shortcodes/shortcodes.go +++ b/backend/api/shortcodes/shortcodes.go @@ -2,15 +2,21 @@ package shortcodes import ( "context" + "fmt" "net/http" + "os" "fr.latosa-escrima/core" "fr.latosa-escrima/core/models" + "fr.latosa-escrima/utils" ) func HandleShortcodes(w http.ResponseWriter, r *http.Request) { var shortcodes []models.Shortcode - err := core.DB.NewSelect().Model(&shortcodes).Scan(context.Background()) + err := core.DB.NewSelect(). + Model(&shortcodes). + Relation("Media"). + Scan(context.Background()) if err != nil { core.JSONError{ Status: core.Error, @@ -19,6 +25,26 @@ func HandleShortcodes(w http.ResponseWriter, r *http.Request) { return } + 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" + } + shortcodes = utils.Map(shortcodes, func(s models.Shortcode) models.Shortcode { + if s.MediaID == nil { + return s + } + s.Media.Author = nil + s.Media.URL = fmt.Sprintf("%s/media/%s/file", baseURL, s.MediaID) + return s + }) + core.JSONSuccess{ Status: core.Success, Message: "Shortcodes retrieved.", diff --git a/backend/api/users/new.go b/backend/api/users/new.go index 08c7368..74449a4 100644 --- a/backend/api/users/new.go +++ b/backend/api/users/new.go @@ -8,9 +8,11 @@ import ( core "fr.latosa-escrima/core" "fr.latosa-escrima/core/models" + "fr.latosa-escrima/utils" ) func HandleNew(w http.ResponseWriter, r *http.Request) { + ctx := context.Background() var user models.User err := json.NewDecoder(r.Body).Decode(&user) if err != nil { @@ -22,15 +24,8 @@ func HandleNew(w http.ResponseWriter, r *http.Request) { } log.Println("User : ", user) - res, err := user.Insert(core.DB, context.Background()) + res, err := user.Insert(core.DB, ctx) log.Println(res) - // if res == nil { - // core.JSONError{ - // Status: core.Error, - // Message: "The user couldn't be inserted.", - // }.Respond(w, http.StatusNotAcceptable) - // return - // } if err != nil { core.JSONError{ @@ -40,6 +35,24 @@ func HandleNew(w http.ResponseWriter, r *http.Request) { return } + userRoles := utils.Map(user.Roles, func(role models.Role) models.UserToRole { + return models.UserToRole{ + UserID: user.UserID, + RoleID: role.ID, + } + }) + + for _, userRole := range userRoles { + _, err := core.DB.NewInsert().Model(&userRole).Ignore().Exec(ctx) + if err != nil { + core.JSONError{ + Status: core.Error, + Message: err.Error(), + }.Respond(w, http.StatusInternalServerError) + return + } + } + core.JSONSuccess{ Status: core.Success, Message: "User inserted successfully.", diff --git a/backend/api/users/update.go b/backend/api/users/update.go index 18950dc..fb05a92 100644 --- a/backend/api/users/update.go +++ b/backend/api/users/update.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "log" "net/http" "reflect" "strings" @@ -11,6 +12,9 @@ import ( "fr.latosa-escrima/core" "fr.latosa-escrima/core/models" + "fr.latosa-escrima/utils" + "github.com/google/uuid" + "github.com/uptrace/bun" ) type UpdateUserArgs struct { @@ -20,9 +24,11 @@ type UpdateUserArgs struct { Password *string `json:"password,omitempty"` Phone *string `json:"phone,omitempty"` Attributes *models.UserAttributes `json:"attributes"` + Roles *[]models.Role `json:"roles"` } func HandleUpdate(w http.ResponseWriter, r *http.Request) { + ctx := context.Background() var updateArgs UpdateUserArgs err := json.NewDecoder(r.Body).Decode(&updateArgs) if err != nil { @@ -33,13 +39,34 @@ func HandleUpdate(w http.ResponseWriter, r *http.Request) { return } + user_uuid := r.PathValue("user_uuid") + uid, err := uuid.Parse(user_uuid) + if err != nil { + return + } var user models.User + err = core.DB. + NewSelect(). + Model(&user). + Where("user_id = ?", user_uuid). + Relation("Roles"). + Scan(ctx) + if err != nil { + core.JSONError{ + Status: core.Error, + Message: err.Error(), + }.Respond(w, http.StatusInternalServerError) + return + } updateQuery := core.DB.NewUpdate().Model(&user) + rolesInsert := []*bun.InsertQuery{} + rolesRemoved := []*bun.DeleteQuery{} + val := reflect.ValueOf(updateArgs) typ := reflect.TypeOf(updateArgs) - for i := 0; i < val.NumField(); i++ { + for i := range val.NumField() { field := val.Field(i) fieldname := typ.Field(i).Name @@ -52,6 +79,37 @@ func HandleUpdate(w http.ResponseWriter, r *http.Request) { if field.IsValid() && !field.IsNil() && !field.IsZero() { if fieldname == "Password" { updateQuery.Set(fmt.Sprintf("%s = crypt(?, gen_salt('bf'))", strings.Split(tag, ",")[0]), field.Interface()) + } else if fieldname == "Roles" { + _roles := field.Interface().(*[]models.Role) + if _roles == nil { + continue + } + + currentRoles := utils.Map(user.Roles, func(role models.Role) uuid.UUID { + return role.ID + }) + + roles := utils.Map(*_roles, func(role models.Role) uuid.UUID { + return role.ID + }) + + log.Println(user.Roles) + toAdd, toRemove := utils.GetDiff(currentRoles, roles) + fmt.Println(toAdd, toRemove) + + rolesInsert = utils.Map(toAdd, func(id uuid.UUID) *bun.InsertQuery { + userRole := models.UserToRole{ + UserID: uid, + RoleID: id, + } + return core.DB.NewInsert().Model(&userRole).Ignore() + }) + + rolesRemoved = utils.Map(toRemove, func(id uuid.UUID) *bun.DeleteQuery { + return core.DB.NewDelete().Model((*models.UserToRole)(nil)). + Where("user_id = ? AND role_id = ?", uid, id) + }) + } else { updateQuery.Set(fmt.Sprintf("%s = ?", strings.Split(tag, ",")[0]), field.Interface()) } @@ -61,11 +119,9 @@ func HandleUpdate(w http.ResponseWriter, r *http.Request) { // Always update the `updated_at` field updateQuery.Set("updated_at = ?", time.Now()) - uuid := r.PathValue("user_uuid") _, err = updateQuery. - Where("user_id = ?", uuid). - Returning("*"). - Exec(context.Background()) + Where("user_id = ?", user_uuid). + Exec(ctx) if err != nil { core.JSONError{ @@ -75,6 +131,28 @@ func HandleUpdate(w http.ResponseWriter, r *http.Request) { return } + for _, insert := range rolesInsert { + _, err = insert.Exec(ctx) + if err != nil { + core.JSONError{ + Status: core.Error, + Message: err.Error(), + }.Respond(w, http.StatusInternalServerError) + return + } + } + + for _, remove := range rolesRemoved { + _, err = remove.Exec(ctx) + if err != nil { + core.JSONError{ + Status: core.Error, + Message: err.Error(), + }.Respond(w, http.StatusInternalServerError) + return + } + } + user.Password = "" core.JSONSuccess{ diff --git a/backend/api/users/users.go b/backend/api/users/users.go index 65bf362..35ab49b 100644 --- a/backend/api/users/users.go +++ b/backend/api/users/users.go @@ -13,6 +13,7 @@ func HandleUsers(w http.ResponseWriter, r *http.Request) { var users []models.User count, err := core.DB.NewSelect(). Model(&users). + Order("created_at ASC"). Relation("Roles"). ScanAndCount(context.Background()) diff --git a/backend/utils/get_diff.go b/backend/utils/get_diff.go new file mode 100644 index 0000000..5481eb3 --- /dev/null +++ b/backend/utils/get_diff.go @@ -0,0 +1,42 @@ +package utils + +import ( + "log" +) + +// GetDiff returns two slices: elements to add and elements to remove +func GetDiff[T comparable](current, newm []T) ([]T, []T) { + log.Println(current, newm) + // Use a single map with an int to track state: + // 1: only in current, 2: only in new, 3: in both + presence := make(map[T]int) + + // Mark all items in current as 1 + for _, item := range current { + presence[item] = 1 + } + + // Update map based on newm: add 2 if not present, set to 3 if present + for _, item := range newm { + if val, exists := presence[item]; exists { + presence[item] = val + 2 // 1 -> 3 (both) + } else { + presence[item] = 2 // only in new + } + } + + var toAdd, toRemove []T + + // Iterate once over the map to build results + for item, state := range presence { + switch state { + case 1: // Only in current -> remove + toRemove = append(toRemove, item) + case 2: // Only in new -> add + toAdd = append(toAdd, item) + // case 3: in both, do nothing + } + } + + return toAdd, toRemove +} diff --git a/frontend/app/(auth)/dashboard/blogs/blogs.tsx b/frontend/app/(auth)/dashboard/blogs/blogs.tsx index 34114c5..849c98d 100644 --- a/frontend/app/(auth)/dashboard/blogs/blogs.tsx +++ b/frontend/app/(auth)/dashboard/blogs/blogs.tsx @@ -48,9 +48,9 @@ export default function BlogTable({ user }: { user: IUser }) { const { replace } = useRouter(); - const canUpdate = hasPermissions(user.roles, { blogs: ["update"] }); - const canInsert = hasPermissions(user.roles, { blogs: ["insert"] }); - const canDelete = hasPermissions(user.roles, { blogs: ["delete"] }); + const { blogs: blogsPerm } = hasPermissions(user.roles, { + blogs: ["update", "insert", "delete"], + } as const); const updateSearchParam = useCallback( (key: string, value?: string) => { @@ -136,7 +136,7 @@ export default function BlogTable({ user }: { user: IUser }) { )} - {canInsert && ( + {blogsPerm.insert && ( @@ -151,7 +151,7 @@ export default function BlogTable({ user }: { user: IUser }) { Catégorie Auteur Publié - {(canUpdate || canDelete) && ( + {(blogsPerm.update || blogsPerm.delete) && ( Actions @@ -175,7 +175,7 @@ export default function BlogTable({ user }: { user: IUser }) { "MMM d, yyyy", )} - {(canDelete || canUpdate) && ( + {(blogsPerm.delete || blogsPerm.update) && ( @@ -190,7 +190,7 @@ export default function BlogTable({ user }: { user: IUser }) { - {canUpdate && ( + {blogsPerm.update && ( )} - {canDelete && ( + {blogsPerm.delete && ( diff --git a/frontend/app/(auth)/dashboard/blogs/new/_new.tsx b/frontend/app/(auth)/dashboard/blogs/new/_new.tsx new file mode 100644 index 0000000..1fa8dd9 --- /dev/null +++ b/frontend/app/(auth)/dashboard/blogs/new/_new.tsx @@ -0,0 +1,16 @@ +"use client"; + +import { Loader2 } from "lucide-react"; +import dynamic from "next/dynamic"; + +const BlogEditor = dynamic( + () => import("@/components/article/edit").then((mod) => mod.default), + { + ssr: false, + loading: () => , + }, +); + +export default function NewBlog() { + return ; +} diff --git a/frontend/app/(auth)/dashboard/blogs/new/page.tsx b/frontend/app/(auth)/dashboard/blogs/new/page.tsx index caaef21..a8b00ce 100644 --- a/frontend/app/(auth)/dashboard/blogs/new/page.tsx +++ b/frontend/app/(auth)/dashboard/blogs/new/page.tsx @@ -1,16 +1,21 @@ -"use client"; +"use server"; +import getMe from "@/lib/getMe"; +import hasPermissions from "@/lib/hasPermissions"; +import { redirect } from "next/navigation"; +import NewBlog from "./_new"; -import { Loader2 } from "lucide-react"; -import dynamic from "next/dynamic"; +export default async function Page() { + const me = await getMe(); + if ( + !me || + me.status === "Error" || + !me.data || + !hasPermissions(me.data.roles, { + blogs: ["insert"], + } as const).all + ) { + redirect("/dashboard"); + } -const BlogEditor = dynamic( - () => import("@/components/article/edit").then((mod) => mod.default), - { - ssr: false, - loading: () => , - }, -); - -export default function Page() { - return ; + return ; } diff --git a/frontend/app/(auth)/dashboard/blogs/page.tsx b/frontend/app/(auth)/dashboard/blogs/page.tsx index f9a2606..fcd32bd 100644 --- a/frontend/app/(auth)/dashboard/blogs/page.tsx +++ b/frontend/app/(auth)/dashboard/blogs/page.tsx @@ -11,8 +11,8 @@ export default async function Page() { me.status === "Error" || !me.data || !hasPermissions(me.data.roles, { - blogs: ["get"], - }) + blogs: ["get"] as const, + }).all ) { redirect("/dashboard"); } diff --git a/frontend/app/(auth)/dashboard/members/[uuid]/_user.tsx b/frontend/app/(auth)/dashboard/members/[uuid]/_user.tsx deleted file mode 100644 index 9b7241a..0000000 --- a/frontend/app/(auth)/dashboard/members/[uuid]/_user.tsx +++ /dev/null @@ -1,238 +0,0 @@ -"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"; -import IUser from "@/interfaces/IUser"; -import hasPermissions from "@/lib/hasPermissions"; - -export default function UserDetailsPage({ user }: { user: IUser }) { - const { uuid } = useParams<{ uuid: string }>(); - const _user = useApi(`/users/${uuid}`, {}, true); - - const availableRoles = useApi("/roles", {}, true); - availableRoles.data ??= []; - const [selectedRole, setSelectedRole] = useState(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

Error

; - - return ( -
- - -
-
- -
-

- {_user.data.firstname} {_user.data.lastname} -

-

- {_user.data.email} -

-
-
- -
-
-

- Rôles -

-
- {_user.data.roles?.map((role) => ( - - {role.name} - {hasPermissions(user.roles, { - users: ["update"], - }) && ( - - )} - - ))} -
- - {hasPermissions(user.roles, { - users: ["update"], - }) && ( -
- - -
- )} -
- - {/*
-

- Organizations -

-
- {user.data.organizations.map((org) => ( - - {org} - - - ))} -
-
- - -
-
*/} -
-
-
-
-
- ); -} diff --git a/frontend/app/(auth)/dashboard/members/[uuid]/page.tsx b/frontend/app/(auth)/dashboard/members/[uuid]/page.tsx deleted file mode 100644 index 8b92c2d..0000000 --- a/frontend/app/(auth)/dashboard/members/[uuid]/page.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import getMe from "@/lib/getMe"; -import hasPermissions from "@/lib/hasPermissions"; -import { redirect } from "next/navigation"; -import UserDetailsPage from "./_user"; - -export default async function Page() { - const me = await getMe(); - if ( - !me || - me.status === "Error" || - !me.data || - !hasPermissions(me.data.roles, { - users: ["get"], - }) - ) { - redirect("/dashboard"); - } - - return ; -} diff --git a/frontend/app/(auth)/dashboard/members/page.tsx b/frontend/app/(auth)/dashboard/members/page.tsx index c75379c..1068525 100644 --- a/frontend/app/(auth)/dashboard/members/page.tsx +++ b/frontend/app/(auth)/dashboard/members/page.tsx @@ -12,7 +12,7 @@ export default async function Page({}) { !me.data || !hasPermissions(me.data.roles, { users: ["get"], - }) + } as const).all ) { redirect("/dashboard"); } diff --git a/frontend/app/(auth)/dashboard/planning/_planning.tsx b/frontend/app/(auth)/dashboard/planning/_planning.tsx index baa44d4..514346f 100644 --- a/frontend/app/(auth)/dashboard/planning/_planning.tsx +++ b/frontend/app/(auth)/dashboard/planning/_planning.tsx @@ -19,9 +19,11 @@ export default function PlanningPage({ user }: { user: IUser }) { if (success) return ( diff --git a/frontend/app/(auth)/dashboard/planning/page.tsx b/frontend/app/(auth)/dashboard/planning/page.tsx index f5d326e..263790b 100644 --- a/frontend/app/(auth)/dashboard/planning/page.tsx +++ b/frontend/app/(auth)/dashboard/planning/page.tsx @@ -12,7 +12,7 @@ export default async function Page() { !me.data || !hasPermissions(me.data.roles, { events: ["get"], - }) + } as const).all ) { redirect("/dashboard"); } diff --git a/frontend/app/(auth)/dashboard/settings/media/page.tsx b/frontend/app/(auth)/dashboard/settings/media/page.tsx index 810e001..ea46c6b 100644 --- a/frontend/app/(auth)/dashboard/settings/media/page.tsx +++ b/frontend/app/(auth)/dashboard/settings/media/page.tsx @@ -11,7 +11,7 @@ export default async function Page() { !me || me.status === "Error" || !me.data || - !hasPermissions(me.data.roles, { media: ["get"] }) + !hasPermissions(me.data.roles, { media: ["get"] } as const).all ) { redirect("/dashboard"); } diff --git a/frontend/app/(auth)/dashboard/settings/roles/_roles.tsx b/frontend/app/(auth)/dashboard/settings/roles/_roles.tsx index 219232c..21e8dff 100644 --- a/frontend/app/(auth)/dashboard/settings/roles/_roles.tsx +++ b/frontend/app/(auth)/dashboard/settings/roles/_roles.tsx @@ -41,6 +41,10 @@ export default function RolesAndPermissions({ user }: { user: IUser }) { const [newRoleName, setNewRoleName] = useState(""); const [isDialogOpen, setIsDialogOpen] = useState(false); + const { roles: rolesPerm } = hasPermissions(user.roles, { + roles: ["insert"], + } as const); + const { data: permissions } = useApi( "/permissions/grouped", {}, @@ -80,7 +84,7 @@ export default function RolesAndPermissions({ user }: { user: IUser }) {

Rôles et Permissions

- {hasPermissions(user.roles, { roles: ["insert"] }) && ( + {rolesPerm.insert && (
); diff --git a/frontend/app/globals.css b/frontend/app/globals.css index 32bac5f..27321e2 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -4,6 +4,7 @@ body { font-family: Arial, Helvetica, sans-serif; + pointer-events: auto !important; } @layer base { @@ -82,6 +83,7 @@ body { * { @apply border-border outline-ring/50; } + body { @apply bg-background text-foreground; } diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index f4c7939..b1fed24 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -1,3 +1,4 @@ +import "@/lib/utils"; import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; import "@/app/globals.css"; diff --git a/frontend/app/robots.ts b/frontend/app/robots.ts new file mode 100644 index 0000000..939873a --- /dev/null +++ b/frontend/app/robots.ts @@ -0,0 +1,13 @@ +import { BASE_URL } from "@/lib/constants"; +import type { MetadataRoute } from "next"; + +export default function robots(): MetadataRoute.Robots { + return { + rules: { + userAgent: "*", + allow: "/", + disallow: ["/dashboard/", "/gallery/"], + }, + sitemap: `${BASE_URL}/sitemap.xml`, + }; +} diff --git a/frontend/app/sitemap.ts b/frontend/app/sitemap.ts new file mode 100644 index 0000000..73c55e9 --- /dev/null +++ b/frontend/app/sitemap.ts @@ -0,0 +1,43 @@ +import { BASE_URL } from "@/lib/constants"; +import type { MetadataRoute } from "next"; + +export default function sitemap(): MetadataRoute.Sitemap { + return [ + { + url: BASE_URL, + lastModified: new Date(), + changeFrequency: "yearly", + priority: 1, + }, + { + url: `${BASE_URL}/about`, + lastModified: new Date(), + changeFrequency: "monthly", + priority: 0.8, + }, + { + url: `${BASE_URL}/blog`, + lastModified: new Date(), + changeFrequency: "weekly", + priority: 0.5, + }, + { + url: `${BASE_URL}/planning`, + lastModified: new Date(), + changeFrequency: "daily", + priority: 0.8, + }, + { + url: `${BASE_URL}/gallery`, + lastModified: new Date(), + changeFrequency: "daily", + priority: 0.8, + }, + { + url: `${BASE_URL}/contact`, + lastModified: new Date(), + changeFrequency: "yearly", + priority: 0.8, + }, + ]; +} diff --git a/frontend/components/article.tsx b/frontend/components/article.tsx index 0b50a80..c8dfc68 100644 --- a/frontend/components/article.tsx +++ b/frontend/components/article.tsx @@ -13,8 +13,10 @@ const BlogArticle: React.FC<{ blog: Blog; user?: IUser }> = ({ blog, user, }) => { + const perms = + user && hasPermissions(user.roles, { blogs: ["update"] } as const); const UpdateButton = () => { - if (!user || !hasPermissions(user.roles, { blogs: ["update"] })) return; + if (!perms?.blogs.update) return; return ( -
- - Ou connectez-vous avec - -
- + Se connecter + + + +
-
+ {/*
Pas de compte ?{" "} Créer un compte -
+
*/} ); } diff --git a/frontend/components/member-dialog.tsx b/frontend/components/member-dialog.tsx index 730fb4c..13dc5ae 100644 --- a/frontend/components/member-dialog.tsx +++ b/frontend/components/member-dialog.tsx @@ -1,4 +1,4 @@ -import { useEffect } from "react"; +import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import * as z from "zod"; @@ -28,6 +28,16 @@ import { SelectTrigger, SelectValue, } from "./ui/select"; +import { useApi } from "@/hooks/use-api"; +import { Role, User } from "@/types/types"; +import { Badge } from "./ui/badge"; +import { Building, X } from "lucide-react"; + +// Define the Role schema based on assumed Role type +const roleSchema = z.object({ + id: z.string().min(1, "Role ID is required"), + name: z.string().min(1, "Role name is required"), +}); const memberSchema = z.object({ userId: z.string().optional(), @@ -39,7 +49,10 @@ const memberSchema = z.object({ .min(6, "Le mot de passe doit avoir au moins 6 caractères.") .optional(), phone: z.string().regex(/^\d{10}$/, "Le numéro doit avoir 10 chiffres."), - role: z.string().min(1, "Le rôle est requis."), + roles: z + .array(roleSchema) + .min(1, "At least one role is required") + .optional(), }); const updateMemberSchema = memberSchema.partial(); @@ -60,6 +73,8 @@ export default function MemberDialog({ onSave, }: MemberDialogProps) { const schema = member?.userId ? updateMemberSchema : memberSchema; + const { data: availableRoles = [] } = useApi("/roles", {}, true); // Fetch roles + const form = useForm({ resolver: zodResolver(schema), defaultValues: member?.userId @@ -71,7 +86,7 @@ export default function MemberDialog({ email: "", password: "", phone: "", - role: "", + roles: [], // Default to empty array }, }); @@ -86,11 +101,38 @@ export default function MemberDialog({ email: "", password: "", phone: "", - role: "", + roles: [], }); } }, [member, form]); + const [selectedRole, setSelectedRole] = useState(null); + + const addRole = () => { + if (selectedRole) { + const currentRoles = form.getValues("roles"); + if (!currentRoles?.some((role) => role.id === selectedRole.id)) { + form.setValue( + "roles", + [...(currentRoles || []), selectedRole], + { + shouldValidate: true, + }, + ); + } + setSelectedRole(null); // Reset selection + } + }; + + const removeRole = (roleToRemove: Role) => { + const currentRoles = form.getValues("roles"); + form.setValue( + "roles", + currentRoles?.filter((role) => role.id !== roleToRemove.id), + { shouldValidate: true }, + ); + }; + const onSubmit = (data: Member) => { onSave(data); onClose(); @@ -118,13 +160,13 @@ export default function MemberDialog({ name="firstname" render={({ field }) => ( - + Prénom )} /> -
- {/* Firstname Field */} - ( - - - Nom - - - - - - - )} - /> - {/* Email Field */} - ( - - - Email - - - - - - - )} - /> - {/* Password Field */} - {!member?.userId && ( - ( - - - Mot de passe - - - - - - - )} - /> + {/* Lastname Field */} + ( + + + Nom + + + + + + )} + /> + {/* Email Field */} + ( + + + Email + + + + + + + )} + /> + + {/* Password Field (only for new members) */} + {!member?.userId && ( ( - - Role - - - - - - - )} - /> - - {/* Phone Field */} - ( - - - Phone number + + Mot de passe @@ -265,7 +246,121 @@ export default function MemberDialog({ )} /> -
+ )} + + {/* Phone Field */} + ( + + + Phone number + + + + + + + )} + /> + + {/* Roles Field */} + ( + + Roles +
+ {field.value?.map((role) => ( + + {role.name} + + + ))} +
+
+ + +
+ +
+ )} + /> - {hasPermissions(user.roles, { users: ["insert"] }) && ( + {users.insert && ( )}
+ {isLoading && } - {selectMode && ( - - Sélectionner - - )} Prénom Nom Email Téléphone - Rôle + Rôles Actions - {isLoading && } {members && - members.map((member) => ( - - {selectMode && ( - - - toggleMemberSelection( - member.userId!, - ) - } - /> - - )} + members.map((_member) => ( + - - - {member.firstname} - - + {_member.firstname} - - - {member.lastname} - - + {_member.lastname} + + {_member.email} + {_member.phone} + + {_member.roles + ?.map((r) => r.name) + .join(", ")} - {member.email} - {member.phone} - {member.role} - {hasPermissions(user.roles, { - users: ["update"], - }) && ( - - )} - {hasPermissions(user.roles, { - users: ["delete"], - }) && ( - - )} + + + + + + {users.update && ( + + handleOpenDialog( + _member, + ) + } + > + Mettre à jour + + )} + {users.delete && ( + + handleDelete( + _member.userId!, + ) + } + > + Supprimer + + )} + + ))} diff --git a/frontend/components/shortcodes-table.tsx b/frontend/components/shortcodes-table.tsx index aa69b32..ed42314 100644 --- a/frontend/components/shortcodes-table.tsx +++ b/frontend/components/shortcodes-table.tsx @@ -21,6 +21,12 @@ import ShortcodeDialog from "@/components/shortcode-dialogue"; import { useState } from "react"; import IUser from "@/interfaces/IUser"; import hasPermissions from "@/lib/hasPermissions"; +import { + HoverCard, + HoverCardContent, + HoverCardTrigger, +} from "@radix-ui/react-hover-card"; +import Image from "next/image"; interface ShortcodeTableProps { user: IUser; @@ -37,13 +43,17 @@ export function ShortcodeTable({ onAdd, user, }: ShortcodeTableProps) { + const permissions = hasPermissions(user.roles, { + shortcodes: ["insert", "update", "delete"], + } as const); const [shortcodeSelected, setUpdateDialog] = useState( null, ); const [addDialog, setAddDialog] = useState(false); + return (
- {hasPermissions(user.roles, { shortcodes: ["insert"] }) && ( + {permissions.shortcodes.insert && (
- {hasPermissions(user.roles, { - shortcodes: ["update"], - }) && ( + {permissions.shortcodes.update && ( setUpdateDialog( @@ -110,9 +132,7 @@ export function ShortcodeTable({ Mettre à jour )} - {hasPermissions(user.roles, { - shortcodes: ["delete"], - }) && ( + {permissions.shortcodes.delete && ( onDelete(shortcode.code) diff --git a/frontend/components/ui/calendar.tsx b/frontend/components/ui/calendar.tsx index 115cff9..5c0a682 100644 --- a/frontend/components/ui/calendar.tsx +++ b/frontend/components/ui/calendar.tsx @@ -1,76 +1,82 @@ -"use client" +"use client"; -import * as React from "react" -import { ChevronLeft, ChevronRight } from "lucide-react" -import { DayPicker } from "react-day-picker" +import * as React from "react"; +import { ChevronLeft, ChevronRight } from "lucide-react"; +import { DayPicker } from "react-day-picker"; -import { cn } from "@/lib/utils" -import { buttonVariants } from "@/components/ui/button" +import { cn } from "@/lib/utils"; +import { buttonVariants } from "@/components/ui/button"; -export type CalendarProps = React.ComponentProps +export type CalendarProps = React.ComponentProps; function Calendar({ - className, - classNames, - showOutsideDays = true, - ...props + className, + classNames, + showOutsideDays = true, + ...props }: CalendarProps) { - return ( - .day-range-end)]:rounded-r-md [&:has(>.day-range-start)]:rounded-l-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md" - : "[&:has([aria-selected])]:rounded-md" - ), - day: cn( - buttonVariants({ variant: "ghost" }), - "h-8 w-8 p-0 font-normal aria-selected:opacity-100" - ), - day_range_start: "day-range-start", - day_range_end: "day-range-end", - day_selected: - "bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground", - day_today: "bg-accent text-accent-foreground", - day_outside: - "day-outside text-muted-foreground aria-selected:bg-accent/50 aria-selected:text-muted-foreground", - day_disabled: "text-muted-foreground opacity-50", - day_range_middle: - "aria-selected:bg-accent aria-selected:text-accent-foreground", - day_hidden: "invisible", - ...classNames, - }} - components={{ - IconLeft: ({ className, ...props }) => ( - - ), - IconRight: ({ className, ...props }) => ( - - ), - }} - {...props} - /> - ) + return ( + .day-range-end)]:rounded-r-md [&:has(>.day-range-start)]:rounded-l-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md" + : "[&:has([aria-selected])]:rounded-md", + ), + day: cn( + buttonVariants({ variant: "ghost" }), + "h-8 w-8 p-0 font-normal aria-selected:opacity-100", + ), + day_range_start: "day-range-start", + day_range_end: "day-range-end", + day_selected: + "bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground", + day_today: "bg-accent text-accent-foreground", + day_outside: + "day-outside text-muted-foreground aria-selected:bg-accent/50 aria-selected:text-muted-foreground", + day_disabled: "text-muted-foreground opacity-50", + day_range_middle: + "aria-selected:bg-accent aria-selected:text-accent-foreground", + day_hidden: "invisible", + ...classNames, + }} + components={{ + IconLeft: ({ className, ...props }) => ( + + ), + IconRight: ({ className, ...props }) => ( + + ), + }} + {...props} + /> + ); } -Calendar.displayName = "Calendar" +Calendar.displayName = "Calendar"; -export { Calendar } +export { Calendar }; diff --git a/frontend/components/ui/hover-card.tsx b/frontend/components/ui/hover-card.tsx new file mode 100644 index 0000000..79f7b10 --- /dev/null +++ b/frontend/components/ui/hover-card.tsx @@ -0,0 +1,29 @@ +"use client"; + +import * as React from "react"; +import * as HoverCardPrimitive from "@radix-ui/react-hover-card"; + +import { cn } from "@/lib/utils"; + +const HoverCard = HoverCardPrimitive.Root; + +const HoverCardTrigger = HoverCardPrimitive.Trigger; + +const HoverCardContent = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( + +)); +HoverCardContent.displayName = HoverCardPrimitive.Content.displayName; + +export { HoverCard, HoverCardTrigger, HoverCardContent }; diff --git a/frontend/components/ui/scroll-area.tsx b/frontend/components/ui/scroll-area.tsx index 6275cf1..9cc0e21 100644 --- a/frontend/components/ui/scroll-area.tsx +++ b/frontend/components/ui/scroll-area.tsx @@ -24,7 +24,7 @@ const ScrollArea = React.forwardRef< ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName; const ScrollBar = React.forwardRef< - React.ElementRef, + React.ComponentRef, React.ComponentPropsWithoutRef< typeof ScrollAreaPrimitive.ScrollAreaScrollbar > diff --git a/frontend/components/ui/toast.tsx b/frontend/components/ui/toast.tsx index 033ed9a..d334732 100644 --- a/frontend/components/ui/toast.tsx +++ b/frontend/components/ui/toast.tsx @@ -41,7 +41,7 @@ const toastVariants = cva( ); const Toast = React.forwardRef< - React.ElementRef, + React.ComponentRef, React.ComponentPropsWithoutRef & VariantProps >(({ className, variant, ...props }, ref) => { diff --git a/frontend/components/ui/toggle-group.tsx b/frontend/components/ui/toggle-group.tsx index fef6b02..e62be8c 100644 --- a/frontend/components/ui/toggle-group.tsx +++ b/frontend/components/ui/toggle-group.tsx @@ -15,7 +15,7 @@ const ToggleGroupContext = React.createContext< }); const ToggleGroup = React.forwardRef< - React.ElementRef, + React.ComponentRef, React.ComponentPropsWithoutRef & VariantProps >(({ className, variant, size, children, ...props }, ref) => ( diff --git a/frontend/components/ui/toggle.tsx b/frontend/components/ui/toggle.tsx index 3b0b220..85f2e28 100644 --- a/frontend/components/ui/toggle.tsx +++ b/frontend/components/ui/toggle.tsx @@ -29,7 +29,7 @@ const toggleVariants = cva( ); const Toggle = React.forwardRef< - React.ElementRef, + React.ComponentRef, React.ComponentPropsWithoutRef & VariantProps >(({ className, variant, size, ...props }, ref) => ( diff --git a/frontend/lib/constants.ts b/frontend/lib/constants.ts index f9175be..da1de46 100644 --- a/frontend/lib/constants.ts +++ b/frontend/lib/constants.ts @@ -1,2 +1,3 @@ export const API_URL = process.env.NEXT_PUBLIC_API_URL ?? ""; export const SITE_NAME = "Latosa® Escrima"; +export const BASE_URL = process.env.SERVER_NAME ?? "latosa.cems.dev"; diff --git a/frontend/lib/hasPermissions.ts b/frontend/lib/hasPermissions.ts index ebfc135..e935289 100644 --- a/frontend/lib/hasPermissions.ts +++ b/frontend/lib/hasPermissions.ts @@ -1,25 +1,43 @@ import { Role } from "@/types/types"; -export default function hasPermissions( - roles: Role[], - permissions: { [key: string]: string[] }, -) { +type PermissionResult> = { + [K in keyof T]: { + [A in T[K][number]]: boolean; + } & { all: boolean }; // Per-resource 'all' +} & { all: boolean }; // Global 'all' + +// hasPermissions function with 'all' flags +export default function hasPermissions< + T extends Record, +>(roles: Role[], permissions: T): PermissionResult { + // Build permissions set const permissionsSet: Map = new Map(); for (const role of roles) { if (!role.permissions) continue; - for (const perm of role?.permissions) { - const key = perm.resource + ":" + perm.action; + for (const perm of role.permissions) { + const key = `${perm.resource}:${perm.action}`; permissionsSet.set(key, null); } } - for (const [perm, actions] of Object.entries(permissions)) { + // Build result + const result = { all: true } as PermissionResult; // Initialize global 'all' as true + let globalAll = true; // Track global state + + for (const resource in permissions) { + const actions = permissions[resource]; + let resourceAll = true; // Track per-resource state + + result[resource] = { all: true } as any; // Initialize resource object for (const action of actions) { - if (!permissionsSet.has(perm + ":" + action)) { - return false; - } + const hasPerm = permissionsSet.has(`${resource}:${action}`); + (result[resource] as Record)[action] = hasPerm; + resourceAll = resourceAll && hasPerm; // Update resource 'all' } + result[resource].all = resourceAll; // Set resource 'all' + globalAll = globalAll && resourceAll; // Update global 'all' } - return true; + result.all = globalAll; // Set global 'all' + return result as PermissionResult; } diff --git a/frontend/lib/utils.ts b/frontend/lib/utils.ts index 4dcc6f4..58ce0e6 100644 --- a/frontend/lib/utils.ts +++ b/frontend/lib/utils.ts @@ -8,3 +8,33 @@ export function cn(...inputs: ClassValue[]) { export function toTitleCase(str: string) { return str.replace(/\b\w/g, (char) => char.toUpperCase()); } + +namespace DiffUtils { + export function getDifferences( + obj1: T, + obj2: T, + ): Partial { + return Object.entries(obj2).reduce((diff, [key, value]) => { + if ( + JSON.stringify(obj1[key as keyof T]) !== + JSON.stringify(obj2[key as keyof T]) + ) { + diff[key as keyof T] = value as T[keyof T]; + } + return diff; + }, {} as Partial); + } + + export function isEmpty(obj: T) { + return Object.keys(obj).length === 0; + } +} + +// Make it globally available +if (typeof window !== "undefined") { + (window as any).DiffUtils = DiffUtils; +} else { + (global as any).DiffUtils = DiffUtils; +} + +export default DiffUtils; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index cacd2fa..6a10e29 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -16,6 +16,7 @@ "@radix-ui/react-collapsible": "^1.1.2", "@radix-ui/react-dialog": "^1.1.4", "@radix-ui/react-dropdown-menu": "^2.1.4", + "@radix-ui/react-hover-card": "^1.1.6", "@radix-ui/react-label": "^2.1.1", "@radix-ui/react-navigation-menu": "^1.2.3", "@radix-ui/react-popover": "^1.1.4", @@ -1354,6 +1355,166 @@ } } }, + "node_modules/@radix-ui/react-hover-card": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-hover-card/-/react-hover-card-1.1.6.tgz", + "integrity": "sha512-E4ozl35jq0VRlrdc4dhHrNSV0JqBb4Jy73WAhBEK7JoYnQ83ED5r0Rb/XdVKw89ReAJN38N492BAPBZQ57VmqQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.5", + "@radix-ui/react-popper": "1.2.2", + "@radix-ui/react-portal": "1.1.4", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-use-controllable-state": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/react-arrow": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.2.tgz", + "integrity": "sha512-G+KcpzXHq24iH0uGG/pF8LyzpFJYGD4RfLjCIBfGdSLXvjLHST31RUiRVrupIBMvIppMgSzQ6l66iAxl03tdlg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.0.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.5.tgz", + "integrity": "sha512-E4TywXY6UsXNRhFrECa5HAvE5/4BFcGyfTyK36gP+pAW1ed7UTK4vKwdr53gAJYwqbfCWC6ATvJa3J3R/9+Qrg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-escape-keydown": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/react-popper": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.2.tgz", + "integrity": "sha512-Rvqc3nOpwseCyj/rgjlJDYAgyfw7OC1tTkKn2ivhaMGcYt8FSBlahHOZak2i3QwkRXUXgGgzeEe2RuqeEHuHgA==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0", + "@radix-ui/react-use-rect": "1.1.0", + "@radix-ui/react-use-size": "1.1.0", + "@radix-ui/rect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/react-portal": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.4.tgz", + "integrity": "sha512-sn2O9k1rPFYVyKd5LAJfo96JlSGVFpa1fS6UuBJfrZadudiw5tAmru+n1x7aMRQ84qDM71Zh1+SzK5QwU0tJfA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/react-primitive": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.2.tgz", + "integrity": "sha512-Ec/0d38EIuvDF+GZjcMU/Ze6MxntVJYO/fRlCPhCaVUyPY9WTalHJw54tp9sXeJo3tlShWpy41vQRgLRGOuz+w==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-id": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index bc88ef3..fe90a49 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,6 +17,7 @@ "@radix-ui/react-collapsible": "^1.1.2", "@radix-ui/react-dialog": "^1.1.4", "@radix-ui/react-dropdown-menu": "^2.1.4", + "@radix-ui/react-hover-card": "^1.1.6", "@radix-ui/react-label": "^2.1.1", "@radix-ui/react-navigation-menu": "^1.2.3", "@radix-ui/react-popover": "^1.1.4", diff --git a/frontend/types/global.d.ts b/frontend/types/global.d.ts new file mode 100644 index 0000000..49f647c --- /dev/null +++ b/frontend/types/global.d.ts @@ -0,0 +1,16 @@ +// types/global.d.ts +declare namespace DiffUtils { + function getDifferences(obj1: T, obj2: T): Partial; + function isEmpty(obj: T): boolean; +} + +declare global { + interface Window { + DiffUtils: typeof DiffUtils; + } + namespace NodeJS { + interface Global { + DiffUtils: typeof DiffUtils; + } + } +}