From c5f7032f43e7c8c87ba64e67f2e05b1346ff8aa2 Mon Sep 17 00:00:00 2001 From: AlbanSdl Date: Fri, 3 Oct 2025 19:33:53 +0200 Subject: [PATCH 01/37] feat(asso): asso detail page --- public/locales/fr/goTo.json.ts | 12 +++-- src/api/assos/asso.interface.ts | 21 +++++++- src/api/assos/fetchAsso.hook.ts | 14 +++++ src/api/assos/fetchAssoMembers.hook.ts | 14 +++++ src/api/assos/member.interface.ts | 20 ++++++++ src/api/assos/searchAssos.hook.ts | 8 +-- src/app/assos/[assoId]/page.tsx | 53 +++++++++++++++++++ src/app/assos/[assoId]/style.module.scss | 65 ++++++++++++++++++++++++ src/app/assos/page.tsx | 2 +- src/components/UI/Link.tsx | 8 ++- src/components/toplevel/GoTo.tsx | 8 ++- src/icons/LinkExternal.tsx | 9 ++++ src/icons/Mail.tsx | 9 ++++ src/icons/Phone.tsx | 9 ++++ src/icons/index.ts | 6 +++ 15 files changed, 245 insertions(+), 13 deletions(-) create mode 100644 src/api/assos/fetchAsso.hook.ts create mode 100644 src/api/assos/fetchAssoMembers.hook.ts create mode 100644 src/api/assos/member.interface.ts create mode 100644 src/app/assos/[assoId]/page.tsx create mode 100644 src/app/assos/[assoId]/style.module.scss create mode 100644 src/icons/LinkExternal.tsx create mode 100644 src/icons/Mail.tsx create mode 100644 src/icons/Phone.tsx diff --git a/public/locales/fr/goTo.json.ts b/public/locales/fr/goTo.json.ts index b2c8489..2e9c755 100644 --- a/public/locales/fr/goTo.json.ts +++ b/public/locales/fr/goTo.json.ts @@ -2,9 +2,11 @@ // For more information, check the common.json.ts file export default { - "search": "Aller vers...", - "users.normal": "Trombinoscope", - "users.normal.keywords": "utilisateurs users personnes", - "ues.normal": "Guide des UEs", - "ues.normal.keywords": "matieres", + search: 'Aller vers...', + 'users.normal': 'Trombinoscope', + 'users.normal.keywords': 'utilisateurs users personnes', + 'ues.normal': 'Guide des UEs', + 'ues.normal.keywords': 'matieres', + 'assos.normal': 'Associations', + 'assos.normal.keywords': 'clubs vie assocative franck jacquemin', } as const; diff --git a/src/api/assos/asso.interface.ts b/src/api/assos/asso.interface.ts index ae7465b..1c2019f 100644 --- a/src/api/assos/asso.interface.ts +++ b/src/api/assos/asso.interface.ts @@ -1,8 +1,27 @@ +export interface AssoOverview { + id: string; + name: string; + logo: string; + shortDescription: string; + president: { + role: { + name: string; + }; + user: { + firstName: string; + lastName: string; + }; + }; +} + export interface Asso { id: string; name: string; logo: string; - descriptionShortTranslation: string; + description: string; + mail: string; + phoneNumber: string; + website: string; president: { role: { name: string; diff --git a/src/api/assos/fetchAsso.hook.ts b/src/api/assos/fetchAsso.hook.ts new file mode 100644 index 0000000..5cb5a27 --- /dev/null +++ b/src/api/assos/fetchAsso.hook.ts @@ -0,0 +1,14 @@ +import { useEffect, useState } from 'react'; +import { useAPI } from '@/api/api'; +import { Asso } from '@/api/assos/asso.interface'; + +export function useAsso(assoId: string): [Asso, (asso: Asso) => void] { + const [asso, setAsso] = useState(null); + const api = useAPI(); + useEffect(() => { + api.get(`/assos/${assoId}`).on('success', (body) => { + setAsso(body); + }); + }, []); + return [asso!, setAsso]; +} diff --git a/src/api/assos/fetchAssoMembers.hook.ts b/src/api/assos/fetchAssoMembers.hook.ts new file mode 100644 index 0000000..0a49e2f --- /dev/null +++ b/src/api/assos/fetchAssoMembers.hook.ts @@ -0,0 +1,14 @@ +import { useEffect, useState } from 'react'; +import { useAPI } from '@/api/api'; +import { Role, RoleResponse } from './member.interface'; + +export function useMembers(assoId: string): [Role[], (roles: Role[]) => void] { + const [roles, setRoles] = useState([]); + const api = useAPI(); + useEffect(() => { + api.get(`/assos/${assoId}/members`).on('success', (body) => { + setRoles(body.roles); + }); + }, []); + return [roles!, setRoles]; +} diff --git a/src/api/assos/member.interface.ts b/src/api/assos/member.interface.ts new file mode 100644 index 0000000..f935aee --- /dev/null +++ b/src/api/assos/member.interface.ts @@ -0,0 +1,20 @@ +export interface Member { + id: string; + userid: string; + firstName: string; + lastName: string; + startAt: Date; + endAt: Date; +} + +export interface Role { + id: string; + name: string; + position: number; + isPresident: boolean; + members: Member[]; +} + +export interface RoleResponse { + roles: Role[]; +} diff --git a/src/api/assos/searchAssos.hook.ts b/src/api/assos/searchAssos.hook.ts index cdf54da..0b7e378 100644 --- a/src/api/assos/searchAssos.hook.ts +++ b/src/api/assos/searchAssos.hook.ts @@ -1,14 +1,14 @@ import { useState } from 'react'; import { useAPI } from '@/api/api'; import { Pagination } from '@/api/api.interface'; -import { Asso } from '@/api/assos/asso.interface'; +import { AssoOverview } from '@/api/assos/asso.interface'; -export function useAssos(): [Asso[], number, (query: Record) => void] { - const [assos, setAssos] = useState([]); +export function useAssos(): [AssoOverview[], number, (query: Record) => void] { + const [assos, setAssos] = useState([]); const [total, setTotal] = useState(0); const api = useAPI(); const updateAssos = async (query: Record) => - api.get>(`/assos?${new URLSearchParams(query)}`).on('success', (body) => { + api.get>(`/assos?${new URLSearchParams(query)}`).on('success', (body) => { setAssos(body.items); setTotal(body.itemCount); }); diff --git a/src/app/assos/[assoId]/page.tsx b/src/app/assos/[assoId]/page.tsx new file mode 100644 index 0000000..8839047 --- /dev/null +++ b/src/app/assos/[assoId]/page.tsx @@ -0,0 +1,53 @@ +'use client'; + +import { useParams } from 'next/navigation'; +import styles from './style.module.scss'; +import { useAsso } from '@/api/assos/fetchAsso.hook'; +import Page from '@/components/utilities/Page'; +import { useMembers } from '@/api/assos/fetchAssoMembers.hook'; +import Icons from '@/icons'; +import Link from '@/components/UI/Link'; + +export default function AssoDetailPage() { + const params = useParams<{ assoId: string }>(); + const [asso, setAsso] = useAsso(params.assoId); + const [members, setMembers] = useMembers(params.assoId); + + return ( + +
i).join(' ')}> + {`Logo +
+
+

{asso?.name}

+
{asso?.description}
+
+
+ + +
{asso?.website?.replace(/^https?:\/\/(?:www\.)?/, '')}
+ + + +
{asso?.mail}
+ + + +
{asso?.phoneNumber}
+ +
+
+
+ {!!members.length &&

Membres

} +
+ ); +} diff --git a/src/app/assos/[assoId]/style.module.scss b/src/app/assos/[assoId]/style.module.scss new file mode 100644 index 0000000..ea1f2c9 --- /dev/null +++ b/src/app/assos/[assoId]/style.module.scss @@ -0,0 +1,65 @@ +@import '@/variables'; +@import '@/components/glimmer.scss'; + +.headerCard { + background: white; + padding: 3ch; + display: flex; + flex-flow: row nowrap; + gap: 2ch; + border-radius: 1ch; + + & > img { + width: 12ch; + height: 12ch; + border-radius: 50%; + object-fit: cover; + display: flex; + text-align: center; + align-items: center; + overflow: hidden; + flex-shrink: 0; + } + + .details { + display: flex; + flex-flow: column nowrap; + gap: 2ch; + justify-content: space-between; + + .actionRow { + display: flex; + flex-flow: row wrap; + gap: 3ch; + + svg + div { + display: inline-block; + vertical-align: super; + margin-left: 0.5ch; + } + } + } + + img { + background: rgba($color: $ung-light-grey, $alpha: 0.2); + } + + h1:empty, + div:empty { + @extend .glimmer-animated; + width: 16ch; + + &:nth-child(2) { + width: 8ch; + } + &:nth-of-type(2) { + width: 18ch; + } + &:nth-of-type(3) { + width: 15ch; + } + &:nth-of-type(4) { + width: 10ch; + } + } +} diff --git a/src/app/assos/page.tsx b/src/app/assos/page.tsx index 0790c78..adc9960 100644 --- a/src/app/assos/page.tsx +++ b/src/app/assos/page.tsx @@ -44,7 +44,7 @@ export default function AssoPage() { itemFactory={({ item }) => (

{item?.name}

-

{item?.descriptionShortTranslation}

+

{item?.shortDescription}

)} getItemId={(asso) => asso.id} diff --git a/src/components/UI/Link.tsx b/src/components/UI/Link.tsx index 409b7aa..507717a 100644 --- a/src/components/UI/Link.tsx +++ b/src/components/UI/Link.tsx @@ -8,14 +8,20 @@ export default function Link({ href, className = '', noStyle = false, + newTab = false, }: { children?: ReactNode; href: Url; className?: string; noStyle?: boolean; + newTab?: boolean; }) { return ( - + {children} ); diff --git a/src/components/toplevel/GoTo.tsx b/src/components/toplevel/GoTo.tsx index 240eb61..11d1f68 100644 --- a/src/components/toplevel/GoTo.tsx +++ b/src/components/toplevel/GoTo.tsx @@ -28,6 +28,11 @@ const searchEntries: SearchEntry[] = [ url: '/ues', keywordTranslationKeys: ['goTo:ues.normal.keywords'], }, + { + name: 'goTo:assos.normal', + url: '/assos', + keywordTranslationKeys: ['goTo:assos.normal.keywords'], + }, ]; export default function GoTo() { @@ -103,7 +108,8 @@ export default function GoTo() { + className={`${styles.result} ${i === selectedResultIndex ? styles.selected : ''}`} + noStyle> {t(translatedSearchEntries[entryIndex].name)} ))} diff --git a/src/icons/LinkExternal.tsx b/src/icons/LinkExternal.tsx new file mode 100644 index 0000000..aa3b878 --- /dev/null +++ b/src/icons/LinkExternal.tsx @@ -0,0 +1,9 @@ +import React from 'react'; + +export default function LinkExternal() { + return ( + + + + ); +} diff --git a/src/icons/Mail.tsx b/src/icons/Mail.tsx new file mode 100644 index 0000000..e143976 --- /dev/null +++ b/src/icons/Mail.tsx @@ -0,0 +1,9 @@ +import React from 'react'; + +export default function Mail() { + return ( + + + + ); +} diff --git a/src/icons/Phone.tsx b/src/icons/Phone.tsx new file mode 100644 index 0000000..db24ea7 --- /dev/null +++ b/src/icons/Phone.tsx @@ -0,0 +1,9 @@ +import React from 'react'; + +export default function Phone() { + return ( + + + + ); +} diff --git a/src/icons/index.ts b/src/icons/index.ts index 389adaf..53e64b4 100644 --- a/src/icons/index.ts +++ b/src/icons/index.ts @@ -22,6 +22,9 @@ import CircleWarning from './CircleWarning'; import Clock from './Clock'; import Rotate from './Rotate'; import Copy from './Copy'; +import Mail from './Mail'; +import Phone from './Phone'; +import LinkExternal from './LinkExternal'; const Icons = { Book, @@ -34,13 +37,16 @@ const Icons = { Language, LeftArrow, LeftChevron, + LinkExternal, Loader, Login, LogoEtu, LogoUNG, LogoUTT, Logout, + Mail, Menu, + Phone, Star, Trash, RightChevron, From 9a81fe12eb49021484e16e65072c950fbdedba9d Mon Sep 17 00:00:00 2001 From: AlbanSdl Date: Fri, 3 Oct 2025 22:54:43 +0200 Subject: [PATCH 02/37] feat: add membership list --- public/locales/fr/assos.json.ts | 12 ++- src/app/assos/[assoId]/page.tsx | 67 ++++++++++++- src/app/assos/[assoId]/style.module.scss | 116 ++++++++++++++++++++--- src/icons/Crown.tsx | 20 ++++ src/icons/index.ts | 2 + 5 files changed, 195 insertions(+), 22 deletions(-) create mode 100644 src/icons/Crown.tsx diff --git a/public/locales/fr/assos.json.ts b/public/locales/fr/assos.json.ts index 890865e..108f0c4 100644 --- a/public/locales/fr/assos.json.ts +++ b/public/locales/fr/assos.json.ts @@ -2,8 +2,12 @@ // For more information, check the common.json.ts file export default { - "browser": "UTT Travail", - "filter.search": "Recherche dans le guide des assos", - "filter.search.title": "Recherche dans le guide des assos" + browser: 'UTT Travail', + 'filter.search': 'Recherche dans le guide des assos', + 'filter.search.title': 'Recherche dans le guide des assos', + 'member.since': 'Depuis ', + 'member.old.from': 'Entre ', + 'member.old.to': ' et ', + 'member.old.display': 'Afficher les anciens', + 'member.old.hide': 'Masquer les anciens', } as const; - diff --git a/src/app/assos/[assoId]/page.tsx b/src/app/assos/[assoId]/page.tsx index 8839047..5691683 100644 --- a/src/app/assos/[assoId]/page.tsx +++ b/src/app/assos/[assoId]/page.tsx @@ -1,5 +1,6 @@ 'use client'; +import { useState } from 'react'; import { useParams } from 'next/navigation'; import styles from './style.module.scss'; import { useAsso } from '@/api/assos/fetchAsso.hook'; @@ -7,11 +8,15 @@ import Page from '@/components/utilities/Page'; import { useMembers } from '@/api/assos/fetchAssoMembers.hook'; import Icons from '@/icons'; import Link from '@/components/UI/Link'; +import { useAppTranslation } from '@/lib/i18n'; +import Button from '@/components/UI/Button'; export default function AssoDetailPage() { const params = useParams<{ assoId: string }>(); - const [asso, setAsso] = useAsso(params.assoId); - const [members, setMembers] = useMembers(params.assoId); + const [asso] = useAsso(params.assoId); + const [members] = useMembers(params.assoId); + const [displayOldMembers, setDisplayOldMembers] = useState(false); + const { t } = useAppTranslation(); return ( @@ -34,9 +39,9 @@ export default function AssoDetailPage() { - {!!members.length &&

Membres

} + {!!members.length && ( +
+

+ Membres +
+ +
+

+ {members.map((role) => ( +
+

+ {role.isPresident ? ( +
+ +
+ ) : ( + '' + )} + {role.name} +

+
+ {role.members.map((member) => { + const isOld = member.endAt < new Date(); + return ( + (!isOld || displayOldMembers) && ( + +
+ +
+
+ {member.firstName} {member.lastName} +
+
+ {t(isOld ? 'assos:member.old.from' : 'assos:member.since')} + {member.startAt.toLocaleString(undefined, { + year: 'numeric', + month: 'long', + })} + {isOld && t('assos:member.old.to')} +
+
+
+ + ) + ); + })} +
+
+ ))} +
+ )}
); } diff --git a/src/app/assos/[assoId]/style.module.scss b/src/app/assos/[assoId]/style.module.scss index ea1f2c9..a33fb71 100644 --- a/src/app/assos/[assoId]/style.module.scss +++ b/src/app/assos/[assoId]/style.module.scss @@ -17,6 +17,7 @@ display: flex; text-align: center; align-items: center; + justify-content: center; overflow: hidden; flex-shrink: 0; } @@ -27,6 +28,19 @@ gap: 2ch; justify-content: space-between; + h1:empty, + div:empty { + @extend .glimmer-animated; + width: 16ch; + height: 0.8em; + margin-top: 0.2em; + margin-bottom: 0.2em; + + &:nth-child(2) { + width: 48ch; + } + } + .actionRow { display: flex; flex-flow: row wrap; @@ -37,29 +51,105 @@ vertical-align: super; margin-left: 0.5ch; } + + :nth-child(1) div:empty { + width: 12ch; + } + :nth-child(2) div:empty { + width: 18ch; + } + :nth-child(3) div:empty { + width: 10ch; + } } } img { background: rgba($color: $ung-light-grey, $alpha: 0.2); } +} - h1:empty, - div:empty { - @extend .glimmer-animated; - width: 16ch; +.membersCard { + background: white; + margin-top: 3ch; + padding: 3ch; + display: flex; + flex-flow: column nowrap; + gap: 2ch; + border-radius: 1ch; - &:nth-child(2) { - width: 8ch; - } - &:nth-of-type(2) { - width: 18ch; + h2 { + display: flex; + flex-flow: row nowrap; + justify-content: space-between; + + .toggleOldMembers { + font-size: 0.8em; + padding: 4px 8px; + border-radius: calc(0.5em + 4px); } - &:nth-of-type(3) { - width: 15ch; + } + + h3 { + position: relative; + } + + > div { + margin-left: 10px; + } + + .crown { + display: inline-block; + background-color: gold; + border-radius: 50%; + padding: 5px; + position: absolute; + left: -30px; + width: 15px; + height: 15px; + box-sizing: content-box; + + svg { + display: block; + width: 100%; + height: 100%; } - &:nth-of-type(4) { - width: 10ch; + } + + :has(:not(path):not(input):not(img):empty) { + display: none; + } + + .members { + display: flex; + flex-flow: row wrap; + gap: 2ch; + margin-top: 1ch; + + .pictureContainer { + display: flex; + flex-flow: row nowrap; + gap: 1ch; + align-items: center; + + img { + width: 5ch; + height: 5ch; + border-radius: 50%; + object-fit: cover; + display: flex; + text-align: center; + align-items: center; + justify-content: center; + overflow: hidden; + flex-shrink: 0; + background-color: rgba($color: $ung-light-grey, $alpha: 0.2); + } + + .temporal { + font-size: 0.8em; + color: $ung-dark-grey; + } } } } diff --git a/src/icons/Crown.tsx b/src/icons/Crown.tsx new file mode 100644 index 0000000..97d16b3 --- /dev/null +++ b/src/icons/Crown.tsx @@ -0,0 +1,20 @@ +import React from 'react'; + +export default function Crown() { + return ( + + + + + ); +} diff --git a/src/icons/index.ts b/src/icons/index.ts index 53e64b4..8375ce9 100644 --- a/src/icons/index.ts +++ b/src/icons/index.ts @@ -25,6 +25,7 @@ import Copy from './Copy'; import Mail from './Mail'; import Phone from './Phone'; import LinkExternal from './LinkExternal'; +import Crown from './Crown'; const Icons = { Book, @@ -33,6 +34,7 @@ const Icons = { CircleWarning, Clock, Collapse, + Crown, Home, Language, LeftArrow, From b6aaa8c167842aa970210cf54a2704eb0c017c43 Mon Sep 17 00:00:00 2001 From: AlbanSdl Date: Thu, 9 Oct 2025 21:27:38 +0200 Subject: [PATCH 03/37] feat(asso): add role reorder ui --- package.json | 4 + pnpm-lock.yaml | 86 +++++++++++-- public/locales/fr/assos.json.ts | 2 + src/api/assos/deleteRole.ts | 8 ++ src/api/assos/fetchAssoMembers.hook.ts | 4 +- src/api/assos/member.interface.ts | 11 +- src/api/assos/updateRole.ts | 18 +++ src/app/assos/[assoId]/page.tsx | 154 ++++++++++++++++------- src/app/assos/[assoId]/style.module.scss | 39 +++++- src/components/UI/VerticalSortDnd.tsx | 113 +++++++++++++++++ 10 files changed, 382 insertions(+), 57 deletions(-) create mode 100644 src/api/assos/deleteRole.ts create mode 100644 src/api/assos/updateRole.ts create mode 100644 src/components/UI/VerticalSortDnd.tsx diff --git a/package.json b/package.json index 2d9b768..522af17 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,10 @@ "lint:fix": "next lint --fix" }, "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/modifiers": "^9.0.0", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@reduxjs/toolkit": "^2.2.2", "date-fns": "^3.6.0", "eslint-config-next": "^14.1.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8339dcc..c104f13 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,18 @@ importers: .: dependencies: + '@dnd-kit/core': + specifier: ^6.3.1 + version: 6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@dnd-kit/modifiers': + specifier: ^9.0.0 + version: 9.0.0(@dnd-kit/core@6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) + '@dnd-kit/sortable': + specifier: ^10.0.0 + version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) + '@dnd-kit/utilities': + specifier: ^3.2.2 + version: 3.2.2(react@18.3.1) '@reduxjs/toolkit': specifier: ^2.2.2 version: 2.2.5(react-redux@9.1.2(@types/react@18.3.3)(react@18.3.1)(redux@5.0.1))(react@18.3.1) @@ -92,7 +104,7 @@ importers: version: 8.57.0 eslint-config-love: specifier: ^43.1.0 - version: 43.1.0(@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0)(typescript@5.4.5))(eslint-plugin-import@2.29.1)(eslint-plugin-n@16.6.2(eslint@8.57.0))(eslint-plugin-promise@6.2.0(eslint@8.57.0))(eslint@8.57.0)(typescript@5.4.5) + version: 43.1.0(@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0)(typescript@5.4.5))(eslint-plugin-import@2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0))(eslint-plugin-n@16.6.2(eslint@8.57.0))(eslint-plugin-promise@6.2.0(eslint@8.57.0))(eslint@8.57.0)(typescript@5.4.5) eslint-plugin-import: specifier: ^2.29.1 version: 2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0) @@ -118,6 +130,34 @@ packages: resolution: {integrity: sha512-Nms86NXrsaeU9vbBJKni6gXiEXZ4CVpYVzEjDH9Sb8vmZ3UljyA1GSOJl/6LGPO8EHLuSF9H+IxNXHPX8QHJ4g==} engines: {node: '>=6.9.0'} + '@dnd-kit/accessibility@3.1.1': + resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==} + peerDependencies: + react: '>=16.8.0' + + '@dnd-kit/core@6.3.1': + resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@dnd-kit/modifiers@9.0.0': + resolution: {integrity: sha512-ybiLc66qRGuZoC20wdSSG6pDXFikui/dCNGthxv4Ndy8ylErY0N3KVxY2bgo7AWwIbxDmXDg3ylAFmnrjcbVvw==} + peerDependencies: + '@dnd-kit/core': ^6.3.0 + react: '>=16.8.0' + + '@dnd-kit/sortable@10.0.0': + resolution: {integrity: sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==} + peerDependencies: + '@dnd-kit/core': ^6.3.0 + react: '>=16.8.0' + + '@dnd-kit/utilities@3.2.2': + resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==} + peerDependencies: + react: '>=16.8.0' + '@eslint-community/eslint-utils@4.4.0': resolution: {integrity: sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -1824,6 +1864,38 @@ snapshots: dependencies: regenerator-runtime: 0.14.1 + '@dnd-kit/accessibility@3.1.1(react@18.3.1)': + dependencies: + react: 18.3.1 + tslib: 2.6.2 + + '@dnd-kit/core@6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@dnd-kit/accessibility': 3.1.1(react@18.3.1) + '@dnd-kit/utilities': 3.2.2(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + tslib: 2.6.2 + + '@dnd-kit/modifiers@9.0.0(@dnd-kit/core@6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)': + dependencies: + '@dnd-kit/core': 6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@dnd-kit/utilities': 3.2.2(react@18.3.1) + react: 18.3.1 + tslib: 2.6.2 + + '@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)': + dependencies: + '@dnd-kit/core': 6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@dnd-kit/utilities': 3.2.2(react@18.3.1) + react: 18.3.1 + tslib: 2.6.2 + + '@dnd-kit/utilities@3.2.2(react@18.3.1)': + dependencies: + react: 18.3.1 + tslib: 2.6.2 + '@eslint-community/eslint-utils@4.4.0(eslint@8.57.0)': dependencies: eslint: 8.57.0 @@ -2497,12 +2569,12 @@ snapshots: eslint: 8.57.0 semver: 7.6.0 - eslint-config-love@43.1.0(@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0)(typescript@5.4.5))(eslint-plugin-import@2.29.1)(eslint-plugin-n@16.6.2(eslint@8.57.0))(eslint-plugin-promise@6.2.0(eslint@8.57.0))(eslint@8.57.0)(typescript@5.4.5): + eslint-config-love@43.1.0(@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0)(typescript@5.4.5))(eslint-plugin-import@2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0))(eslint-plugin-n@16.6.2(eslint@8.57.0))(eslint-plugin-promise@6.2.0(eslint@8.57.0))(eslint@8.57.0)(typescript@5.4.5): dependencies: '@typescript-eslint/eslint-plugin': 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0)(typescript@5.4.5) '@typescript-eslint/parser': 6.21.0(eslint@8.57.0)(typescript@5.4.5) eslint: 8.57.0 - eslint-config-standard: 17.1.0(eslint-plugin-import@2.29.1)(eslint-plugin-n@16.6.2(eslint@8.57.0))(eslint-plugin-promise@6.2.0(eslint@8.57.0))(eslint@8.57.0) + eslint-config-standard: 17.1.0(eslint-plugin-import@2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0))(eslint-plugin-n@16.6.2(eslint@8.57.0))(eslint-plugin-promise@6.2.0(eslint@8.57.0))(eslint@8.57.0) eslint-plugin-import: 2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0) eslint-plugin-n: 16.6.2(eslint@8.57.0) eslint-plugin-promise: 6.2.0(eslint@8.57.0) @@ -2532,7 +2604,7 @@ snapshots: dependencies: eslint: 8.57.0 - eslint-config-standard@17.1.0(eslint-plugin-import@2.29.1)(eslint-plugin-n@16.6.2(eslint@8.57.0))(eslint-plugin-promise@6.2.0(eslint@8.57.0))(eslint@8.57.0): + eslint-config-standard@17.1.0(eslint-plugin-import@2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0))(eslint-plugin-n@16.6.2(eslint@8.57.0))(eslint-plugin-promise@6.2.0(eslint@8.57.0))(eslint@8.57.0): dependencies: eslint: 8.57.0 eslint-plugin-import: 2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0) @@ -2552,7 +2624,7 @@ snapshots: debug: 4.3.4 enhanced-resolve: 5.16.0 eslint: 8.57.0 - eslint-module-utils: 2.8.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0) + eslint-module-utils: 2.8.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1)(eslint@8.57.0))(eslint@8.57.0) eslint-plugin-import: 2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0) fast-glob: 3.3.2 get-tsconfig: 4.7.3 @@ -2564,7 +2636,7 @@ snapshots: - eslint-import-resolver-webpack - supports-color - eslint-module-utils@2.8.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0): + eslint-module-utils@2.8.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1)(eslint@8.57.0))(eslint@8.57.0): dependencies: debug: 3.2.7 optionalDependencies: @@ -2592,7 +2664,7 @@ snapshots: doctrine: 2.1.0 eslint: 8.57.0 eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.8.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0) + eslint-module-utils: 2.8.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1)(eslint@8.57.0))(eslint@8.57.0) hasown: 2.0.2 is-core-module: 2.13.1 is-glob: 4.0.3 diff --git a/public/locales/fr/assos.json.ts b/public/locales/fr/assos.json.ts index 108f0c4..8bbb943 100644 --- a/public/locales/fr/assos.json.ts +++ b/public/locales/fr/assos.json.ts @@ -10,4 +10,6 @@ export default { 'member.old.to': ' et ', 'member.old.display': 'Afficher les anciens', 'member.old.hide': 'Masquer les anciens', + 'member.edit': 'Modifier les membres', + 'member.edit.stop': 'Fermer', } as const; diff --git a/src/api/assos/deleteRole.ts b/src/api/assos/deleteRole.ts new file mode 100644 index 0000000..ee652f4 --- /dev/null +++ b/src/api/assos/deleteRole.ts @@ -0,0 +1,8 @@ +import { API } from '@/api/api'; +import { Role } from './member.interface'; + +export type DeletedRole = Omit; + +export function deleteRole(api: API, assoId: string, roleId: string) { + return api.delete(`/assos/${assoId}/roles/${roleId}`); +} diff --git a/src/api/assos/fetchAssoMembers.hook.ts b/src/api/assos/fetchAssoMembers.hook.ts index 0a49e2f..c3a724d 100644 --- a/src/api/assos/fetchAssoMembers.hook.ts +++ b/src/api/assos/fetchAssoMembers.hook.ts @@ -1,8 +1,8 @@ -import { useEffect, useState } from 'react'; +import { Dispatch, SetStateAction, useEffect, useState } from 'react'; import { useAPI } from '@/api/api'; import { Role, RoleResponse } from './member.interface'; -export function useMembers(assoId: string): [Role[], (roles: Role[]) => void] { +export function useMembers(assoId: string): [Role[], Dispatch>] { const [roles, setRoles] = useState([]); const api = useAPI(); useEffect(() => { diff --git a/src/api/assos/member.interface.ts b/src/api/assos/member.interface.ts index f935aee..6b317e8 100644 --- a/src/api/assos/member.interface.ts +++ b/src/api/assos/member.interface.ts @@ -1,10 +1,11 @@ export interface Member { id: string; - userid: string; + userId: string; firstName: string; lastName: string; startAt: Date; endAt: Date; + permissions: string[]; } export interface Role { @@ -18,3 +19,11 @@ export interface Role { export interface RoleResponse { roles: Role[]; } + +export interface RoleCreateRequest { + name: string; +} + +export interface RoleUpdateRequest extends RoleCreateRequest { + position: number; +} diff --git a/src/api/assos/updateRole.ts b/src/api/assos/updateRole.ts new file mode 100644 index 0000000..7cb50cd --- /dev/null +++ b/src/api/assos/updateRole.ts @@ -0,0 +1,18 @@ +import { API } from '@/api/api'; +import { Role, RoleResponse, RoleUpdateRequest } from './member.interface'; + +export async function updateRole( + api: API, + assoId: string, + roleId: string, + position: number, + name: string, +): Promise { + const res = await api + .put(`/assos/${assoId}/roles/${roleId}`, { + name, + position, + }) + .toPromise(); + return res?.roles; +} diff --git a/src/app/assos/[assoId]/page.tsx b/src/app/assos/[assoId]/page.tsx index 5691683..5030950 100644 --- a/src/app/assos/[assoId]/page.tsx +++ b/src/app/assos/[assoId]/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { useParams } from 'next/navigation'; import styles from './style.module.scss'; import { useAsso } from '@/api/assos/fetchAsso.hook'; @@ -10,14 +10,62 @@ import Icons from '@/icons'; import Link from '@/components/UI/Link'; import { useAppTranslation } from '@/lib/i18n'; import Button from '@/components/UI/Button'; +import { useAppSelector } from '@/lib/hooks'; +import { deleteRole } from '@/api/assos/deleteRole'; +import { useAPI } from '@/api/api'; +import { VerticalSortDnd } from '@/components/UI/VerticalSortDnd'; +import { updateRole } from '@/api/assos/updateRole'; export default function AssoDetailPage() { const params = useParams<{ assoId: string }>(); + const user = useAppSelector((state) => state.user); const [asso] = useAsso(params.assoId); - const [members] = useMembers(params.assoId); + const [members, setMembers] = useMembers(params.assoId); + const [permissions, setPermissions] = useState(new Set()); const [displayOldMembers, setDisplayOldMembers] = useState(false); + const [editMembersMode, setEditMembersMode] = useState(false); const { t } = useAppTranslation(); + const api = useAPI(); + + useEffect(() => { + const permissions = new Set( + members + .map((role) => + role.members.filter((member) => member.userId === user?.id).flatMap((member) => member.permissions), + ) + .flat(), + ); + setPermissions(permissions); + console.log('Current user permissions:'); + console.log(permissions); + }, [members, user]); + + const updateAssoRole = async (roleId: string, data: Partial<{ name: string; position: number }>) => { + const role = members.find((r) => r.id === roleId); + if (!role) return; + const updatedRoles = await updateRole( + api, + asso!.id, + roleId, + data?.position ?? role.position, + data?.name ?? role.name, + ); + if (updatedRoles) + setMembers( + updatedRoles.map((role) => { + const legacyRole = members.find((r) => r.id === role.id); + return { ...role, members: legacyRole?.members ?? [] }; + }), + ); + }; + + const deleteAssoRole = async (roleId: string) => { + // TODO: add popup for confirmation + const deletedRole = await deleteRole(api, asso!.id, roleId).toPromise(); + setMembers(members.filter((role) => role.id !== deletedRole?.id)); + }; + return (
i).join(' ')}> @@ -53,56 +101,78 @@ export default function AssoDetailPage() {
{!!members.length && ( -
+
i).join(' ')}>

Membres -
+
+ {!!permissions.size && ( + + )}

- {members.map((role) => ( -
-

- {role.isPresident ? ( -
- -
- ) : ( - '' - )} - {role.name} -

-
- {role.members.map((member) => { - const isOld = member.endAt < new Date(); - return ( - (!isOld || displayOldMembers) && ( - -
- -
+ updateAssoRole(id, { position: newIndex })} + inflater={({ item: role }) => ( + <> +

+ {role.isPresident ? ( +
+ +
+ ) : ( + '' + )} + {role.name} + {editMembersMode && ( + <> + + + + )} +

+
+ {role.members.map((member) => { + const isOld = member.endAt < new Date(); + return ( + (!isOld || displayOldMembers) && ( + +
+
- {member.firstName} {member.lastName} -
-
- {t(isOld ? 'assos:member.old.from' : 'assos:member.since')} - {member.startAt.toLocaleString(undefined, { - year: 'numeric', - month: 'long', - })} - {isOld && t('assos:member.old.to')} +
+ {member.firstName} {member.lastName} +
+
+ {t(isOld ? 'assos:member.old.from' : 'assos:member.since')} + {member.startAt.toLocaleString(undefined, { + year: 'numeric', + month: 'long', + })} + {isOld && t('assos:member.old.to')} +
-
- - ) - ); - })} -
-
- ))} + + ) + ); + })} +
+ + )} + disabled={!editMembersMode || !permissions.has('manage_roles')}>
)} diff --git a/src/app/assos/[assoId]/style.module.scss b/src/app/assos/[assoId]/style.module.scss index a33fb71..fb9ab77 100644 --- a/src/app/assos/[assoId]/style.module.scss +++ b/src/app/assos/[assoId]/style.module.scss @@ -72,17 +72,41 @@ .membersCard { background: white; margin-top: 3ch; - padding: 3ch; + padding: calc(3ch - 2px); display: flex; flex-flow: column nowrap; - gap: 2ch; + gap: 1ch; border-radius: 1ch; + border: 2px solid transparent; + + &.editMode { + border: 2px dashed $ung-light-blue; + background: rgba($color: $ung-light-blue, $alpha: 0.1); + + & > div { + cursor: grab; + background-color: color-mix(in srgb, $ung-light-blue 20%, $very-light-gray 80%); + border-radius: 10px; + } + + & > [aria-pressed] { + opacity: 0.3; + pointer-events: none; + } + } h2 { display: flex; flex-flow: row nowrap; justify-content: space-between; + .actionRow { + display: flex; + flex-flow: row wrap; + gap: 1ch; + align-items: center; + } + .toggleOldMembers { font-size: 0.8em; padding: 4px 8px; @@ -95,7 +119,7 @@ } > div { - margin-left: 10px; + padding: 1ch 10px; } .crown { @@ -104,7 +128,8 @@ border-radius: 50%; padding: 5px; position: absolute; - left: -30px; + left: -31px; + top: -0.05em; width: 15px; height: 15px; box-sizing: content-box; @@ -116,10 +141,14 @@ } } - :has(:not(path):not(input):not(img):empty) { + :has(> :not(path):nth-child(2):empty) { display: none; } + &.editMode :has(> :not(path):nth-child(2):empty) { + display: block; + } + .members { display: flex; flex-flow: row wrap; diff --git a/src/components/UI/VerticalSortDnd.tsx b/src/components/UI/VerticalSortDnd.tsx new file mode 100644 index 0000000..7b6bff3 --- /dev/null +++ b/src/components/UI/VerticalSortDnd.tsx @@ -0,0 +1,113 @@ +import { + ComponentType, + Dispatch, + PropsWithChildren, + PropsWithoutRef, + PropsWithRef, + SetStateAction, + useState, +} from 'react'; +import { + closestCenter, + DndContext, + DragEndEvent, + DragOverlay, + DragStartEvent, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, +} from '@dnd-kit/core'; +import { + arrayMove, + SortableContext, + sortableKeyboardCoordinates, + useSortable, + verticalListSortingStrategy, +} from '@dnd-kit/sortable'; +import { restrictToParentElement, restrictToVerticalAxis } from '@dnd-kit/modifiers'; +import { CSS } from '@dnd-kit/utilities'; + +function SortableItem(props: PropsWithChildren<{ id: string }>) { + const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id: props.id }); + + if (transform) { + transform.scaleX = 1; + transform.scaleY = 1; + } + const style = { + transform: CSS.Transform.toString(transform), + transition, + }; + + return ( +
+ {props.children} +
+ ); +} + +/** + * Allows to use drag and drop for element sorting (vertical layout) + * For more information about this component, see documentation here : https://docs.dndkit.com + */ +export const VerticalSortDnd = ({ + disabled, + items, + setItems, + inflater: Inflater, + onItemMoved = () => {}, +}: PropsWithoutRef<{ + disabled?: boolean; + inflater: ComponentType>; + items: T[]; + setItems: Dispatch>; + onItemMoved?: (id: string, newIndex: number, oldIndex: number) => void; +}>) => { + const sensors = useSensors( + useSensor(PointerSensor), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }), + ); + const [activeId, setActiveId] = useState(null); + + const handleDragStart = (veent: DragStartEvent) => { + setActiveId(veent.active.id as string); + }; + + const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event; + + if (active.id !== over?.id) { + setItems((items) => { + const oldIndex = items.findIndex((item) => item.id === active.id); + const newIndex = items.findIndex((item) => item.id === over?.id); + onItemMoved(active.id as string, newIndex, oldIndex); + return arrayMove(items, oldIndex, newIndex); // keep arrayMove call for animation purpose, the list may also be updated by api calls later on + }); + } + setActiveId(null); + }; + + return ( + + + i.id === activeId)!} /> + + + {items.map((item) => ( + + + + ))} + + + ); +}; From b0d7a9e47ec8c67fb11cd65033219f7d540c207c Mon Sep 17 00:00:00 2001 From: AlbanSdl Date: Thu, 9 Oct 2025 22:36:16 +0200 Subject: [PATCH 04/37] feat(asso): role names can be ui modified --- src/app/assos/[assoId]/page.tsx | 155 +++++++++++++++++--------- src/components/UI/VerticalSortDnd.tsx | 36 +++--- 2 files changed, 125 insertions(+), 66 deletions(-) diff --git a/src/app/assos/[assoId]/page.tsx b/src/app/assos/[assoId]/page.tsx index 5030950..4fcd893 100644 --- a/src/app/assos/[assoId]/page.tsx +++ b/src/app/assos/[assoId]/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useEffect, useState } from 'react'; +import { PropsWithoutRef, useEffect, useState } from 'react'; import { useParams } from 'next/navigation'; import styles from './style.module.scss'; import { useAsso } from '@/api/assos/fetchAsso.hook'; @@ -15,6 +15,97 @@ import { deleteRole } from '@/api/assos/deleteRole'; import { useAPI } from '@/api/api'; import { VerticalSortDnd } from '@/components/UI/VerticalSortDnd'; import { updateRole } from '@/api/assos/updateRole'; +import Input from '@/components/UI/Input'; +import { Role } from '@/api/assos/member.interface'; + +function RoleComponent({ + role, + editing = false, + canEdit, + hasPermission, + displayOldMembers, + deleteAssoRole, + updateAssoRole, + setCurrentEditingRole, +}: PropsWithoutRef<{ + role: Role; + editing?: boolean; + canEdit: boolean; + hasPermission: boolean; + displayOldMembers: boolean; + deleteAssoRole: (id: string) => void; + updateAssoRole: (id: string, data: Partial<{ name: string; position: number }>) => void; + setCurrentEditingRole: (id: string | null) => void; +}>) { + const [currentEditingRoleValue, setCurrentEditingRoleValue] = useState(role.name); + const { t } = useAppTranslation(); + + return ( + <> +

+ {role.isPresident ? ( +
+ +
+ ) : ( + '' + )} + {editing && canEdit ? ( + + ) : ( + role.name + )} + {canEdit && ( + <> + + + + )} +

+
+ {role.members.map((member) => { + const isOld = member.endAt < new Date(); + return ( + (!isOld || displayOldMembers) && ( + +
+ +
+
+ {member.firstName} {member.lastName} +
+
+ {t(isOld ? 'assos:member.old.from' : 'assos:member.since')} + {member.startAt.toLocaleString(undefined, { + year: 'numeric', + month: 'long', + })} + {isOld && t('assos:member.old.to')} +
+
+
+ + ) + ); + })} +
+ + ); +} export default function AssoDetailPage() { const params = useParams<{ assoId: string }>(); @@ -24,6 +115,7 @@ export default function AssoDetailPage() { const [permissions, setPermissions] = useState(new Set()); const [displayOldMembers, setDisplayOldMembers] = useState(false); const [editMembersMode, setEditMembersMode] = useState(false); + const [currentEditingRole, setCurrentEditingRole] = useState(null); const { t } = useAppTranslation(); const api = useAPI(); @@ -120,57 +212,16 @@ export default function AssoDetailPage() { setItems={setMembers} onItemMoved={(id, newIndex) => updateAssoRole(id, { position: newIndex })} inflater={({ item: role }) => ( - <> -

- {role.isPresident ? ( -
- -
- ) : ( - '' - )} - {role.name} - {editMembersMode && ( - <> - - - - )} -

-
- {role.members.map((member) => { - const isOld = member.endAt < new Date(); - return ( - (!isOld || displayOldMembers) && ( - -
- -
-
- {member.firstName} {member.lastName} -
-
- {t(isOld ? 'assos:member.old.from' : 'assos:member.since')} - {member.startAt.toLocaleString(undefined, { - year: 'numeric', - month: 'long', - })} - {isOld && t('assos:member.old.to')} -
-
-
- - ) - ); - })} -
- + )} disabled={!editMembersMode || !permissions.has('manage_roles')}>
diff --git a/src/components/UI/VerticalSortDnd.tsx b/src/components/UI/VerticalSortDnd.tsx index 7b6bff3..966ed24 100644 --- a/src/components/UI/VerticalSortDnd.tsx +++ b/src/components/UI/VerticalSortDnd.tsx @@ -1,6 +1,8 @@ +/* eslint-disable import/named */ import { ComponentType, Dispatch, + PointerEvent, PropsWithChildren, PropsWithoutRef, PropsWithRef, @@ -13,21 +15,32 @@ import { DragEndEvent, DragOverlay, DragStartEvent, - KeyboardSensor, PointerSensor, useSensor, useSensors, } from '@dnd-kit/core'; -import { - arrayMove, - SortableContext, - sortableKeyboardCoordinates, - useSortable, - verticalListSortingStrategy, -} from '@dnd-kit/sortable'; +import { arrayMove, SortableContext, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable'; import { restrictToParentElement, restrictToVerticalAxis } from '@dnd-kit/modifiers'; import { CSS } from '@dnd-kit/utilities'; +class ClickPreservingPointerSensor extends PointerSensor { + static activators = [ + { + eventName: 'onPointerDown' as const, + handler: ({ nativeEvent: event }: PointerEvent) => { + const target = event.target as HTMLElement; + return !( + target.closest('button') || + target.closest('a') || + target.closest('input') || + target.closest('textarea') || + target.closest('select') + ); + }, + }, + ]; +} + function SortableItem(props: PropsWithChildren<{ id: string }>) { const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id: props.id }); @@ -64,12 +77,7 @@ export const VerticalSortDnd = ({ setItems: Dispatch>; onItemMoved?: (id: string, newIndex: number, oldIndex: number) => void; }>) => { - const sensors = useSensors( - useSensor(PointerSensor), - useSensor(KeyboardSensor, { - coordinateGetter: sortableKeyboardCoordinates, - }), - ); + const sensors = useSensors(useSensor(ClickPreservingPointerSensor)); const [activeId, setActiveId] = useState(null); const handleDragStart = (veent: DragStartEvent) => { From fb3de1f2dc7253bcf3c474b2de4a57a7e0343e20 Mon Sep 17 00:00:00 2001 From: AlbanSdl Date: Thu, 9 Oct 2025 22:40:02 +0200 Subject: [PATCH 05/37] fix(asso): dnd should consume pointer events on links --- src/components/UI/VerticalSortDnd.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/components/UI/VerticalSortDnd.tsx b/src/components/UI/VerticalSortDnd.tsx index 966ed24..bf73fef 100644 --- a/src/components/UI/VerticalSortDnd.tsx +++ b/src/components/UI/VerticalSortDnd.tsx @@ -31,7 +31,6 @@ class ClickPreservingPointerSensor extends PointerSensor { const target = event.target as HTMLElement; return !( target.closest('button') || - target.closest('a') || target.closest('input') || target.closest('textarea') || target.closest('select') From d5e73ebae928e2351af6a33642d332d84fdcf529 Mon Sep 17 00:00:00 2001 From: AlbanSdl Date: Thu, 9 Oct 2025 22:50:06 +0200 Subject: [PATCH 06/37] feat(asso): add button to create role --- src/api/assos/createRole.ts | 13 +++++++++++++ src/app/assos/[assoId]/page.tsx | 13 +++++++++++++ 2 files changed, 26 insertions(+) create mode 100644 src/api/assos/createRole.ts diff --git a/src/api/assos/createRole.ts b/src/api/assos/createRole.ts new file mode 100644 index 0000000..383ad8e --- /dev/null +++ b/src/api/assos/createRole.ts @@ -0,0 +1,13 @@ +import { API } from '@/api/api'; +import { Role } from './member.interface'; + +export type CreateRoleRequest = { + name: string; +}; +export type CreatedRole = Omit; + +export function createRole(api: API, assoId: string, name: string) { + return api.post(`/assos/${assoId}/roles`, { + name, + }); +} diff --git a/src/app/assos/[assoId]/page.tsx b/src/app/assos/[assoId]/page.tsx index 4fcd893..2e80bdd 100644 --- a/src/app/assos/[assoId]/page.tsx +++ b/src/app/assos/[assoId]/page.tsx @@ -17,6 +17,7 @@ import { VerticalSortDnd } from '@/components/UI/VerticalSortDnd'; import { updateRole } from '@/api/assos/updateRole'; import Input from '@/components/UI/Input'; import { Role } from '@/api/assos/member.interface'; +import { createRole } from '@/api/assos/createRole'; function RoleComponent({ role, @@ -158,6 +159,11 @@ export default function AssoDetailPage() { setMembers(members.filter((role) => role.id !== deletedRole?.id)); }; + const createAssoRole = async (name: string) => { + const createdRole = await createRole(api, asso!.id, name).toPromise(); + if (createdRole) setMembers([...members, { ...createdRole, members: [] }].sort((a, b) => a.position - b.position)); + }; + return (
i).join(' ')}> @@ -197,6 +203,13 @@ export default function AssoDetailPage() {

Membres
+ {permissions.has('manage_roles') && editMembersMode && ( + + )} {!!permissions.size && ( - - - )} +
+ {editing && canEdit ? ( + + ) : ( +
{role.name}
+ )} + {canEdit && ( + <> + + + + )} +

{role.members.map((member) => { const isOld = member.endAt < new Date(); return ( - (!isOld || displayOldMembers) && ( - + (!isOld || displayOldMembers || canEdit) && ( +
@@ -95,7 +101,15 @@ function RoleComponent({ year: 'numeric', month: 'long', })} - {isOld && t('assos:member.old.to')} + {isOld && ( + <> + {t('assos:member.old.to')} + {member.endAt.toLocaleString(undefined, { + year: 'numeric', + month: 'long', + })} + + )}
@@ -207,17 +221,22 @@ export default function AssoDetailPage() { )} {!!permissions.size && ( )} - + {!editMembersMode && ( + + )}
div { diff --git a/src/icons/Add.tsx b/src/icons/Add.tsx new file mode 100644 index 0000000..3d5e07b --- /dev/null +++ b/src/icons/Add.tsx @@ -0,0 +1,9 @@ +import React from 'react'; + +export default function Add() { + return ( + + + + ); +} diff --git a/src/icons/Close.tsx b/src/icons/Close.tsx new file mode 100644 index 0000000..2cb725e --- /dev/null +++ b/src/icons/Close.tsx @@ -0,0 +1,9 @@ +import React from 'react'; + +export default function Close() { + return ( + + + + ); +} diff --git a/src/icons/Confirm.tsx b/src/icons/Confirm.tsx new file mode 100644 index 0000000..70bb7c9 --- /dev/null +++ b/src/icons/Confirm.tsx @@ -0,0 +1,9 @@ +import React from 'react'; + +export default function Confirm() { + return ( + + + + ); +} diff --git a/src/icons/Edit.tsx b/src/icons/Edit.tsx new file mode 100644 index 0000000..0e67de4 --- /dev/null +++ b/src/icons/Edit.tsx @@ -0,0 +1,9 @@ +import React from 'react'; + +export default function Edit() { + return ( + + + + ); +} diff --git a/src/icons/EyeOff.tsx b/src/icons/EyeOff.tsx new file mode 100644 index 0000000..a1a2890 --- /dev/null +++ b/src/icons/EyeOff.tsx @@ -0,0 +1,10 @@ +import React from 'react'; + +export default function EyeOff() { + return ( + + + + + ); +} diff --git a/src/icons/EyeOn.tsx b/src/icons/EyeOn.tsx new file mode 100644 index 0000000..8090462 --- /dev/null +++ b/src/icons/EyeOn.tsx @@ -0,0 +1,10 @@ +import React from 'react'; + +export default function EyeOn() { + return ( + + + + + ); +} diff --git a/src/icons/Trash.tsx b/src/icons/Trash.tsx index 7f360d9..21815ed 100644 --- a/src/icons/Trash.tsx +++ b/src/icons/Trash.tsx @@ -1,6 +1,12 @@ export default function Trash({ className }: { className?: string }) { return ( - + diff --git a/src/icons/index.ts b/src/icons/index.ts index 8375ce9..c4c5082 100644 --- a/src/icons/index.ts +++ b/src/icons/index.ts @@ -26,15 +26,27 @@ import Mail from './Mail'; import Phone from './Phone'; import LinkExternal from './LinkExternal'; import Crown from './Crown'; +import Add from './Add'; +import Close from './Close'; +import Edit from './Edit'; +import EyeOff from './EyeOff'; +import EyeOn from './EyeOn'; +import Confirm from './Confirm'; const Icons = { + Add, Book, Caret, CircleCheck, CircleWarning, Clock, + Close, Collapse, + Confirm, Crown, + Edit, + EyeOff, + EyeOn, Home, Language, LeftArrow, From 5df208f8371369322d323a3d8e0e2ab58dee8a2a Mon Sep 17 00:00:00 2001 From: AlbanSdl Date: Fri, 10 Oct 2025 01:57:14 +0200 Subject: [PATCH 08/37] feat(asso): add user selector for future memberAdd ui --- public/locales/fr/users.json.ts | 71 +++++++------- src/components/users/UserSelector.module.scss | 61 ++++++++++++ src/components/users/UserSelector.tsx | 92 +++++++++++++++++++ 3 files changed, 190 insertions(+), 34 deletions(-) create mode 100644 src/components/users/UserSelector.module.scss create mode 100644 src/components/users/UserSelector.tsx diff --git a/public/locales/fr/users.json.ts b/public/locales/fr/users.json.ts index 93dfd5f..4348eb7 100644 --- a/public/locales/fr/users.json.ts +++ b/public/locales/fr/users.json.ts @@ -2,38 +2,41 @@ // For more information, check the common.json.ts file export default { - "search.title": "Trombinoscope", - "filter.global.title": "Générique", - "filter.global.placeholder": "Recherche générique", - "filter.firstName.title": "Prénom", - "filter.firstName.placeholder": "Recherche par prénom", - "filter.lastName.title": "Nom de famille", - "filter.lastName.placeholder": "Recherche par nom de famille", - "filter.nickname.title": "Surnom", - "filter.nickname.placeholder": "Recherche par surnom", - "nickname": "Surnom", - "sex": "Sexe", - "mailUTT": "Mail UTT", - "mailPersonal": "Mail personnel", - "facebook": "Facebook", - "phone": "Téléphone", - "website": "Site web", - "passions": "Passions", - "birthday": "Date de naissance", - "branch": "Branche", - "semester": "Semestre", - "branchOption": "Filière", - "generalInfo.tabName": "Informations", - "generalInfo.title": "Informations générales sur l'utilisateur", - "assos.tabName": "Associatif", - "assos.title": "Associations de {{name}}", - "assos.noAssos": "Cet utilisateur n'est membre d'aucune association.", - "profile.title": "Profil", - "firstName": "Prénom", - "lastName": "Nom", - "username": "Nom d'utilisateur", - "mail": "Adresse mail", - "password": "Mot de passe", - "password.confirmation": "Confirmation de mot de passe", + 'search.title': 'Trombinoscope', + 'filter.global.title': 'Générique', + 'filter.global.placeholder': 'Recherche générique', + 'filter.firstName.title': 'Prénom', + 'filter.firstName.placeholder': 'Recherche par prénom', + 'filter.lastName.title': 'Nom de famille', + 'filter.lastName.placeholder': 'Recherche par nom de famille', + 'filter.nickname.title': 'Surnom', + 'filter.nickname.placeholder': 'Recherche par surnom', + nickname: 'Surnom', + sex: 'Sexe', + mailUTT: 'Mail UTT', + mailPersonal: 'Mail personnel', + facebook: 'Facebook', + phone: 'Téléphone', + website: 'Site web', + passions: 'Passions', + birthday: 'Date de naissance', + branch: 'Branche', + semester: 'Semestre', + branchOption: 'Filière', + 'generalInfo.tabName': 'Informations', + 'generalInfo.title': "Informations générales sur l'utilisateur", + 'assos.tabName': 'Associatif', + 'assos.title': 'Associations de {{name}}', + 'assos.noAssos': "Cet utilisateur n'est membre d'aucune association.", + 'profile.title': 'Profil', + firstName: 'Prénom', + lastName: 'Nom', + username: "Nom d'utilisateur", + mail: 'Adresse mail', + password: 'Mot de passe', + 'password.confirmation': 'Confirmation de mot de passe', + 'selector.ui.noResult': 'Aucun résultat', + 'selector.ui.placeholder': 'Rechercher un utilisateur', + 'selector.ui.noName': 'Nom inconnu', + 'selector.ui.noCursus': 'Parcours inconnu', } as const; - diff --git a/src/components/users/UserSelector.module.scss b/src/components/users/UserSelector.module.scss new file mode 100644 index 0000000..ff085a8 --- /dev/null +++ b/src/components/users/UserSelector.module.scss @@ -0,0 +1,61 @@ +@import '@/variables.scss'; + +.userSelector { + display: flex; + flex-flow: column nowrap; + gap: 1ch; + padding: 10px; + + .resultPool { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); + gap: 1ch; + + .noResult { + display: block; + position: absolute; + text-align: center; + font-style: italic; + width: 100%; + top: 50%; + transform: translateY(-50%); + } + + .user { + background: white; + border-radius: 5px; + padding: 1ch; + display: flex; + flex-flow: row nowrap; + align-items: center; + gap: 1ch; + user-select: none; + + .avatar { + width: 2em; + height: 2em; + border-radius: 50%; + object-fit: cover; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.2em; + background: $ung-light-blue; + color: $very-light-gray; + } + + .info { + display: flex; + flex-flow: column nowrap; + gap: 0.2ch; + color: $ung-dark-grey; + + :nth-child(2) { + font-style: italic; + opacity: 0.8; + } + } + } + } +} diff --git a/src/components/users/UserSelector.tsx b/src/components/users/UserSelector.tsx new file mode 100644 index 0000000..b525803 --- /dev/null +++ b/src/components/users/UserSelector.tsx @@ -0,0 +1,92 @@ +'use client'; + +import { PropsWithoutRef, useEffect, useState } from 'react'; +import { useAppTranslation } from '@/lib/i18n'; +import { useUsers } from '@/api/users/searchUsers.hook'; +import { User } from '@/api/users/user.interface'; +import Input from '../UI/Input'; +import styles from './UserSelector.module.scss'; + +export default function UserSelector({ + updateInterval = 1000, + onSelect, +}: PropsWithoutRef<{ updateInterval?: number; onSelect?: (user: User) => void }>) { + const { t } = useAppTranslation(); + + const { items, updateFilters } = useUsers(); + + const [searchString, setSearchString] = useState(''); + const [timeoutId, setTimeoutId] = useState(-1); + const [lastUpdate, setLastUpdate] = useState(0); + + useEffect(() => { + if (Date.now() - lastUpdate >= updateInterval) { + updateFilters({ q: searchString }); + setLastUpdate(Date.now()); + } else { + if (timeoutId >= 0) clearTimeout(timeoutId); + setTimeoutId( + window.setTimeout( + () => { + updateFilters({ q: searchString }); + setLastUpdate(Date.now()); + setTimeoutId(-1); + }, + updateInterval - (Date.now() - lastUpdate), + ), + ); + } + }, [searchString]); + + const select = (user: User) => { + setSearchString(''); + onSelect?.(user); + }; + + return ( +
+ setSearchString(value)} + placeholder={t('users:selector.ui.placeholder')} + /> +
+ {!items.length &&
{t('users:selector.ui.noResult')}
} + {items.map( + (user: User | null) => + user && ( +
select(user)}> +
+ {user?.avatar ? ( + {`${user.firstName} + ) : ( + user?.firstName.charAt(0) || '?' + )} +
+
+
+ {user?.firstName || user?.lastName ? ( + <> + {user?.firstName} {user?.lastName} + + ) : ( + t('users:selector.ui.noName') + )} +
+
+ {user?.branch || user?.semester ? ( + <> + {user?.branch} {user?.semester} + + ) : ( + t('users:selector.ui.noCursus') + )} +
+
+
+ ), + )} +
+
+ ); +} From cd1d7e30bafe00f59920d2f06d8f52fc8b022407 Mon Sep 17 00:00:00 2001 From: AlbanSdl Date: Fri, 10 Oct 2025 18:57:16 +0200 Subject: [PATCH 09/37] feat(ui): add code of data modal --- public/locales/fr/common.json.ts | 35 +++-- public/locales/fr/users.json.ts | 1 + src/api/assos/manageMembers.ts | 42 +++++ src/app/assos/[assoId]/page.tsx | 86 ++++++++++- src/app/assos/[assoId]/style.module.scss | 5 +- src/components/UI/Link.tsx | 8 +- src/components/UI/ModalForm.tsx | 145 ++++++++++++++++++ src/components/users/UserCard.module.scss | 38 +++++ src/components/users/UserCard.tsx | 38 +++++ src/components/users/UserSelector.module.scss | 37 ----- src/components/users/UserSelector.tsx | 36 +---- src/icons/UserAdd.tsx | 9 ++ src/icons/UserRemove.tsx | 9 ++ src/icons/index.ts | 4 + 14 files changed, 401 insertions(+), 92 deletions(-) create mode 100644 src/api/assos/manageMembers.ts create mode 100644 src/components/UI/ModalForm.tsx create mode 100644 src/components/users/UserCard.module.scss create mode 100644 src/components/users/UserCard.tsx create mode 100644 src/icons/UserAdd.tsx create mode 100644 src/icons/UserRemove.tsx diff --git a/public/locales/fr/common.json.ts b/public/locales/fr/common.json.ts index 484baac..791489a 100644 --- a/public/locales/fr/common.json.ts +++ b/public/locales/fr/common.json.ts @@ -17,20 +17,21 @@ // } export default { - "filter.all": "Tous", - "filter.filters": "Filtres", - "input.editableText.modify": "Modifier", - "loading": "Chargement", - "navbar.addWidget": "Ajouter", - "navbar.associations": "Associations", - "navbar.home": "Accueil", - "navbar.myAssociations": "Mes Assos", - "navbar.myTimetable": "Mon EdT", - "navbar.myUEs": "Mes matières", - "navbar.profile": "Mon profil", - "navbar.uesBrowser": "Guide des UEs", - "navbar.userBrowser": "Trombinoscope", - "or": "Ou", - "results": "résultats", - "404": "404 - Page not found", -} as const; \ No newline at end of file + 'filter.all': 'Tous', + 'filter.filters': 'Filtres', + 'input.editableText.modify': 'Modifier', + loading: 'Chargement', + 'navbar.addWidget': 'Ajouter', + 'navbar.associations': 'Associations', + 'navbar.home': 'Accueil', + 'navbar.myAssociations': 'Mes Assos', + 'navbar.myTimetable': 'Mon EdT', + 'navbar.myUEs': 'Mes matières', + 'navbar.profile': 'Mon profil', + 'navbar.uesBrowser': 'Guide des UEs', + 'navbar.userBrowser': 'Trombinoscope', + or: 'Ou', + results: 'résultats', + confirm: 'Confirmer', + '404': '404 - Page not found', +} as const; diff --git a/public/locales/fr/users.json.ts b/public/locales/fr/users.json.ts index 4348eb7..66ba7e4 100644 --- a/public/locales/fr/users.json.ts +++ b/public/locales/fr/users.json.ts @@ -39,4 +39,5 @@ export default { 'selector.ui.placeholder': 'Rechercher un utilisateur', 'selector.ui.noName': 'Nom inconnu', 'selector.ui.noCursus': 'Parcours inconnu', + 'modal.form.change': "Changer l'utilisateur", } as const; diff --git a/src/api/assos/manageMembers.ts b/src/api/assos/manageMembers.ts new file mode 100644 index 0000000..de6badf --- /dev/null +++ b/src/api/assos/manageMembers.ts @@ -0,0 +1,42 @@ +import { API } from '../api'; + +type Member = { + id: string; + userId: string; + roleId: string; + startAt: Date; + endAt: Date; +}; + +type MemberCreateRequest = { + roleId: string; + userId: string; + endAt: Date; + permissions: string[]; +}; + +type MemberUpdateRequest = Partial>; + +export function addMember( + api: API, + assoId: string, + roleId: string, + userId: string, + endAt: Date, + permissions: string[], +) { + return api.post(`/assos/${assoId}/members`, { + roleId, + userId, + endAt, + permissions, + }); +} + +export function updateMember(api: API, assoId: string, memberId: string, data: MemberUpdateRequest) { + return api.patch(`/assos/${assoId}/members/${memberId}`, data); +} + +export function deleteMember(api: API, assoId: string, memberId: string) { + return api.delete(`/assos/${assoId}/members/${memberId}`); +} diff --git a/src/app/assos/[assoId]/page.tsx b/src/app/assos/[assoId]/page.tsx index fa5b60e..5f3c83a 100644 --- a/src/app/assos/[assoId]/page.tsx +++ b/src/app/assos/[assoId]/page.tsx @@ -18,24 +18,38 @@ import { updateRole } from '@/api/assos/updateRole'; import Input from '@/components/UI/Input'; import { Role } from '@/api/assos/member.interface'; import { createRole } from '@/api/assos/createRole'; +import { addMember, deleteMember, updateMember } from '@/api/assos/manageMembers'; +import { User } from '@/api/users/user.interface'; function RoleComponent({ role, editing = false, canEdit, hasPermission, + hasMembersPermission, displayOldMembers, deleteAssoRole, updateAssoRole, + createAssoMember, + deleteAssoMember, + updateAssoMember, setCurrentEditingRole, }: PropsWithoutRef<{ role: Role; editing?: boolean; canEdit: boolean; hasPermission: boolean; + hasMembersPermission: boolean; displayOldMembers: boolean; deleteAssoRole: (id: string) => void; updateAssoRole: (id: string, data: Partial<{ name: string; position: number }>) => void; + createAssoMember: (roleId: string, endAt: Date, permissions: string[], user: User) => void; + deleteAssoMember: (id: string) => void; + updateAssoMember: ( + id: string, + data: Partial<{ endAt: Date; permissions: string[]; roleId: string }>, + fromRoleId: string, + ) => void; setCurrentEditingRole: (id: string | null) => void; }>) { const [currentEditingRoleValue, setCurrentEditingRoleValue] = useState(role.name); @@ -59,6 +73,11 @@ function RoleComponent({ )} {canEdit && ( <> + @@ -88,9 +107,10 @@ function RoleComponent({ key={member.id} noStyle href={`/users/${member.userId}`} + disabled={canEdit} className={isOld ? styles.oldMember : styles.member}>
- + {member?.firstName.charAt(0)
{member.firstName} {member.lastName} @@ -112,6 +132,17 @@ function RoleComponent({ )}
+ {!isOld && canEdit && ( + <> + + + + )}
) @@ -178,6 +209,55 @@ export default function AssoDetailPage() { if (createdRole) setMembers([...members, { ...createdRole, members: [] }].sort((a, b) => a.position - b.position)); }; + const createAssoMember = async (roleId: string, endAt: Date, permissions: string[], user: User) => { + const newMembership = await addMember(api, asso!.id, roleId, user.id, endAt, permissions).toPromise(); + if (newMembership) + setMembers((members) => { + const role = members.find((r) => r.id === roleId); + if (!role) return members; + role.members.push({ + ...newMembership, + firstName: user.firstName, + lastName: user.lastName, + permissions: permissions, + }); + return [...members]; // force rerender + }); + }; + + const updateAssoMember = async ( + id: string, + data: Partial<{ endAt: Date; permissions: string[]; roleId: string }>, + roleId: string, + ) => { + const updatedMembership = await updateMember(api, asso.id, id, data).toPromise(); + setMembers((members) => { + const affectedMembership = members + .find((role) => role.id === roleId) + ?.members.find((member) => member.id === updatedMembership?.id); + if (affectedMembership && updatedMembership) { + Object.assign(affectedMembership, updatedMembership); + if (roleId !== data.roleId) { + // Move member to another role + const oldRole = members.find((role) => role.id === roleId); + oldRole?.members.splice(oldRole.members.indexOf(affectedMembership!), 1); + members.find((role) => role.id === data.roleId)?.members.push(affectedMembership!); + } + } + return [...members]; // force rerender + }); + }; + + const deleteAssoMember = async (id: string) => { + const deletedMembership = await deleteMember(api, asso.id, id).toPromise(); + setMembers((members) => { + const affectedRole = members.find((role) => role.id === deletedMembership?.roleId); + const affectedMembership = affectedRole?.members.find((member) => member.id === deletedMembership?.id); + if (affectedMembership) Object.assign(affectedMembership, deletedMembership); + return [...members]; // force rerender + }); + }; + return (
i).join(' ')}> @@ -248,10 +328,14 @@ export default function AssoDetailPage() { role={role} editing={currentEditingRole === role.id} hasPermission={permissions.has('manage_roles')} + hasMembersPermission={permissions.has('manage_members')} canEdit={editMembersMode} displayOldMembers={displayOldMembers} deleteAssoRole={deleteAssoRole} updateAssoRole={updateAssoRole} + createAssoMember={createAssoMember} + deleteAssoMember={deleteAssoMember} + updateAssoMember={updateAssoMember} setCurrentEditingRole={setCurrentEditingRole} /> )} diff --git a/src/app/assos/[assoId]/style.module.scss b/src/app/assos/[assoId]/style.module.scss index 592e7ac..1a86095 100644 --- a/src/app/assos/[assoId]/style.module.scss +++ b/src/app/assos/[assoId]/style.module.scss @@ -181,8 +181,8 @@ align-items: center; img { - width: 5ch; - height: 5ch; + width: 3.125ch; + height: 3.125ch; border-radius: 50%; object-fit: cover; display: flex; @@ -191,6 +191,7 @@ justify-content: center; overflow: hidden; flex-shrink: 0; + font-size: 1.6em; background-color: rgba($color: $ung-light-grey, $alpha: 0.2); } diff --git a/src/components/UI/Link.tsx b/src/components/UI/Link.tsx index 507717a..8d29a89 100644 --- a/src/components/UI/Link.tsx +++ b/src/components/UI/Link.tsx @@ -9,14 +9,20 @@ export default function Link({ className = '', noStyle = false, newTab = false, + disabled = false, }: { children?: ReactNode; href: Url; className?: string; noStyle?: boolean; newTab?: boolean; + disabled?: boolean; }) { - return ( + return disabled ? ( +
+ {children} +
+ ) : ( { + type: T; + defaultValue?: DataModalType[T]; + options: DataModalType[T]; + label: ReactNode; +} + +type DataModalEntry = T extends OptionsFieldsRequired + ? DataModalEntryBase + : T extends OptionsFieldsPossible + ? Omit, 'options'> & { options?: DataModalEntryBase['options'][] } + : Omit, 'options'>; + +type DataModalKeys = { [key: string]: keyof DataModalType }; + +type DataModalSchema = { + [S in keyof T]: DataModalEntry; +}; + +type ModalFormProps = PropsWithoutRef<{ + fields: DataModalSchema; + onSubmit: (data: { [K in keyof Schema]: DataModalType[Schema[K]] }) => void; +}>; + +type ModalStates = { + [K in keyof Schema]: DataModalType[Schema[K]]; +}; + +export function ModalForm({ fields, onSubmit }: ModalFormProps) { + const { t } = useAppTranslation(); + const [states, setStates] = useState>( + Object.fromEntries( + Object.entries(fields).map(([key, field]) => { + if (field.type === 'stringList') return [key, field.defaultValue || []]; + if (field.type === 'date') return [key, field.defaultValue ?? new Date()]; + if (field.type === 'string') return [key, field.defaultValue ?? '']; + return [key, undefined]; + }), + ), + ); + + return ( + <> + {Object.entries(states).map(([key, state]) => { + switch (fields[key].type) { + case 'string': + return 'options' in fields[key] ? ( + <> + {fields[key].label} + {fields[key].options!.map((option) => ( + + ))} + + ) : ( + <> + {fields[key].label} + setStates({ ...states, [key]: value })} /> + + ); + case 'date': + return ( + <> + {fields[key].label} + setStates({ ...states, [key]: new Date(value) })} + /> + + ); + case 'user': + return ( + <> + {fields[key].label} + {state ? ( + <> + + + + ) : ( + setStates({ ...states, [key]: user })} /> + )} + + ); + case 'stringList': + return ( + <> + {fields[key].label} + {fields[key].options.map((option) => ( + + ))} + + ); + } + })} + + + ); +} diff --git a/src/components/users/UserCard.module.scss b/src/components/users/UserCard.module.scss new file mode 100644 index 0000000..e86b438 --- /dev/null +++ b/src/components/users/UserCard.module.scss @@ -0,0 +1,38 @@ +@import '@/variables.scss'; + +.user { + background: white; + border-radius: 5px; + padding: 1ch; + display: flex; + flex-flow: row nowrap; + align-items: center; + gap: 1ch; + user-select: none; + + .avatar { + width: 2em; + height: 2em; + border-radius: 50%; + object-fit: cover; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.2em; + background: $ung-light-blue; + color: $very-light-gray; + } + + .info { + display: flex; + flex-flow: column nowrap; + gap: 0.2ch; + color: $ung-dark-grey; + + :nth-child(2) { + font-style: italic; + opacity: 0.8; + } + } +} diff --git a/src/components/users/UserCard.tsx b/src/components/users/UserCard.tsx new file mode 100644 index 0000000..77b2f5a --- /dev/null +++ b/src/components/users/UserCard.tsx @@ -0,0 +1,38 @@ +import { User } from '@/api/users/user.interface'; +import { useAppTranslation } from '@/lib/i18n'; +import styles from './UserCard.module.scss'; + +export function UserCard({ user, onSelect }: { user: User; onSelect?: (user: User) => void }) { + const { t } = useAppTranslation(); + return ( +
onSelect?.(user)}> +
+ {user?.avatar ? ( + {`${user.firstName} + ) : ( + user?.firstName.charAt(0) || '?' + )} +
+
+
+ {user?.firstName || user?.lastName ? ( + <> + {user?.firstName} {user?.lastName} + + ) : ( + t('users:selector.ui.noName') + )} +
+
+ {user?.branch || user?.semester ? ( + <> + {user?.branch} {user?.semester} + + ) : ( + t('users:selector.ui.noCursus') + )} +
+
+
+ ); +} diff --git a/src/components/users/UserSelector.module.scss b/src/components/users/UserSelector.module.scss index ff085a8..b6345a4 100644 --- a/src/components/users/UserSelector.module.scss +++ b/src/components/users/UserSelector.module.scss @@ -20,42 +20,5 @@ top: 50%; transform: translateY(-50%); } - - .user { - background: white; - border-radius: 5px; - padding: 1ch; - display: flex; - flex-flow: row nowrap; - align-items: center; - gap: 1ch; - user-select: none; - - .avatar { - width: 2em; - height: 2em; - border-radius: 50%; - object-fit: cover; - flex-shrink: 0; - display: flex; - align-items: center; - justify-content: center; - font-size: 1.2em; - background: $ung-light-blue; - color: $very-light-gray; - } - - .info { - display: flex; - flex-flow: column nowrap; - gap: 0.2ch; - color: $ung-dark-grey; - - :nth-child(2) { - font-style: italic; - opacity: 0.8; - } - } - } } } diff --git a/src/components/users/UserSelector.tsx b/src/components/users/UserSelector.tsx index b525803..4dbf0b6 100644 --- a/src/components/users/UserSelector.tsx +++ b/src/components/users/UserSelector.tsx @@ -1,11 +1,10 @@ -'use client'; - import { PropsWithoutRef, useEffect, useState } from 'react'; import { useAppTranslation } from '@/lib/i18n'; import { useUsers } from '@/api/users/searchUsers.hook'; import { User } from '@/api/users/user.interface'; import Input from '../UI/Input'; import styles from './UserSelector.module.scss'; +import { UserCard } from './UserCard'; export default function UserSelector({ updateInterval = 1000, @@ -53,38 +52,7 @@ export default function UserSelector({
{!items.length &&
{t('users:selector.ui.noResult')}
} {items.map( - (user: User | null) => - user && ( -
select(user)}> -
- {user?.avatar ? ( - {`${user.firstName} - ) : ( - user?.firstName.charAt(0) || '?' - )} -
-
-
- {user?.firstName || user?.lastName ? ( - <> - {user?.firstName} {user?.lastName} - - ) : ( - t('users:selector.ui.noName') - )} -
-
- {user?.branch || user?.semester ? ( - <> - {user?.branch} {user?.semester} - - ) : ( - t('users:selector.ui.noCursus') - )} -
-
-
- ), + (user: User | null) => user && select(user)} />, )}
diff --git a/src/icons/UserAdd.tsx b/src/icons/UserAdd.tsx new file mode 100644 index 0000000..349f312 --- /dev/null +++ b/src/icons/UserAdd.tsx @@ -0,0 +1,9 @@ +import React from 'react'; + +export default function UserAdd() { + return ( + + {' '} + + ); +} diff --git a/src/icons/UserRemove.tsx b/src/icons/UserRemove.tsx new file mode 100644 index 0000000..3211946 --- /dev/null +++ b/src/icons/UserRemove.tsx @@ -0,0 +1,9 @@ +import React from 'react'; + +export default function UserRemove() { + return ( + + + + ); +} diff --git a/src/icons/index.ts b/src/icons/index.ts index c4c5082..46e8e8c 100644 --- a/src/icons/index.ts +++ b/src/icons/index.ts @@ -32,6 +32,8 @@ import Edit from './Edit'; import EyeOff from './EyeOff'; import EyeOn from './EyeOn'; import Confirm from './Confirm'; +import UserAdd from './UserAdd'; +import UserRemove from './UserRemove'; const Icons = { Add, @@ -66,6 +68,8 @@ const Icons = { RightChevron, Rotate, User, + UserAdd, + UserRemove, Users, Copy, }; From 221722c853975def8f4c8438c673f67f8b164ddc Mon Sep 17 00:00:00 2001 From: AlbanSdl Date: Fri, 10 Oct 2025 21:10:12 +0200 Subject: [PATCH 10/37] feat: add style and required properties to modalform --- src/components/UI/ModalForm.module.scss | 119 ++++++++++ src/components/UI/ModalForm.tsx | 224 +++++++++++------- src/components/users/UserSelector.module.scss | 5 +- 3 files changed, 263 insertions(+), 85 deletions(-) create mode 100644 src/components/UI/ModalForm.module.scss diff --git a/src/components/UI/ModalForm.module.scss b/src/components/UI/ModalForm.module.scss new file mode 100644 index 0000000..1095681 --- /dev/null +++ b/src/components/UI/ModalForm.module.scss @@ -0,0 +1,119 @@ +@import '@/variables'; + +.darkModal { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background-color: rgba(0, 0, 0, 0.5); + transition: opacity 200ms ease-in-out; + + &.hidden { + opacity: 0; + pointer-events: none; + } +} + +.modal { + position: fixed; + right: 0; + top: 0; + width: 600px; + height: 100vh; + background-color: $light-gray; + box-shadow: -2px 0 5px rgba(0, 0, 0, 0.3); + transition: right 200ms ease-in-out; + + &.hidden { + right: -600px; + pointer-events: none; + } + + .title { + width: 100%; + padding: 1em; + background-color: $ung-dark-grey; + color: $very-light-gray; + font-weight: bold; + font-size: 1.2em; + display: flex; + flex-flow: row nowrap; + justify-content: space-between; + align-items: center; + gap: 1em; + + .close { + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + } + } + + .container { + display: flex; + flex-flow: column nowrap; + justify-content: space-between; + height: calc(100% - 4em); + + .confirm { + flex: 0 0 auto; + margin: 1em; + } + + .content { + flex: 1 1 auto; + display: flex; + flex-flow: column nowrap; + padding: 1em; + gap: 1em; + overflow: auto; + + .element { + .label { + margin-bottom: 0.5em; + font-size: 1.1em; + + .required { + color: crimson; + font-weight: bold; + } + } + + .resetUser { + margin-top: 0.5em; + } + + .options { + display: flex; + flex-flow: row wrap; + gap: 0.5em; + + .option { + padding: 2px 6px 2px 2px; + font-size: 0.9em; + border-radius: calc(10px + 0.9em); + background: transparent; + border: 1px solid $ung-dark-grey; + margin: 1px; + color: $ung-dark-grey; + text-transform: uppercase; + + & > svg { + width: 20px; + height: 20px; + } + + &.selected { + border-color: $ung-light-blue; + border-width: 2px; + margin: 0; + color: $ung-light-blue; + } + } + } + } + } + } +} diff --git a/src/components/UI/ModalForm.tsx b/src/components/UI/ModalForm.tsx index 6ce4893..0c8de3d 100644 --- a/src/components/UI/ModalForm.tsx +++ b/src/components/UI/ModalForm.tsx @@ -1,4 +1,4 @@ -import { PropsWithoutRef, ReactNode, useState } from 'react'; +import { PropsWithoutRef, ReactNode, useEffect, useState } from 'react'; import { User } from '@/api/users/user.interface'; import { useAppTranslation } from '@/lib/i18n'; import Icons from '@/icons'; @@ -6,6 +6,7 @@ import { UserCard } from '../users/UserCard'; import UserSelector from '../users/UserSelector'; import Input from './Input'; import Button from './Button'; +import styles from './ModalForm.module.scss'; interface DataModalType { date: Date; @@ -24,6 +25,7 @@ interface DataModalEntryBase { defaultValue?: DataModalType[T]; options: DataModalType[T]; label: ReactNode; + required?: boolean; } type DataModalEntry = T extends OptionsFieldsRequired @@ -38,17 +40,25 @@ type DataModalSchema = { [S in keyof T]: DataModalEntry; }; +type ModalStates = { + [K in keyof Schema]: DataModalType[Schema[K]]; +}; + +type WindowOptions = { + title: string; + submitText: ReactNode; +}; + type ModalFormProps = PropsWithoutRef<{ fields: DataModalSchema; + window: WindowOptions; onSubmit: (data: { [K in keyof Schema]: DataModalType[Schema[K]] }) => void; + onClose: () => void; }>; -type ModalStates = { - [K in keyof Schema]: DataModalType[Schema[K]]; -}; - -export function ModalForm({ fields, onSubmit }: ModalFormProps) { +export function ModalForm({ fields, window, onSubmit, onClose }: ModalFormProps) { const { t } = useAppTranslation(); + const [isHidden, setIsHidden] = useState(true); const [states, setStates] = useState>( Object.fromEntries( Object.entries(fields).map(([key, field]) => { @@ -60,86 +70,132 @@ export function ModalForm({ fields, onSubmit }: ModalFo ), ); + // Fade in effect + useEffect(() => { + requestAnimationFrame(() => setIsHidden(false)); + }, [fields]); + + const handleClose = () => { + setIsHidden(true); + setTimeout(onClose, 200); + }; + + const handleSubmit = () => { + onSubmit(states); + handleClose(); + }; + + const canValidate = () => + Object.entries(fields).every( + ([key, field]) => + !field.required || (Array.isArray(states[key]) ? (states[key] as string[]).length > 0 : states[key]), + ); + return ( <> - {Object.entries(states).map(([key, state]) => { - switch (fields[key].type) { - case 'string': - return 'options' in fields[key] ? ( - <> - {fields[key].label} - {fields[key].options!.map((option) => ( - - ))} - - ) : ( - <> - {fields[key].label} - setStates({ ...states, [key]: value })} /> - - ); - case 'date': - return ( - <> - {fields[key].label} - setStates({ ...states, [key]: new Date(value) })} - /> - - ); - case 'user': - return ( - <> - {fields[key].label} - {state ? ( - <> - - - - ) : ( - setStates({ ...states, [key]: user })} /> - )} - - ); - case 'stringList': - return ( - <> - {fields[key].label} - {fields[key].options.map((option) => ( - - ))} - - ); - } - })} - +
c).join(' ')} + onClick={handleClose}>
+
c).join(' ')}> +
+ {window.title} +
+ +
+
+
+
+ {Object.entries(states).map(([key, state]) => { + let variant: ReactNode | null; + switch (fields[key].type) { + case 'string': + variant = + 'options' in fields[key] ? ( +
+ {fields[key].options!.map((option) => ( + + ))} +
+ ) : ( + setStates({ ...states, [key]: value })} /> + ); + break; + case 'date': + variant = ( + setStates({ ...states, [key]: new Date(value) })} + /> + ); + break; + case 'user': + variant = state ? ( + <> + + + + ) : ( + setStates({ ...states, [key]: user })} /> + ); + break; + case 'stringList': + variant = ( +
+ {fields[key].options.map((option) => ( + + ))} +
+ ); + break; + } + return ( +
+
+ {fields[key].label} + {fields[key].required ? * : ''} +
+ {variant} +
+ ); + })} +
+ +
+
); } diff --git a/src/components/users/UserSelector.module.scss b/src/components/users/UserSelector.module.scss index b6345a4..b7cc4d3 100644 --- a/src/components/users/UserSelector.module.scss +++ b/src/components/users/UserSelector.module.scss @@ -4,7 +4,6 @@ display: flex; flex-flow: column nowrap; gap: 1ch; - padding: 10px; .resultPool { display: grid; @@ -20,5 +19,9 @@ top: 50%; transform: translateY(-50%); } + + & > :not(.noResult) { + cursor: pointer; + } } } From cb8e26cbd9c323fa359d700317ebdee910ee6cea Mon Sep 17 00:00:00 2001 From: AlbanSdl Date: Fri, 10 Oct 2025 21:26:38 +0200 Subject: [PATCH 11/37] feat: add labels for options in ModalForm --- src/components/UI/ModalForm.tsx | 82 +++++++++++++++++++-------------- 1 file changed, 47 insertions(+), 35 deletions(-) diff --git a/src/components/UI/ModalForm.tsx b/src/components/UI/ModalForm.tsx index 0c8de3d..b1ab39e 100644 --- a/src/components/UI/ModalForm.tsx +++ b/src/components/UI/ModalForm.tsx @@ -23,7 +23,8 @@ type OptionsFieldsPossible = 'string'; interface DataModalEntryBase { type: T; defaultValue?: DataModalType[T]; - options: DataModalType[T]; + /** When using options, you can choose to add label. The label must be provided as first element of the tuple */ + options: DataModalType[T] | [DataModalType[T], DataModalType[T]]; label: ReactNode; required?: boolean; } @@ -112,22 +113,26 @@ export function ModalForm({ fields, window, onSubmit, o variant = 'options' in fields[key] ? (
- {fields[key].options!.map((option) => ( - - ))} + {fields[key].options!.map((option) => { + const optionLabel = Array.isArray(option) ? option[0] : option; + const optionValue = Array.isArray(option) ? option[1] : option; + return ( + + ); + })}
) : ( setStates({ ...states, [key]: value })} /> @@ -158,24 +163,31 @@ export function ModalForm({ fields, window, onSubmit, o case 'stringList': variant = (
- {fields[key].options.map((option) => ( - - ))} + {fields[key].options.map((option) => { + const optionLabel = Array.isArray(option) ? option[0] : option; + const optionValue = Array.isArray(option) ? option[1] : option; + return ( + + ); + })}
); break; From 7b57aaaf994f986b497e852a4a91f348ddc3b56b Mon Sep 17 00:00:00 2001 From: AlbanSdl Date: Fri, 10 Oct 2025 23:03:39 +0200 Subject: [PATCH 12/37] feat(asso): interface complete --- public/locales/fr/assos.json.ts | 21 +++++ src/app/assos/[assoId]/page.tsx | 147 +++++++++++++++++++++++++++----- src/components/UI/ModalForm.tsx | 8 +- 3 files changed, 153 insertions(+), 23 deletions(-) diff --git a/public/locales/fr/assos.json.ts b/public/locales/fr/assos.json.ts index 8bbb943..5c9d35b 100644 --- a/public/locales/fr/assos.json.ts +++ b/public/locales/fr/assos.json.ts @@ -5,6 +5,7 @@ export default { browser: 'UTT Travail', 'filter.search': 'Recherche dans le guide des assos', 'filter.search.title': 'Recherche dans le guide des assos', + 'member.list.title': 'Membres', 'member.since': 'Depuis ', 'member.old.from': 'Entre ', 'member.old.to': ' et ', @@ -12,4 +13,24 @@ export default { 'member.old.hide': 'Masquer les anciens', 'member.edit': 'Modifier les membres', 'member.edit.stop': 'Fermer', + 'member.role.add': 'Ajouter un rôle', + 'member.role.create.title': 'Créer un rôle', + 'member.role.create.submit': 'Créer', + 'member.role.create.label': 'Nom du rôle', + 'member.role.delete.title': 'Supprimer le rôle', + 'member.role.delete.label': + 'Êtes-vous sûr de vouloir supprimer ce rôle ? Cela supprimera toutes les adhésions à ce rôle ainsi que les anciens membres.', + 'member.role.delete.confirm': "J'ai compris", + 'member.role.delete.submit': 'Supprimer', + 'member.add.title': 'Ajouter un membre', + 'member.add.label.user': 'Nouveau membre', + 'member.add.label.role': 'Rôle', + 'member.add.label.permissions': 'Permissions', + 'member.add.label.endAt': "Date de fin d'adhésion", + 'member.add.submit': 'Ajouter', + 'member.edit.title': 'Modifier un membre', + 'member.edit.label.role': 'Rôle', + 'member.edit.label.permissions': 'Permissions', + 'member.edit.label.endAt': "Date de fin d'adhésion", + 'member.edit.submit': 'Modifier', } as const; diff --git a/src/app/assos/[assoId]/page.tsx b/src/app/assos/[assoId]/page.tsx index 5f3c83a..94a542c 100644 --- a/src/app/assos/[assoId]/page.tsx +++ b/src/app/assos/[assoId]/page.tsx @@ -16,10 +16,11 @@ import { useAPI } from '@/api/api'; import { VerticalSortDnd } from '@/components/UI/VerticalSortDnd'; import { updateRole } from '@/api/assos/updateRole'; import Input from '@/components/UI/Input'; -import { Role } from '@/api/assos/member.interface'; +import { Member, Role } from '@/api/assos/member.interface'; import { createRole } from '@/api/assos/createRole'; import { addMember, deleteMember, updateMember } from '@/api/assos/manageMembers'; import { User } from '@/api/users/user.interface'; +import { DataModalSchema, ModalCallbackType, ModalForm, WindowOptions } from '@/components/UI/ModalForm'; function RoleComponent({ role, @@ -43,13 +44,9 @@ function RoleComponent({ displayOldMembers: boolean; deleteAssoRole: (id: string) => void; updateAssoRole: (id: string, data: Partial<{ name: string; position: number }>) => void; - createAssoMember: (roleId: string, endAt: Date, permissions: string[], user: User) => void; + createAssoMember: (roleId: string) => void; deleteAssoMember: (id: string) => void; - updateAssoMember: ( - id: string, - data: Partial<{ endAt: Date; permissions: string[]; roleId: string }>, - fromRoleId: string, - ) => void; + updateAssoMember: (member: Member, fromRoleId: string) => void; setCurrentEditingRole: (id: string | null) => void; }>) { const [currentEditingRoleValue, setCurrentEditingRoleValue] = useState(role.name); @@ -73,9 +70,7 @@ function RoleComponent({ )} {canEdit && ( <> -
{!isOld && canEdit && ( <> - @@ -331,17 +427,28 @@ export default function AssoDetailPage() { hasMembersPermission={permissions.has('manage_members')} canEdit={editMembersMode} displayOldMembers={displayOldMembers} - deleteAssoRole={deleteAssoRole} + deleteAssoRole={openModalForRoleDeletion} updateAssoRole={updateAssoRole} - createAssoMember={createAssoMember} + createAssoMember={openModalForMemberCreation} deleteAssoMember={deleteAssoMember} - updateAssoMember={updateAssoMember} + updateAssoMember={openModalForMemberUpdate} setCurrentEditingRole={setCurrentEditingRole} /> )} disabled={!editMembersMode || !permissions.has('manage_roles')}> )} + {modalForm && modalFormWindow && ( + + onSubmit={handlePopupSubmit} + onClose={() => { + setModalForm(null); + setModalFormWindow(null); + }} + window={modalFormWindow} + fields={modalForm} + /> + )}
); } diff --git a/src/components/UI/ModalForm.tsx b/src/components/UI/ModalForm.tsx index b1ab39e..5ed6c5b 100644 --- a/src/components/UI/ModalForm.tsx +++ b/src/components/UI/ModalForm.tsx @@ -37,7 +37,7 @@ type DataModalEntry = T extends OptionsFieldsRequ type DataModalKeys = { [key: string]: keyof DataModalType }; -type DataModalSchema = { +export type DataModalSchema = { [S in keyof T]: DataModalEntry; }; @@ -45,15 +45,17 @@ type ModalStates = { [K in keyof Schema]: DataModalType[Schema[K]]; }; -type WindowOptions = { +export type WindowOptions = { title: string; submitText: ReactNode; }; +export type ModalCallbackType = { [K in keyof Schema]: DataModalType[Schema[K]] }; + type ModalFormProps = PropsWithoutRef<{ fields: DataModalSchema; window: WindowOptions; - onSubmit: (data: { [K in keyof Schema]: DataModalType[Schema[K]] }) => void; + onSubmit: (data: ModalCallbackType) => void; onClose: () => void; }>; From 6a0fb637851e665bbd11e182d72151c47b4a0411 Mon Sep 17 00:00:00 2001 From: AlbanSdl Date: Fri, 10 Oct 2025 23:30:18 +0200 Subject: [PATCH 13/37] fix: bug fix and split page to AssoRole component --- src/app/assos/[assoId]/page.tsx | 163 +++------------------- src/app/assos/[assoId]/style.module.scss | 76 ---------- src/components/assos/AssoRole.module.scss | 77 ++++++++++ src/components/assos/AssoRole.tsx | 133 ++++++++++++++++++ 4 files changed, 231 insertions(+), 218 deletions(-) create mode 100644 src/components/assos/AssoRole.module.scss create mode 100644 src/components/assos/AssoRole.tsx diff --git a/src/app/assos/[assoId]/page.tsx b/src/app/assos/[assoId]/page.tsx index 94a542c..44a94e2 100644 --- a/src/app/assos/[assoId]/page.tsx +++ b/src/app/assos/[assoId]/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { PropsWithoutRef, useEffect, useState } from 'react'; +import { useEffect, useState } from 'react'; import { useParams } from 'next/navigation'; import styles from './style.module.scss'; import { useAsso } from '@/api/assos/fetchAsso.hook'; @@ -15,137 +15,12 @@ import { deleteRole } from '@/api/assos/deleteRole'; import { useAPI } from '@/api/api'; import { VerticalSortDnd } from '@/components/UI/VerticalSortDnd'; import { updateRole } from '@/api/assos/updateRole'; -import Input from '@/components/UI/Input'; -import { Member, Role } from '@/api/assos/member.interface'; +import { Member } from '@/api/assos/member.interface'; import { createRole } from '@/api/assos/createRole'; import { addMember, deleteMember, updateMember } from '@/api/assos/manageMembers'; import { User } from '@/api/users/user.interface'; import { DataModalSchema, ModalCallbackType, ModalForm, WindowOptions } from '@/components/UI/ModalForm'; - -function RoleComponent({ - role, - editing = false, - canEdit, - hasPermission, - hasMembersPermission, - displayOldMembers, - deleteAssoRole, - updateAssoRole, - createAssoMember, - deleteAssoMember, - updateAssoMember, - setCurrentEditingRole, -}: PropsWithoutRef<{ - role: Role; - editing?: boolean; - canEdit: boolean; - hasPermission: boolean; - hasMembersPermission: boolean; - displayOldMembers: boolean; - deleteAssoRole: (id: string) => void; - updateAssoRole: (id: string, data: Partial<{ name: string; position: number }>) => void; - createAssoMember: (roleId: string) => void; - deleteAssoMember: (id: string) => void; - updateAssoMember: (member: Member, fromRoleId: string) => void; - setCurrentEditingRole: (id: string | null) => void; -}>) { - const [currentEditingRoleValue, setCurrentEditingRoleValue] = useState(role.name); - const { t } = useAppTranslation(); - - return ( - <> -

- {role.isPresident ? ( -
- -
- ) : ( - '' - )} -
- {editing && canEdit ? ( - - ) : ( -
{role.name}
- )} - {canEdit && ( - <> - - - - - )} -
-

-
- {role.members.map((member) => { - const isOld = member.endAt < new Date(); - return ( - (!isOld || displayOldMembers || canEdit) && ( - -
- {member?.firstName.charAt(0) -
-
- {member.firstName} {member.lastName} -
-
- {t(isOld ? 'assos:member.old.from' : 'assos:member.since')} - {member.startAt.toLocaleString(undefined, { - year: 'numeric', - month: 'long', - })} - {isOld && ( - <> - {t('assos:member.old.to')} - {member.endAt.toLocaleString(undefined, { - year: 'numeric', - month: 'long', - })} - - )} -
-
- {!isOld && canEdit && ( - <> - - - - )} -
- - ) - ); - })} -
- - ); -} +import { AssoRole } from '@/components/assos/AssoRole'; type AssoDetailModalType = | { user: 'user'; roleId: 'string'; permissions: 'stringList'; endAt: 'date' } @@ -218,18 +93,21 @@ export default function AssoDetailPage() { const createAssoMember = async (roleId: string, endAt: Date, permissions: string[], user: User) => { const newMembership = await addMember(api, asso!.id, roleId, user.id, endAt, permissions).toPromise(); - if (newMembership) - setMembers((members) => { - const role = members.find((r) => r.id === roleId); - if (!role) return members; - role.members.push({ - ...newMembership, - firstName: user.firstName, - lastName: user.lastName, - permissions: permissions, - }); - return [...members]; // force rerender - }); + if (newMembership) { + setMembers( + members.map((role) => + role.id === roleId + ? { + ...role, + members: [ + ...role.members, + { ...newMembership, firstName: user.firstName, lastName: user.lastName, permissions: permissions }, + ], + } + : role, + ), + ); + } }; const updateAssoMember = async ( @@ -237,6 +115,7 @@ export default function AssoDetailPage() { data: Partial<{ endAt: Date; permissions: string[]; roleId: string }>, roleId: string, ) => { + if (data.roleId === roleId) delete data.roleId; // the api will refuse to update if roleId is the same const updatedMembership = await updateMember(api, asso.id, id, data).toPromise(); setMembers((members) => { const affectedMembership = members @@ -244,7 +123,7 @@ export default function AssoDetailPage() { ?.members.find((member) => member.id === updatedMembership?.id); if (affectedMembership && updatedMembership) { Object.assign(affectedMembership, updatedMembership); - if (roleId !== data.roleId) { + if (data.roleId && roleId !== data.roleId) { // Move member to another role const oldRole = members.find((role) => role.id === roleId); oldRole?.members.splice(oldRole.members.indexOf(affectedMembership!), 1); @@ -420,7 +299,7 @@ export default function AssoDetailPage() { setItems={setMembers} onItemMoved={(id, newIndex) => updateAssoRole(id, { position: newIndex })} inflater={({ item: role }) => ( - div { padding: 1ch 10px; } - .crown { - display: inline-block; - background-color: gold; - border-radius: 50%; - padding: 5px; - position: absolute; - left: -31px; - top: -0.05em; - width: 15px; - height: 15px; - box-sizing: content-box; - - svg { - display: block; - width: 100%; - height: 100%; - } - } - :has(> :not(path):nth-child(2):empty) { display: none; } @@ -167,38 +125,4 @@ &.editMode :has(> :not(path):nth-child(2):empty) { display: block; } - - .members { - display: flex; - flex-flow: row wrap; - gap: 2ch; - margin-top: 1ch; - - .pictureContainer { - display: flex; - flex-flow: row nowrap; - gap: 1ch; - align-items: center; - - img { - width: 3.125ch; - height: 3.125ch; - border-radius: 50%; - object-fit: cover; - display: flex; - text-align: center; - align-items: center; - justify-content: center; - overflow: hidden; - flex-shrink: 0; - font-size: 1.6em; - background-color: rgba($color: $ung-light-grey, $alpha: 0.2); - } - - .temporal { - font-size: 0.8em; - color: $ung-dark-grey; - } - } - } } diff --git a/src/components/assos/AssoRole.module.scss b/src/components/assos/AssoRole.module.scss new file mode 100644 index 0000000..d205d25 --- /dev/null +++ b/src/components/assos/AssoRole.module.scss @@ -0,0 +1,77 @@ +@import '@/variables'; + +.roleRoot { + position: relative; + + .actionRow { + display: flex; + flex-flow: row wrap; + gap: 0.5ch; + + :nth-child(1) { + flex: 2; + } + } + + .crown { + display: inline-block; + background-color: gold; + border-radius: 50%; + padding: 5px; + position: absolute; + left: -31px; + top: -0.05em; + width: 15px; + height: 15px; + box-sizing: content-box; + + svg { + display: block; + width: 100%; + height: 100%; + } + } +} + +.members { + display: flex; + flex-flow: row wrap; + gap: 2ch; + margin-top: 1ch; + + .oldMember { + opacity: 0.55; + transition: opacity 150ms ease-in-out; + + &:hover { + opacity: 1; + } + } + + .pictureContainer { + display: flex; + flex-flow: row nowrap; + gap: 1ch; + align-items: center; + + img { + width: 3.125ch; + height: 3.125ch; + border-radius: 50%; + object-fit: cover; + display: flex; + text-align: center; + align-items: center; + justify-content: center; + overflow: hidden; + flex-shrink: 0; + font-size: 1.6em; + background-color: rgba($color: $ung-light-grey, $alpha: 0.2); + } + + .temporal { + font-size: 0.8em; + color: $ung-dark-grey; + } + } +} diff --git a/src/components/assos/AssoRole.tsx b/src/components/assos/AssoRole.tsx new file mode 100644 index 0000000..42709c7 --- /dev/null +++ b/src/components/assos/AssoRole.tsx @@ -0,0 +1,133 @@ +import { PropsWithoutRef, useState } from 'react'; +import { Role, Member } from '@/api/assos/member.interface'; +import { useAppTranslation } from '@/lib/i18n'; +import styles from './AssoRole.module.scss'; +import Icons from '@/icons'; +import Button from '../UI/Button'; +import Input from '../UI/Input'; +import Link from '../UI/Link'; + +export function AssoRole({ + role, + editing = false, + canEdit, + hasPermission, + hasMembersPermission, + displayOldMembers, + deleteAssoRole, + updateAssoRole, + createAssoMember, + deleteAssoMember, + updateAssoMember, + setCurrentEditingRole, +}: PropsWithoutRef<{ + role: Role; + editing?: boolean; + canEdit: boolean; + hasPermission: boolean; + hasMembersPermission: boolean; + displayOldMembers: boolean; + deleteAssoRole: (id: string) => void; + updateAssoRole: (id: string, data: Partial<{ name: string; position: number }>) => void; + createAssoMember: (roleId: string) => void; + deleteAssoMember: (id: string) => void; + updateAssoMember: (member: Member, fromRoleId: string) => void; + setCurrentEditingRole: (id: string | null) => void; +}>) { + const [currentEditingRoleValue, setCurrentEditingRoleValue] = useState(role.name); + const { t } = useAppTranslation(); + + return ( + <> +

+ {role.isPresident ? ( +
+ +
+ ) : ( + '' + )} +
+ {editing && canEdit ? ( + + ) : ( +
{role.name}
+ )} + {canEdit && ( + <> + + + + + )} +
+

+
+ {role.members.map((member) => { + const isOld = member.endAt < new Date(); + return ( + (!isOld || displayOldMembers || canEdit) && ( + +
+ {member?.firstName.charAt(0) +
+
+ {member.firstName} {member.lastName} +
+
+ {t(isOld ? 'assos:member.old.from' : 'assos:member.since')} + {member.startAt.toLocaleString(undefined, { + year: 'numeric', + month: 'long', + })} + {isOld && ( + <> + {t('assos:member.old.to')} + {member.endAt.toLocaleString(undefined, { + year: 'numeric', + month: 'long', + })} + + )} +
+
+ {!isOld && canEdit && ( + <> + + + + )} +
+ + ) + ); + })} +
+ + ); +} From 1cf7059b05a162588353f0cee9017d309e6d8df9 Mon Sep 17 00:00:00 2001 From: AlbanSdl Date: Wed, 15 Oct 2025 22:17:32 +0200 Subject: [PATCH 14/37] fix: sort role members --- src/components/assos/AssoRole.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/components/assos/AssoRole.tsx b/src/components/assos/AssoRole.tsx index 42709c7..b1fbdde 100644 --- a/src/components/assos/AssoRole.tsx +++ b/src/components/assos/AssoRole.tsx @@ -37,6 +37,14 @@ export function AssoRole({ const [currentEditingRoleValue, setCurrentEditingRoleValue] = useState(role.name); const { t } = useAppTranslation(); + const userSorter = (a: Member, b: Member) => { + const aSign = Math.sign(a.endAt.getTime() - Date.now()); + const oldComparison = Math.sign(b.endAt.getTime() - Date.now()) - aSign; + return ( + oldComparison || (aSign > 0 ? a.startAt.getTime() - b.startAt.getTime() : b.endAt.getTime() - a.endAt.getTime()) + ); + }; + return ( <>

@@ -79,7 +87,7 @@ export function AssoRole({

- {role.members.map((member) => { + {role.members.sort(userSorter).map((member) => { const isOld = member.endAt < new Date(); return ( (!isOld || displayOldMembers || canEdit) && ( From 38c2fbd443152db3b1e065f160e45bf1818f825f Mon Sep 17 00:00:00 2001 From: AlbanSdl Date: Fri, 17 Oct 2025 19:41:38 +0200 Subject: [PATCH 15/37] feat: update icon set and introduce lexical rte --- package.json | 9 + pnpm-lock.yaml | 366 ++++++++++++++++++ public/locales/fr/assos.json.ts | 2 + src/app/assos/[assoId]/page.tsx | 32 +- src/app/assos/[assoId]/style.module.scss | 9 + src/app/assos/page.tsx | 4 +- src/app/developers/application/page.tsx | 4 +- src/app/ues/[code]/Comments.tsx | 4 +- src/app/ues/page.tsx | 4 +- src/components/Navbar.tsx | 19 +- .../LexicalPlugins/AutoLinkMatcherPlugin.tsx | 19 + .../UI/LexicalPlugins/EnableDisablePlugin.tsx | 12 + .../UI/LexicalPlugins/ImageNode.tsx | 107 +++++ .../UI/LexicalPlugins/ImagePlugin.tsx | 177 +++++++++ .../UI/LexicalPlugins/ToolbarPlugin.tsx | 179 +++++++++ src/components/UI/LexicalTextEditor.tsx | 80 ++++ src/components/UI/ModalForm.tsx | 10 +- src/components/assos/AssoRole.tsx | 14 +- .../homeWidgets/DailyTimetableWidget.tsx | 6 +- .../homeWidgets/UEBrowserWidget.tsx | 4 +- .../homeWidgets/UserBrowserWidget.tsx | 4 +- src/module/navbar.ts | 14 +- 22 files changed, 1029 insertions(+), 50 deletions(-) create mode 100644 src/components/UI/LexicalPlugins/AutoLinkMatcherPlugin.tsx create mode 100644 src/components/UI/LexicalPlugins/EnableDisablePlugin.tsx create mode 100644 src/components/UI/LexicalPlugins/ImageNode.tsx create mode 100644 src/components/UI/LexicalPlugins/ImagePlugin.tsx create mode 100644 src/components/UI/LexicalPlugins/ToolbarPlugin.tsx create mode 100644 src/components/UI/LexicalTextEditor.tsx diff --git a/package.json b/package.json index 522af17..8d00e38 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,13 @@ "@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", + "@lexical/code": "^0.37.0", + "@lexical/link": "^0.37.0", + "@lexical/list": "^0.37.0", + "@lexical/react": "^0.37.0", + "@lexical/rich-text": "^0.37.0", + "@lexical/table": "^0.37.0", + "@lexical/utils": "^0.37.0", "@reduxjs/toolkit": "^2.2.2", "date-fns": "^3.6.0", "eslint-config-next": "^14.1.4", @@ -23,8 +30,10 @@ "i18next": "^23.11.5", "i18next-browser-languagedetector": "^7.2.1", "i18next-resources-to-backend": "^1.2.1", + "lexical": "^0.37.0", "modern-normalize": "^2.0.0", "next": "^14.1.4", + "obra-icons-react": "^1.23.1", "react": "^18.3.1", "react-i18next": "^14.1.2", "react-redux": "^9.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c104f13..c6c44df 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,27 @@ importers: '@dnd-kit/utilities': specifier: ^3.2.2 version: 3.2.2(react@18.3.1) + '@lexical/code': + specifier: ^0.37.0 + version: 0.37.0 + '@lexical/link': + specifier: ^0.37.0 + version: 0.37.0 + '@lexical/list': + specifier: ^0.37.0 + version: 0.37.0 + '@lexical/react': + specifier: ^0.37.0 + version: 0.37.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(yjs@13.6.27) + '@lexical/rich-text': + specifier: ^0.37.0 + version: 0.37.0 + '@lexical/table': + specifier: ^0.37.0 + version: 0.37.0 + '@lexical/utils': + specifier: ^0.37.0 + version: 0.37.0 '@reduxjs/toolkit': specifier: ^2.2.2 version: 2.2.5(react-redux@9.1.2(@types/react@18.3.3)(react@18.3.1)(redux@5.0.1))(react@18.3.1) @@ -47,12 +68,18 @@ importers: i18next-resources-to-backend: specifier: ^1.2.1 version: 1.2.1 + lexical: + specifier: ^0.37.0 + version: 0.37.0 modern-normalize: specifier: ^2.0.0 version: 2.0.0 next: specifier: ^14.1.4 version: 14.2.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.5) + obra-icons-react: + specifier: ^1.23.1 + version: 1.23.1 react: specifier: ^18.3.1 version: 18.3.1 @@ -176,6 +203,27 @@ packages: resolution: {integrity: sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + '@floating-ui/core@1.7.3': + resolution: {integrity: sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==} + + '@floating-ui/dom@1.7.4': + resolution: {integrity: sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==} + + '@floating-ui/react-dom@2.1.6': + resolution: {integrity: sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@floating-ui/react@0.27.16': + resolution: {integrity: sha512-9O8N4SeG2z++TSM8QA/KTeKFBVCNEz/AGS7gWPJf6KFRzmRWixFRnCnkPHRDwSVZW6QPDO6uT0P2SpWNKCc9/g==} + peerDependencies: + react: '>=17.0.0' + react-dom: '>=17.0.0' + + '@floating-ui/utils@0.2.10': + resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==} + '@humanwhocodes/config-array@0.11.14': resolution: {integrity: sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==} engines: {node: '>=10.10.0'} @@ -191,6 +239,80 @@ packages: resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} + '@lexical/clipboard@0.37.0': + resolution: {integrity: sha512-hRwASFX/ilaI5r8YOcZuQgONFshRgCPfdxfofNL7uruSFYAO6LkUhsjzZwUgf0DbmCJmbBADFw15FSthgCUhGA==} + + '@lexical/code@0.37.0': + resolution: {integrity: sha512-ZXA4j/S8yLrxjrTnEp39VeDMp4Rd8bLYUlT4Buy1MQlS1WafxOiMhNQJG7k0BP/pO96YPkAebpA81ATKJL0IgA==} + + '@lexical/devtools-core@0.37.0': + resolution: {integrity: sha512-iOR+aKLJR92nKYcEOW3K/bgjTN7dJIRC/OM4OvzigU0Xygxped0lXV6UmkYBp0eoqOOwckB8+rZWZszj9lKA8Q==} + peerDependencies: + react: '>=17.x' + react-dom: '>=17.x' + + '@lexical/dragon@0.37.0': + resolution: {integrity: sha512-iC4OKivEPtt7cGVSwZylLfz5T7Oqr9q9EOosS6E/byMyoqwkYWGjXn/qFiwIv1Xo3+G19vhfChi/+ZcYLXpHPw==} + + '@lexical/extension@0.37.0': + resolution: {integrity: sha512-Z58f2tIdz9bn8gltUu5cVg37qROGha38dUZv20gI2GeNugXAkoPzJYEcxlI1D/26tkevJ/7VaFUr9PTk+iKmaA==} + + '@lexical/hashtag@0.37.0': + resolution: {integrity: sha512-DHoDpiokJRBu+GnC0qQH529hamn9YNjL7vzzkTAeEMKsT9+4O848Cq6F2GJn8QjQToySlkVZW3mkh76uf/XLfg==} + + '@lexical/history@0.37.0': + resolution: {integrity: sha512-QKkrWCw4bsn/ZeLIkMVIpbtWKPhMYeax1nE7erHqTEwE52QR6pmZsZBgGSQDO73Ae29vahOmqlN7+ZJFvTKMVA==} + + '@lexical/html@0.37.0': + resolution: {integrity: sha512-oTsBc45eL8/lmF7fqGR+UCjrJYP04gumzf5nk4TczrxWL2pM4GIMLLKG1mpQI2H1MDiRLzq3T/xdI7Gh74z7Zw==} + + '@lexical/link@0.37.0': + resolution: {integrity: sha512-gglkjE99tKYnGAxQbrUq9TcaVKBQhidXhgPPbVw3x1Fba9biMafkbSJhE/7/pzQTPoQBAIl0w7DOUWmBOv+JbQ==} + + '@lexical/list@0.37.0': + resolution: {integrity: sha512-AOC6yAA3mfNvJKbwo+kvAbPJI+13yF2ISA65vbA578CugvJ08zIVgM+pSzxquGhD0ioJY3cXVW7+gdkCP1qu5g==} + + '@lexical/mark@0.37.0': + resolution: {integrity: sha512-ncjaL6kNHVioekx6vI5oJRDExFDJLbnXT7AdMnUv2LE3sxn/ea+JsZO/MDI4Ygmxq+lGtgZvbBDER8Yh/+5jdA==} + + '@lexical/markdown@0.37.0': + resolution: {integrity: sha512-pcLMpxWkSxU2QaN2GLA3hNy4lV2A8sJOvb5YEkcsFEcVvFFbAz7lxgyKVYtDboRCW1eZFks1UGGuJEogLeEFdg==} + + '@lexical/offset@0.37.0': + resolution: {integrity: sha512-q9Ckftfhb+VepJQeaClOYzpuV+WqWWGkSUuoexV4zjAm/HVjOie9lrNF4NkhQe5crnIBXI5zOofhuEfiCQWsbQ==} + + '@lexical/overflow@0.37.0': + resolution: {integrity: sha512-GC5qoQJQzaofCq1eMMvv9wIGMAbpFbFwny5BKA1C2Nmn+/2bi6v+7qlHwiBlbSVqfLVPvT4nYdrmNdnKoE0jZg==} + + '@lexical/plain-text@0.37.0': + resolution: {integrity: sha512-4IxG9Tr0NnQ+clN1eoXfe2W8JTgw0xtPMzqvHP2IaO7RILUE6H8VFSOdhAOI0dHrjlXRMUS3I2Fhqr2ZRq8kdQ==} + + '@lexical/react@0.37.0': + resolution: {integrity: sha512-PGIGmI5xDSAguqpAStd+89TfWsi6hs/R4a3hQAyNwXXDEt4anUFJic4Qet4YftybLGajP3vMvouLE5hrkmBihg==} + peerDependencies: + react: '>=17.x' + react-dom: '>=17.x' + + '@lexical/rich-text@0.37.0': + resolution: {integrity: sha512-A9i5Es/RrZv71tB6dDSyd4TYdbkn/+oUrUdTwnWa+B8EZW26q0h+wgxCGwPtTU7ho4JNP9HOot+EIhe2DbyaYg==} + + '@lexical/selection@0.37.0': + resolution: {integrity: sha512-Lix1s2r71jHfsTEs4q/YqK2s3uXKOnyA3fd1VDMWysO+bZzRwEO5+qyDvENZ0WrXSDCnlibNFV1HttWX9/zqyw==} + + '@lexical/table@0.37.0': + resolution: {integrity: sha512-g7S8ml8kIujEDLWlzYKETgPCQ2U9oeWqdytRuHjHGi/rjAAGHSej5IRqTPIMxNP3VVQHnBoQ+Y9hBtjiuddhgQ==} + + '@lexical/text@0.37.0': + resolution: {integrity: sha512-qByNjHp88mlUWHxfYutH4vhSs3nzfCGHKsf/MqUMOC8K7Kmp0V1NK6cOW1sgsHpzkovfpgcNOGDzZxTNCFgHtg==} + + '@lexical/utils@0.37.0': + resolution: {integrity: sha512-CFp4diY/kR5RqhzQSl/7SwsMod1sgLpI1FBifcOuJ6L/S6YywGpEB4B7aV5zqW21A/jU2T+2NZtxSUn6S+9gMg==} + + '@lexical/yjs@0.37.0': + resolution: {integrity: sha512-7UjHvXDd+Is/qTdNkpQ/K04Zduh2uh7UTlSWbMiqwbQh8VRJNXXgcH8iK0TXLwc7M3VgVk+FlnNApNvcReKB6g==} + peerDependencies: + yjs: '>=13.5.22' + '@next/env@14.2.4': resolution: {integrity: sha512-3EtkY5VDkuV2+lNmKlbkibIJxcO4oIHEhBWne6PaAp+76J9KoSsGvNikp6ivzAT8dhhBMYrm6op2pS1ApG0Hzg==} @@ -271,6 +393,9 @@ packages: resolution: {integrity: sha512-cq8o4cWH0ibXh9VGi5P20Tu9XF/0fFXl9EUinr9QfTM7a7p0oTA4iJRCQWppXR1Pg8dSM0UCItCkPwsk9qWWYA==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + '@preact/signals-core@1.12.1': + resolution: {integrity: sha512-BwbTXpj+9QutoZLQvbttRg5x3l5468qaV2kufh+51yha1c53ep5dY4kTuZR35+3pAZxpfQerGJiQqg34ZNZ6uA==} + '@reduxjs/toolkit@2.2.5': resolution: {integrity: sha512-aeFA/s5NCG7NoJe/MhmwREJxRkDs0ZaSqt0MxhWUrwCf1UQXpwR87RROJEql0uAkLI6U7snBOYOcKw83ew3FPg==} peerDependencies: @@ -1204,6 +1329,9 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + isomorphic.js@0.2.5: + resolution: {integrity: sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==} + iterator.prototype@1.1.2: resolution: {integrity: sha512-DR33HMMr8EzwuRL8Y9D3u2BMj8+RqSE850jfGu59kS7tbmPLzGkZmVSfyCFSDxuZiEY6Rzt3T2NA/qU+NwVj1w==} @@ -1249,6 +1377,14 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} + lexical@0.37.0: + resolution: {integrity: sha512-r5VJR2TioQPAsZATfktnJFrGIiy6gjQN8b/+0a2u1d7/QTH7lhbB7byhGSvcq1iaa1TV/xcf/pFV55a5V5hTDQ==} + + lib0@0.2.114: + resolution: {integrity: sha512-gcxmNFzA4hv8UYi8j43uPlQ7CGcyMJ2KQb5kZASw6SnAKAf10hK12i2fjrS3Cl/ugZa5Ui6WwIu1/6MIXiHttQ==} + engines: {node: '>=16'} + hasBin: true + locate-path@6.0.0: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} @@ -1382,6 +1518,9 @@ packages: resolution: {integrity: sha512-yBYjY9QX2hnRmZHAjG/f13MzmBzxzYgQhFrke06TTyKY5zSTEqkOeukBzIdVA3j3ulu8Qa3MbVFShV7T2RmGtQ==} engines: {node: '>= 0.4'} + obra-icons-react@1.23.1: + resolution: {integrity: sha512-jbn/1zqBipS3aOd5tTb3JLjWz49pwA0NCEmc/GMvHJG75Tn+jp4KBI4BG032BfYEGl3sYld7RCcRByPle3uymg==} + once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} @@ -1457,6 +1596,10 @@ packages: engines: {node: '>=14'} hasBin: true + prismjs@1.30.0: + resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==} + engines: {node: '>=6'} + prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} @@ -1482,6 +1625,11 @@ packages: peerDependencies: react: ^18.3.1 + react-error-boundary@6.0.0: + resolution: {integrity: sha512-gdlJjD7NWr0IfkPlaREN2d9uUZUlksrfOx7SX62VRerwXbMY6ftGCIZua1VG1aXFNOimhISsTq+Owp725b9SiA==} + peerDependencies: + react: '>=16.13.1' + react-i18next@14.1.2: resolution: {integrity: sha512-FSIcJy6oauJbGEXfhUgVeLzvWBhIBIS+/9c6Lj4niwKZyGaGb4V4vUbATXSlsHJDXXB+ociNxqFNiFuV1gmoqg==} peerDependencies: @@ -1727,6 +1875,9 @@ packages: resolution: {integrity: sha512-HwOKAP7Wc5aRGYdKH+dw0PRRpbO841v2DENBtjnR5HFWoiNByAl7vrx3p0G/rCyYXQsrxqtX48TImFtPcIHSpQ==} engines: {node: ^14.18.0 || >=16.0.0} + tabbable@6.2.0: + resolution: {integrity: sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==} + tapable@2.2.1: resolution: {integrity: sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==} engines: {node: '>=6'} @@ -1854,6 +2005,10 @@ packages: yallist@4.0.0: resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + yjs@13.6.27: + resolution: {integrity: sha512-OIDwaflOaq4wC6YlPBy2L6ceKeKuF7DeTxx+jPzv1FHn9tCZ0ZwSRnUBxD05E3yed46fv/FWJbvR+Ud7x0L7zw==} + engines: {node: '>=16.0.0', npm: '>=8.0.0'} + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -1919,6 +2074,31 @@ snapshots: '@eslint/js@8.57.0': {} + '@floating-ui/core@1.7.3': + dependencies: + '@floating-ui/utils': 0.2.10 + + '@floating-ui/dom@1.7.4': + dependencies: + '@floating-ui/core': 1.7.3 + '@floating-ui/utils': 0.2.10 + + '@floating-ui/react-dom@2.1.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@floating-ui/dom': 1.7.4 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@floating-ui/react@0.27.16(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@floating-ui/react-dom': 2.1.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@floating-ui/utils': 0.2.10 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + tabbable: 6.2.0 + + '@floating-ui/utils@0.2.10': {} + '@humanwhocodes/config-array@0.11.14': dependencies: '@humanwhocodes/object-schema': 2.0.3 @@ -1940,6 +2120,167 @@ snapshots: wrap-ansi: 8.1.0 wrap-ansi-cjs: wrap-ansi@7.0.0 + '@lexical/clipboard@0.37.0': + dependencies: + '@lexical/html': 0.37.0 + '@lexical/list': 0.37.0 + '@lexical/selection': 0.37.0 + '@lexical/utils': 0.37.0 + lexical: 0.37.0 + + '@lexical/code@0.37.0': + dependencies: + '@lexical/utils': 0.37.0 + lexical: 0.37.0 + prismjs: 1.30.0 + + '@lexical/devtools-core@0.37.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@lexical/html': 0.37.0 + '@lexical/link': 0.37.0 + '@lexical/mark': 0.37.0 + '@lexical/table': 0.37.0 + '@lexical/utils': 0.37.0 + lexical: 0.37.0 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@lexical/dragon@0.37.0': + dependencies: + '@lexical/extension': 0.37.0 + lexical: 0.37.0 + + '@lexical/extension@0.37.0': + dependencies: + '@lexical/utils': 0.37.0 + '@preact/signals-core': 1.12.1 + lexical: 0.37.0 + + '@lexical/hashtag@0.37.0': + dependencies: + '@lexical/text': 0.37.0 + '@lexical/utils': 0.37.0 + lexical: 0.37.0 + + '@lexical/history@0.37.0': + dependencies: + '@lexical/extension': 0.37.0 + '@lexical/utils': 0.37.0 + lexical: 0.37.0 + + '@lexical/html@0.37.0': + dependencies: + '@lexical/selection': 0.37.0 + '@lexical/utils': 0.37.0 + lexical: 0.37.0 + + '@lexical/link@0.37.0': + dependencies: + '@lexical/extension': 0.37.0 + '@lexical/utils': 0.37.0 + lexical: 0.37.0 + + '@lexical/list@0.37.0': + dependencies: + '@lexical/extension': 0.37.0 + '@lexical/selection': 0.37.0 + '@lexical/utils': 0.37.0 + lexical: 0.37.0 + + '@lexical/mark@0.37.0': + dependencies: + '@lexical/utils': 0.37.0 + lexical: 0.37.0 + + '@lexical/markdown@0.37.0': + dependencies: + '@lexical/code': 0.37.0 + '@lexical/link': 0.37.0 + '@lexical/list': 0.37.0 + '@lexical/rich-text': 0.37.0 + '@lexical/text': 0.37.0 + '@lexical/utils': 0.37.0 + lexical: 0.37.0 + + '@lexical/offset@0.37.0': + dependencies: + lexical: 0.37.0 + + '@lexical/overflow@0.37.0': + dependencies: + lexical: 0.37.0 + + '@lexical/plain-text@0.37.0': + dependencies: + '@lexical/clipboard': 0.37.0 + '@lexical/dragon': 0.37.0 + '@lexical/selection': 0.37.0 + '@lexical/utils': 0.37.0 + lexical: 0.37.0 + + '@lexical/react@0.37.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(yjs@13.6.27)': + dependencies: + '@floating-ui/react': 0.27.16(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@lexical/devtools-core': 0.37.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@lexical/dragon': 0.37.0 + '@lexical/extension': 0.37.0 + '@lexical/hashtag': 0.37.0 + '@lexical/history': 0.37.0 + '@lexical/link': 0.37.0 + '@lexical/list': 0.37.0 + '@lexical/mark': 0.37.0 + '@lexical/markdown': 0.37.0 + '@lexical/overflow': 0.37.0 + '@lexical/plain-text': 0.37.0 + '@lexical/rich-text': 0.37.0 + '@lexical/table': 0.37.0 + '@lexical/text': 0.37.0 + '@lexical/utils': 0.37.0 + '@lexical/yjs': 0.37.0(yjs@13.6.27) + lexical: 0.37.0 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-error-boundary: 6.0.0(react@18.3.1) + transitivePeerDependencies: + - yjs + + '@lexical/rich-text@0.37.0': + dependencies: + '@lexical/clipboard': 0.37.0 + '@lexical/dragon': 0.37.0 + '@lexical/selection': 0.37.0 + '@lexical/utils': 0.37.0 + lexical: 0.37.0 + + '@lexical/selection@0.37.0': + dependencies: + lexical: 0.37.0 + + '@lexical/table@0.37.0': + dependencies: + '@lexical/clipboard': 0.37.0 + '@lexical/extension': 0.37.0 + '@lexical/utils': 0.37.0 + lexical: 0.37.0 + + '@lexical/text@0.37.0': + dependencies: + lexical: 0.37.0 + + '@lexical/utils@0.37.0': + dependencies: + '@lexical/list': 0.37.0 + '@lexical/selection': 0.37.0 + '@lexical/table': 0.37.0 + lexical: 0.37.0 + + '@lexical/yjs@0.37.0(yjs@13.6.27)': + dependencies: + '@lexical/offset': 0.37.0 + '@lexical/selection': 0.37.0 + lexical: 0.37.0 + yjs: 13.6.27 + '@next/env@14.2.4': {} '@next/eslint-plugin-next@14.2.4': @@ -1990,6 +2331,8 @@ snapshots: '@pkgr/core@0.1.1': {} + '@preact/signals-core@1.12.1': {} + '@reduxjs/toolkit@2.2.5(react-redux@9.1.2(@types/react@18.3.3)(react@18.3.1)(redux@5.0.1))(react@18.3.1)': dependencies: immer: 10.1.1 @@ -3146,6 +3489,8 @@ snapshots: isexe@2.0.0: {} + isomorphic.js@0.2.5: {} + iterator.prototype@1.1.2: dependencies: define-properties: 1.2.1 @@ -3198,6 +3543,12 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 + lexical@0.37.0: {} + + lib0@0.2.114: + dependencies: + isomorphic.js: 0.2.5 + locate-path@6.0.0: dependencies: p-locate: 5.0.0 @@ -3327,6 +3678,8 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.0.0 + obra-icons-react@1.23.1: {} + once@1.4.0: dependencies: wrappy: 1.0.2 @@ -3402,6 +3755,8 @@ snapshots: prettier@3.2.5: {} + prismjs@1.30.0: {} + prop-types@15.8.1: dependencies: loose-envify: 1.4.0 @@ -3432,6 +3787,11 @@ snapshots: react: 18.3.1 scheduler: 0.23.2 + react-error-boundary@6.0.0(react@18.3.1): + dependencies: + '@babel/runtime': 7.24.5 + react: 18.3.1 + react-i18next@14.1.2(i18next@23.11.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: '@babel/runtime': 7.24.5 @@ -3705,6 +4065,8 @@ snapshots: '@pkgr/core': 0.1.1 tslib: 2.6.2 + tabbable@6.2.0: {} + tapable@2.2.1: {} tar-fs@2.1.1: @@ -3880,4 +4242,8 @@ snapshots: yallist@4.0.0: {} + yjs@13.6.27: + dependencies: + lib0: 0.2.114 + yocto-queue@0.1.0: {} diff --git a/public/locales/fr/assos.json.ts b/public/locales/fr/assos.json.ts index 5c9d35b..b62879a 100644 --- a/public/locales/fr/assos.json.ts +++ b/public/locales/fr/assos.json.ts @@ -5,6 +5,8 @@ export default { browser: 'UTT Travail', 'filter.search': 'Recherche dans le guide des assos', 'filter.search.title': 'Recherche dans le guide des assos', + 'infos.edit': 'Modifier', + 'infos.edit.stop': 'Fermer', 'member.list.title': 'Membres', 'member.since': 'Depuis ', 'member.old.from': 'Entre ', diff --git a/src/app/assos/[assoId]/page.tsx b/src/app/assos/[assoId]/page.tsx index 44a94e2..5b86476 100644 --- a/src/app/assos/[assoId]/page.tsx +++ b/src/app/assos/[assoId]/page.tsx @@ -6,7 +6,6 @@ import styles from './style.module.scss'; import { useAsso } from '@/api/assos/fetchAsso.hook'; import Page from '@/components/utilities/Page'; import { useMembers } from '@/api/assos/fetchAssoMembers.hook'; -import Icons from '@/icons'; import Link from '@/components/UI/Link'; import { useAppTranslation } from '@/lib/i18n'; import Button from '@/components/UI/Button'; @@ -21,6 +20,17 @@ import { addMember, deleteMember, updateMember } from '@/api/assos/manageMembers import { User } from '@/api/users/user.interface'; import { DataModalSchema, ModalCallbackType, ModalForm, WindowOptions } from '@/components/UI/ModalForm'; import { AssoRole } from '@/components/assos/AssoRole'; +import LexicalTextEditor from '@/components/UI/LexicalTextEditor'; +import { + IconAdd, + IconCall, + IconClose, + IconEdit, + IconEmail, + IconExternalLink, + IconEye, + IconEyeOff, +} from 'obra-icons-react'; type AssoDetailModalType = | { user: 'user'; roleId: 'string'; permissions: 'stringList'; endAt: 'date' } @@ -39,6 +49,7 @@ export default function AssoDetailPage() { const [members, setMembers] = useMembers(params.assoId); const [permissions, setPermissions] = useState(new Set()); const [displayOldMembers, setDisplayOldMembers] = useState(false); + const [editInfosMode, setEditInfosMode] = useState(false); const [editMembersMode, setEditMembersMode] = useState(false); const [currentEditingRole, setCurrentEditingRole] = useState(null); const { t } = useAppTranslation(); @@ -238,19 +249,26 @@ export default function AssoDetailPage() { return (
i).join(' ')}> + {permissions.has('manage_infos') && ( + + )} {`Logo

{asso?.name}

{asso?.description}
+
- +
{asso?.website?.replace(/^https?:\/\/(?:www\.)?/, '')}
- +
{asso?.mail}
- +
{asso?.phoneNumber}
@@ -276,19 +294,19 @@ export default function AssoDetailPage() {
{permissions.has('manage_roles') && editMembersMode && ( )} {!!permissions.size && ( )} {!editMembersMode && ( )} diff --git a/src/app/assos/[assoId]/style.module.scss b/src/app/assos/[assoId]/style.module.scss index 08c35bd..9a19066 100644 --- a/src/app/assos/[assoId]/style.module.scss +++ b/src/app/assos/[assoId]/style.module.scss @@ -6,9 +6,18 @@ padding: 3ch; display: flex; flex-flow: row nowrap; + position: relative; gap: 2ch; border-radius: 1ch; + & > .edit { + position: absolute; + right: 3ch; + top: 3ch; + padding: 4px 8px !important; + border-radius: calc(1em + 4px) !important; + } + & > img { width: 12ch; height: 12ch; diff --git a/src/app/assos/page.tsx b/src/app/assos/page.tsx index adc9960..8f65046 100644 --- a/src/app/assos/page.tsx +++ b/src/app/assos/page.tsx @@ -1,8 +1,8 @@ 'use client'; import styles from './style.module.scss'; +import { IconBook } from 'obra-icons-react'; import { createInputFilter } from '@/components/filteredSearch/InputFilter'; import FilteredSearch, { FiltersDataType, GenericFiltersType } from '@/components/filteredSearch/FilteredSearch'; -import Icons from '@/icons'; import { ResultsList } from '@/components/ResultsList'; import { useAppTranslation } from '@/lib/i18n'; import { useAssos } from '@/api/assos/searchAssos.hook'; @@ -22,7 +22,7 @@ type FilterNames = 'name'; */ const assoFilters = Object.freeze({ name: { - component: createInputFilter('assos:filter.search', 'assos:filter.search.title', Icons.Book), + component: createInputFilter('assos:filter.search', 'assos:filter.search.title', IconBook), parameterName: 'q', updateDelayed: true, }, // This one does not need a name as it will never be displayed diff --git a/src/app/developers/application/page.tsx b/src/app/developers/application/page.tsx index 438d40d..7426030 100644 --- a/src/app/developers/application/page.tsx +++ b/src/app/developers/application/page.tsx @@ -1,6 +1,7 @@ 'use client'; import styles from './style.module.scss'; +import { IconCopy } from 'obra-icons-react'; import useApplications from '@/api/auth/applications/fetchApplications'; import Trash from '@/icons/Trash'; import Input from '@/components/UI/Input'; @@ -10,7 +11,6 @@ import createApplication from '@/api/auth/applications/createApplication'; import { useAPI } from '@/api/api'; import { useConnectedUser } from '@/module/user'; import updateApplicationToken from '@/api/auth/applications/updateToken'; -import Icons from '@/icons'; import Page from '@/components/utilities/Page'; export default function ApplicationsPage() { @@ -71,7 +71,7 @@ export default function ApplicationsPage() { {token}
diff --git a/src/app/ues/[code]/Comments.tsx b/src/app/ues/[code]/Comments.tsx index e2d6ef7..97cda6d 100644 --- a/src/app/ues/[code]/Comments.tsx +++ b/src/app/ues/[code]/Comments.tsx @@ -2,7 +2,7 @@ import styles from './Comments.module.scss'; import useComments from '@/api/comment/fetchComments'; import { TFunction, useAppTranslation } from '@/lib/i18n'; -import Icons from '@/icons'; +import { IconChevronRight } from 'obra-icons-react'; import EditableText from '@/components/EditableText'; import Button from '@/components/UI/Button'; import { useConnectedUser } from '@/module/user'; @@ -79,7 +79,7 @@ function CommentFooter( {comment.answers.length === 0 ? t('ues:detailed.comments.conversation.see.empty') : t('ues:detailed.comments.conversation.see', { responseCount: comment.answers.length.toString() })} - +
); diff --git a/src/app/ues/page.tsx b/src/app/ues/page.tsx index 944c57e..b1f789e 100644 --- a/src/app/ues/page.tsx +++ b/src/app/ues/page.tsx @@ -3,7 +3,7 @@ import styles from './style.module.scss'; import { createInputFilter } from '@/components/filteredSearch/InputFilter'; import { useUEs } from '@/api/ue/search'; import FilteredSearch, { FiltersDataType, GenericFiltersType } from '@/components/filteredSearch/FilteredSearch'; -import Icons from '@/icons'; +import { IconBook } from 'obra-icons-react'; import { createSelectFilter, SelectFilter } from '@/components/filteredSearch/SelectFilter'; import { ResultsList } from '@/components/ResultsList'; import { useAppTranslation } from '@/lib/i18n'; @@ -33,7 +33,7 @@ function useUeFilters(creditCategories: CreditCategory[] | null, branches: Branc return useMemo(() => { return Object.freeze({ name: { - component: createInputFilter('ues:filter.search', 'ues:filter.search.title', Icons.Book), + component: createInputFilter('ues:filter.search', 'ues:filter.search.title', IconBook), parameterName: 'q', updateDelayed: true, }, // This one does not need a name as it will never be displayed diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx index b773af3..3e4908f 100644 --- a/src/components/Navbar.tsx +++ b/src/components/Navbar.tsx @@ -2,11 +2,12 @@ import { useAppDispatch, useAppSelector } from '@/lib/hooks'; import styles from './Navbar.module.scss'; -import { FC, useState } from 'react'; +import { ForwardRefExoticComponent, useState } from 'react'; import { getMenu, setCollapsed, useCollapsed } from '@/module/navbar'; import Link from 'next/link'; import { type NotParameteredTranslationKey, useAppTranslation } from '@/lib/i18n'; import Icons from '@/icons'; +import { IconArrowLeft, IconLanguage, IconLogIn, IconLogOut, IconMenu } from 'obra-icons-react'; import { isLoggedIn, logout } from '@/module/session'; import Button from './UI/Button'; import { usePageSettings } from '@/module/pageSettings'; @@ -18,7 +19,7 @@ import { LocalStorageNames } from '@/global'; * This is an internal type that should not be used when developping features. * */ type MenuItemProperties = { - icon: FC; + icon: ForwardRefExoticComponent; name: Translate extends true ? NotParameteredTranslationKey : Translate extends false @@ -121,7 +122,7 @@ export default function Navbar() { - {'icon' in item ? (item as MenuItem).icon({}) : ''} + {'icon' in item && item.icon ? : ''} {item.translate ? t(item.name as NotParameteredTranslationKey) : item.name} @@ -135,7 +136,7 @@ export default function Navbar() {
toggleSelected([after, item.name].join(','))}> - {'icon' in item ? (item as MenuItem).icon({}) : ''} + {'icon' in item && item.icon ? : ''}
{item.translate ? t(item.name as NotParameteredTranslationKey) : item.name}
@@ -155,11 +156,11 @@ export default function Navbar() { EtuUTT
- +
- +
{/* NAVIGATION */} @@ -189,7 +190,7 @@ export default function Navbar() {
setLanguageSelectorOpen(!languageSelectorOpen)}> - +
@@ -217,7 +218,7 @@ export default function Navbar() {
- +
@@ -227,7 +228,7 @@ export default function Navbar() { {!loggedIn && (
- + Connexion
diff --git a/src/components/UI/LexicalPlugins/AutoLinkMatcherPlugin.tsx b/src/components/UI/LexicalPlugins/AutoLinkMatcherPlugin.tsx new file mode 100644 index 0000000..badc4b4 --- /dev/null +++ b/src/components/UI/LexicalPlugins/AutoLinkMatcherPlugin.tsx @@ -0,0 +1,19 @@ +const URL_MATCHER = + /(?:(?:https?:\/\/(?:www\.)?)|(?:www\.))[-a-zA-Z0-9@:.]{1,256}\.[a-zA-Z]{1,6}\b(?:[-a-zA-Z0-9@:%_+.~#?&//=]*)/; + +export const MATCHERS = [ + (text: string) => { + const match = URL_MATCHER.exec(text); + if (match === null) { + return null; + } + const fullMatch = match[0]; + return { + index: match.index, + length: fullMatch.length, + text: fullMatch, + url: fullMatch.startsWith('http') ? fullMatch : `https://${fullMatch}`, + attributes: { rel: 'noreferrer' }, + }; + }, +]; diff --git a/src/components/UI/LexicalPlugins/EnableDisablePlugin.tsx b/src/components/UI/LexicalPlugins/EnableDisablePlugin.tsx new file mode 100644 index 0000000..666bd7b --- /dev/null +++ b/src/components/UI/LexicalPlugins/EnableDisablePlugin.tsx @@ -0,0 +1,12 @@ +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; +import { PropsWithoutRef, useEffect } from 'react'; + +export function EnableDisablePlugin({ disabled }: PropsWithoutRef<{ disabled: boolean }>) { + const [editor] = useLexicalComposerContext(); + + useEffect(() => { + editor?.setEditable(!disabled); + }, [editor, disabled]); + + return <>; +} diff --git a/src/components/UI/LexicalPlugins/ImageNode.tsx b/src/components/UI/LexicalPlugins/ImageNode.tsx new file mode 100644 index 0000000..1ea500b --- /dev/null +++ b/src/components/UI/LexicalPlugins/ImageNode.tsx @@ -0,0 +1,107 @@ +import { useLexicalNodeSelection } from '@lexical/react/useLexicalNodeSelection'; +import { DecoratorNode, EditorConfig, NodeKey, SerializedLexicalNode, Spread } from 'lexical'; + +type SerializedImageNode = Spread< + { + key: NodeKey; + src: string; + altText: string; + width: number | 'inherit'; + height: number | 'inherit'; + }, + SerializedLexicalNode +>; + +export class ImageNode extends DecoratorNode { + __src: string; + __altText: string; + __width: number | 'inherit'; + __height: number | 'inherit'; + + static getType() { + return 'image'; + } + + static clone(node: ImageNode) { + return new ImageNode(node.__src, node.__altText, node.__width, node.__height, node.__key); + } + + constructor(src: string, altText?: string, width?: number | 'inherit', height?: number | 'inherit', key?: NodeKey) { + super(key); + this.__src = src; + this.__altText = altText || ''; + this.__width = width || 'inherit'; + this.__height = height || 'inherit'; + } + + createDOM(config: EditorConfig) { + const span = document.createElement('span'); + if (config?.theme?.image) span.className = config.theme.image; + return span; + } + + decorate() { + // eslint-disable-next-line @typescript-eslint/no-this-alias + const image = this; + function ImageComponent() { + const [isSelected, setSelected, clearSelection] = useLexicalNodeSelection(image.getKey()); + return ( + {image.__altText} { + if (event.shiftKey) { + setSelected(!isSelected); + } else { + clearSelection(); + setSelected(true); + } + event.preventDefault(); + event.stopPropagation(); + return true; + }} + referrerPolicy="no-referrer" + /> + ); + } + return ; + } + + static importJSON(serializedNode: SerializedImageNode): ImageNode { + return $createImageNode( + serializedNode.src, + serializedNode.altText, + serializedNode.width, + serializedNode.height, + serializedNode.key, + ).updateFromJSON(serializedNode); + } + + exportJSON(): SerializedImageNode { + return { + ...super.exportJSON(), + key: this.__key, + src: this.__src, + altText: this.__altText, + width: this.__width, + height: this.__height, + }; + } +} + +export function $createImageNode( + src: string, + altText?: string, + width?: number | 'inherit', + height?: number | 'inherit', + nodeKey?: NodeKey, +): ImageNode { + return new ImageNode(src, altText, width, height, nodeKey); +} + +export function $isImageNode(node: unknown): node is ImageNode { + return node instanceof ImageNode; +} diff --git a/src/components/UI/LexicalPlugins/ImagePlugin.tsx b/src/components/UI/LexicalPlugins/ImagePlugin.tsx new file mode 100644 index 0000000..2bfee23 --- /dev/null +++ b/src/components/UI/LexicalPlugins/ImagePlugin.tsx @@ -0,0 +1,177 @@ +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; +import { $wrapNodeInElement, mergeRegister } from '@lexical/utils'; +import { + $createParagraphNode, + $createRangeSelection, + $getSelection, + $insertNodes, + $isNodeSelection, + $isRootOrShadowRoot, + $setSelection, + COMMAND_PRIORITY_EDITOR, + COMMAND_PRIORITY_HIGH, + COMMAND_PRIORITY_LOW, + createCommand, + DRAGOVER_COMMAND, + DRAGSTART_COMMAND, + DROP_COMMAND, + LexicalEditor, + TextNode, +} from 'lexical'; +import { useEffect } from 'react'; +import { $createImageNode, $isImageNode, ImageNode } from './ImageNode'; + +export const INSERT_IMAGE_COMMAND = createCommand<{ + key: string; + src: string; + altText: string; + width: number; + height: number; +}>('INSERT_IMAGE_COMMAND'); + +function textNodeTransform(node: TextNode): void { + if (!node.isSimpleText() || node.hasFormat('code')) return; + + const text = node.getTextContent(); + const match = text.match(/(?:https:\/\/|www\.)\S+?\.(?:jpe?g|png|webp)(?:\?\S+)?/); + if (!match || typeof match.index !== 'number') return; + const start = match.index; + const end = start + match[0].length; + + let targetNode; + if (start === 0) [targetNode] = node.splitText(end); + else [, targetNode] = node.splitText(start, end); + const imageNode = $createImageNode(match[0]); + targetNode.replace(imageNode); +} + +export function ImagePlugin() { + const [editor] = useLexicalComposerContext(); + + useEffect(() => { + if (!editor.hasNodes([ImageNode])) throw new Error('ImagePlugin: ImageNode not registered on editor'); + + return mergeRegister( + editor.registerCommand( + INSERT_IMAGE_COMMAND, + (payload) => { + const imageNode = $createImageNode(payload.src, payload.altText, payload.width, payload.height, payload.key); + $insertNodes([imageNode]); + if ($isRootOrShadowRoot(imageNode.getParentOrThrow())) + $wrapNodeInElement(imageNode, $createParagraphNode).selectEnd(); + return true; + }, + COMMAND_PRIORITY_EDITOR, + ), + editor.registerCommand(DRAGSTART_COMMAND, (event) => onDragStart(event), COMMAND_PRIORITY_HIGH), + editor.registerCommand(DRAGOVER_COMMAND, (event) => onDragover(event), COMMAND_PRIORITY_LOW), + editor.registerCommand(DROP_COMMAND, (event) => onDrop(event, editor), COMMAND_PRIORITY_HIGH), + editor.registerNodeTransform(TextNode, textNodeTransform), + ); + }, [editor]); + + return null; +} + +const TRANSPARENT_IMAGE = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'; + +function onDragStart(event: DragEvent) { + const node = getImageNodeInSelection(); + if (!node) return false; + const dataTransfer = event.dataTransfer; + if (!dataTransfer) return false; + const img = document.createElement('img'); + img.src = TRANSPARENT_IMAGE; + dataTransfer.setData('text/plain', '_'); + dataTransfer.setDragImage(img, 0, 0); + dataTransfer.setData( + 'application/x-lexical-drag', + JSON.stringify({ + data: { + altText: node.__altText, + height: node.__height, + width: node.__width, + src: node.__src, + key: node.getKey(), + }, + type: ImageNode.getType(), + }), + ); + return true; +} + +function onDragover(event: DragEvent) { + const node = getImageNodeInSelection(); + if (!node) return false; + if (!canDropImage(event)) event.preventDefault(); + return true; +} + +function onDrop(event: DragEvent, editor: LexicalEditor) { + const node = getImageNodeInSelection(); + if (!node) return false; + const data = getDragImageData(event); + if (!data) return false; + event.preventDefault(); + if (canDropImage(event)) { + const range = getDragSelection(event); + node.remove(); + const rangeSelection = $createRangeSelection(); + if (range !== null && range !== undefined) rangeSelection.applyDOMRange(range); + $setSelection(rangeSelection); + editor.dispatchCommand(INSERT_IMAGE_COMMAND, data); + } + return true; +} + +function getImageNodeInSelection() { + const selection = $getSelection(); + if (!$isNodeSelection(selection)) return null; + const nodes = selection.getNodes(); + const node = nodes[0]; + return $isImageNode(node) ? node : null; +} + +function getDragImageData(event: DragEvent) { + const dragData = event.dataTransfer?.getData('application/x-lexical-drag'); + if (!dragData) return null; + const { type, data } = JSON.parse(dragData); + if (type !== ImageNode.getType()) return null; + return data; +} + +function canDropImage(event: DragEvent) { + const target = event.target; + return !!( + target && + target instanceof HTMLElement && + !target.closest('code, span.editor-image') && + target.parentElement && + target.parentElement.closest('div.editor-root') + ); +} + +declare global { + interface DragEvent { + rangeOffset?: number; + rangeParent?: Node; + } +} + +function getDragSelection(event: DragEvent): Range | null | undefined { + let range; + const target = event.target as HTMLElement; + const targetWindow = + target?.nodeType === 9 ? (target as unknown as Document).defaultView : target?.ownerDocument?.defaultView; + const domSelection = (targetWindow || window).getSelection(); + if (document.caretRangeFromPoint) { + range = document.caretRangeFromPoint(event.clientX, event.clientY); + } else if (event.rangeParent && domSelection !== null) { + domSelection.collapse(event.rangeParent, event.rangeOffset || 0); + range = domSelection.getRangeAt(0); + } else { + throw Error(`Cannot get the selection when dragging`); + } + + return range; +} diff --git a/src/components/UI/LexicalPlugins/ToolbarPlugin.tsx b/src/components/UI/LexicalPlugins/ToolbarPlugin.tsx new file mode 100644 index 0000000..9bd28cc --- /dev/null +++ b/src/components/UI/LexicalPlugins/ToolbarPlugin.tsx @@ -0,0 +1,179 @@ +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; +import { mergeRegister } from '@lexical/utils'; +import { + $getSelection, + $isRangeSelection, + CAN_REDO_COMMAND, + CAN_UNDO_COMMAND, + COMMAND_PRIORITY_LOW, + FORMAT_ELEMENT_COMMAND, + FORMAT_TEXT_COMMAND, + REDO_COMMAND, + SELECTION_CHANGE_COMMAND, + UNDO_COMMAND, +} from 'lexical'; +import { + IconAlignText4Center, + IconAlignText4Justify, + IconAlignText4Left, + IconAlignText4Right, + IconBold, + IconItalic, + IconNext, + IconPrevious, + IconStrikethrough, + IconUnderline, +} from 'obra-icons-react'; +import { useCallback, useEffect, useRef, useState } from 'react'; + +function Divider() { + return
; +} + +export function ToolbarPlugin() { + const [editor] = useLexicalComposerContext(); + const toolbarRef = useRef(null); + const [canUndo, setCanUndo] = useState(false); + const [canRedo, setCanRedo] = useState(false); + const [isBold, setIsBold] = useState(false); + const [isItalic, setIsItalic] = useState(false); + const [isUnderline, setIsUnderline] = useState(false); + const [isStrikethrough, setIsStrikethrough] = useState(false); + + const $updateToolbar = useCallback(() => { + const selection = $getSelection(); + if ($isRangeSelection(selection)) { + setIsBold(selection.hasFormat('bold')); + setIsItalic(selection.hasFormat('italic')); + setIsUnderline(selection.hasFormat('underline')); + setIsStrikethrough(selection.hasFormat('strikethrough')); + } + }, []); + + useEffect(() => { + return mergeRegister( + editor.registerUpdateListener(({ editorState }) => { + editorState.read( + () => { + $updateToolbar(); + }, + { editor }, + ); + }), + editor.registerCommand( + SELECTION_CHANGE_COMMAND, + () => { + $updateToolbar(); + return false; + }, + COMMAND_PRIORITY_LOW, + ), + editor.registerCommand( + CAN_UNDO_COMMAND, + (payload) => { + setCanUndo(payload); + return false; + }, + COMMAND_PRIORITY_LOW, + ), + editor.registerCommand( + CAN_REDO_COMMAND, + (payload) => { + setCanRedo(payload); + return false; + }, + COMMAND_PRIORITY_LOW, + ), + ); + }, [editor, $updateToolbar]); + + return ( +
+ + + + + + + + + + + + +
+ ); +} diff --git a/src/components/UI/LexicalTextEditor.tsx b/src/components/UI/LexicalTextEditor.tsx new file mode 100644 index 0000000..097d477 --- /dev/null +++ b/src/components/UI/LexicalTextEditor.tsx @@ -0,0 +1,80 @@ +import { LexicalComposer } from '@lexical/react/LexicalComposer'; +import { ContentEditable } from '@lexical/react/LexicalContentEditable'; +import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary'; +import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin'; +import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin'; +import { ListPlugin } from '@lexical/react/LexicalListPlugin'; +import { CheckListPlugin } from '@lexical/react/LexicalCheckListPlugin'; +import { TablePlugin } from '@lexical/react/LexicalTablePlugin'; +import { TabIndentationPlugin } from '@lexical/react/LexicalTabIndentationPlugin'; +import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin'; +import { LinkPlugin } from '@lexical/react/LexicalLinkPlugin'; +import { MarkdownShortcutPlugin } from '@lexical/react/LexicalMarkdownShortcutPlugin'; +import { HorizontalRulePlugin } from '@lexical/react/LexicalHorizontalRulePlugin'; +import { AutoLinkPlugin } from '@lexical/react/LexicalAutoLinkPlugin'; +import { AutoLinkNode, LinkNode } from '@lexical/link'; +import { HeadingNode, QuoteNode } from '@lexical/rich-text'; +import { CodeHighlightNode, CodeNode } from '@lexical/code'; +import { TableNode, TableCellNode, TableRowNode } from '@lexical/table'; +import { ListNode, ListItemNode } from '@lexical/list'; +import { HorizontalRuleNode } from '@lexical/react/LexicalHorizontalRuleNode'; +import { ToolbarPlugin } from './LexicalPlugins/ToolbarPlugin'; +import { EnableDisablePlugin } from './LexicalPlugins/EnableDisablePlugin'; +import { MATCHERS } from './LexicalPlugins/AutoLinkMatcherPlugin'; +import { ImagePlugin } from './LexicalPlugins/ImagePlugin'; +import { ImageNode } from './LexicalPlugins/ImageNode'; +import type { EditorThemeClasses } from 'lexical'; + +const theme = { + root: 'editor-root', + image: 'editor-image', +} satisfies EditorThemeClasses; + +function LexicalTextEditor({ disabled = false }) { + const initialConfig = { + namespace: 'EtuUTT Front Editor', + theme, + onError: console.error, + nodes: [ + AutoLinkNode, + CodeHighlightNode, + CodeNode, + HeadingNode, + HorizontalRuleNode, + ImageNode, + LinkNode, + ListItemNode, + ListNode, + TableCellNode, + TableNode, + TableRowNode, + QuoteNode, + ], + }; + + return ( + + {!disabled && } + Enter some text...
} /> + } + ErrorBoundary={LexicalErrorBoundary} + /> + + console.log(state.toJSON())} /> + + + + + + + + + + + + ); +} + +export default LexicalTextEditor; diff --git a/src/components/UI/ModalForm.tsx b/src/components/UI/ModalForm.tsx index 5ed6c5b..7aba176 100644 --- a/src/components/UI/ModalForm.tsx +++ b/src/components/UI/ModalForm.tsx @@ -1,7 +1,7 @@ import { PropsWithoutRef, ReactNode, useEffect, useState } from 'react'; import { User } from '@/api/users/user.interface'; import { useAppTranslation } from '@/lib/i18n'; -import Icons from '@/icons'; +import { IconAdd, IconClose } from 'obra-icons-react'; import { UserCard } from '../users/UserCard'; import UserSelector from '../users/UserSelector'; import Input from './Input'; @@ -103,7 +103,7 @@ export function ModalForm({ fields, window, onSubmit, o
{window.title}
- +
@@ -130,7 +130,7 @@ export function ModalForm({ fields, window, onSubmit, o [key]: (states[key] as string) === optionValue ? '' : optionValue, }) }> - {(state as string) === optionValue ? : } + {(state as string) === optionValue ? : } {optionLabel} ); @@ -154,7 +154,7 @@ export function ModalForm({ fields, window, onSubmit, o <> @@ -185,7 +185,7 @@ export function ModalForm({ fields, window, onSubmit, o : [...(states[key] as string[]), optionValue], }) }> - {(state as string[]).includes(optionValue) ? : } + {(state as string[]).includes(optionValue) ? : } {optionLabel} ); diff --git a/src/components/assos/AssoRole.tsx b/src/components/assos/AssoRole.tsx index b1fbdde..1179a9d 100644 --- a/src/components/assos/AssoRole.tsx +++ b/src/components/assos/AssoRole.tsx @@ -2,10 +2,10 @@ import { PropsWithoutRef, useState } from 'react'; import { Role, Member } from '@/api/assos/member.interface'; import { useAppTranslation } from '@/lib/i18n'; import styles from './AssoRole.module.scss'; -import Icons from '@/icons'; import Button from '../UI/Button'; import Input from '../UI/Input'; import Link from '../UI/Link'; +import { IconCheck, IconCrown, IconDelete, IconEdit, IconUserAdd, IconUserCross } from 'obra-icons-react'; export function AssoRole({ role, @@ -50,7 +50,7 @@ export function AssoRole({

{role.isPresident ? (
- +
) : ( '' @@ -64,10 +64,10 @@ export function AssoRole({ {canEdit && ( <> )} @@ -123,10 +123,10 @@ export function AssoRole({ {!isOld && canEdit && ( <> )} diff --git a/src/components/homeWidgets/DailyTimetableWidget.tsx b/src/components/homeWidgets/DailyTimetableWidget.tsx index d4d86b2..0afc0c2 100644 --- a/src/components/homeWidgets/DailyTimetableWidget.tsx +++ b/src/components/homeWidgets/DailyTimetableWidget.tsx @@ -5,7 +5,7 @@ import { GetDailyTimetableResponseDto, TimetableEvent } from '@/api/users/getDai import { useAPI } from '@/api/api'; import { format } from 'date-fns'; import * as locale from 'date-fns/locale'; -import Icons from '@/icons'; +import { IconChevronLeft, IconChevronRight } from 'obra-icons-react'; import Button from '@/components/UI/Button'; import { WidgetLayout } from '@/components/homeWidgets/WidgetLayout'; import { useAppTranslation } from '@/lib/i18n'; @@ -83,13 +83,13 @@ export default function DailyTimetableWidget() { subtitle={t('homepage:dailyTimetable.subtitle')}>
{format(selectedDate, `cccc d MMMM${selectedDate.getFullYear() === new Date().getFullYear() ? '' : ' yyyy'}`, { locale: locale.fr, })}
diff --git a/src/components/homeWidgets/UEBrowserWidget.tsx b/src/components/homeWidgets/UEBrowserWidget.tsx index 93b60ee..e164107 100644 --- a/src/components/homeWidgets/UEBrowserWidget.tsx +++ b/src/components/homeWidgets/UEBrowserWidget.tsx @@ -3,7 +3,7 @@ import Input from '@/components/UI/Input'; import { useState } from 'react'; import { WidgetLayout } from '@/components/homeWidgets/WidgetLayout'; import { useRouter } from 'next/navigation'; -import Icons from '@/icons'; +import { IconBook } from 'obra-icons-react'; export default function UEBrowserWidget() { const { t } = useAppTranslation(); @@ -14,7 +14,7 @@ export default function UEBrowserWidget() { router.push(`/ues?q=${search}`)} /> diff --git a/src/components/homeWidgets/UserBrowserWidget.tsx b/src/components/homeWidgets/UserBrowserWidget.tsx index 5e91739..5fb16cb 100644 --- a/src/components/homeWidgets/UserBrowserWidget.tsx +++ b/src/components/homeWidgets/UserBrowserWidget.tsx @@ -3,7 +3,7 @@ import Input from '@/components/UI/Input'; import { useState } from 'react'; import { WidgetLayout } from '@/components/homeWidgets/WidgetLayout'; import { useRouter } from 'next/navigation'; -import Icons from '@/icons'; +import { IconUser } from 'obra-icons-react'; export default function UserBrowserWidget() { const { t } = useAppTranslation(); @@ -14,7 +14,7 @@ export default function UserBrowserWidget() { router.push(`/users?q=${search}`)} /> diff --git a/src/module/navbar.ts b/src/module/navbar.ts index 76e07db..f2f7cc5 100644 --- a/src/module/navbar.ts +++ b/src/module/navbar.ts @@ -1,9 +1,9 @@ import { MenuItem } from '@/components/Navbar'; import { createSlice, type PayloadAction } from '@reduxjs/toolkit'; import { AppThunk, RootState } from 'src/lib/store'; -import Icons from '@/icons'; import { LocalStorageNames } from '@/global'; import { useAppSelector } from '@/lib/hooks'; +import { IconBook, IconChevronRight, IconHome, IconUser, IconUsers } from 'obra-icons-react'; interface NavbarSlice { items: MenuItem[]; @@ -77,35 +77,35 @@ export const navbarSlice = createSlice({ initialState: { items: [ { - icon: Icons.Home, + icon: IconHome, name: 'common:navbar.home', path: '/', translate: true, needLogin: false, }, { - icon: Icons.User, + icon: IconUser, name: 'common:navbar.userBrowser', path: '/users', translate: true, needLogin: true, }, { - icon: Icons.Book, + icon: IconBook, name: 'common:navbar.uesBrowser', path: '/ues', translate: true, needLogin: false, }, { - icon: Icons.Users, + icon: IconUsers, name: 'common:navbar.associations', path: '/assos', translate: true, needLogin: false, }, { - icon: Icons.Caret, + icon: IconChevronRight, name: 'common:navbar.myUEs', translate: true, needLogin: true, @@ -129,7 +129,7 @@ export const navbarSlice = createSlice({ ], }, { - icon: Icons.Caret, + icon: IconChevronRight, name: 'common:navbar.myAssociations', translate: true, needLogin: true, From 24ffee64057b95b641f15016f0e7faa6f43d1a0f Mon Sep 17 00:00:00 2001 From: AlbanSdl Date: Fri, 17 Oct 2025 21:04:40 +0200 Subject: [PATCH 16/37] feat: add image upload on drop --- .env.dist | 2 +- src/api/api.ts | 68 +++++++-------- .../UI/LexicalPlugins/ImageDropPlugin.tsx | 83 +++++++++++++++++++ .../UI/LexicalPlugins/ImagePlugin.tsx | 8 +- src/components/UI/LexicalTextEditor.tsx | 2 + src/utils/environment.ts | 2 +- 6 files changed, 125 insertions(+), 40 deletions(-) create mode 100644 src/components/UI/LexicalPlugins/ImageDropPlugin.tsx diff --git a/.env.dist b/.env.dist index 86d26c6..5e05eb8 100644 --- a/.env.dist +++ b/.env.dist @@ -6,7 +6,7 @@ NODE_ENV=development NEXT_PUBLIC_API_URL=http://localhost:3000 # The version of the API, will be concatenated to the API base URL. -NEXT_PUBLIC_API_VERSION=v1 +NEXT_PUBLIC_API_VERSION=1 # The timeout when doing API requests, in milliseconds. NEXT_PUBLIC_API_REQUEST_TIMEOUT=10000 diff --git a/src/api/api.ts b/src/api/api.ts index b124fb9..35d3b64 100644 --- a/src/api/api.ts +++ b/src/api/api.ts @@ -1,6 +1,11 @@ import { apiTimeout, apiUrl, apiVersion, etuuttWebApplicationId } from '@/utils/environment'; import { StatusCodes } from 'http-status-codes'; +export const computeApiURL = (path: string, version = apiVersion) => + `${apiUrl.slice(-1) === '/' ? apiUrl.slice(0, -1) : apiUrl}/v${version}/${ + path.slice(0, 1) === '/' ? path.slice(1) : path + }`; + /** * The type of error that can be produced while making a request to the API. * Note that these errors are not errors that the API can return, but rather errors that can happen while making a request / interpreting the result. @@ -60,8 +65,8 @@ type RawResponseType = T extends Date */ export class ResponseHandler< T, - R extends { [status in StatusCodes]?: any } & { fallback: any } & { - [status in ResponseError | 'success' | 'error' | 'failure']?: any; + R extends { [status in StatusCodes]?: unknown } & { fallback: unknown } & { + [status in ResponseError | 'success' | 'error' | 'failure']?: unknown; } = { fallback: undefined }, > { private readonly handlers = { fallback: () => undefined } as { @@ -137,7 +142,7 @@ async function internalRequestAPI( route: string, body: RequestType | null, timeoutMillis: number, - version: string, + version: number, isFile: true, applicationId: string, ): Promise>; @@ -146,7 +151,7 @@ async function internalRequestAPI( route: string, body: RequestType | null, timeoutMillis: number, - version: string, + version: number, isFile: boolean, applicationId: string, ): Promise>; @@ -155,7 +160,7 @@ async function internalRequestAPI( route: string, body: RequestType | null, timeoutMillis: number, - version: string, + version: number, isFile: boolean, applicationId: string, ): Promise> { @@ -173,21 +178,16 @@ async function internalRequestAPI( try { // Make the request - const response = await fetch( - `${apiUrl.slice(-1) === '/' ? apiUrl.slice(0, -1) : apiUrl}/${version}/${ - route.slice(0, 1) === '/' ? route.slice(1) : route - }`, - { - method, - headers, - body: (method === 'GET' || method === 'DELETE' ? undefined : isFile ? body : JSON.stringify(body)) as - | BodyInit - | null - | undefined, - cache: 'no-cache', - signal: abortController.signal, - }, - ); + const response = await fetch(computeApiURL(route, version), { + method, + headers, + body: (method === 'GET' || method === 'DELETE' ? undefined : isFile ? body : JSON.stringify(body)) as + | BodyInit + | null + | undefined, + cache: 'no-cache', + signal: abortController.signal, + }); if (response.status === StatusCodes.NO_CONTENT) { return { code: response.status, body: null as ResponseType }; @@ -236,13 +236,13 @@ function requestAPI( method: 'GET', route: string, body: RequestType | null, - params: { timeoutMillis?: number; version?: string; isFile: true; applicationId?: string }, + params: { timeoutMillis?: number; version?: number; isFile: true; applicationId?: string }, ): ResponseHandler; function requestAPI( method: string, route: string, body: RequestType | null, - params: { timeoutMillis?: number; version?: string; isFile?: boolean; applicationId?: string }, + params: { timeoutMillis?: number; version?: number; isFile?: boolean; applicationId?: string }, ): ResponseHandler; function requestAPI( method: string, @@ -253,7 +253,7 @@ function requestAPI( version = apiVersion, isFile = false, applicationId = etuuttWebApplicationId, - }: { timeoutMillis?: number; version?: string; isFile?: boolean; applicationId?: string } = {}, + }: { timeoutMillis?: number; version?: number; isFile?: boolean; applicationId?: string } = {}, ): ResponseHandler { return new ResponseHandler(internalRequestAPI(method, route, body, timeoutMillis, version, isFile, applicationId)); } @@ -273,24 +273,24 @@ export function useAPI(): API { return { get: ( route: string, - options: { timeoutMillis?: number; version?: string; isFile?: boolean } = {}, + options: { timeoutMillis?: number; version?: number; isFile?: boolean } = {}, ) => applyDefaultHandler(requestAPI('GET', route, null, options)), post: ( route: string, body = {} as RequestType, - options: { version?: string; isFile?: boolean; applicationId?: string } = {}, + options: { version?: number; isFile?: boolean; applicationId?: string } = {}, ) => applyDefaultHandler(requestAPI('POST', route, body, options)), put: ( route: string, body = {} as RequestType, - options: { version?: string; isFile?: boolean; applicationId?: string } = {}, + options: { version?: number; isFile?: boolean; applicationId?: string } = {}, ) => applyDefaultHandler(requestAPI('PUT', route, body, options)), patch: ( route: string, body = {} as RequestType, - options: { version?: string; isFile?: boolean } = {}, + options: { version?: number; isFile?: boolean } = {}, ) => applyDefaultHandler(requestAPI('PATCH', route, body, options)), - delete: (route: string, options: { version?: string } = {}) => + delete: (route: string, options: { version?: number } = {}) => applyDefaultHandler(requestAPI('DELETE', route, null, options)), }; } @@ -298,28 +298,28 @@ export function useAPI(): API { export interface API { get( route: string, - options: { timeoutMillis?: number; version?: string; isFile: true }, + options: { timeoutMillis?: number; version?: number; isFile: true }, ): DefaultResponseHandlerType; get( route: string, - options?: { timeoutMillis?: number; version?: string; isFile?: boolean }, + options?: { timeoutMillis?: number; version?: number; isFile?: boolean }, ): DefaultResponseHandlerType; post( route: string, body?: RequestType, - options?: { version?: string; isFile?: boolean; applicationId?: string }, + options?: { version?: number; isFile?: boolean; applicationId?: string }, ): DefaultResponseHandlerType; put( route: string, body?: RequestType, - options?: { version?: string; isFile?: boolean }, + options?: { version?: number; isFile?: boolean }, ): DefaultResponseHandlerType; patch: ( route: string, body?: RequestType, - options?: { version?: string; isFile?: boolean }, + options?: { version?: number; isFile?: boolean }, ) => DefaultResponseHandlerType; - delete(route: string, options?: { version?: string }): DefaultResponseHandlerType; + delete(route: string, options?: { version?: number }): DefaultResponseHandlerType; } /** diff --git a/src/components/UI/LexicalPlugins/ImageDropPlugin.tsx b/src/components/UI/LexicalPlugins/ImageDropPlugin.tsx new file mode 100644 index 0000000..fceae3e --- /dev/null +++ b/src/components/UI/LexicalPlugins/ImageDropPlugin.tsx @@ -0,0 +1,83 @@ +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; +import { mergeRegister } from '@lexical/utils'; +import { + COMMAND_PRIORITY_HIGH, + COMMAND_PRIORITY_LOW, + DRAGEND_COMMAND, + DRAGOVER_COMMAND, + DROP_COMMAND, + LexicalEditor, +} from 'lexical'; +import { useEffect, useState } from 'react'; +import { ImageNode } from './ImageNode'; +import { INSERT_IMAGE_COMMAND } from './ImagePlugin'; +import { computeApiURL, useAPI } from '@/api/api'; + +interface PartialUploadResponse { + id: string; + width: number; + height: number; +} + +export function ImageDropPlugin() { + const [editor] = useLexicalComposerContext(); + const [isHovered, setIsHovered] = useState(false); + const api = useAPI(); + + function onDragover(event: DragEvent) { + const file = getImageFromDataTransfer(event.dataTransfer!); + if (file) { + setIsHovered(true); + event.preventDefault(); + } + return !!file; + } + + function onDragEnd() { + setIsHovered(false); + return true; + } + + function onDrop(event: DragEvent, editor: LexicalEditor) { + const file = getImageFromDataTransfer(event.dataTransfer!); + if (!file) return false; + setIsHovered(false); + event.preventDefault(); + uploadFile(file, editor); + return true; + } + + async function uploadFile(file: File, editor: LexicalEditor) { + const formData = new FormData(); + formData.append('file', file); + const uploadResponse = await api + .post(`/media/image?public=true`, formData, { isFile: true }) + .toPromise(); + editor.dispatchCommand(INSERT_IMAGE_COMMAND, { + src: computeApiURL(`/media/image/${uploadResponse!.id}.webp`), + width: uploadResponse!.width, + height: uploadResponse!.height, + }); + } + + useEffect(() => { + if (!editor.hasNodes([ImageNode])) throw new Error('ImagePlugin: ImageNode not registered on editor'); + + return mergeRegister( + editor.registerCommand(DRAGOVER_COMMAND, (event) => onDragover(event), COMMAND_PRIORITY_LOW), + editor.registerCommand(DRAGEND_COMMAND, () => onDragEnd(), COMMAND_PRIORITY_HIGH), + editor.registerCommand(DROP_COMMAND, (event) => onDrop(event, editor), COMMAND_PRIORITY_HIGH), + ); + }, [editor]); + + return
; +} + +function getImageFromDataTransfer(dataTransfer: DataTransfer) { + const files = dataTransfer.files; + for (let i = 0; i < files.length; i++) { + const file = files[i]; + if (file.type.startsWith('image/')) return file; + } + return null; +} diff --git a/src/components/UI/LexicalPlugins/ImagePlugin.tsx b/src/components/UI/LexicalPlugins/ImagePlugin.tsx index 2bfee23..907c6af 100644 --- a/src/components/UI/LexicalPlugins/ImagePlugin.tsx +++ b/src/components/UI/LexicalPlugins/ImagePlugin.tsx @@ -22,11 +22,11 @@ import { useEffect } from 'react'; import { $createImageNode, $isImageNode, ImageNode } from './ImageNode'; export const INSERT_IMAGE_COMMAND = createCommand<{ - key: string; src: string; - altText: string; - width: number; - height: number; + key?: string; + altText?: string; + width?: number; + height?: number; }>('INSERT_IMAGE_COMMAND'); function textNodeTransform(node: TextNode): void { diff --git a/src/components/UI/LexicalTextEditor.tsx b/src/components/UI/LexicalTextEditor.tsx index 097d477..37a76d6 100644 --- a/src/components/UI/LexicalTextEditor.tsx +++ b/src/components/UI/LexicalTextEditor.tsx @@ -24,6 +24,7 @@ import { MATCHERS } from './LexicalPlugins/AutoLinkMatcherPlugin'; import { ImagePlugin } from './LexicalPlugins/ImagePlugin'; import { ImageNode } from './LexicalPlugins/ImageNode'; import type { EditorThemeClasses } from 'lexical'; +import { ImageDropPlugin } from './LexicalPlugins/ImageDropPlugin'; const theme = { root: 'editor-root', @@ -65,6 +66,7 @@ function LexicalTextEditor({ disabled = false }) { console.log(state.toJSON())} /> + diff --git a/src/utils/environment.ts b/src/utils/environment.ts index 31f47bd..e73f762 100644 --- a/src/utils/environment.ts +++ b/src/utils/environment.ts @@ -2,7 +2,7 @@ export const nodeEnv = () => process.env.NODE_ENV; export const isDevEnv = () => process.env.NODE_ENV === 'development'; export const apiUrl = process.env.NEXT_PUBLIC_API_URL || ''; -export const apiVersion = process.env.NEXT_PUBLIC_API_VERSION || 'v0'; +export const apiVersion = Number(process.env.NEXT_PUBLIC_API_VERSION || 0); export const apiTimeout = Number(process.env.NEXT_PUBLIC_API_REQUEST_TIMEOUT || 0); export const isServerSide = () => typeof window === 'undefined'; export const isClientSide = () => typeof window !== 'undefined'; From f736f1a93eecd7c12dcff6465e9ad64f0566d518 Mon Sep 17 00:00:00 2001 From: AlbanSdl Date: Sat, 18 Oct 2025 00:31:25 +0200 Subject: [PATCH 17/37] feat(media): add image wrapper to load private media --- src/components/UI/ImageMedia.tsx | 42 +++++++++++++++++++ .../UI/LexicalPlugins/ImageNode.tsx | 10 ++--- 2 files changed, 47 insertions(+), 5 deletions(-) create mode 100644 src/components/UI/ImageMedia.tsx diff --git a/src/components/UI/ImageMedia.tsx b/src/components/UI/ImageMedia.tsx new file mode 100644 index 0000000..f15372b --- /dev/null +++ b/src/components/UI/ImageMedia.tsx @@ -0,0 +1,42 @@ +import { computeApiURL, useAPI } from '@/api/api'; +import { type MouseEventHandler, PropsWithoutRef, useEffect, useState } from 'react'; + +export type ImageMediaProps = PropsWithoutRef<{ + src: string; + altText?: string; + className?: string; + width?: number | 'inherit'; + height?: number | 'inherit'; + onClick?: MouseEventHandler; +}>; + +export function ImageMedia({ src: worldSrc, altText, className, width, height, onClick }: ImageMediaProps) { + const [src, setSrc] = useState(''); + const api = useAPI(); + + useEffect(() => { + if (!worldSrc.startsWith(computeApiURL('/media/image/'))) { + setSrc(worldSrc); + return; + } + api + .get(worldSrc.slice(computeApiURL('').length), { isFile: true }) + .toPromise() + .then((blob) => setSrc(URL.createObjectURL(blob!))); + return () => { + if (src.startsWith('blob:')) URL.revokeObjectURL(src); + }; + }, [worldSrc]); + + return ( + {altText} + ); +} diff --git a/src/components/UI/LexicalPlugins/ImageNode.tsx b/src/components/UI/LexicalPlugins/ImageNode.tsx index 1ea500b..e245ef1 100644 --- a/src/components/UI/LexicalPlugins/ImageNode.tsx +++ b/src/components/UI/LexicalPlugins/ImageNode.tsx @@ -1,5 +1,6 @@ import { useLexicalNodeSelection } from '@lexical/react/useLexicalNodeSelection'; import { DecoratorNode, EditorConfig, NodeKey, SerializedLexicalNode, Spread } from 'lexical'; +import { ImageMedia } from '../ImageMedia'; type SerializedImageNode = Spread< { @@ -46,12 +47,12 @@ export class ImageNode extends DecoratorNode { function ImageComponent() { const [isSelected, setSelected, clearSelection] = useLexicalNodeSelection(image.getKey()); return ( - {image.__altText} { if (event.shiftKey) { setSelected(!isSelected); @@ -63,7 +64,6 @@ export class ImageNode extends DecoratorNode { event.stopPropagation(); return true; }} - referrerPolicy="no-referrer" /> ); } From 88da667c3e12e076455013763762d23502c6b7ab Mon Sep 17 00:00:00 2001 From: AlbanSdl Date: Sat, 18 Oct 2025 02:36:08 +0200 Subject: [PATCH 18/37] feat: add style to rte, rte-dnd-zone and toolbar --- public/locales/fr/common.json.ts | 1 + src/api/api.ts | 22 +++-- src/app/assos/[assoId]/style.module.scss | 3 +- src/components/UI/ImageMedia.tsx | 2 +- .../UI/LexicalPlugins/ImageDropPlugin.tsx | 26 +++-- .../UI/LexicalPlugins/ImageNode.tsx | 4 +- .../UI/LexicalPlugins/ImagePlugin.tsx | 14 +-- .../UI/LexicalPlugins/ToolbarPlugin.tsx | 59 ++++-------- .../UI/LexicalTextEditor.module.scss | 96 +++++++++++++++++++ src/components/UI/LexicalTextEditor.tsx | 25 +++-- 10 files changed, 173 insertions(+), 79 deletions(-) create mode 100644 src/components/UI/LexicalTextEditor.module.scss diff --git a/public/locales/fr/common.json.ts b/public/locales/fr/common.json.ts index 791489a..57235a2 100644 --- a/public/locales/fr/common.json.ts +++ b/public/locales/fr/common.json.ts @@ -34,4 +34,5 @@ export default { results: 'résultats', confirm: 'Confirmer', '404': '404 - Page not found', + 'rte.dnd.drop': "Déposez le fichier pour l'importer", } as const; diff --git a/src/api/api.ts b/src/api/api.ts index 35d3b64..aaaf874 100644 --- a/src/api/api.ts +++ b/src/api/api.ts @@ -145,6 +145,7 @@ async function internalRequestAPI( version: number, isFile: true, applicationId: string, + forceCache: boolean, ): Promise>; async function internalRequestAPI( method: string, @@ -154,6 +155,7 @@ async function internalRequestAPI( version: number, isFile: boolean, applicationId: string, + forceCache: boolean, ): Promise>; async function internalRequestAPI( method: string, @@ -163,6 +165,7 @@ async function internalRequestAPI( version: number, isFile: boolean, applicationId: string, + forceCache: boolean, ): Promise> { // Generate headers const headers = new Headers(); @@ -185,7 +188,7 @@ async function internalRequestAPI( | BodyInit | null | undefined, - cache: 'no-cache', + cache: forceCache ? 'force-cache' : 'no-cache', signal: abortController.signal, }); @@ -236,13 +239,13 @@ function requestAPI( method: 'GET', route: string, body: RequestType | null, - params: { timeoutMillis?: number; version?: number; isFile: true; applicationId?: string }, + params: { timeoutMillis?: number; version?: number; isFile: true; applicationId?: string; forceCache?: boolean }, ): ResponseHandler; function requestAPI( method: string, route: string, body: RequestType | null, - params: { timeoutMillis?: number; version?: number; isFile?: boolean; applicationId?: string }, + params: { timeoutMillis?: number; version?: number; isFile?: boolean; applicationId?: string; forceCache?: boolean }, ): ResponseHandler; function requestAPI( method: string, @@ -253,9 +256,12 @@ function requestAPI( version = apiVersion, isFile = false, applicationId = etuuttWebApplicationId, - }: { timeoutMillis?: number; version?: number; isFile?: boolean; applicationId?: string } = {}, + forceCache = false, + }: { timeoutMillis?: number; version?: number; isFile?: boolean; applicationId?: string; forceCache?: boolean } = {}, ): ResponseHandler { - return new ResponseHandler(internalRequestAPI(method, route, body, timeoutMillis, version, isFile, applicationId)); + return new ResponseHandler( + internalRequestAPI(method, route, body, timeoutMillis, version, isFile, applicationId, forceCache), + ); } // Set the authorization header with the given token for next requests @@ -273,7 +279,7 @@ export function useAPI(): API { return { get: ( route: string, - options: { timeoutMillis?: number; version?: number; isFile?: boolean } = {}, + options: { timeoutMillis?: number; version?: number; isFile?: boolean; forceCache?: boolean } = {}, ) => applyDefaultHandler(requestAPI('GET', route, null, options)), post: ( route: string, @@ -298,11 +304,11 @@ export function useAPI(): API { export interface API { get( route: string, - options: { timeoutMillis?: number; version?: number; isFile: true }, + options: { timeoutMillis?: number; version?: number; isFile: true; forceCache?: boolean }, ): DefaultResponseHandlerType; get( route: string, - options?: { timeoutMillis?: number; version?: number; isFile?: boolean }, + options?: { timeoutMillis?: number; version?: number; isFile?: boolean; forceCache?: boolean }, ): DefaultResponseHandlerType; post( route: string, diff --git a/src/app/assos/[assoId]/style.module.scss b/src/app/assos/[assoId]/style.module.scss index 9a19066..4a80d83 100644 --- a/src/app/assos/[assoId]/style.module.scss +++ b/src/app/assos/[assoId]/style.module.scss @@ -38,7 +38,8 @@ justify-content: space-between; h1:empty, - div:empty { + & > div:empty, + .actionRow div:empty { @extend .glimmer-animated; width: 16ch; height: 0.8em; diff --git a/src/components/UI/ImageMedia.tsx b/src/components/UI/ImageMedia.tsx index f15372b..3ec2621 100644 --- a/src/components/UI/ImageMedia.tsx +++ b/src/components/UI/ImageMedia.tsx @@ -20,7 +20,7 @@ export function ImageMedia({ src: worldSrc, altText, className, width, height, o return; } api - .get(worldSrc.slice(computeApiURL('').length), { isFile: true }) + .get(worldSrc.slice(computeApiURL('').length), { isFile: true, forceCache: true }) .toPromise() .then((blob) => setSrc(URL.createObjectURL(blob!))); return () => { diff --git a/src/components/UI/LexicalPlugins/ImageDropPlugin.tsx b/src/components/UI/LexicalPlugins/ImageDropPlugin.tsx index fceae3e..cd1978d 100644 --- a/src/components/UI/LexicalPlugins/ImageDropPlugin.tsx +++ b/src/components/UI/LexicalPlugins/ImageDropPlugin.tsx @@ -3,7 +3,7 @@ import { mergeRegister } from '@lexical/utils'; import { COMMAND_PRIORITY_HIGH, COMMAND_PRIORITY_LOW, - DRAGEND_COMMAND, + createCommand, DRAGOVER_COMMAND, DROP_COMMAND, LexicalEditor, @@ -12,6 +12,10 @@ import { useEffect, useState } from 'react'; import { ImageNode } from './ImageNode'; import { INSERT_IMAGE_COMMAND } from './ImagePlugin'; import { computeApiURL, useAPI } from '@/api/api'; +import styles from '../LexicalTextEditor.module.scss'; +import { useAppTranslation } from '@/lib/i18n'; + +export const DRAGLEAVE_COMMAND = createCommand('DRAGLEAVE_COMMAND'); interface PartialUploadResponse { id: string; @@ -23,17 +27,18 @@ export function ImageDropPlugin() { const [editor] = useLexicalComposerContext(); const [isHovered, setIsHovered] = useState(false); const api = useAPI(); + const { t } = useAppTranslation(); - function onDragover(event: DragEvent) { - const file = getImageFromDataTransfer(event.dataTransfer!); - if (file) { + function onDragOver(event: DragEvent) { + if (event.dataTransfer!.types.includes('Files')) { setIsHovered(true); event.preventDefault(); + return true; } - return !!file; + return false; } - function onDragEnd() { + function onDragLeave() { setIsHovered(false); return true; } @@ -63,14 +68,17 @@ export function ImageDropPlugin() { useEffect(() => { if (!editor.hasNodes([ImageNode])) throw new Error('ImagePlugin: ImageNode not registered on editor'); + const listener = (event: DragEvent) => editor.dispatchCommand(DRAGLEAVE_COMMAND, event); + editor.getRootElement()?.addEventListener('dragleave', listener); return mergeRegister( - editor.registerCommand(DRAGOVER_COMMAND, (event) => onDragover(event), COMMAND_PRIORITY_LOW), - editor.registerCommand(DRAGEND_COMMAND, () => onDragEnd(), COMMAND_PRIORITY_HIGH), + editor.registerCommand(DRAGOVER_COMMAND, (event) => onDragOver(event), COMMAND_PRIORITY_LOW), + editor.registerCommand(DRAGLEAVE_COMMAND, () => onDragLeave(), COMMAND_PRIORITY_HIGH), editor.registerCommand(DROP_COMMAND, (event) => onDrop(event, editor), COMMAND_PRIORITY_HIGH), + () => editor.getRootElement()?.removeEventListener('dragleave', listener), ); }, [editor]); - return
; + return
{isHovered && t('common:rte.dnd.drop')}
; } function getImageFromDataTransfer(dataTransfer: DataTransfer) { diff --git a/src/components/UI/LexicalPlugins/ImageNode.tsx b/src/components/UI/LexicalPlugins/ImageNode.tsx index e245ef1..fa13b86 100644 --- a/src/components/UI/LexicalPlugins/ImageNode.tsx +++ b/src/components/UI/LexicalPlugins/ImageNode.tsx @@ -1,6 +1,7 @@ import { useLexicalNodeSelection } from '@lexical/react/useLexicalNodeSelection'; import { DecoratorNode, EditorConfig, NodeKey, SerializedLexicalNode, Spread } from 'lexical'; import { ImageMedia } from '../ImageMedia'; +import styles from '../LexicalTextEditor.module.scss'; type SerializedImageNode = Spread< { @@ -48,7 +49,7 @@ export class ImageNode extends DecoratorNode { const [isSelected, setSelected, clearSelection] = useLexicalNodeSelection(image.getKey()); return ( { serializedNode.altText, serializedNode.width, serializedNode.height, - serializedNode.key, ).updateFromJSON(serializedNode); } diff --git a/src/components/UI/LexicalPlugins/ImagePlugin.tsx b/src/components/UI/LexicalPlugins/ImagePlugin.tsx index 907c6af..a4676bb 100644 --- a/src/components/UI/LexicalPlugins/ImagePlugin.tsx +++ b/src/components/UI/LexicalPlugins/ImagePlugin.tsx @@ -64,7 +64,7 @@ export function ImagePlugin() { COMMAND_PRIORITY_EDITOR, ), editor.registerCommand(DRAGSTART_COMMAND, (event) => onDragStart(event), COMMAND_PRIORITY_HIGH), - editor.registerCommand(DRAGOVER_COMMAND, (event) => onDragover(event), COMMAND_PRIORITY_LOW), + editor.registerCommand(DRAGOVER_COMMAND, (event) => onDragover(event, editor), COMMAND_PRIORITY_LOW), editor.registerCommand(DROP_COMMAND, (event) => onDrop(event, editor), COMMAND_PRIORITY_HIGH), editor.registerNodeTransform(TextNode, textNodeTransform), ); @@ -100,10 +100,10 @@ function onDragStart(event: DragEvent) { return true; } -function onDragover(event: DragEvent) { +function onDragover(event: DragEvent, editor: LexicalEditor) { const node = getImageNodeInSelection(); if (!node) return false; - if (!canDropImage(event)) event.preventDefault(); + if (!canDropImage(event, editor)) event.preventDefault(); return true; } @@ -113,7 +113,7 @@ function onDrop(event: DragEvent, editor: LexicalEditor) { const data = getDragImageData(event); if (!data) return false; event.preventDefault(); - if (canDropImage(event)) { + if (canDropImage(event, editor)) { const range = getDragSelection(event); node.remove(); const rangeSelection = $createRangeSelection(); @@ -140,14 +140,14 @@ function getDragImageData(event: DragEvent) { return data; } -function canDropImage(event: DragEvent) { +function canDropImage(event: DragEvent, editor: LexicalEditor) { const target = event.target; return !!( target && target instanceof HTMLElement && - !target.closest('code, span.editor-image') && + !target.closest(`code, span.${editor._config.theme.image}`) && target.parentElement && - target.parentElement.closest('div.editor-root') + target.parentElement.closest(`div.${editor._config.theme.root}`) ); } diff --git a/src/components/UI/LexicalPlugins/ToolbarPlugin.tsx b/src/components/UI/LexicalPlugins/ToolbarPlugin.tsx index 9bd28cc..f458382 100644 --- a/src/components/UI/LexicalPlugins/ToolbarPlugin.tsx +++ b/src/components/UI/LexicalPlugins/ToolbarPlugin.tsx @@ -25,9 +25,10 @@ import { IconUnderline, } from 'obra-icons-react'; import { useCallback, useEffect, useRef, useState } from 'react'; +import styles from '../LexicalTextEditor.module.scss'; function Divider() { - return
; + return
; } export function ToolbarPlugin() { @@ -53,48 +54,22 @@ export function ToolbarPlugin() { useEffect(() => { return mergeRegister( editor.registerUpdateListener(({ editorState }) => { - editorState.read( - () => { - $updateToolbar(); - }, - { editor }, - ); + editorState.read(() => $updateToolbar(), { editor }); }), - editor.registerCommand( - SELECTION_CHANGE_COMMAND, - () => { - $updateToolbar(); - return false; - }, - COMMAND_PRIORITY_LOW, - ), - editor.registerCommand( - CAN_UNDO_COMMAND, - (payload) => { - setCanUndo(payload); - return false; - }, - COMMAND_PRIORITY_LOW, - ), - editor.registerCommand( - CAN_REDO_COMMAND, - (payload) => { - setCanRedo(payload); - return false; - }, - COMMAND_PRIORITY_LOW, - ), + editor.registerCommand(SELECTION_CHANGE_COMMAND, () => ($updateToolbar(), false), COMMAND_PRIORITY_LOW), + editor.registerCommand(CAN_UNDO_COMMAND, (payload) => (setCanUndo(payload), false), COMMAND_PRIORITY_LOW), + editor.registerCommand(CAN_REDO_COMMAND, (payload) => (setCanRedo(payload), false), COMMAND_PRIORITY_LOW), ); }, [editor, $updateToolbar]); return ( -
+
@@ -113,7 +88,7 @@ export function ToolbarPlugin() { onClick={() => { editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'bold'); }} - className={'toolbar-item spaced ' + (isBold ? 'active' : '')} + className={`${styles.item} ${isBold ? styles.active : ''}`} aria-label="Format Bold"> @@ -121,7 +96,7 @@ export function ToolbarPlugin() { onClick={() => { editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'italic'); }} - className={'toolbar-item spaced ' + (isItalic ? 'active' : '')} + className={`${styles.item} ${isItalic ? styles.active : ''}`} aria-label="Format Italics"> @@ -129,7 +104,7 @@ export function ToolbarPlugin() { onClick={() => { editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'underline'); }} - className={'toolbar-item spaced ' + (isUnderline ? 'active' : '')} + className={`${styles.item} ${isUnderline ? styles.active : ''}`} aria-label="Format Underline"> @@ -137,7 +112,7 @@ export function ToolbarPlugin() { onClick={() => { editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'strikethrough'); }} - className={'toolbar-item spaced ' + (isStrikethrough ? 'active' : '')} + className={`${styles.item} ${isStrikethrough ? styles.active : ''}`} aria-label="Format Strikethrough"> @@ -146,7 +121,7 @@ export function ToolbarPlugin() { onClick={() => { editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, 'left'); }} - className="toolbar-item spaced" + className={styles.item} aria-label="Left Align"> @@ -154,7 +129,7 @@ export function ToolbarPlugin() { onClick={() => { editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, 'center'); }} - className="toolbar-item spaced" + className={styles.item} aria-label="Center Align"> @@ -162,7 +137,7 @@ export function ToolbarPlugin() { onClick={() => { editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, 'right'); }} - className="toolbar-item spaced" + className={styles.item} aria-label="Right Align"> @@ -170,7 +145,7 @@ export function ToolbarPlugin() { onClick={() => { editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, 'justify'); }} - className="toolbar-item" + className={styles.item} aria-label="Justify Align"> diff --git a/src/components/UI/LexicalTextEditor.module.scss b/src/components/UI/LexicalTextEditor.module.scss new file mode 100644 index 0000000..fb19b0e --- /dev/null +++ b/src/components/UI/LexicalTextEditor.module.scss @@ -0,0 +1,96 @@ +@import '@/variables'; + +.editor-root { + position: relative; + margin: 5px -5px -5px; + padding: 5px; + outline: none; + + &[contenteditable='true'] { + border: 1px solid rgba($color: $ung-light-grey, $alpha: 0.6); + border-radius: 5px; + } +} + +.editor-image { + img { + max-width: 100%; + max-height: 100%; + border-radius: 3px; + background-color: rgba($color: $ung-light-grey, $alpha: 0.5); + &.selected { + outline: 3px solid $ung-light-blue; + border-radius: 0; + } + } +} + +.toolbar { + display: flex; + flex-flow: row wrap; + gap: 5px; + margin: 5px 0; + align-items: center; + + .divider { + width: 1px; + height: 2em; + margin: 0 5px; + background-color: rgba($color: $ung-light-grey, $alpha: 0.5); + } + + .item { + border: none; + border-radius: 3px; + padding: 0; + height: calc(24px + 0.4em); + padding: 0.2em; + background-color: $ung-light-blue; + color: $very-light-gray; + + &.active { + background-color: darken($ung-light-blue, 20%); + } + + &:disabled { + background-color: desaturate($color: $ung-light-grey, $amount: 100%); + cursor: not-allowed; + } + } +} + +.editor-link { + color: $ung-light-blue; + border-bottom: 1px solid $ung-light-blue; +} + +.placeholderContainer { + position: relative; + + .placeholder { + color: rgba($color: $ung-dark-grey, $alpha: 0.6); + font-style: italic; + position: absolute; + top: 5px; + left: 2px; + pointer-events: none; + } +} + +.dropZone { + position: absolute; + top: 0; + left: -5px; + right: -5px; + bottom: 0; + background-color: rgba($color: $ung-light-blue, $alpha: 0.3); + border: 4px dashed $ung-light-blue; + display: flex; + align-items: center; + justify-content: center; + text-transform: uppercase; + color: $ung-dark-grey; + font-size: 2em; + border-radius: 5px; + pointer-events: none; +} diff --git a/src/components/UI/LexicalTextEditor.tsx b/src/components/UI/LexicalTextEditor.tsx index 37a76d6..cc60b5b 100644 --- a/src/components/UI/LexicalTextEditor.tsx +++ b/src/components/UI/LexicalTextEditor.tsx @@ -25,10 +25,12 @@ import { ImagePlugin } from './LexicalPlugins/ImagePlugin'; import { ImageNode } from './LexicalPlugins/ImageNode'; import type { EditorThemeClasses } from 'lexical'; import { ImageDropPlugin } from './LexicalPlugins/ImageDropPlugin'; +import styles from './LexicalTextEditor.module.scss'; const theme = { - root: 'editor-root', - image: 'editor-image', + root: styles['editor-root'], + image: styles['editor-image'], + link: styles['editor-link'], } satisfies EditorThemeClasses; function LexicalTextEditor({ disabled = false }) { @@ -56,17 +58,22 @@ function LexicalTextEditor({ disabled = false }) { return ( {!disabled && } - Enter some text...
} /> - } - ErrorBoundary={LexicalErrorBoundary} - /> +
+ Enter some text...
} + /> + } + ErrorBoundary={LexicalErrorBoundary} + /> + +
console.log(state.toJSON())} /> - From 105648cdda20ba990c928b2c735fdb464cdf6759 Mon Sep 17 00:00:00 2001 From: AlbanSdl Date: Sat, 18 Oct 2025 03:11:05 +0200 Subject: [PATCH 19/37] feat(rte): add placeholder config --- public/locales/fr/assos.json.ts | 2 ++ src/app/assos/[assoId]/page.tsx | 6 +++++- src/components/UI/LexicalPlugins/ImageNode.tsx | 7 ++++++- src/components/UI/LexicalTextEditor.module.scss | 3 +-- src/components/UI/LexicalTextEditor.tsx | 12 +++++++++--- 5 files changed, 23 insertions(+), 7 deletions(-) diff --git a/public/locales/fr/assos.json.ts b/public/locales/fr/assos.json.ts index b62879a..62b8170 100644 --- a/public/locales/fr/assos.json.ts +++ b/public/locales/fr/assos.json.ts @@ -7,6 +7,8 @@ export default { 'filter.search.title': 'Recherche dans le guide des assos', 'infos.edit': 'Modifier', 'infos.edit.stop': 'Fermer', + 'infos.edit.description.placeholder': 'Notre association est géniale parce que...', + 'infos.description.empty': "Cette association n'a pas encore de description.", 'member.list.title': 'Membres', 'member.since': 'Depuis ', 'member.old.from': 'Entre ', diff --git a/src/app/assos/[assoId]/page.tsx b/src/app/assos/[assoId]/page.tsx index 5b86476..04b8811 100644 --- a/src/app/assos/[assoId]/page.tsx +++ b/src/app/assos/[assoId]/page.tsx @@ -260,7 +260,11 @@ export default function AssoDetailPage() {

{asso?.name}

{asso?.description}
- +
diff --git a/src/components/UI/LexicalPlugins/ImageNode.tsx b/src/components/UI/LexicalPlugins/ImageNode.tsx index fa13b86..ab93a5f 100644 --- a/src/components/UI/LexicalPlugins/ImageNode.tsx +++ b/src/components/UI/LexicalPlugins/ImageNode.tsx @@ -2,6 +2,7 @@ import { useLexicalNodeSelection } from '@lexical/react/useLexicalNodeSelection' import { DecoratorNode, EditorConfig, NodeKey, SerializedLexicalNode, Spread } from 'lexical'; import { ImageMedia } from '../ImageMedia'; import styles from '../LexicalTextEditor.module.scss'; +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; type SerializedImageNode = Spread< { @@ -46,6 +47,7 @@ export class ImageNode extends DecoratorNode { // eslint-disable-next-line @typescript-eslint/no-this-alias const image = this; function ImageComponent() { + const [editor] = useLexicalComposerContext(); const [isSelected, setSelected, clearSelection] = useLexicalNodeSelection(image.getKey()); return ( { height={image.__height} altText={image.__altText} onClick={(event) => { - if (event.shiftKey) { + if (!editor._editable) { + setSelected(false); + clearSelection(); + } else if (event.shiftKey) { setSelected(!isSelected); } else { clearSelection(); diff --git a/src/components/UI/LexicalTextEditor.module.scss b/src/components/UI/LexicalTextEditor.module.scss index fb19b0e..bc59611 100644 --- a/src/components/UI/LexicalTextEditor.module.scss +++ b/src/components/UI/LexicalTextEditor.module.scss @@ -71,8 +71,7 @@ color: rgba($color: $ung-dark-grey, $alpha: 0.6); font-style: italic; position: absolute; - top: 5px; - left: 2px; + top: 6px; pointer-events: none; } } diff --git a/src/components/UI/LexicalTextEditor.tsx b/src/components/UI/LexicalTextEditor.tsx index cc60b5b..ed66f85 100644 --- a/src/components/UI/LexicalTextEditor.tsx +++ b/src/components/UI/LexicalTextEditor.tsx @@ -33,7 +33,13 @@ const theme = { link: styles['editor-link'], } satisfies EditorThemeClasses; -function LexicalTextEditor({ disabled = false }) { +interface LexicalTextEditorProps { + placeholder: string; + emptyText?: string; + disabled?: boolean; +} + +function LexicalTextEditor({ placeholder, emptyText, disabled = false }: LexicalTextEditorProps) { const initialConfig = { namespace: 'EtuUTT Front Editor', theme, @@ -62,8 +68,8 @@ function LexicalTextEditor({ disabled = false }) { Enter some text...
} + aria-placeholder={(disabled && emptyText) || placeholder} + placeholder={
{(disabled && emptyText) || placeholder}
} /> } ErrorBoundary={LexicalErrorBoundary} From 55d7cfe73b0de0faaecf43b85e08035fa09adf8c Mon Sep 17 00:00:00 2001 From: AlbanSdl Date: Sat, 18 Oct 2025 13:20:18 +0200 Subject: [PATCH 20/37] feat(rte): add image upload from clipboard paste --- .../UI/LexicalPlugins/ImageDropPlugin.tsx | 41 +++++++++++++------ 1 file changed, 28 insertions(+), 13 deletions(-) diff --git a/src/components/UI/LexicalPlugins/ImageDropPlugin.tsx b/src/components/UI/LexicalPlugins/ImageDropPlugin.tsx index cd1978d..f378f39 100644 --- a/src/components/UI/LexicalPlugins/ImageDropPlugin.tsx +++ b/src/components/UI/LexicalPlugins/ImageDropPlugin.tsx @@ -7,13 +7,14 @@ import { DRAGOVER_COMMAND, DROP_COMMAND, LexicalEditor, + PASTE_COMMAND, } from 'lexical'; import { useEffect, useState } from 'react'; import { ImageNode } from './ImageNode'; import { INSERT_IMAGE_COMMAND } from './ImagePlugin'; import { computeApiURL, useAPI } from '@/api/api'; -import styles from '../LexicalTextEditor.module.scss'; import { useAppTranslation } from '@/lib/i18n'; +import styles from '../LexicalTextEditor.module.scss'; export const DRAGLEAVE_COMMAND = createCommand('DRAGLEAVE_COMMAND'); @@ -44,12 +45,13 @@ export function ImageDropPlugin() { } function onDrop(event: DragEvent, editor: LexicalEditor) { - const file = getImageFromDataTransfer(event.dataTransfer!); - if (!file) return false; - setIsHovered(false); - event.preventDefault(); - uploadFile(file, editor); - return true; + const file = getImagesFromFileList(event.dataTransfer!.files); + if (file.length) { + setIsHovered(false); + event.preventDefault(); + } + file.forEach((f) => uploadFile(f, editor)); + return !!file.length; } async function uploadFile(file: File, editor: LexicalEditor) { @@ -74,6 +76,19 @@ export function ImageDropPlugin() { editor.registerCommand(DRAGOVER_COMMAND, (event) => onDragOver(event), COMMAND_PRIORITY_LOW), editor.registerCommand(DRAGLEAVE_COMMAND, () => onDragLeave(), COMMAND_PRIORITY_HIGH), editor.registerCommand(DROP_COMMAND, (event) => onDrop(event, editor), COMMAND_PRIORITY_HIGH), + editor.registerCommand( + PASTE_COMMAND, + (event) => { + if (event instanceof ClipboardEvent) { + const files = getImagesFromFileList(event.clipboardData!.files); + if (files.length) event.preventDefault(); + files.forEach((file) => uploadFile(file, editor)); + return !!files.length; + } + return false; + }, + COMMAND_PRIORITY_HIGH, + ), () => editor.getRootElement()?.removeEventListener('dragleave', listener), ); }, [editor]); @@ -81,11 +96,11 @@ export function ImageDropPlugin() { return
{isHovered && t('common:rte.dnd.drop')}
; } -function getImageFromDataTransfer(dataTransfer: DataTransfer) { - const files = dataTransfer.files; - for (let i = 0; i < files.length; i++) { - const file = files[i]; - if (file.type.startsWith('image/')) return file; +function getImagesFromFileList(fileList: FileList) { + const list = [] as File[]; + for (let i = 0; i < fileList.length; i++) { + const file = fileList[i]; + if (file.type.startsWith('image/')) list.push(file); } - return null; + return list; } From ca88b7c8a9292553e6e255b36238c9e52c020263 Mon Sep 17 00:00:00 2001 From: AlbanSdl Date: Sat, 18 Oct 2025 20:41:30 +0200 Subject: [PATCH 21/37] feat(rte): add interface for last customization types --- package.json | 1 + pnpm-lock.yaml | 3 + public/locales/fr/common.json.ts | 1 + src/components/UI/Input.tsx | 6 +- .../UI/LexicalPlugins/ColorTextNode.tsx | 331 +++++++++++++++ .../UI/LexicalPlugins/ColorTextPlugin.tsx | 40 ++ .../UI/LexicalPlugins/ImageDropPlugin.tsx | 39 +- .../UI/LexicalPlugins/ImageNode.tsx | 2 +- .../UI/LexicalPlugins/ToolbarPlugin.tsx | 383 ++++++++++++++++-- .../UI/LexicalTextEditor.module.scss | 121 +++++- src/components/UI/LexicalTextEditor.tsx | 12 +- 11 files changed, 887 insertions(+), 52 deletions(-) create mode 100644 src/components/UI/LexicalPlugins/ColorTextNode.tsx create mode 100644 src/components/UI/LexicalPlugins/ColorTextPlugin.tsx diff --git a/package.json b/package.json index 8d00e38..429961c 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "@lexical/list": "^0.37.0", "@lexical/react": "^0.37.0", "@lexical/rich-text": "^0.37.0", + "@lexical/selection": "^0.37.0", "@lexical/table": "^0.37.0", "@lexical/utils": "^0.37.0", "@reduxjs/toolkit": "^2.2.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c6c44df..934dcad 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,6 +35,9 @@ importers: '@lexical/rich-text': specifier: ^0.37.0 version: 0.37.0 + '@lexical/selection': + specifier: ^0.37.0 + version: 0.37.0 '@lexical/table': specifier: ^0.37.0 version: 0.37.0 diff --git a/public/locales/fr/common.json.ts b/public/locales/fr/common.json.ts index 57235a2..afd95a0 100644 --- a/public/locales/fr/common.json.ts +++ b/public/locales/fr/common.json.ts @@ -35,4 +35,5 @@ export default { confirm: 'Confirmer', '404': '404 - Page not found', 'rte.dnd.drop': "Déposez le fichier pour l'importer", + 'rte.toolbar.uploadImage': "Cliquez pour sélectionner l'image", } as const; diff --git a/src/components/UI/Input.tsx b/src/components/UI/Input.tsx index 455383e..b6f1a52 100644 --- a/src/components/UI/Input.tsx +++ b/src/components/UI/Input.tsx @@ -1,5 +1,5 @@ import styles from './Input.module.scss'; -import { FC, HTMLInputTypeAttribute, Ref, forwardRef } from 'react'; +import { FC, HTMLInputTypeAttribute, KeyboardEvent, Ref, forwardRef } from 'react'; import Button from '@/components/UI/Button'; function Input( @@ -16,7 +16,7 @@ function Input( }: { className?: string; onChange?: (v: T) => void; - onEnter?: () => void; + onEnter?: (event?: KeyboardEvent) => void; value?: T; placeholder?: string; type?: HTMLInputTypeAttribute; @@ -32,7 +32,7 @@ function Input( ref={ref} onChange={(v) => onChange(v.target.value as T)} onKeyDown={(e) => { - if (e.key === 'Enter') onEnter(); + if (e.key === 'Enter') onEnter(e); else if (e.key === 'ArrowUp') onArrowPressed('up'); else if (e.key === 'ArrowDown') onArrowPressed('down'); }} diff --git a/src/components/UI/LexicalPlugins/ColorTextNode.tsx b/src/components/UI/LexicalPlugins/ColorTextNode.tsx new file mode 100644 index 0000000..cdcb6a5 --- /dev/null +++ b/src/components/UI/LexicalPlugins/ColorTextNode.tsx @@ -0,0 +1,331 @@ +import { + $getEditor, + $getSelection, + $isElementNode, + $isRangeSelection, + $isTextNode, + $setCompositionKey, + EditorConfig, + LexicalNode, + NodeKey, + RangeSelection, + SerializedTextNode, + Spread, + TextNode, +} from 'lexical'; +import styles from '../LexicalTextEditor.module.scss'; + +export type ColorType = 'blue' | 'darkblue' | 'grey' | 'darkgrey'; + +type SerializedColorTextNode = Spread<{ color?: ColorType }, SerializedTextNode>; + +export class ColorTextNode extends TextNode { + __color?: ColorType; + + static getType() { + return 'color-text'; + } + + static clone(node: ColorTextNode) { + return new ColorTextNode(node.__text, node.__color, node.__key); + } + + constructor(text?: string, color?: ColorType, key?: NodeKey) { + super(text, key); + this.__color = color; + } + + setColor(color?: ColorType) { + const self = this.getWritable(); + self.__color = color; + } + + /** + * Comes from the method of base class TextNode. If this is not overwritten, + * the base class uses $createTextNode directly to split text, losing the benefits of this custom class + * @see TextNode.splitText + */ + splitText(...splitOffsets: Array): Array { + super.splitText(...splitOffsets); // Keep this to fail on read-only + const self = this.getLatest(); + const textContent = self.getTextContent(); + if (textContent === '') { + return []; + } + const key = self.__key; + const compositionKey = $getEditor()._compositionKey; + const textLength = textContent.length; + splitOffsets.sort((a, b) => a - b); + splitOffsets.push(textLength); + const parts = []; + const splitOffsetsLength = splitOffsets.length; + for (let start = 0, offsetIndex = 0; start < textLength && offsetIndex <= splitOffsetsLength; offsetIndex++) { + const end = splitOffsets[offsetIndex]; + if (end > start) { + parts.push(textContent.slice(start, end)); + start = end; + } + } + const partsLength = parts.length; + if (partsLength === 1) { + return [self]; + } + const firstPart = parts[0]; + const parent = self.getParent(); + let writableNode; + const format = self.getFormat(); + const style = self.getStyle(); + const detail = self.__detail; + let hasReplacedSelf = false; + + // Prepare to handle selection + const selection = $getSelection(); + let endTextPoint: RangeSelection['anchor'] | null = null; + let startTextPoint: RangeSelection['anchor'] | null = null; + if ($isRangeSelection(selection)) { + const [startPoint, endPoint] = selection.isBackward() + ? [selection.focus, selection.anchor] + : [selection.anchor, selection.focus]; + if (startPoint.type === 'text' && startPoint.key === key) { + startTextPoint = startPoint; + } + if (endPoint.type === 'text' && endPoint.key === key) { + endTextPoint = endPoint; + } + } + + if (self.isSegmented()) { + // Create a new TextNode + writableNode = $createColorTextNode(firstPart, this.__color); + writableNode.__format = format; + writableNode.__style = style; + writableNode.__detail = detail; + writableNode.__state = $cloneNodeState(self, writableNode); + hasReplacedSelf = true; + } else { + // For the first part, update the existing node + writableNode = self.setTextContent(firstPart); + } + + // Then handle all other parts + const splitNodes: ColorTextNode[] = [writableNode]; + let textSize = firstPart.length; + + for (let i = 1; i < partsLength; i++) { + const part = parts[i]; + const partSize = part.length; + const sibling = $createColorTextNode(part, this.__color); + sibling.__format = format; + sibling.__style = style; + sibling.__detail = detail; + sibling.__state = $cloneNodeState(self, sibling); + const siblingKey = sibling.__key; + const nextTextSize = textSize + partSize; + if (compositionKey === key) { + $setCompositionKey(siblingKey); + } + textSize = nextTextSize; + splitNodes.push(sibling); + } + + // Move the selection to the best location in the split string. + // The end point is always left-biased, and the start point is + // generally left biased unless the end point would land on a + // later node in the split in which case it will prefer the start + // of that node so they will tend to be on the same node. + const originalStartOffset = startTextPoint ? startTextPoint.offset : null; + const originalEndOffset = endTextPoint ? endTextPoint.offset : null; + let startOffset = 0; + for (const node of splitNodes) { + if (!(startTextPoint || endTextPoint)) { + break; + } + const endOffset = startOffset + node.getTextContentSize(); + if ( + startTextPoint !== null && + originalStartOffset !== null && + originalStartOffset <= endOffset && + originalStartOffset >= startOffset + ) { + // Set the start point to the first valid node + startTextPoint.set(node.getKey(), originalStartOffset - startOffset, 'text'); + if (originalStartOffset < endOffset) { + // The start isn't on a border so we can stop checking + startTextPoint = null; + } + } + if ( + endTextPoint !== null && + originalEndOffset !== null && + originalEndOffset <= endOffset && + originalEndOffset >= startOffset + ) { + endTextPoint.set(node.getKey(), originalEndOffset - startOffset, 'text'); + break; + } + startOffset = endOffset; + } + + // Insert the nodes into the parent's children + if (parent !== null) { + this.getNextSibling()?.markDirty(); + this.getPreviousSibling()?.markDirty(); + const writableParent = parent.getWritable(); + const insertionIndex = this.getIndexWithinParent(); + if (hasReplacedSelf) { + writableParent.splice(insertionIndex, 0, splitNodes); + this.remove(); + } else { + writableParent.splice(insertionIndex, 1, splitNodes); + } + + if ($isRangeSelection(selection)) { + $updateElementSelectionOnCreateDeleteNode(selection, parent, insertionIndex, partsLength - 1); + } + } + + return splitNodes; + } + + createDOM(config: EditorConfig) { + const dom = super.createDOM(config); + dom.classList.toggle(styles[`color-text-${this.__color}`], true); + return dom; + } + + updateDOM(prevNode: this, dom: HTMLElement, config: EditorConfig) { + const updated = super.updateDOM(prevNode, dom, config); + if (prevNode.__color !== this.__color) dom.classList.toggle(styles[`color-text-${prevNode.__color}`], false); + dom.classList.toggle(styles[`color-text-${this.__color}`], true); + return updated; + } + + static importJSON(serializedNode: SerializedColorTextNode): ColorTextNode { + return $createColorTextNode(serializedNode.text, serializedNode.color).updateFromJSON(serializedNode); + } + + exportJSON(): SerializedColorTextNode { + return { + ...super.exportJSON(), + color: this.__color, + }; + } +} + +export function $createColorTextNode(text?: string, color?: ColorType, nodeKey?: NodeKey): ColorTextNode { + return new ColorTextNode(text, color, nodeKey); +} + +export function $isColorTextNode(node: unknown): node is ColorTextNode { + return node instanceof ColorTextNode; +} + +/** Comes from non exported function from lexical */ +export function $cloneNodeState(from: T, to: T): undefined | T['__state'] { + const state = from.__state; + return state && state.node === from ? state.getWritable(to) : state; +} + +/** Comes from non exported function from lexical : https://github.com/facebook/lexical/blob/main/packages/lexical/src/LexicalSelection.ts */ +export function $updateElementSelectionOnCreateDeleteNode( + selection: RangeSelection, + parentNode: LexicalNode, + nodeOffset: number, + times = 1, +): void { + const anchor = selection.anchor; + const focus = selection.focus; + const anchorNode = anchor.getNode(); + const focusNode = focus.getNode(); + if (!parentNode.is(anchorNode) && !parentNode.is(focusNode)) { + return; + } + const parentKey = parentNode.__key; + // Single node. We shift selection but never redimension it + if (selection.isCollapsed()) { + const selectionOffset = anchor.offset; + if ((nodeOffset <= selectionOffset && times > 0) || (nodeOffset < selectionOffset && times < 0)) { + const newSelectionOffset = Math.max(0, selectionOffset + times); + anchor.set(parentKey, newSelectionOffset, 'element'); + focus.set(parentKey, newSelectionOffset, 'element'); + // The new selection might point to text nodes, try to resolve them + $updateSelectionResolveTextNodes(selection); + } + } else { + // Multiple nodes selected. We shift or redimension selection + const isBackward = selection.isBackward(); + const firstPoint = isBackward ? focus : anchor; + const firstPointNode = firstPoint.getNode(); + const lastPoint = isBackward ? anchor : focus; + const lastPointNode = lastPoint.getNode(); + if (parentNode.is(firstPointNode)) { + const firstPointOffset = firstPoint.offset; + if ((nodeOffset <= firstPointOffset && times > 0) || (nodeOffset < firstPointOffset && times < 0)) { + firstPoint.set(parentKey, Math.max(0, firstPointOffset + times), 'element'); + } + } + if (parentNode.is(lastPointNode)) { + const lastPointOffset = lastPoint.offset; + if ((nodeOffset <= lastPointOffset && times > 0) || (nodeOffset < lastPointOffset && times < 0)) { + lastPoint.set(parentKey, Math.max(0, lastPointOffset + times), 'element'); + } + } + } + // The new selection might point to text nodes, try to resolve them + $updateSelectionResolveTextNodes(selection); +} + +/** Comes from non exported function from lexical : https://github.com/facebook/lexical/blob/main/packages/lexical/src/LexicalSelection.ts */ +function $updateSelectionResolveTextNodes(selection: RangeSelection): void { + const anchor = selection.anchor; + const anchorOffset = anchor.offset; + const focus = selection.focus; + const focusOffset = focus.offset; + const anchorNode = anchor.getNode(); + const focusNode = focus.getNode(); + if (selection.isCollapsed()) { + if (!$isElementNode(anchorNode)) { + return; + } + const childSize = anchorNode.getChildrenSize(); + const anchorOffsetAtEnd = anchorOffset >= childSize; + const child = anchorOffsetAtEnd + ? anchorNode.getChildAtIndex(childSize - 1) + : anchorNode.getChildAtIndex(anchorOffset); + if ($isTextNode(child)) { + let newOffset = 0; + if (anchorOffsetAtEnd) { + newOffset = child.getTextContentSize(); + } + anchor.set(child.__key, newOffset, 'text'); + focus.set(child.__key, newOffset, 'text'); + } + return; + } + if ($isElementNode(anchorNode)) { + const childSize = anchorNode.getChildrenSize(); + const anchorOffsetAtEnd = anchorOffset >= childSize; + const child = anchorOffsetAtEnd + ? anchorNode.getChildAtIndex(childSize - 1) + : anchorNode.getChildAtIndex(anchorOffset); + if ($isTextNode(child)) { + let newOffset = 0; + if (anchorOffsetAtEnd) { + newOffset = child.getTextContentSize(); + } + anchor.set(child.__key, newOffset, 'text'); + } + } + if ($isElementNode(focusNode)) { + const childSize = focusNode.getChildrenSize(); + const focusOffsetAtEnd = focusOffset >= childSize; + const child = focusOffsetAtEnd ? focusNode.getChildAtIndex(childSize - 1) : focusNode.getChildAtIndex(focusOffset); + if ($isTextNode(child)) { + let newOffset = 0; + if (focusOffsetAtEnd) { + newOffset = child.getTextContentSize(); + } + focus.set(child.__key, newOffset, 'text'); + } + } +} diff --git a/src/components/UI/LexicalPlugins/ColorTextPlugin.tsx b/src/components/UI/LexicalPlugins/ColorTextPlugin.tsx new file mode 100644 index 0000000..023f060 --- /dev/null +++ b/src/components/UI/LexicalPlugins/ColorTextPlugin.tsx @@ -0,0 +1,40 @@ +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; +import { + $addUpdateTag, + $getSelection, + $isRangeSelection, + COMMAND_PRIORITY_EDITOR, + createCommand, + SKIP_SELECTION_FOCUS_TAG, +} from 'lexical'; +import { useEffect } from 'react'; +import { $createColorTextNode, ColorTextNode, ColorType } from './ColorTextNode'; + +export const FORMAT_COLOR_COMMAND = createCommand('FORMAT_COLOR_COMMAND'); + +export function ColorTextPlugin() { + const [editor] = useLexicalComposerContext(); + + useEffect(() => { + if (!editor.hasNodes([ColorTextNode])) throw new Error('ColorTextPlugin: ColorTextNode not registered on editor'); + + return editor.registerCommand( + FORMAT_COLOR_COMMAND, + (color) => { + editor.update(() => { + $addUpdateTag(SKIP_SELECTION_FOCUS_TAG); + const selection = $getSelection(); + if ($isRangeSelection(selection)) { + const text = selection.getTextContent(); + const colorNode = $createColorTextNode(text, color); + selection.insertNodes([colorNode]); + } + }); + return true; + }, + COMMAND_PRIORITY_EDITOR, + ); + }, [editor]); + + return null; +} diff --git a/src/components/UI/LexicalPlugins/ImageDropPlugin.tsx b/src/components/UI/LexicalPlugins/ImageDropPlugin.tsx index f378f39..d788b7d 100644 --- a/src/components/UI/LexicalPlugins/ImageDropPlugin.tsx +++ b/src/components/UI/LexicalPlugins/ImageDropPlugin.tsx @@ -1,6 +1,7 @@ import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; import { mergeRegister } from '@lexical/utils'; import { + $addUpdateTag, COMMAND_PRIORITY_HIGH, COMMAND_PRIORITY_LOW, createCommand, @@ -8,22 +9,39 @@ import { DROP_COMMAND, LexicalEditor, PASTE_COMMAND, + SKIP_SELECTION_FOCUS_TAG, } from 'lexical'; import { useEffect, useState } from 'react'; import { ImageNode } from './ImageNode'; import { INSERT_IMAGE_COMMAND } from './ImagePlugin'; -import { computeApiURL, useAPI } from '@/api/api'; +import { API, computeApiURL, useAPI } from '@/api/api'; import { useAppTranslation } from '@/lib/i18n'; import styles from '../LexicalTextEditor.module.scss'; export const DRAGLEAVE_COMMAND = createCommand('DRAGLEAVE_COMMAND'); -interface PartialUploadResponse { +export interface PartialUploadResponse { id: string; width: number; height: number; } +export async function uploadFile(file: File, api: API, editor: LexicalEditor) { + const formData = new FormData(); + formData.append('file', file); + const uploadResponse = await api + .post(`/media/image?public=true`, formData, { isFile: true }) + .toPromise(); + editor.update(() => { + $addUpdateTag(SKIP_SELECTION_FOCUS_TAG); + editor.dispatchCommand(INSERT_IMAGE_COMMAND, { + src: computeApiURL(`/media/image/${uploadResponse!.id}.webp`), + width: uploadResponse!.width, + height: uploadResponse!.height, + }); + }); +} + export function ImageDropPlugin() { const [editor] = useLexicalComposerContext(); const [isHovered, setIsHovered] = useState(false); @@ -50,23 +68,10 @@ export function ImageDropPlugin() { setIsHovered(false); event.preventDefault(); } - file.forEach((f) => uploadFile(f, editor)); + file.forEach((f) => uploadFile(f, api, editor)); return !!file.length; } - async function uploadFile(file: File, editor: LexicalEditor) { - const formData = new FormData(); - formData.append('file', file); - const uploadResponse = await api - .post(`/media/image?public=true`, formData, { isFile: true }) - .toPromise(); - editor.dispatchCommand(INSERT_IMAGE_COMMAND, { - src: computeApiURL(`/media/image/${uploadResponse!.id}.webp`), - width: uploadResponse!.width, - height: uploadResponse!.height, - }); - } - useEffect(() => { if (!editor.hasNodes([ImageNode])) throw new Error('ImagePlugin: ImageNode not registered on editor'); @@ -82,7 +87,7 @@ export function ImageDropPlugin() { if (event instanceof ClipboardEvent) { const files = getImagesFromFileList(event.clipboardData!.files); if (files.length) event.preventDefault(); - files.forEach((file) => uploadFile(file, editor)); + files.forEach((file) => uploadFile(file, api, editor)); return !!files.length; } return false; diff --git a/src/components/UI/LexicalPlugins/ImageNode.tsx b/src/components/UI/LexicalPlugins/ImageNode.tsx index ab93a5f..141f29c 100644 --- a/src/components/UI/LexicalPlugins/ImageNode.tsx +++ b/src/components/UI/LexicalPlugins/ImageNode.tsx @@ -51,7 +51,7 @@ export class ImageNode extends DecoratorNode { const [isSelected, setSelected, clearSelection] = useLexicalNodeSelection(image.getKey()); return ( ; } +type ToolbarFloatingMenuProps = PropsWithChildren<{ display: boolean }>; +export function ToolbarFloatingMenu({ children, display }: ToolbarFloatingMenuProps) { + return ( + <> + {display && ( +
e.stopPropagation()}> + {children} +
+ )} + + ); +} + export function ToolbarPlugin() { const [editor] = useLexicalComposerContext(); const toolbarRef = useRef(null); @@ -40,17 +90,101 @@ export function ToolbarPlugin() { const [isItalic, setIsItalic] = useState(false); const [isUnderline, setIsUnderline] = useState(false); const [isStrikethrough, setIsStrikethrough] = useState(false); + const [isCode, setIsCode] = useState(false); + const [link, setLink] = useState(null); + const [editingLink, setEditingLink] = useState(''); + const [align, setAlign] = useState('left'); + const [blockType, setBlockType] = useState(null); + const [isColorPaletteOpen, setIsColorPaletteOpen] = useState(false); + const [isTablePaletteOpen, setIsTablePaletteOpen] = useState(false); + const [tablePaletteHoverIndex, setTablePaletteHoverIndex] = useState(-1); + const [isLinkPaletteOpen, setIsLinkPaletteOpen] = useState(false); + const [isFilePaletteOpen, setIsFilePaletteOpen] = useState(false); + const { t } = useAppTranslation(); + const api = useAPI(); + + function $findTopLevelElement(node: LexicalNode) { + let topLevelElement = + node.getKey() === 'root' + ? node + : $findMatchingParent(node, (e) => { + const parent = e.getParent(); + return parent !== null && $isRootOrShadowRoot(parent); + }); + if (topLevelElement === null) topLevelElement = node.getTopLevelElementOrThrow(); + return topLevelElement; + } + + function getSelectedNode(selection: RangeSelection): TextNode | ElementNode { + const anchorNode = selection.anchor.getNode(); + const focusNode = selection.focus.getNode(); + if (anchorNode === focusNode) return anchorNode; + return selection.isBackward() + ? $isAtNodeEnd(selection.focus) + ? anchorNode + : focusNode + : $isAtNodeEnd(selection.anchor) + ? anchorNode + : focusNode; + } const $updateToolbar = useCallback(() => { const selection = $getSelection(); - if ($isRangeSelection(selection)) { + if ($isRangeSelection(selection) || $isTableSelection(selection)) { setIsBold(selection.hasFormat('bold')); setIsItalic(selection.hasFormat('italic')); setIsUnderline(selection.hasFormat('underline')); setIsStrikethrough(selection.hasFormat('strikethrough')); + setIsCode(selection.hasFormat('code')); + } + if ($isRangeSelection(selection)) { + const anchorNode = selection!.anchor.getNode(); + const element = $findTopLevelElement(anchorNode); + const elementKey = element.getKey(); + const elementDOM = editor.getElementByKey(elementKey); + + let type: string | null = null; + if (elementDOM !== null) { + if ($isListNode(element)) { + const parentList = $getNearestNodeOfType(anchorNode, ListNode); + type = parentList ? parentList.getListType() : element.getListType(); + } else type = $isHeadingNode(element) ? element.getTag() : (element.getType() as 'paragraph' | 'quote'); + } + + const node = getSelectedNode(selection); + const parent = node.getParent(); + let alignCheckNode: LexicalNode = node; + let link: string | null = null; + if ($isLinkNode(parent)) link = parent.getURL(); + if ($isLinkNode(node)) { + link = node.getURL(); + alignCheckNode = $findMatchingParent( + node, + (parentNode) => $isElementNode(parentNode) && !parentNode.isInline(), + )!; + } + setLink(link); + if ($findMatchingParent(node, $isTableNode)) type === 'table'; + + setBlockType(type); + setAlign( + $isElementNode(alignCheckNode) + ? alignCheckNode.getFormatType() + : $isElementNode(node) + ? node.getFormatType() + : parent?.getFormatType() || 'left', + ); } }, []); + const toggleToolbarFloatingMenu = (type: 'color' | 'table' | 'link' | 'file') => { + setIsColorPaletteOpen(type === 'color' ? !isColorPaletteOpen : false); + setIsTablePaletteOpen(type === 'table' ? !isTablePaletteOpen : false); + setIsLinkPaletteOpen(type === 'link' ? !isLinkPaletteOpen : false); + setIsFilePaletteOpen(type === 'file' ? !isFilePaletteOpen : false); + if (type === 'link' && !isLinkPaletteOpen) setEditingLink(link || ''); + }; + useEffect(() => { return mergeRegister( editor.registerUpdateListener(({ editorState }) => { @@ -66,9 +200,7 @@ export function ToolbarPlugin() {
+ + + + + + + + + + + + + + + @@ -129,7 +387,7 @@ export function ToolbarPlugin() { onClick={() => { editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, 'center'); }} - className={styles.item} + className={`${styles.item} ${align === 'center' ? styles.active : ''}`} aria-label="Center Align"> @@ -137,7 +395,7 @@ export function ToolbarPlugin() { onClick={() => { editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, 'right'); }} - className={styles.item} + className={`${styles.item} ${align === 'right' ? styles.active : ''}`} aria-label="Right Align"> @@ -145,10 +403,85 @@ export function ToolbarPlugin() { onClick={() => { editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, 'justify'); }} - className={styles.item} + className={`${styles.item} ${align === 'justify' ? styles.active : ''}`} aria-label="Justify Align">
); } + +export const formatParagraph = (editor: LexicalEditor) => { + editor.update(() => { + $addUpdateTag(SKIP_SELECTION_FOCUS_TAG); + const selection = $getSelection(); + $setBlocksType(selection, () => $createParagraphNode()); + }); +}; + +export const formatHeading = (editor: LexicalEditor, blockType: string | null, headingSize: HeadingTagType) => { + if (blockType !== headingSize) { + editor.update(() => { + $addUpdateTag(SKIP_SELECTION_FOCUS_TAG); + const selection = $getSelection(); + $setBlocksType(selection, () => $createHeadingNode(headingSize)); + }); + } +}; + +export const formatBulletList = (editor: LexicalEditor, blockType: string | null) => { + if (blockType !== 'bullet') { + editor.update(() => { + $addUpdateTag(SKIP_SELECTION_FOCUS_TAG); + editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined); + }); + } else { + formatParagraph(editor); + } +}; + +export const formatCheckList = (editor: LexicalEditor, blockType: string | null) => { + if (blockType !== 'check') { + editor.update(() => { + $addUpdateTag(SKIP_SELECTION_FOCUS_TAG); + editor.dispatchCommand(INSERT_CHECK_LIST_COMMAND, undefined); + }); + } else { + formatParagraph(editor); + } +}; + +export const formatNumberedList = (editor: LexicalEditor, blockType: string | null) => { + if (blockType !== 'number') { + editor.update(() => { + $addUpdateTag(SKIP_SELECTION_FOCUS_TAG); + editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND, undefined); + }); + } else { + formatParagraph(editor); + } +}; + +export const formatQuote = (editor: LexicalEditor, blockType: string | null) => { + if (blockType !== 'quote') { + editor.update(() => { + $addUpdateTag(SKIP_SELECTION_FOCUS_TAG); + const selection = $getSelection(); + $setBlocksType(selection, () => $createQuoteNode()); + }); + } +}; + +export const formatTable = (editor: LexicalEditor, cellIndex: number) => { + editor.update(() => { + $addUpdateTag(SKIP_SELECTION_FOCUS_TAG); + editor.dispatchCommand(INSERT_TABLE_COMMAND, { + columns: `${1 + (cellIndex % 8)}`, + rows: `${1 + Math.floor(cellIndex / 8)}`, + }); + }); +}; + +export const formatLink = (editor: LexicalEditor, url: string) => { + editor.update(() => editor.dispatchCommand(TOGGLE_LINK_COMMAND, url || null)); +}; diff --git a/src/components/UI/LexicalTextEditor.module.scss b/src/components/UI/LexicalTextEditor.module.scss index bc59611..00a6a9a 100644 --- a/src/components/UI/LexicalTextEditor.module.scss +++ b/src/components/UI/LexicalTextEditor.module.scss @@ -40,22 +40,59 @@ } .item { + position: relative; border: none; border-radius: 3px; padding: 0; - height: calc(24px + 0.4em); - padding: 0.2em; - background-color: $ung-light-blue; - color: $very-light-gray; + height: calc(28px + 0.2em); + padding: 0.1em; + background-color: $very-light-gray; + color: $ung-light-blue; + border: 2px solid $ung-light-blue; + cursor: pointer; &.active { - background-color: darken($ung-light-blue, 20%); + background-color: $ung-light-blue; + color: $very-light-gray; } &:disabled { background-color: desaturate($color: $ung-light-grey, $amount: 100%); + border-color: desaturate($color: $ung-light-grey, $amount: 100%); + color: $very-light-gray; cursor: not-allowed; } + + .floatingMenu { + position: absolute; + top: 100%; + left: 50%; + transform: translate(-50%, 6px); + background-color: $very-light-gray; + border: 2px solid $ung-light-blue; + color: $ung-light-blue; + border-radius: 5px; + padding: 5px; + display: flex; + flex-flow: row wrap; + gap: 2px; + z-index: 10; + max-width: 188px; + width: max-content; + + &::before { + content: ''; + position: absolute; + top: -6px; + left: 50%; + transform: translateX(-50%); + width: 0; + height: 0; + border-left: 6px solid transparent; + border-right: 6px solid transparent; + border-bottom: 6px solid $ung-light-blue; + } + } } } @@ -93,3 +130,77 @@ border-radius: 5px; pointer-events: none; } + +.color-palette, +.table-palette { + width: 20px; + height: 20px; + border: 2px solid rgba($color: $ung-light-grey, $alpha: 0.5); + border-radius: 1px; + + &.color-palette-blue { + background-color: $ung-light-blue; + } + &.color-palette-darkblue { + background-color: darken($color: $ung-light-blue, $amount: 20%); + } + &.color-palette-grey { + background-color: $ung-light-grey; + } + &.color-palette-darkgrey { + background-color: $ung-dark-grey; + } + &.active { + background-color: rgba($color: $ung-light-blue, $alpha: 0.2); + } +} + +.link-palette { + border: none; + background-color: transparent; + &:focus-within { + background-color: transparent; + } + input { + background-color: transparent; + padding: 2px; + } +} + +.file-palette { + display: block; + width: 100%; + overflow: hidden; + text-overflow: ellipsis; + cursor: pointer; + + & > input { + display: none; + } +} + +.color-text-blue { + color: $ung-light-blue; +} +.color-text-darkblue { + color: darken($color: $ung-light-blue, $amount: 20%); +} +.color-text-grey { + color: $ung-light-grey; +} +.color-text-darkgrey { + color: $ung-dark-grey; +} + +.bold { + font-weight: bold; +} +.italic { + font-style: italic; +} +.underline { + text-decoration: underline; +} +.strikethrough { + text-decoration: line-through; +} diff --git a/src/components/UI/LexicalTextEditor.tsx b/src/components/UI/LexicalTextEditor.tsx index ed66f85..a990e82 100644 --- a/src/components/UI/LexicalTextEditor.tsx +++ b/src/components/UI/LexicalTextEditor.tsx @@ -23,14 +23,22 @@ import { EnableDisablePlugin } from './LexicalPlugins/EnableDisablePlugin'; import { MATCHERS } from './LexicalPlugins/AutoLinkMatcherPlugin'; import { ImagePlugin } from './LexicalPlugins/ImagePlugin'; import { ImageNode } from './LexicalPlugins/ImageNode'; -import type { EditorThemeClasses } from 'lexical'; +import { ColorTextNode } from './LexicalPlugins/ColorTextNode'; import { ImageDropPlugin } from './LexicalPlugins/ImageDropPlugin'; +import { ColorTextPlugin } from './LexicalPlugins/ColorTextPlugin'; import styles from './LexicalTextEditor.module.scss'; +import type { EditorThemeClasses } from 'lexical'; const theme = { root: styles['editor-root'], image: styles['editor-image'], link: styles['editor-link'], + text: { + bold: styles['bold'], + italic: styles['italic'], + underline: styles['underline'], + strikethrough: styles['strikethrough'], + }, } satisfies EditorThemeClasses; interface LexicalTextEditorProps { @@ -48,6 +56,7 @@ function LexicalTextEditor({ placeholder, emptyText, disabled = false }: Lexical AutoLinkNode, CodeHighlightNode, CodeNode, + ColorTextNode, HeadingNode, HorizontalRuleNode, ImageNode, @@ -80,6 +89,7 @@ function LexicalTextEditor({ placeholder, emptyText, disabled = false }: Lexical console.log(state.toJSON())} /> + From 9f5c4524c24afd88e4d1d90d795a88aafdb66929 Mon Sep 17 00:00:00 2001 From: AlbanSdl Date: Sat, 18 Oct 2025 21:40:29 +0200 Subject: [PATCH 22/37] fix(rte): color removing base format when applied --- .../UI/LexicalPlugins/ColorTextNode.tsx | 6 ++++- .../UI/LexicalPlugins/ColorTextPlugin.tsx | 25 ++++++++++++++++--- 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/src/components/UI/LexicalPlugins/ColorTextNode.tsx b/src/components/UI/LexicalPlugins/ColorTextNode.tsx index cdcb6a5..8dde8a0 100644 --- a/src/components/UI/LexicalPlugins/ColorTextNode.tsx +++ b/src/components/UI/LexicalPlugins/ColorTextNode.tsx @@ -46,7 +46,7 @@ export class ColorTextNode extends TextNode { * @see TextNode.splitText */ splitText(...splitOffsets: Array): Array { - super.splitText(...splitOffsets); // Keep this to fail on read-only + if (!$getEditor()._editable) throw new Error('splitText: Cannot split text on a read-only editor'); const self = this.getLatest(); const textContent = self.getTextContent(); if (textContent === '') { @@ -216,6 +216,10 @@ export function $createColorTextNode(text?: string, color?: ColorType, nodeKey?: return new ColorTextNode(text, color, nodeKey); } +export function $createColorTextNodeFromTextNode(textNode: TextNode, color?: ColorType): ColorTextNode { + return $createColorTextNode(textNode.getTextContent(), color).updateFromJSON(textNode.exportJSON()); +} + export function $isColorTextNode(node: unknown): node is ColorTextNode { return node instanceof ColorTextNode; } diff --git a/src/components/UI/LexicalPlugins/ColorTextPlugin.tsx b/src/components/UI/LexicalPlugins/ColorTextPlugin.tsx index 023f060..f6a15ef 100644 --- a/src/components/UI/LexicalPlugins/ColorTextPlugin.tsx +++ b/src/components/UI/LexicalPlugins/ColorTextPlugin.tsx @@ -1,14 +1,16 @@ import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; import { $addUpdateTag, + $create, $getSelection, $isRangeSelection, + $isTextNode, COMMAND_PRIORITY_EDITOR, createCommand, SKIP_SELECTION_FOCUS_TAG, } from 'lexical'; import { useEffect } from 'react'; -import { $createColorTextNode, ColorTextNode, ColorType } from './ColorTextNode'; +import { $createColorTextNodeFromTextNode, $isColorTextNode, ColorTextNode, ColorType } from './ColorTextNode'; export const FORMAT_COLOR_COMMAND = createCommand('FORMAT_COLOR_COMMAND'); @@ -25,9 +27,24 @@ export function ColorTextPlugin() { $addUpdateTag(SKIP_SELECTION_FOCUS_TAG); const selection = $getSelection(); if ($isRangeSelection(selection)) { - const text = selection.getTextContent(); - const colorNode = $createColorTextNode(text, color); - selection.insertNodes([colorNode]); + const colorNodes = selection + .getNodes() + .map((node) => ($isTextNode(node) ? $createColorTextNodeFromTextNode(node, color) : node)); + const lastIndex = colorNodes.length - 1; + // Order is important here, we must start by the last one in case it is also the first one. + if ($isColorTextNode(colorNodes[lastIndex])) + colorNodes[lastIndex] = colorNodes[lastIndex].spliceText( + selection.isBackward() ? selection.anchor.offset : selection.focus.offset, + colorNodes[lastIndex].getTextContent().length, + '', + ); + if ($isColorTextNode(colorNodes[0])) + colorNodes[0] = colorNodes[0].spliceText( + 0, + selection.isBackward() ? selection.focus.offset : selection.anchor.offset, + '', + ); + selection.insertNodes(colorNodes); } }); return true; From fb15c88d20fe6c054b097dba316eef467d089013 Mon Sep 17 00:00:00 2001 From: AlbanSdl Date: Sat, 18 Oct 2025 22:29:37 +0200 Subject: [PATCH 23/37] fix(rte): add color node automerging --- src/components/UI/LexicalPlugins/ColorTextNode.tsx | 10 ++++++++++ src/components/UI/LexicalPlugins/ColorTextPlugin.tsx | 9 +++++++-- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/src/components/UI/LexicalPlugins/ColorTextNode.tsx b/src/components/UI/LexicalPlugins/ColorTextNode.tsx index 8dde8a0..a02c104 100644 --- a/src/components/UI/LexicalPlugins/ColorTextNode.tsx +++ b/src/components/UI/LexicalPlugins/ColorTextNode.tsx @@ -210,6 +210,16 @@ export class ColorTextNode extends TextNode { color: this.__color, }; } + + mayMerge(node: LexicalNode): boolean { + return ( + $isColorTextNode(node) && + node.__color === this.__color && + node.__format === this.__format && + !this.isUnmergeable() && + !node.isUnmergeable() + ); + } } export function $createColorTextNode(text?: string, color?: ColorType, nodeKey?: NodeKey): ColorTextNode { diff --git a/src/components/UI/LexicalPlugins/ColorTextPlugin.tsx b/src/components/UI/LexicalPlugins/ColorTextPlugin.tsx index f6a15ef..94f0190 100644 --- a/src/components/UI/LexicalPlugins/ColorTextPlugin.tsx +++ b/src/components/UI/LexicalPlugins/ColorTextPlugin.tsx @@ -1,7 +1,6 @@ import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; import { $addUpdateTag, - $create, $getSelection, $isRangeSelection, $isTextNode, @@ -44,7 +43,13 @@ export function ColorTextPlugin() { selection.isBackward() ? selection.focus.offset : selection.anchor.offset, '', ); - selection.insertNodes(colorNodes); + const insertion = colorNodes.filter((node) => node.getTextContent().length > 0); + selection.insertNodes(insertion); + for (let i = 0; i < insertion.length; i++) { + const node = insertion[i]; + if ($isColorTextNode(node) && node.mayMerge(insertion[i + 1])) + insertion[i + 1] = node.mergeWithSibling(insertion[i + 1] as ColorTextNode); + } } }); return true; From 41a6d76f2623325fabf6d78107e63b56647c3121 Mon Sep 17 00:00:00 2001 From: AlbanSdl Date: Sun, 19 Oct 2025 00:51:50 +0200 Subject: [PATCH 24/37] fix(rte): add replacement for textnode --- .../UI/LexicalPlugins/ColorTextNode.tsx | 280 +----------------- src/components/UI/LexicalTextEditor.tsx | 15 +- 2 files changed, 20 insertions(+), 275 deletions(-) diff --git a/src/components/UI/LexicalPlugins/ColorTextNode.tsx b/src/components/UI/LexicalPlugins/ColorTextNode.tsx index a02c104..21d2e1b 100644 --- a/src/components/UI/LexicalPlugins/ColorTextNode.tsx +++ b/src/components/UI/LexicalPlugins/ColorTextNode.tsx @@ -1,18 +1,4 @@ -import { - $getEditor, - $getSelection, - $isElementNode, - $isRangeSelection, - $isTextNode, - $setCompositionKey, - EditorConfig, - LexicalNode, - NodeKey, - RangeSelection, - SerializedTextNode, - Spread, - TextNode, -} from 'lexical'; +import { createState, EditorConfig, LexicalNode, NodeKey, SerializedTextNode, Spread, TextNode } from 'lexical'; import styles from '../LexicalTextEditor.module.scss'; export type ColorType = 'blue' | 'darkblue' | 'grey' | 'darkgrey'; @@ -38,165 +24,23 @@ export class ColorTextNode extends TextNode { setColor(color?: ColorType) { const self = this.getWritable(); self.__color = color; + return this; } - /** - * Comes from the method of base class TextNode. If this is not overwritten, - * the base class uses $createTextNode directly to split text, losing the benefits of this custom class - * @see TextNode.splitText - */ splitText(...splitOffsets: Array): Array { - if (!$getEditor()._editable) throw new Error('splitText: Cannot split text on a read-only editor'); - const self = this.getLatest(); - const textContent = self.getTextContent(); - if (textContent === '') { - return []; - } - const key = self.__key; - const compositionKey = $getEditor()._compositionKey; - const textLength = textContent.length; - splitOffsets.sort((a, b) => a - b); - splitOffsets.push(textLength); - const parts = []; - const splitOffsetsLength = splitOffsets.length; - for (let start = 0, offsetIndex = 0; start < textLength && offsetIndex <= splitOffsetsLength; offsetIndex++) { - const end = splitOffsets[offsetIndex]; - if (end > start) { - parts.push(textContent.slice(start, end)); - start = end; - } - } - const partsLength = parts.length; - if (partsLength === 1) { - return [self]; - } - const firstPart = parts[0]; - const parent = self.getParent(); - let writableNode; - const format = self.getFormat(); - const style = self.getStyle(); - const detail = self.__detail; - let hasReplacedSelf = false; - - // Prepare to handle selection - const selection = $getSelection(); - let endTextPoint: RangeSelection['anchor'] | null = null; - let startTextPoint: RangeSelection['anchor'] | null = null; - if ($isRangeSelection(selection)) { - const [startPoint, endPoint] = selection.isBackward() - ? [selection.focus, selection.anchor] - : [selection.anchor, selection.focus]; - if (startPoint.type === 'text' && startPoint.key === key) { - startTextPoint = startPoint; - } - if (endPoint.type === 'text' && endPoint.key === key) { - endTextPoint = endPoint; - } - } - - if (self.isSegmented()) { - // Create a new TextNode - writableNode = $createColorTextNode(firstPart, this.__color); - writableNode.__format = format; - writableNode.__style = style; - writableNode.__detail = detail; - writableNode.__state = $cloneNodeState(self, writableNode); - hasReplacedSelf = true; - } else { - // For the first part, update the existing node - writableNode = self.setTextContent(firstPart); - } - - // Then handle all other parts - const splitNodes: ColorTextNode[] = [writableNode]; - let textSize = firstPart.length; - - for (let i = 1; i < partsLength; i++) { - const part = parts[i]; - const partSize = part.length; - const sibling = $createColorTextNode(part, this.__color); - sibling.__format = format; - sibling.__style = style; - sibling.__detail = detail; - sibling.__state = $cloneNodeState(self, sibling); - const siblingKey = sibling.__key; - const nextTextSize = textSize + partSize; - if (compositionKey === key) { - $setCompositionKey(siblingKey); - } - textSize = nextTextSize; - splitNodes.push(sibling); - } - - // Move the selection to the best location in the split string. - // The end point is always left-biased, and the start point is - // generally left biased unless the end point would land on a - // later node in the split in which case it will prefer the start - // of that node so they will tend to be on the same node. - const originalStartOffset = startTextPoint ? startTextPoint.offset : null; - const originalEndOffset = endTextPoint ? endTextPoint.offset : null; - let startOffset = 0; - for (const node of splitNodes) { - if (!(startTextPoint || endTextPoint)) { - break; - } - const endOffset = startOffset + node.getTextContentSize(); - if ( - startTextPoint !== null && - originalStartOffset !== null && - originalStartOffset <= endOffset && - originalStartOffset >= startOffset - ) { - // Set the start point to the first valid node - startTextPoint.set(node.getKey(), originalStartOffset - startOffset, 'text'); - if (originalStartOffset < endOffset) { - // The start isn't on a border so we can stop checking - startTextPoint = null; - } - } - if ( - endTextPoint !== null && - originalEndOffset !== null && - originalEndOffset <= endOffset && - originalEndOffset >= startOffset - ) { - endTextPoint.set(node.getKey(), originalEndOffset - startOffset, 'text'); - break; - } - startOffset = endOffset; - } - - // Insert the nodes into the parent's children - if (parent !== null) { - this.getNextSibling()?.markDirty(); - this.getPreviousSibling()?.markDirty(); - const writableParent = parent.getWritable(); - const insertionIndex = this.getIndexWithinParent(); - if (hasReplacedSelf) { - writableParent.splice(insertionIndex, 0, splitNodes); - this.remove(); - } else { - writableParent.splice(insertionIndex, 1, splitNodes); - } - - if ($isRangeSelection(selection)) { - $updateElementSelectionOnCreateDeleteNode(selection, parent, insertionIndex, partsLength - 1); - } - } - - return splitNodes; + return super.splitText(...splitOffsets).map((node) => (node as ColorTextNode).setColor(this.__color)); } createDOM(config: EditorConfig) { const dom = super.createDOM(config); - dom.classList.toggle(styles[`color-text-${this.__color}`], true); + if (this.__color) dom.classList.toggle(styles[`color-text-${this.__color}`], true); return dom; } updateDOM(prevNode: this, dom: HTMLElement, config: EditorConfig) { const updated = super.updateDOM(prevNode, dom, config); if (prevNode.__color !== this.__color) dom.classList.toggle(styles[`color-text-${prevNode.__color}`], false); - dom.classList.toggle(styles[`color-text-${this.__color}`], true); + if (this.__color) dom.classList.toggle(styles[`color-text-${this.__color}`], true); return updated; } @@ -211,6 +55,10 @@ export class ColorTextNode extends TextNode { }; } + isSimpleText(): boolean { + return this.__type === 'color-text' && !this.__color && this.__mode === 0; + } + mayMerge(node: LexicalNode): boolean { return ( $isColorTextNode(node) && @@ -233,113 +81,3 @@ export function $createColorTextNodeFromTextNode(textNode: TextNode, color?: Col export function $isColorTextNode(node: unknown): node is ColorTextNode { return node instanceof ColorTextNode; } - -/** Comes from non exported function from lexical */ -export function $cloneNodeState(from: T, to: T): undefined | T['__state'] { - const state = from.__state; - return state && state.node === from ? state.getWritable(to) : state; -} - -/** Comes from non exported function from lexical : https://github.com/facebook/lexical/blob/main/packages/lexical/src/LexicalSelection.ts */ -export function $updateElementSelectionOnCreateDeleteNode( - selection: RangeSelection, - parentNode: LexicalNode, - nodeOffset: number, - times = 1, -): void { - const anchor = selection.anchor; - const focus = selection.focus; - const anchorNode = anchor.getNode(); - const focusNode = focus.getNode(); - if (!parentNode.is(anchorNode) && !parentNode.is(focusNode)) { - return; - } - const parentKey = parentNode.__key; - // Single node. We shift selection but never redimension it - if (selection.isCollapsed()) { - const selectionOffset = anchor.offset; - if ((nodeOffset <= selectionOffset && times > 0) || (nodeOffset < selectionOffset && times < 0)) { - const newSelectionOffset = Math.max(0, selectionOffset + times); - anchor.set(parentKey, newSelectionOffset, 'element'); - focus.set(parentKey, newSelectionOffset, 'element'); - // The new selection might point to text nodes, try to resolve them - $updateSelectionResolveTextNodes(selection); - } - } else { - // Multiple nodes selected. We shift or redimension selection - const isBackward = selection.isBackward(); - const firstPoint = isBackward ? focus : anchor; - const firstPointNode = firstPoint.getNode(); - const lastPoint = isBackward ? anchor : focus; - const lastPointNode = lastPoint.getNode(); - if (parentNode.is(firstPointNode)) { - const firstPointOffset = firstPoint.offset; - if ((nodeOffset <= firstPointOffset && times > 0) || (nodeOffset < firstPointOffset && times < 0)) { - firstPoint.set(parentKey, Math.max(0, firstPointOffset + times), 'element'); - } - } - if (parentNode.is(lastPointNode)) { - const lastPointOffset = lastPoint.offset; - if ((nodeOffset <= lastPointOffset && times > 0) || (nodeOffset < lastPointOffset && times < 0)) { - lastPoint.set(parentKey, Math.max(0, lastPointOffset + times), 'element'); - } - } - } - // The new selection might point to text nodes, try to resolve them - $updateSelectionResolveTextNodes(selection); -} - -/** Comes from non exported function from lexical : https://github.com/facebook/lexical/blob/main/packages/lexical/src/LexicalSelection.ts */ -function $updateSelectionResolveTextNodes(selection: RangeSelection): void { - const anchor = selection.anchor; - const anchorOffset = anchor.offset; - const focus = selection.focus; - const focusOffset = focus.offset; - const anchorNode = anchor.getNode(); - const focusNode = focus.getNode(); - if (selection.isCollapsed()) { - if (!$isElementNode(anchorNode)) { - return; - } - const childSize = anchorNode.getChildrenSize(); - const anchorOffsetAtEnd = anchorOffset >= childSize; - const child = anchorOffsetAtEnd - ? anchorNode.getChildAtIndex(childSize - 1) - : anchorNode.getChildAtIndex(anchorOffset); - if ($isTextNode(child)) { - let newOffset = 0; - if (anchorOffsetAtEnd) { - newOffset = child.getTextContentSize(); - } - anchor.set(child.__key, newOffset, 'text'); - focus.set(child.__key, newOffset, 'text'); - } - return; - } - if ($isElementNode(anchorNode)) { - const childSize = anchorNode.getChildrenSize(); - const anchorOffsetAtEnd = anchorOffset >= childSize; - const child = anchorOffsetAtEnd - ? anchorNode.getChildAtIndex(childSize - 1) - : anchorNode.getChildAtIndex(anchorOffset); - if ($isTextNode(child)) { - let newOffset = 0; - if (anchorOffsetAtEnd) { - newOffset = child.getTextContentSize(); - } - anchor.set(child.__key, newOffset, 'text'); - } - } - if ($isElementNode(focusNode)) { - const childSize = focusNode.getChildrenSize(); - const focusOffsetAtEnd = focusOffset >= childSize; - const child = focusOffsetAtEnd ? focusNode.getChildAtIndex(childSize - 1) : focusNode.getChildAtIndex(focusOffset); - if ($isTextNode(child)) { - let newOffset = 0; - if (focusOffsetAtEnd) { - newOffset = child.getTextContentSize(); - } - focus.set(child.__key, newOffset, 'text'); - } - } -} diff --git a/src/components/UI/LexicalTextEditor.tsx b/src/components/UI/LexicalTextEditor.tsx index a990e82..d3b4d9a 100644 --- a/src/components/UI/LexicalTextEditor.tsx +++ b/src/components/UI/LexicalTextEditor.tsx @@ -1,4 +1,4 @@ -import { LexicalComposer } from '@lexical/react/LexicalComposer'; +import { InitialConfigType, LexicalComposer } from '@lexical/react/LexicalComposer'; import { ContentEditable } from '@lexical/react/LexicalContentEditable'; import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary'; import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin'; @@ -23,11 +23,11 @@ import { EnableDisablePlugin } from './LexicalPlugins/EnableDisablePlugin'; import { MATCHERS } from './LexicalPlugins/AutoLinkMatcherPlugin'; import { ImagePlugin } from './LexicalPlugins/ImagePlugin'; import { ImageNode } from './LexicalPlugins/ImageNode'; -import { ColorTextNode } from './LexicalPlugins/ColorTextNode'; +import { $createColorTextNodeFromTextNode, ColorTextNode } from './LexicalPlugins/ColorTextNode'; import { ImageDropPlugin } from './LexicalPlugins/ImageDropPlugin'; import { ColorTextPlugin } from './LexicalPlugins/ColorTextPlugin'; import styles from './LexicalTextEditor.module.scss'; -import type { EditorThemeClasses } from 'lexical'; +import { TextNode, type EditorThemeClasses } from 'lexical'; const theme = { root: styles['editor-root'], @@ -67,8 +67,15 @@ function LexicalTextEditor({ placeholder, emptyText, disabled = false }: Lexical TableNode, TableRowNode, QuoteNode, + { + replace: TextNode, + with: (node: TextNode) => { + return $createColorTextNodeFromTextNode(node); + }, + withKlass: ColorTextNode, + }, ], - }; + } satisfies InitialConfigType; return ( From b58d63e02a0ba6d988b01a0cdf710938472135fd Mon Sep 17 00:00:00 2001 From: AlbanSdl Date: Sun, 19 Oct 2025 01:59:26 +0200 Subject: [PATCH 25/37] fix(rte): move color property to $getState --- .../UI/LexicalPlugins/ColorTextNode.tsx | 64 +++++++++---------- .../UI/LexicalPlugins/ColorTextPlugin.tsx | 8 +-- 2 files changed, 30 insertions(+), 42 deletions(-) diff --git a/src/components/UI/LexicalPlugins/ColorTextNode.tsx b/src/components/UI/LexicalPlugins/ColorTextNode.tsx index 21d2e1b..33c0571 100644 --- a/src/components/UI/LexicalPlugins/ColorTextNode.tsx +++ b/src/components/UI/LexicalPlugins/ColorTextNode.tsx @@ -1,81 +1,75 @@ -import { createState, EditorConfig, LexicalNode, NodeKey, SerializedTextNode, Spread, TextNode } from 'lexical'; +import { + $getState, + $setState, + createState, + EditorConfig, + NodeKey, + SerializedTextNode, + Spread, + TextNode, +} from 'lexical'; import styles from '../LexicalTextEditor.module.scss'; -export type ColorType = 'blue' | 'darkblue' | 'grey' | 'darkgrey'; +const ColorOptions = ['blue', 'darkblue', 'grey', 'darkgrey'] as const; +export type ColorType = (typeof ColorOptions)[number]; type SerializedColorTextNode = Spread<{ color?: ColorType }, SerializedTextNode>; -export class ColorTextNode extends TextNode { - __color?: ColorType; +const colorState = createState('color', { + parse: (v) => (ColorOptions.includes(v as ColorType) ? (v as ColorType) : undefined), +}); +export class ColorTextNode extends TextNode { static getType() { return 'color-text'; } static clone(node: ColorTextNode) { - return new ColorTextNode(node.__text, node.__color, node.__key); - } - - constructor(text?: string, color?: ColorType, key?: NodeKey) { - super(text, key); - this.__color = color; + return new ColorTextNode(node.__text, node.__key); } setColor(color?: ColorType) { - const self = this.getWritable(); - self.__color = color; + $setState(this, colorState, color); return this; } - splitText(...splitOffsets: Array): Array { - return super.splitText(...splitOffsets).map((node) => (node as ColorTextNode).setColor(this.__color)); - } - createDOM(config: EditorConfig) { const dom = super.createDOM(config); - if (this.__color) dom.classList.toggle(styles[`color-text-${this.__color}`], true); + if ($getState(this, colorState)) dom.classList.toggle(styles[`color-text-${$getState(this, colorState)}`], true); return dom; } updateDOM(prevNode: this, dom: HTMLElement, config: EditorConfig) { const updated = super.updateDOM(prevNode, dom, config); - if (prevNode.__color !== this.__color) dom.classList.toggle(styles[`color-text-${prevNode.__color}`], false); - if (this.__color) dom.classList.toggle(styles[`color-text-${this.__color}`], true); + if ($getState(prevNode, colorState) !== $getState(this, colorState)) + dom.classList.toggle(styles[`color-text-${$getState(prevNode, colorState)}`], false); + if ($getState(this, colorState)) dom.classList.toggle(styles[`color-text-${$getState(this, colorState)}`], true); return updated; } static importJSON(serializedNode: SerializedColorTextNode): ColorTextNode { - return $createColorTextNode(serializedNode.text, serializedNode.color).updateFromJSON(serializedNode); + return $createColorTextNode(serializedNode.text).updateFromJSON(serializedNode).setColor(serializedNode.color); } exportJSON(): SerializedColorTextNode { return { ...super.exportJSON(), - color: this.__color, + color: $getState(this, colorState), + $: undefined, }; } isSimpleText(): boolean { - return this.__type === 'color-text' && !this.__color && this.__mode === 0; - } - - mayMerge(node: LexicalNode): boolean { - return ( - $isColorTextNode(node) && - node.__color === this.__color && - node.__format === this.__format && - !this.isUnmergeable() && - !node.isUnmergeable() - ); + return this.__type === 'color-text' && this.__mode === 0; } } -export function $createColorTextNode(text?: string, color?: ColorType, nodeKey?: NodeKey): ColorTextNode { - return new ColorTextNode(text, color, nodeKey); +export function $createColorTextNode(text?: string, nodeKey?: NodeKey): ColorTextNode { + return new ColorTextNode(text, nodeKey); } export function $createColorTextNodeFromTextNode(textNode: TextNode, color?: ColorType): ColorTextNode { - return $createColorTextNode(textNode.getTextContent(), color).updateFromJSON(textNode.exportJSON()); + return $createColorTextNode(textNode.getTextContent()).updateFromJSON(textNode.exportJSON()).setColor(color); } export function $isColorTextNode(node: unknown): node is ColorTextNode { diff --git a/src/components/UI/LexicalPlugins/ColorTextPlugin.tsx b/src/components/UI/LexicalPlugins/ColorTextPlugin.tsx index 94f0190..06daf5e 100644 --- a/src/components/UI/LexicalPlugins/ColorTextPlugin.tsx +++ b/src/components/UI/LexicalPlugins/ColorTextPlugin.tsx @@ -43,13 +43,7 @@ export function ColorTextPlugin() { selection.isBackward() ? selection.focus.offset : selection.anchor.offset, '', ); - const insertion = colorNodes.filter((node) => node.getTextContent().length > 0); - selection.insertNodes(insertion); - for (let i = 0; i < insertion.length; i++) { - const node = insertion[i]; - if ($isColorTextNode(node) && node.mayMerge(insertion[i + 1])) - insertion[i + 1] = node.mergeWithSibling(insertion[i + 1] as ColorTextNode); - } + selection.insertNodes(colorNodes); } }); return true; From c771b37c693f33b4ccf2f5816e39c82fb80027ae Mon Sep 17 00:00:00 2001 From: AlbanSdl Date: Sun, 19 Oct 2025 03:42:57 +0200 Subject: [PATCH 26/37] fix(rte): add some style --- .../UI/LexicalPlugins/ToolbarPlugin.tsx | 6 +- .../UI/LexicalTextEditor.module.scss | 105 ++++++++++++++++++ src/components/UI/LexicalTextEditor.tsx | 15 +++ 3 files changed, 123 insertions(+), 3 deletions(-) diff --git a/src/components/UI/LexicalPlugins/ToolbarPlugin.tsx b/src/components/UI/LexicalPlugins/ToolbarPlugin.tsx index c71b89b..1817a10 100644 --- a/src/components/UI/LexicalPlugins/ToolbarPlugin.tsx +++ b/src/components/UI/LexicalPlugins/ToolbarPlugin.tsx @@ -270,19 +270,19 @@ export function ToolbarPlugin() { onClick={() => formatHeading(editor, blockType, 'h1')} className={`${styles.item} ${blockType === 'h1' ? styles.active : ''}`} aria-label="Format H1"> - + H1
diff --git a/src/components/UI/LexicalPlugins/ToolbarPlugin.tsx b/src/components/UI/LexicalPlugins/ToolbarPlugin.tsx index 1817a10..341d9bd 100644 --- a/src/components/UI/LexicalPlugins/ToolbarPlugin.tsx +++ b/src/components/UI/LexicalPlugins/ToolbarPlugin.tsx @@ -47,7 +47,14 @@ import { } from 'obra-icons-react'; import { PropsWithChildren, useCallback, useEffect, useRef, useState } from 'react'; import styles from '../LexicalTextEditor.module.scss'; -import { $createHeadingNode, $createQuoteNode, $isHeadingNode, HeadingTagType } from '@lexical/rich-text'; +import { + $createHeadingNode, + $createQuoteNode, + $isHeadingNode, + HeadingNode, + HeadingTagType, + QuoteNode, +} from '@lexical/rich-text'; import { $isListNode, INSERT_CHECK_LIST_COMMAND, @@ -56,13 +63,17 @@ import { ListNode, } from '@lexical/list'; import { $isAtNodeEnd, $setBlocksType } from '@lexical/selection'; -import { $isLinkNode, TOGGLE_LINK_COMMAND } from '@lexical/link'; -import { $isTableNode, $isTableSelection, INSERT_TABLE_COMMAND } from '@lexical/table'; +import { $isLinkNode, LinkNode, TOGGLE_LINK_COMMAND } from '@lexical/link'; +import { $isTableNode, $isTableSelection, INSERT_TABLE_COMMAND, TableNode } from '@lexical/table'; import Input from '../Input'; import { useAppTranslation } from '@/lib/i18n'; import { useAPI } from '@/api/api'; import { uploadFile } from './ImageDropPlugin'; import { FORMAT_COLOR_COMMAND } from './ColorTextPlugin'; +import type { InitialConfigType } from '@lexical/react/LexicalComposer'; +import { ColorTextNode } from './ColorTextNode'; +import { CodeNode } from '@lexical/code'; +import { ImageNode } from './ImageNode'; function Divider() { return
; @@ -81,7 +92,7 @@ export function ToolbarFloatingMenu({ children, display }: ToolbarFloatingMenuPr ); } -export function ToolbarPlugin() { +export function ToolbarPlugin({ enabledNodes }: { enabledNodes: InitialConfigType['nodes'] }) { const [editor] = useLexicalComposerContext(); const toolbarRef = useRef(null); const [canUndo, setCanUndo] = useState(false); @@ -238,142 +249,169 @@ export function ToolbarPlugin() { aria-label="Format Strikethrough"> - - - - - - - - - - - - +
(setIsColorPaletteOpen(false), editor.dispatchCommand(FORMAT_COLOR_COMMAND, 'darkblue'))} + /> +
(setIsColorPaletteOpen(false), editor.dispatchCommand(FORMAT_COLOR_COMMAND, 'grey'))} + /> +
(setIsColorPaletteOpen(false), editor.dispatchCommand(FORMAT_COLOR_COMMAND, 'darkgrey'))} + /> + + + )} - - + + + + + )} + {enabledNodes?.includes(QuoteNode) && ( + + )} + {enabledNodes?.includes(CodeNode) && ( + + )} + {enabledNodes?.includes(ListNode) && ( + <> + + + + + )} + {enabledNodes?.includes(TableNode) && ( + + )} + + {enabledNodes?.includes(LinkNode) && ( + + + + )} + {enabledNodes?.includes(ImageNode) && ( + + )} )} - {`Logo + updateAssoEdit({ logo: mediaId })} + />
-

{asso?.name}

-
{asso?.description}
- +

+ {editInfosMode ? ( + updateAssoEdit({ name })} /> + ) : ( + asso?.name + )} +

+ {asso ? ( + updateAssoEdit({ description: { fr: state } })} + setStateRef={stateRef} + disabled={!editInfosMode} + /> + ) : ( + <> +
+
+
+
+ + )}
- - -
{asso?.website?.replace(/^https?:\/\/(?:www\.)?/, '')}
- - - -
{asso?.mail}
- - - -
{asso?.phoneNumber}
- + {editInfosMode ? ( + <> + updateAssoEdit({ website })} + /> + updateAssoEdit({ mail })} /> + updateAssoEdit({ phoneNumber })} + /> + + ) : ( + <> + + +
{asso?.website?.replace(/^https?:\/\/(?:www\.)?/, '')}
+ + + +
{asso?.mail}
+ + + +
{asso?.phoneNumber}
+ + + )}
diff --git a/src/app/assos/[assoId]/style.module.scss b/src/app/assos/[assoId]/style.module.scss index 4a80d83..cf1935f 100644 --- a/src/app/assos/[assoId]/style.module.scss +++ b/src/app/assos/[assoId]/style.module.scss @@ -16,19 +16,19 @@ top: 3ch; padding: 4px 8px !important; border-radius: calc(1em + 4px) !important; + + svg { + margin-right: 0.5ch; + } } - & > img { - width: 12ch; - height: 12ch; - border-radius: 50%; - object-fit: cover; - display: flex; - text-align: center; - align-items: center; - justify-content: center; - overflow: hidden; - flex-shrink: 0; + & > .logo { + width: 125px; + height: 125px; + background: rgba($color: $ung-light-grey, $alpha: 0.2); + border: 2px solid rgba($color: $ung-light-grey, $alpha: 0.2); + color: $ung-dark-grey; + font-size: 6ch; } .details { @@ -38,7 +38,7 @@ justify-content: space-between; h1:empty, - & > div:empty, + & > div > :empty, .actionRow div:empty { @extend .glimmer-animated; width: 16ch; @@ -49,6 +49,14 @@ &:nth-child(2) { width: 48ch; } + + &:nth-child(3) { + width: 36ch; + } + + &:nth-child(4) { + width: 62ch; + } } .actionRow { @@ -73,10 +81,6 @@ } } } - - img { - background: rgba($color: $ung-light-grey, $alpha: 0.2); - } } .membersCard { @@ -121,6 +125,10 @@ font-size: 0.8em; padding: 4px 8px; border-radius: calc(0.5em + 4px); + + svg { + margin-right: 0.5ch; + } } } diff --git a/src/components/UI/Avatar.module.scss b/src/components/UI/Avatar.module.scss new file mode 100644 index 0000000..98c2825 --- /dev/null +++ b/src/components/UI/Avatar.module.scss @@ -0,0 +1,68 @@ +@import '@/variables'; + +.avatar { + display: block; + position: relative; + width: 2em; + height: 2em; + border-radius: 50%; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + font-size: 1.2em; + background: $ung-light-blue; + color: $very-light-gray; + user-select: none; + border: 2px solid $ung-light-blue; + + img { + position: absolute; + object-fit: cover; + top: 0; + left: 0; + width: 100%; + height: 100%; + } +} + +.avatar:hover > .avatarEdit { + opacity: 1; +} + +.avatar > svg { + position: absolute; + width: 60%; + height: 60%; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + fill: $very-light-gray; + color: color-mix(in srgb, $ung-dark-grey 40%, $very-light-gray 60%); + pointer-events: none; + z-index: 1; +} + +.avatarEdit { + position: absolute; + width: 100%; + height: 100%; + overflow: hidden; + text-overflow: ellipsis; + cursor: pointer; + z-index: 2; + font-size: 0.3em; + display: flex; + align-items: center; + justify-content: center; + text-align: center; + color: $very-light-gray; + background-color: rgba($color: $ung-dark-grey, $alpha: 0.6); + opacity: 0; + transition: opacity 0.2s ease-in-out; + + & > input { + display: none; + } +} diff --git a/src/components/UI/Avatar.tsx b/src/components/UI/Avatar.tsx new file mode 100644 index 0000000..ce57969 --- /dev/null +++ b/src/components/UI/Avatar.tsx @@ -0,0 +1,53 @@ +import { PropsWithoutRef } from 'react'; +import styles from './Avatar.module.scss'; +import { computeApiURL, useAPI } from '@/api/api'; +import { PartialUploadResponse } from './LexicalPlugins/ImageDropPlugin'; +import { useAppTranslation } from '@/lib/i18n'; +import { IconEdit } from 'obra-icons-react'; +import { ImageMedia } from './ImageMedia'; + +type AvatarProps = PropsWithoutRef<{ + localSrc?: string; + name?: string; + editable?: boolean; + className?: string; + isPublic?: boolean; + onChange?: (mediaId: string) => void; +}>; + +export default function Avatar({ localSrc, name, editable, className, onChange, isPublic }: AvatarProps) { + const api = useAPI(); + const { t } = useAppTranslation(); + + const uploadFile = async (file: File) => { + const formData = new FormData(); + formData.append('file', file); + const uploadResponse = await api + .post< + FormData, + PartialUploadResponse + >(`/media/image?public=${!!isPublic}&preset=AVATAR`, formData, { isFile: true }) + .toPromise(); + if (uploadResponse?.id && onChange) onChange(uploadResponse.id); + }; + + return ( +
c).join(' ')}> + {editable && ( + <> + + + + )} + {name?.charAt(0) || '?'} + {localSrc && } +
+ ); +} diff --git a/src/components/UI/ImageMedia.tsx b/src/components/UI/ImageMedia.tsx index 3ec2621..008da90 100644 --- a/src/components/UI/ImageMedia.tsx +++ b/src/components/UI/ImageMedia.tsx @@ -8,9 +8,18 @@ export type ImageMediaProps = PropsWithoutRef<{ width?: number | 'inherit'; height?: number | 'inherit'; onClick?: MouseEventHandler; + displayWhileLoading?: boolean; }>; -export function ImageMedia({ src: worldSrc, altText, className, width, height, onClick }: ImageMediaProps) { +export function ImageMedia({ + src: worldSrc, + altText, + className, + width, + height, + onClick, + displayWhileLoading = true, +}: ImageMediaProps) { const [src, setSrc] = useState(''); const api = useAPI(); @@ -29,14 +38,16 @@ export function ImageMedia({ src: worldSrc, altText, className, width, height, o }, [worldSrc]); return ( - {altText} + (src || displayWhileLoading) && ( + {altText} + ) ); } diff --git a/src/components/UI/LexicalPlugins/EnableDisablePlugin.tsx b/src/components/UI/LexicalPlugins/EnableDisablePlugin.tsx index 666bd7b..18c6ebc 100644 --- a/src/components/UI/LexicalPlugins/EnableDisablePlugin.tsx +++ b/src/components/UI/LexicalPlugins/EnableDisablePlugin.tsx @@ -1,11 +1,15 @@ import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; -import { PropsWithoutRef, useEffect } from 'react'; +import { MutableRefObject, PropsWithRef, useEffect } from 'react'; -export function EnableDisablePlugin({ disabled }: PropsWithoutRef<{ disabled: boolean }>) { +export function EnableDisablePlugin({ + disabled, + ref, +}: PropsWithRef<{ disabled: boolean; ref?: MutableRefObject<(s: string) => void> }>) { const [editor] = useLexicalComposerContext(); useEffect(() => { editor?.setEditable(!disabled); + if (ref) ref.current = (s) => editor.update(() => editor.setEditorState(editor.parseEditorState(s))); }, [editor, disabled]); return <>; diff --git a/src/components/UI/LexicalTextEditor.tsx b/src/components/UI/LexicalTextEditor.tsx index 4bcbf8f..101b84d 100644 --- a/src/components/UI/LexicalTextEditor.tsx +++ b/src/components/UI/LexicalTextEditor.tsx @@ -28,7 +28,7 @@ import { ColorTextPlugin } from './LexicalPlugins/ColorTextPlugin'; import { ImagePlugin } from './LexicalPlugins/ImagePlugin'; import styles from './LexicalTextEditor.module.scss'; import { TextNode, type EditorThemeClasses } from 'lexical'; -import type { FC } from 'react'; +import type { FC, MutableRefObject } from 'react'; /** * Bundle of features (nodes and plugins) to use in Lexical Editor. @@ -146,6 +146,12 @@ interface LexicalTextEditorProps { initialState?: string; /** Callback called when the content of the Editor changes, providing the new state in Lexical's JSON format */ onChange?: (state: string) => void; + /** + * A ref to a hook to update Editor state (contents). This hook must be only used when setting different content, + * not for regular updated sent by `onChange` as state is already retained by Lexical (and it would be a bummer to + * recompute the whole state for every change). + */ + setStateRef?: MutableRefObject<(s: string) => void>; } /** @@ -164,7 +170,7 @@ interface LexicalTextEditorProps { * @@ -176,6 +182,7 @@ function LexicalTextEditor({ disabled = false, initialState, onChange, + setStateRef, }: LexicalTextEditorProps) { const { nodes, plugins } = EDITOR_BUNDLES[bundle]; const initialConfig = { @@ -200,8 +207,8 @@ function LexicalTextEditor({ ErrorBoundary={LexicalErrorBoundary} /> - onChange?.(JSON.stringify(state))} /> - + !disabled && onChange?.(JSON.stringify(state))} /> + {plugins.map((Plugin, index) => typeof Plugin === 'function' ? : , )} @@ -221,3 +228,20 @@ export default LexicalTextEditor; export function $registerBundle(name: string, bundle: RTEFeatureBundle) { if (!(name in EDITOR_BUNDLES)) EDITOR_BUNDLES[name] = bundle; } + +/** + * Use this function to ensure `str` can be used in a {@link LexicalTextEditor} + * (either in the `initialState` prop or through the `setStateRef` hook) + */ +export function $makeJson(str: string) { + try { + JSON.parse(str); + return str; + } catch { + return ( + `{"root":{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"${str.replaceAll(/"/g, '\\"')}",` + + `"type":"color-text","version":1}],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],` + + `"direction":"ltr","format":"","indent":0,"type":"root","version":1}}` + ); + } +} diff --git a/src/components/users/UserCard.module.scss b/src/components/users/UserCard.module.scss index e86b438..3f765f3 100644 --- a/src/components/users/UserCard.module.scss +++ b/src/components/users/UserCard.module.scss @@ -11,17 +11,29 @@ user-select: none; .avatar { + display: block; + position: relative; width: 2em; height: 2em; border-radius: 50%; - object-fit: cover; flex-shrink: 0; display: flex; align-items: center; justify-content: center; + overflow: hidden; font-size: 1.2em; background: $ung-light-blue; color: $very-light-gray; + user-select: none; + + img { + position: absolute; + object-fit: cover; + top: 0; + left: 0; + width: 100%; + height: 100%; + } } .info { From 9ceedd8eb50caa9a7df0f57f02f86b8064af4e21 Mon Sep 17 00:00:00 2001 From: AlbanSdl Date: Sun, 19 Oct 2025 18:08:06 +0200 Subject: [PATCH 29/37] fix(asso): escaping double quotes --- src/components/UI/LexicalTextEditor.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/UI/LexicalTextEditor.tsx b/src/components/UI/LexicalTextEditor.tsx index 101b84d..617b56e 100644 --- a/src/components/UI/LexicalTextEditor.tsx +++ b/src/components/UI/LexicalTextEditor.tsx @@ -239,7 +239,7 @@ export function $makeJson(str: string) { return str; } catch { return ( - `{"root":{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"${str.replaceAll(/"/g, '\\"')}",` + + `{"root":{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"${str.replaceAll(/\\/g, '\\\\').replaceAll(/"/g, '\\"')}",` + `"type":"color-text","version":1}],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],` + `"direction":"ltr","format":"","indent":0,"type":"root","version":1}}` ); From e074978335aa9969d4e378acde164476a498e754 Mon Sep 17 00:00:00 2001 From: AlbanSdl Date: Sun, 19 Oct 2025 18:12:52 +0200 Subject: [PATCH 30/37] =?UTF-8?q?fix:=20je=20ne=20sais=20pas=20=C3=A9crire?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/locales/fr/common.json.ts | 2 +- src/components/UI/Avatar.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/public/locales/fr/common.json.ts b/public/locales/fr/common.json.ts index 552aefe..94db31e 100644 --- a/public/locales/fr/common.json.ts +++ b/public/locales/fr/common.json.ts @@ -36,5 +36,5 @@ export default { '404': '404 - Page not found', 'rte.dnd.drop': "Déposez le fichier pour l'importer", 'rte.toolbar.uploadImage': "Cliquez pour sélectionner l'image", - 'ui.profilepicture.uplaod': 'Télécharger une image', + 'ui.profilepicture.upload': 'Télécharger une image', } as const; diff --git a/src/components/UI/Avatar.tsx b/src/components/UI/Avatar.tsx index ce57969..41d93a0 100644 --- a/src/components/UI/Avatar.tsx +++ b/src/components/UI/Avatar.tsx @@ -36,7 +36,7 @@ export default function Avatar({ localSrc, name, editable, className, onChange, {editable && ( <>
))}
-

{t('ues:detailed.comments.answers.answerTitle')}

+

{t('ues:detailed.comments.answers.title')}