From 66de99186198ad22c6954e496d11f713e7ea68b9 Mon Sep 17 00:00:00 2001 From: tenkus47 Date: Thu, 28 May 2026 23:23:03 +0530 Subject: [PATCH 1/4] feat(groups): add groups management functionality - Updated package version to 2026.05.28.1200. - Introduced new routes for managing groups, including group creation, editing, and details pages. - Enhanced the Navbar to include a link for groups management. - Modified PlanTagSearchInput to conditionally hide the label. - Replaced error handling in Tags component to utilize a centralized API error message function. --- package.json | 2 +- .../routes/create-plan/PlanTagSearchInput.tsx | 4 +- .../routes/groups/GroupDetailsPage.tsx | 209 +++++++ .../routes/groups/GroupFormPage.tsx | 541 ++++++++++++++++++ src/components/routes/groups/Groups.tsx | 156 +++++ src/components/routes/groups/GroupsTable.tsx | 87 +++ .../routes/groups/api/groupPickerApi.ts | 56 ++ src/components/routes/groups/api/groupsApi.ts | 341 +++++++++++ .../routes/groups/api/seriesSearchApi.ts | 42 ++ .../components/FkMultiSearchSelector.tsx | 198 +++++++ .../components/GroupFormAssociationsPanel.tsx | 120 ++++ .../groups/components/GroupImageField.tsx | 37 ++ .../groups/components/GroupMembersPanel.tsx | 288 ++++++++++ .../groups/components/GroupMembersTable.tsx | 35 ++ .../groups/components/GroupPageShell.tsx | 73 +++ .../routes/groups/components/GroupSection.tsx | 57 ++ .../components/GroupSocialLinksEditor.tsx | 117 ++++ .../components/GroupTitleWithAvatar.tsx | 40 ++ .../groups/hooks/useGroupLinkedTitles.ts | 39 ++ src/components/routes/tags/Tags.tsx | 25 +- .../ui/molecules/nav-bar/Navbar.tsx | 18 +- src/lib/apiErrors.ts | 18 + src/lib/constant.ts | 1 + src/main.tsx | 35 ++ src/routes/paths.ts | 4 + src/schema/GroupSchema.ts | 59 ++ 26 files changed, 2578 insertions(+), 24 deletions(-) create mode 100644 src/components/routes/groups/GroupDetailsPage.tsx create mode 100644 src/components/routes/groups/GroupFormPage.tsx create mode 100644 src/components/routes/groups/Groups.tsx create mode 100644 src/components/routes/groups/GroupsTable.tsx create mode 100644 src/components/routes/groups/api/groupPickerApi.ts create mode 100644 src/components/routes/groups/api/groupsApi.ts create mode 100644 src/components/routes/groups/api/seriesSearchApi.ts create mode 100644 src/components/routes/groups/components/FkMultiSearchSelector.tsx create mode 100644 src/components/routes/groups/components/GroupFormAssociationsPanel.tsx create mode 100644 src/components/routes/groups/components/GroupImageField.tsx create mode 100644 src/components/routes/groups/components/GroupMembersPanel.tsx create mode 100644 src/components/routes/groups/components/GroupMembersTable.tsx create mode 100644 src/components/routes/groups/components/GroupPageShell.tsx create mode 100644 src/components/routes/groups/components/GroupSection.tsx create mode 100644 src/components/routes/groups/components/GroupSocialLinksEditor.tsx create mode 100644 src/components/routes/groups/components/GroupTitleWithAvatar.tsx create mode 100644 src/components/routes/groups/hooks/useGroupLinkedTitles.ts create mode 100644 src/lib/apiErrors.ts create mode 100644 src/schema/GroupSchema.ts diff --git a/package.json b/package.json index 56f770ac..ab35fe96 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "pechastudio", "private": true, - "version": "2025.11.05.0657", + "version": "2026.05.28.1200", "type": "module", "scripts": { "dev": "vite", diff --git a/src/components/routes/create-plan/PlanTagSearchInput.tsx b/src/components/routes/create-plan/PlanTagSearchInput.tsx index d3ee908e..be0606d1 100644 --- a/src/components/routes/create-plan/PlanTagSearchInput.tsx +++ b/src/components/routes/create-plan/PlanTagSearchInput.tsx @@ -16,6 +16,7 @@ interface PlanTagSearchInputProps { onChange?: (tagIds: string[]) => void; initialTags?: PlanTagSummary[]; planId?: string; + hideLabel?: boolean; } const SUGGESTIONS_LIMIT = 10; @@ -28,6 +29,7 @@ const PlanTagSearchInput = ({ onChange, initialTags = [], planId, + hideLabel = false, }: PlanTagSearchInputProps) => { const queryClient = useQueryClient(); const containerRef = useRef(null); @@ -152,7 +154,7 @@ const PlanTagSearchInput = ({ ref={containerRef} className="w-full space-y-2 h-full font-dynamic flex flex-col" > -

Tags

+ {!hideLabel ?

Tags

: null} {value.length > 0 && (
diff --git a/src/components/routes/groups/GroupDetailsPage.tsx b/src/components/routes/groups/GroupDetailsPage.tsx new file mode 100644 index 00000000..eb678735 --- /dev/null +++ b/src/components/routes/groups/GroupDetailsPage.tsx @@ -0,0 +1,209 @@ +import { Link, useNavigate, useParams } from "react-router-dom"; +import { useQuery } from "@tanstack/react-query"; +import { IoMdCreate } from "react-icons/io"; +import { Pecha } from "@/components/ui/shadimport"; +import { Button } from "@/components/ui/atoms/button"; +import { getApiErrorMessage } from "@/lib/apiErrors"; +import { ROUTES } from "@/routes/paths"; +import type { LanguageCode } from "@/schema/SeriesSchema"; +import { + fetchGroup, + languageLabelForCode, + pickGroupTitle, + resolveGroupBannerUrl, +} from "./api/groupsApi"; +import { GroupDetailCard } from "./components/GroupSection"; +import GroupMembersTable from "./components/GroupMembersTable"; +import { GroupPageShell } from "./components/GroupPageShell"; +import { useGroupLinkedTitles } from "./hooks/useGroupLinkedTitles"; + +const GroupDetailsPage = () => { + const { groupId } = useParams<{ groupId: string }>(); + const navigate = useNavigate(); + + const { + data: group, + isLoading, + isError, + error, + } = useQuery({ + queryKey: ["cms-group", groupId], + queryFn: () => fetchGroup(groupId!), + enabled: Boolean(groupId), + refetchOnWindowFocus: false, + }); + + const { linkedPlans, linkedSeries } = useGroupLinkedTitles( + group?.plan_ids, + group?.series_ids, + ); + + if (!groupId) return null; + + if (isLoading) { + return ( +
+ Loading group… +
+ ); + } + + if (isError || !group) { + return ( +
+

+ {getApiErrorMessage(error, "Could not load this group")} +

+ +
+ ); + } + + const bannerUrl = resolveGroupBannerUrl(group); + + return ( + navigate(ROUTES.groups)} + title={pickGroupTitle(group.metadata)} + subtitle={ +

+ /{group.slug} +

+ } + headerActions={ + + } + > +
+
+ {bannerUrl ? ( + + ) : null} + +
+ + {group.is_public ? "Public" : "Private"} + + + {group.member_count} member{group.member_count === 1 ? "" : "s"} + + + {group.follower_count} follower + {group.follower_count === 1 ? "" : "s"} + +
+ + {group.metadata.length > 0 && ( + +
+ {group.metadata.map((meta) => ( +
+

+ {languageLabelForCode(meta.language as LanguageCode)} +

+

{meta.title}

+ {meta.description?.trim() ? ( +

+ {meta.description} +

+ ) : null} +
+ ))} +
+
+ )} + + {group.tags.length > 0 && ( + +
+ {group.tags.map((tag) => ( + + {tag.name} + + ))} +
+
+ )} + + {linkedPlans.length > 0 && ( + +
    + {linkedPlans.map((plan) => ( +
  • + + {plan.title} + +
  • + ))} +
+
+ )} + + {linkedSeries.length > 0 && ( + +
    + {linkedSeries.map((series) => ( +
  • + + {series.title} + +
  • + ))} +
+
+ )} + + {group.social_links.length > 0 && ( + +
    + {group.social_links.map((link, index) => ( +
  • + + {link.platform}: + {" "} + + {link.url} + +
  • + ))} +
+
+ )} + + {group.members.length > 0 && ( + + + + )} +
+
+
+ ); +}; + +export default GroupDetailsPage; diff --git a/src/components/routes/groups/GroupFormPage.tsx b/src/components/routes/groups/GroupFormPage.tsx new file mode 100644 index 00000000..4347bf7d --- /dev/null +++ b/src/components/routes/groups/GroupFormPage.tsx @@ -0,0 +1,541 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { useNavigate, useParams } from "react-router-dom"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { IoMdAdd, IoMdClose } from "react-icons/io"; +import { toast } from "sonner"; +import { Pecha } from "@/components/ui/shadimport"; +import { Textarea } from "@/components/ui/atoms/textarea"; +import { Button } from "@/components/ui/atoms/button"; +import ImageContentData from "@/components/ui/molecules/modals/image-upload/ImageContentData"; +import { uploadImageToS3 } from "@/components/routes/task/api/taskApi"; +import { getApiErrorMessage } from "@/lib/apiErrors"; +import { PLAN_LANGUAGE } from "@/lib/constant"; +import { ROUTES } from "@/routes/paths"; +import type { LanguageCode } from "@/schema/SeriesSchema"; +import { + groupCoreSchema, + type GroupCoreFormData, +} from "@/schema/GroupSchema"; +import { mapIdsToFkOptions } from "./api/groupPickerApi"; +import { + buildGroupMetadata, + createGroup, + fetchGroup, + languageLabelForCode, + patchGroup, + pickGroupTitle, + replaceGroupPlans, + replaceGroupSeries, + replaceGroupSocialLinks, + replaceGroupTags, + resolveGroupBannerUrl, + type GroupSocialLinkDTO, + type TagSummaryDTO, +} from "./api/groupsApi"; +import { searchSeries } from "./api/seriesSearchApi"; +import GroupFormAssociationsPanel from "./components/GroupFormAssociationsPanel"; +import GroupImageField from "./components/GroupImageField"; +import { GroupPageShell } from "./components/GroupPageShell"; +import GroupMembersPanel from "./components/GroupMembersPanel"; +import type { FkOption } from "./components/FkMultiSearchSelector"; +import { useGroupLinkedTitles } from "./hooks/useGroupLinkedTitles"; + +const GroupFormPage = () => { + const { groupId } = useParams<{ groupId: string }>(); + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const isNew = !groupId; + const hydratedRef = useRef(null); + + const [addedLanguages, setAddedLanguages] = useState(["EN"]); + const [avatarPreview, setAvatarPreview] = useState(null); + const [bannerPreview, setBannerPreview] = useState(null); + const [avatarKey, setAvatarKey] = useState(null); + const [bannerKey, setBannerKey] = useState(null); + const [avatarDialogOpen, setAvatarDialogOpen] = useState(false); + const [bannerDialogOpen, setBannerDialogOpen] = useState(false); + const [avatarUploading, setAvatarUploading] = useState(false); + const [bannerUploading, setBannerUploading] = useState(false); + const [tagIds, setTagIds] = useState([]); + const [initialTags, setInitialTags] = useState([]); + const [selectedPlans, setSelectedPlans] = useState([]); + const [selectedSeries, setSelectedSeries] = useState([]); + const [socialLinks, setSocialLinks] = useState([]); + + const form = useForm({ + resolver: zodResolver(groupCoreSchema), + defaultValues: { + slug: "", + is_public: true, + languages: { EN: { title: "", description: "" } }, + avatar_key: "", + banner_key: "", + }, + }); + + const { + data: groupData, + isLoading: isGroupLoading, + error: groupError, + } = useQuery({ + queryKey: ["cms-group", groupId], + queryFn: () => fetchGroup(groupId!), + enabled: Boolean(groupId), + refetchOnWindowFocus: false, + }); + + const { planOptions } = useGroupLinkedTitles( + groupData?.plan_ids, + groupData?.series_ids, + ); + + useEffect(() => { + if (isNew || !groupData) return; + if (hydratedRef.current === groupData.id) return; + hydratedRef.current = groupData.id; + + const languages: GroupCoreFormData["languages"] = {}; + const langs: LanguageCode[] = []; + for (const meta of groupData.metadata) { + const code = meta.language as LanguageCode; + langs.push(code); + languages[code] = { + title: meta.title ?? "", + description: meta.description ?? "", + }; + } + setAddedLanguages(langs.length ? langs : ["EN"]); + form.reset({ + slug: groupData.slug, + is_public: groupData.is_public, + languages: + langs.length > 0 ? languages : { EN: { title: "", description: "" } }, + avatar_key: groupData.avatar_key ?? "", + banner_key: groupData.banner_key ?? "", + }); + + setAvatarKey(groupData.avatar_key ?? null); + setBannerKey(groupData.banner_key ?? null); + setBannerPreview(resolveGroupBannerUrl(groupData)); + setTagIds(groupData.tags.map((t) => t.id)); + setInitialTags(groupData.tags); + setSocialLinks(groupData.social_links ?? []); + + const planTitleMap = new Map(planOptions.map((p) => [p.id, p.title])); + setSelectedPlans(mapIdsToFkOptions(groupData.plan_ids ?? [], planTitleMap)); + setSelectedSeries( + (groupData.series_ids ?? []).map((id) => ({ id, title: id })), + ); + }, [isNew, groupData, form, planOptions]); + + useEffect(() => { + if (isNew || !groupData?.series_ids?.length) return; + let cancelled = false; + (async () => { + const result = await searchSeries({ limit: 500 }); + if (cancelled) return; + const titleMap = new Map(result.series.map((s) => [s.id, s.title])); + setSelectedSeries(mapIdsToFkOptions(groupData.series_ids, titleMap)); + })(); + return () => { + cancelled = true; + }; + }, [isNew, groupData?.id, groupData?.series_ids]); + + const availableLanguages = PLAN_LANGUAGE.filter( + (l) => !addedLanguages.includes(l.value as LanguageCode), + ); + + const addLanguage = (code: LanguageCode) => { + if (addedLanguages.includes(code)) return; + setAddedLanguages((prev) => [...prev, code]); + form.setValue(`languages.${code}`, { title: "", description: "" }, { + shouldDirty: true, + }); + }; + + const removeLanguage = (code: LanguageCode) => { + if (addedLanguages.length <= 1) { + toast.error("At least one language is required"); + return; + } + setAddedLanguages((prev) => prev.filter((c) => c !== code)); + form.unregister(`languages.${code}`); + }; + + const invalidateGroup = () => { + queryClient.invalidateQueries({ queryKey: ["cms-groups"] }); + if (groupId) { + queryClient.invalidateQueries({ queryKey: ["cms-group", groupId] }); + } + }; + + const toastOnError = (err: unknown) => + toast.error(getApiErrorMessage(err)); + + const createMutation = useMutation({ + mutationFn: createGroup, + onSuccess: (data) => { + toast.success("Group created"); + invalidateGroup(); + navigate(ROUTES.groupEdit(data.id)); + }, + onError: toastOnError, + }); + + const patchMutation = useMutation({ + mutationFn: (payload: Parameters[1]) => + patchGroup(groupId!, payload), + onSuccess: () => { + toast.success("Group updated"); + invalidateGroup(); + }, + onError: toastOnError, + }); + + const tagsMutation = useMutation({ + mutationFn: () => replaceGroupTags(groupId!, { tag_ids: tagIds }), + onSuccess: () => { + toast.success("Tags saved"); + invalidateGroup(); + }, + onError: toastOnError, + }); + + const plansMutation = useMutation({ + mutationFn: () => + replaceGroupPlans(groupId!, { + plan_ids: selectedPlans.map((p) => p.id), + }), + onSuccess: () => { + toast.success("Plans saved"); + invalidateGroup(); + }, + onError: toastOnError, + }); + + const seriesMutation = useMutation({ + mutationFn: () => + replaceGroupSeries(groupId!, { + series_ids: selectedSeries.map((s) => s.id), + }), + onSuccess: () => { + toast.success("Series saved"); + invalidateGroup(); + }, + onError: toastOnError, + }); + + const socialMutation = useMutation({ + mutationFn: () => + replaceGroupSocialLinks(groupId!, { social_links: socialLinks }), + onSuccess: () => { + toast.success("Social links saved"); + invalidateGroup(); + }, + onError: toastOnError, + }); + + const handleImageUpload = async ( + file: File, + kind: "avatar" | "banner", + ) => { + const setUploading = + kind === "avatar" ? setAvatarUploading : setBannerUploading; + const setPreview = kind === "avatar" ? setAvatarPreview : setBannerPreview; + const setKey = kind === "avatar" ? setAvatarKey : setBannerKey; + const setDialog = + kind === "avatar" ? setAvatarDialogOpen : setBannerDialogOpen; + const field = kind === "avatar" ? "avatar_key" : "banner_key"; + + setUploading(true); + try { + const { image, key } = await uploadImageToS3(file, groupId ?? ""); + setPreview(image.original); + setKey(key); + form.setValue(field, key, { shouldDirty: true }); + setDialog(false); + toast.success("Image uploaded"); + } catch { + toast.error("Failed to upload image"); + } finally { + setUploading(false); + } + }; + + const onSaveCore = form.handleSubmit((data) => { + const metadata = buildGroupMetadata(data.languages); + const payload = { + slug: data.slug.trim(), + is_public: data.is_public, + metadata, + avatar_key: avatarKey, + banner_key: bannerKey, + }; + if (isNew) { + createMutation.mutate(payload); + return; + } + patchMutation.mutate(payload); + }); + + const corePending = createMutation.isPending || patchMutation.isPending; + const pageTitle = useMemo( + () => + isNew ? "Create group" : pickGroupTitle(groupData?.metadata, "Edit group"), + [isNew, groupData?.metadata], + ); + + + if (!isNew && isGroupLoading) { + return ( +
+ Loading group… +
+ ); + } + + if (!isNew && groupError) { + return ( +
+

{getApiErrorMessage(groupError)}

+ +
+ ); + } + + return ( + navigate(ROUTES.groups)} + title={pageTitle} + > +
+
+
+

General

+ +
+ ( + + + Slug + + + + + + + )} + /> + ( + + + + field.onChange(checked === true) + } + /> + + + Public group + + + )} + /> +
+ {addedLanguages.map((code) => ( +
+ + ( + + + {languageLabelForCode(code)} title + + + + + + + )} + /> + ( + + + {languageLabelForCode(code)} description + + +