Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
"react-icons": "^5.5.0",
"react-toastify": "^11.0.5",
"styled-components": "^6.1.19",
"textarea-caret": "^3.1.0",
"ts-type-safe": "^1.5.0",
"zod": "^4.1.13"
},
Expand All @@ -40,6 +41,7 @@
"@types/react": "19.2.2",
"@types/react-dom": "^19",
"@types/react-toastify": "^4.1.0",
"@types/textarea-caret": "^3.0.4",
"eslint": "^9",
"eslint-config-next": "15.3.1",
"husky": "^9.1.7",
Expand Down
1 change: 1 addition & 0 deletions public/locales/de/translations.json
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,7 @@
},
"dashboard": {
"common": {
"loading": "Wird geladen...",
"datePrefix": {
"appliedOn": "Beworben am",
"matchedOn": "Zugeordnet am",
Expand Down
1 change: 1 addition & 0 deletions public/locales/en/translations.json
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,7 @@
},
"dashboard": {
"common": {
"loading": "Loading...",
"datePrefix": {
"appliedOn": "Applied on",
"matchedOn": "Matched on",
Expand Down
5 changes: 5 additions & 0 deletions src/app/[lang]/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,11 @@ html {
--comments-menu-item-font-weight: var(--font-weight-regular);
--comments-menu-item-danger-font-weight: var(--font-weight-semi-bold);

/* user tags */
--tag-text-stroke: 0.5px var(--color-midnight);
--tag-autocomplete-padding: 5px;
--tag-autocomplete-font-size: 1.05rem;

/* icon */
--icon-color: var(--color-papaya);
--icon-size-24: 24px;
Expand Down
8 changes: 7 additions & 1 deletion src/components/Dashboard/Home/NewestOpportunities.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,12 @@ export function NewestOpportunities() {
}

return opportunities?.map((opp) => (
<OpportunityCard key={opp.id} opportunity={opp} volunteerId={undefined} activitiesList={activitiesList} districtsList={districtsList} />
<OpportunityCard
key={opp.id}
opportunity={opp}
volunteerId={undefined}
activitiesList={activitiesList}
districtsList={districtsList}
/>
));
}
4 changes: 1 addition & 3 deletions src/components/Dashboard/Opportunities/OpportunityCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,7 @@ export function OpportunityCard({ opportunity, volunteerId, activitiesList, dist
const mainCommunication = getLanguagesByPurpose(languages, LangPurpose.GENERAL);
const recipientLanguage = getLanguagesByPurpose(languages, LangPurpose.RECIPIENT);
const activityTitles = getActivityTitles(activities, activitiesList);
const districtTitle = district?.id
? (districtsList?.find((d) => d.id === district.id)?.title ?? null)
: null;
const districtTitle = district?.id ? (districtsList?.find((d) => d.id === district.id)?.title ?? null) : null;

const isAccompanying = volunteerType === ProfileVolunteeringType.ACCOMPANYING;
const scheduleText = isAccompanying
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,13 @@ export function OpportunityCardList({
districtsList,
}: Props) {
const items = opportunities.map((opp) => (
<OpportunityCard key={opp.id} opportunity={opp} volunteerId={volunteerId} activitiesList={activitiesList} districtsList={districtsList} />
<OpportunityCard
key={opp.id}
opportunity={opp}
volunteerId={volunteerId}
activitiesList={activitiesList}
districtsList={districtsList}
/>
));

return (
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { AutocompleteContainer, AutocompleteRow } from "./styles";
import { ApiUserGet, SortOrder, UserRole } from "need4deed-sdk";
import { useGetQuery } from "@/hooks";
import { apiPathUser, cacheTTL, defaultAvatarVolunteerProfile } from "@/config/constants";
import { AvatarImg } from "../../OpportunityVolunteers/styles";
import { getImageUrl } from "@/utils";
import getCaretCoordinates from "textarea-caret";

type Props = {
handleTagAdd: (userId: number, fullName: string) => void;
newCommentText: string;
textAreaRef: React.RefObject<HTMLTextAreaElement | null>;
activeRowIndex: number;
setFilteredListLength: (length: number) => void;
setOnSelectTrigger: (callback: (() => void) | null) => void;
};

export default function Autocomplete({
handleTagAdd,
newCommentText,
textAreaRef,
activeRowIndex,
setFilteredListLength,
setOnSelectTrigger,
}: Props) {
const [coords, setCoords] = useState({ top: 0, left: 0 });
const containerRef = useRef<HTMLDivElement>(null);

const userFilter = useMemo(() => {
if (!newCommentText || !textAreaRef?.current) return "";
const cursorPosition = textAreaRef?.current.selectionStart;
const textBeforeCaret = newCommentText.substring(0, cursorPosition);
if (newCommentText[0] !== "@" && newCommentText.length > 1 && !textBeforeCaret.includes(" @")) return null;
const lastAtIndex = textBeforeCaret.lastIndexOf("@");
if (lastAtIndex === -1) return "";
const textAfterAt = textBeforeCaret.substring(lastAtIndex + 1);
if (textAfterAt.includes(" ")) return null;
return textAfterAt.toLowerCase();
}, [newCommentText, textAreaRef]);

const { data: users } = useGetQuery<ApiUserGet[]>({
queryKey: ["users"],
apiPath: apiPathUser,
params: {
sortOrder: SortOrder.NewToOld,
},
enabled: userFilter !== null,
staleTime: cacheTTL,
});

const filteredUsers = useMemo(() => {
if (userFilter === null) return;
return users
?.filter((user) => user.role === UserRole.COORDINATOR)
?.map((user) => {
return {
id: user.id,
fullName: user.fullName,
avatarUrl: user.avatarUrl,
};
})
?.filter((user) => user?.fullName?.toLowerCase().includes(userFilter));
}, [userFilter, users]);

useEffect(() => {
if (!filteredUsers) return;
setFilteredListLength(filteredUsers?.length);
return () => setFilteredListLength(0);
}, [filteredUsers, setFilteredListLength]);

useEffect(() => {
if (!filteredUsers) return;
const activeUser = filteredUsers[activeRowIndex];
if (activeUser) {
setOnSelectTrigger(() => () => handleTagAdd(activeUser.id, activeUser.fullName.replace(" ", "")));
} else {
setOnSelectTrigger(null);
}
return () => setOnSelectTrigger(null);
}, [filteredUsers, activeRowIndex, setOnSelectTrigger]);

useEffect(() => {
if (!containerRef.current || filteredUsers?.length === 0) return;

const container = containerRef.current;

const activeChild = container.children[activeRowIndex] as HTMLElement;

if (!activeChild) return;

const containerTop = container.scrollTop;
const containerBottom = containerTop + container.clientHeight;
const elemTop = activeChild.offsetTop;
const elemBottom = elemTop + activeChild.offsetHeight;

if (elemBottom > containerBottom) {
container.scrollTop = elemBottom - container.clientHeight;
} else if (elemTop < containerTop) {
container.scrollTop = elemTop;
}
}, [activeRowIndex, filteredUsers]);

const handleUserSelect = (userId: number, fullName: string) => {
handleTagAdd(userId, fullName.replace(" ", ""));
};

const resolvedAvatarUrl = (url: string) => {
return getImageUrl(url || defaultAvatarVolunteerProfile);
};

useLayoutEffect(() => {
const el = textAreaRef?.current;
if (!el || userFilter === null) return;
const textBeforeCaret = el.value.substring(0, el.selectionStart);
const lastAtIndex = textBeforeCaret?.lastIndexOf("@");

const positioningIndex = lastAtIndex !== -1 ? lastAtIndex : el.selectionStart;

const caret = getCaretCoordinates(el, positioningIndex ?? 0);

setCoords({
top: caret.top + 20 - el?.scrollTop,
left: caret.left - el?.scrollLeft,
});
}, [userFilter, textAreaRef]);

return (
filteredUsers &&
filteredUsers.length > 0 && (
<AutocompleteContainer
ref={containerRef}
role="listbox"
aria-label="User mentions options"
style={{
top: `${coords.top}px`,
left: `${coords.left}px`,
}}
>
{filteredUsers?.map((user, index) => {
const isActive = index === activeRowIndex;

return (
<AutocompleteRow
key={user.id}
role="option"
aria-selected={isActive}
onClick={() => handleUserSelect(user.id, user.fullName)}
style={{
backgroundColor: isActive ? "var(--editableField-optionRow-selectedBg)" : "transparent",
cursor: "pointer",
}}
>
<AvatarImg src={resolvedAvatarUrl(user.avatarUrl)} alt={user.fullName} />
<span>{user.fullName}</span>
</AutocompleteRow>
);
})}
</AutocompleteContainer>
)
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { DotsThreeOutline } from "@phosphor-icons/react";
import { useTranslation } from "react-i18next";
import { CommentActionMenu } from "./CommentActionMenu";
import { CommentText, MenuAction } from "./styles";
import { useCommentTag } from "./hooks/useCommentTag";

type MenuState = {
isOpen: boolean;
Expand All @@ -21,10 +22,10 @@ type Props = {

export function CommentDisplay({ commentId, content, menu }: Props) {
const { t } = useTranslation();

const { renderHighlightedText } = useCommentTag(content);
return (
<>
<CommentText>{content}</CommentText>
<CommentText>{renderHighlightedText()}</CommentText>
<MenuAction
ref={menu.buttonRef}
onClick={menu.onToggle}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
import { useTranslation } from "react-i18next";
import { CommentEditButtons, EditCancelButton, EditSaveButton, TextArea } from "./styles";
import {
CommentEditButtons,
EditCancelButton,
EditSaveButton,
NewCommentSection,
TagOverlay,
TextArea,
} from "./styles";
import { useCommentTag } from "./hooks/useCommentTag";
import { useEffect, useRef } from "react";
import Autocomplete from "./Autocomplete";

type EditState = {
text: string;
Expand All @@ -18,15 +28,55 @@ type Props = {

export function CommentEdit({ commentId, edit }: Props) {
const { t } = useTranslation();
const editTextAreaRef = useRef<HTMLTextAreaElement>(null);
const editOverlayRef = useRef<HTMLDivElement>(null);
const {
showAutocomplete,
handleTagAdd,
activeRowIndex,
setFilteredListLength,
setOnSelectTrigger,
renderHighlightedText,
setShowAutocomplete,
convertDbTextToEditable,
handleKeyDown,
} = useCommentTag(edit.text, edit.onTextChange, editTextAreaRef);

const handleScroll = () => {
if (!editTextAreaRef?.current || !editOverlayRef?.current) return;
editOverlayRef.current.style.transform = `translateY(-${editTextAreaRef.current.scrollTop}px)`;
};

useEffect(() => {
const sanitisedText = convertDbTextToEditable(edit.text);
edit.onTextChange(sanitisedText);
}, []);

return (
<>
<TextArea
value={edit.text}
onChange={(e) => edit.onTextChange(e.target.value)}
onKeyPress={edit.onKeyPress}
data-testid={`edit-comment-textarea-${commentId}`}
/>
<NewCommentSection>
{showAutocomplete && (
<Autocomplete
handleTagAdd={handleTagAdd}
newCommentText={edit.text}
textAreaRef={editTextAreaRef}
activeRowIndex={activeRowIndex}
setFilteredListLength={setFilteredListLength}
setOnSelectTrigger={setOnSelectTrigger}
/>
)}
<TagOverlay ref={editOverlayRef}>{renderHighlightedText()}</TagOverlay>
<TextArea
ref={editTextAreaRef}
value={edit.text}
onChange={(e) => edit.onTextChange(e.target.value)}
onKeyPress={edit.onKeyPress}
onKeyDown={handleKeyDown}
data-testid={`edit-comment-textarea-${commentId}`}
onScroll={handleScroll}
onClick={() => setShowAutocomplete(false)}
/>
</NewCommentSection>
<CommentEditButtons>
<EditCancelButton onClick={edit.onCancel} data-testid={`cancel-edit-${commentId}`}>
{t("dashboard.commentsSection.cancelEdit")}
Expand Down
Loading
Loading