diff --git a/admin_app/src/app/content/edit/page.tsx b/admin_app/src/app/content/edit/page.tsx index 2c5980dd2..06e52285a 100644 --- a/admin_app/src/app/content/edit/page.tsx +++ b/admin_app/src/app/content/edit/page.tsx @@ -1,49 +1,108 @@ "use client"; +import { DeleteContentModal } from "@/components/ContentModal"; import LanguageButtonBar from "@/components/LanguageButtonBar"; import { Layout } from "@/components/Layout"; import { FullAccessComponent } from "@/components/ProtectedComponent"; import { appColors, appStyles, sizes } from "@/utils"; import { apiCalls } from "@/utils/api"; import { useAuth } from "@/utils/auth"; -import { ChevronLeft } from "@mui/icons-material"; -import { Button, CircularProgress, TextField, Typography } from "@mui/material"; +import { ChevronLeft, Delete } from "@mui/icons-material"; +import { Button, CircularProgress, IconButton, Snackbar, TextField, Typography } from "@mui/material"; import Alert from "@mui/material/Alert"; import { useRouter, useSearchParams } from "next/navigation"; import React from "react"; export interface Content extends EditContentBody { - content_id: number | null; + content_text_id: number | null; created_datetime_utc: string; updated_datetime_utc: string; } interface EditContentBody { + content_id: number | null; content_title: string; content_text: string; - content_language: string; + language_id: number; content_metadata: Record; } const AddEditContentPage = () => { const searchParams = useSearchParams(); const content_id = Number(searchParams.get("content_id")) || null; - + const defaultLanguageId = Number(searchParams.get("default_language_id")); + const language_id = Number(searchParams.get("language_id")) || defaultLanguageId; + const [contentData, setContentData] = React.useState<{ [key: number]: Content }>({}); + const [contentId, setContentId] = React.useState(content_id); + const [languageId, setLanguageId] = React.useState(language_id); const [content, setContent] = React.useState(null); const [isLoading, setIsLoading] = React.useState(true); - - const { token } = useAuth(); + const [reloadTrigger, setReloadTrigger] = React.useState(0); + const { token, accessLevel } = useAuth(); + const [snackMessage, setSnackMessage] = React.useState( + null, + ); + const getSnackMessage = ( + action: string, + content_id: number | null, + language_id: number | null, + ): string | null => { + if (action === "delete") { + return `Content #${content_id} with language_id:#${language_id} deleted successfully`; + } + else if (action == "edit" || action == "add") { + return `Content #${content_id} with language_id:#${language_id} ${action}ed successfully`; + } + else { + return null; + } + }; React.useEffect(() => { - if (!content_id) { + if (!contentId) { setIsLoading(false); return; } else { - apiCalls.getContent(content_id, token!).then((data) => { - setContent(data); - setIsLoading(false); + apiCalls.getContent(contentId, null, token!).then((data) => { + const contentDic: { [key: number]: Content } = data.reduce( + (acc: { [key: number]: Content }, currentContent: Content) => { + acc[currentContent.language_id] = currentContent; + return acc; + }, + {} as { [key: string]: Content } + ); + setContentData(contentDic); + setContent(languageId !== null ? contentDic[languageId] : null); }); + setIsLoading(false); } - }, [content_id]); + }, [contentId, token, reloadTrigger]); + const handleSaveSuccess = (content: Content, action: string) => { + setContentId(content.content_id); + setLanguageId(content.language_id); + setReloadTrigger(prev => prev + 1); + setSnackMessage(getSnackMessage(action, content.content_id, content.language_id)); + + }; + const handleDeleteSuccess = (content_id: number, language_id: number | null) => { + + if (language_id) { + setContentData(prevContentData => { + const updatedContentData = { ...prevContentData }; + delete updatedContentData[language_id]; + if (Object.keys(updatedContentData).length === 0) { + const router = useRouter(); + setTimeout(() => router + .push(`/content?content_id=${content_id}&action=delete`), 0); + } + else { + setReloadTrigger(prev => prev + 1); + setSnackMessage(getSnackMessage("delete", content_id, language_id)); + } + setLanguageId(Object.keys(updatedContentData).map(Number)[0]); + return updatedContentData; + }); + } + }; if (isLoading) { return (
{ return ( -
+
- + + + { + setSnackMessage(null); + }} + > + { + setSnackMessage(null); + }} + severity="success" + variant="filled" + sx={{ width: "100%" }} + > + {snackMessage} + + @@ -78,42 +166,64 @@ const AddEditContentPage = () => { }; const ContentBox = ({ + contentId, content, setContent, + contentData, + setContentData, + languageId, + onSaveSuccess, + onDeleteSuccess, + setReloadTrigger }: { + contentId: number; content: Content | null; setContent: React.Dispatch>; + contentData: { [key: number]: Content }; + setContentData: React.Dispatch>; + languageId: number; + onSaveSuccess: (content: Content, action: string) => void; + onDeleteSuccess: (content_id: number, language_id: number | null) => void; + setReloadTrigger: React.Dispatch>; }) => { const [isSaved, setIsSaved] = React.useState(true); const [saveError, setSaveError] = React.useState(false); + const [errorText, setErrorText] = React.useState(""); const [isTitleEmpty, setIsTitleEmpty] = React.useState(false); const [isContentEmpty, setIsContentEmpty] = React.useState(false); + const [openDeleteModal, setOpenDeleteModal] = React.useState(false); + const { token, accessLevel } = useAuth(); + const editAccess = accessLevel === "fullaccess"; - const { token } = useAuth(); - - const router = useRouter(); const saveContent = async (content: Content) => { const body: EditContentBody = { + content_id: content.content_id, content_title: content.content_title, content_text: content.content_text, - content_language: content.content_language, + language_id: content.language_id, content_metadata: content.content_metadata, }; - + if (body.language_id === 0) { + setErrorText("Please select a language"); + setSaveError(true); + return null; + } + setIsSaved(true); const promise = content.content_id === null ? apiCalls.addContent(body, token!) - : apiCalls.editContent(content.content_id, body, token!); + : apiCalls.editContent(content.content_id, content.language_id, body, token!); const result = promise .then((data) => { - setIsSaved(true); setSaveError(false); return data.content_id; }) .catch((error: Error) => { console.error("Error processing content:", error); setSaveError(true); + setIsSaved(false); + return null; }); @@ -125,24 +235,61 @@ const ContentBox = ({ key: keyof Content, ) => { const emptyContent: Content = { - content_id: null, + + content_text_id: null, + content_id: content?.content_id || contentId, created_datetime_utc: "", updated_datetime_utc: "", content_title: "", content_text: "", - content_language: "ENGLISH", + language_id: content?.language_id || languageId, content_metadata: {}, }; setIsTitleEmpty(false); setIsContentEmpty(false); - content ? setContent({ ...content, [key]: e.target.value }) : setContent({ ...emptyContent, [key]: e.target.value }); setIsSaved(false); }; + const handleLanguageSelect = (language_id: number) => { + if (contentData[language_id]?.content_text_id) { + setContent(contentData[language_id]); + } + else { + handleNewLanguageSelect(language_id); + } + }; + const handleNewLanguageSelect = (language_id: number) => { + const newContent: Content = { + content_text_id: null, + content_id: content?.content_id || contentId, + created_datetime_utc: "", + updated_datetime_utc: "", + content_title: content?.content_text_id ? "" : content?.content_title || "", + content_text: content?.content_text_id ? "" : content?.content_text || "", + language_id: language_id, + content_metadata: {}, + }; + + setContentData((prevContentData) => { + const updatedContentData = { ...prevContentData, [language_id]: newContent }; + setContent(updatedContentData[language_id]); + return updatedContentData; + }); + }; + const handleDeleteClick = () => { + if (content) { + if (content.content_text_id && content.content_text_id > 0) { + setOpenDeleteModal(true) + } + else { + setReloadTrigger(prev => prev + 1); + } + } + } return ( - + { + return apiCalls.getLanguageList(token!); + }} + onLanguageSelect={handleLanguageSelect} + defaultLanguageId={content?.language_id || languageId} + enabledLanguages={ + Object.keys(contentData).length === 0 ? [languageId] : Object.keys(contentData).map(Number) + } + onMenuItemSelect={handleNewLanguageSelect} + isEdit={true} + /> - Title + + Title + {contentId && ( + + + + )} + handleChange(e, "content_text")} /> + { const content_id = await saveContent(content); + const action = content.content_id === null ? "add" : "edit"; if (content_id) { - const actionType = content.content_id ? "edit" : "add"; - router.push( - `/content/?content_id=${content_id}&action=${actionType}`, - ); + if (content.content_id === null) { + content.content_id = content_id; + } + setContent(content); + onSaveSuccess(content, action); + } }; handleSaveContent(content); @@ -225,11 +404,28 @@ const ContentBox = ({ > Save + {saveError ? ( - Failed to save content. + {errorText ? errorText : "Failed to save content"} ) : null} + setOpenDeleteModal(false)} + onSuccessfulDelete={onDeleteSuccess} + onFailedDelete={(content_id: number, language_id: number | null) => { + setErrorText( + `Failed to delete content #${content_id} with language_id: #${language_id}`, + ); + setSaveError(true); + }} + deleteContent={(content_id: number, language_id: number | null) => { + return apiCalls.deleteContent(content_id, language_id, token!); + }} + /> ); @@ -242,7 +438,7 @@ const Header = ({ content_id }: { content_id: number | null }) => { (content_id ? router.back() : router.push("/content"))} + onClick={() => (router.push("/content"))} /> {content_id ? ( @@ -259,5 +455,4 @@ const Header = ({ content_id }: { content_id: number | null }) => { ); }; - export default AddEditContentPage; diff --git a/admin_app/src/app/content/page.tsx b/admin_app/src/app/content/page.tsx index 1a94f1ae5..75732fc1d 100644 --- a/admin_app/src/app/content/page.tsx +++ b/admin_app/src/app/content/page.tsx @@ -2,11 +2,19 @@ import type { Content } from "@/app/content/edit/page"; import ContentCard from "@/components/ContentCard"; import { Layout } from "@/components/Layout"; -import { LANGUAGE_OPTIONS, sizes } from "@/utils"; +import { appColors, sizes } from "@/utils"; import { apiCalls } from "@/utils/api"; import { useAuth } from "@/utils/auth"; -import { Add } from "@mui/icons-material"; -import { Button, CircularProgress, Grid } from "@mui/material"; +import { Add, Sort } from "@mui/icons-material"; +import { + Button, + CircularProgress, + FormControl, + Grid, + InputLabel, + MenuItem, + Select, +} from "@mui/material"; import Alert from "@mui/material/Alert"; import Snackbar from "@mui/material/Snackbar"; import Link from "next/link"; @@ -17,56 +25,142 @@ import { SearchBar } from "../../components/SearchBar"; const MAX_CARDS_PER_PAGE = 12; +interface ContentLanding extends Content { + languages: string[]; +} +interface Language { + language_id: number; + language_name: string; +} const CardsPage = () => { - const [displayLanguage, setDisplayLanguage] = React.useState( - LANGUAGE_OPTIONS[0].label, - ); + const [displayLanguage, setDisplayLanguage] = React.useState(); + const [defaultLanguage, setDefaultLanguage] = React.useState(); const [searchTerm, setSearchTerm] = React.useState(""); - const { accessLevel } = useAuth(); - + const { token, accessLevel } = useAuth(); + React.useEffect(() => { + if (!displayLanguage && token) { + const fetchDefaultLanguage = async () => { + try { + const defaultLanguage = await apiCalls.getDefaultLanguage(token!); + setDisplayLanguage(defaultLanguage); + setDefaultLanguage(defaultLanguage); + } catch (error) { + console.error("Failed to fetch default language:", error); + } + }; + fetchDefaultLanguage(); + } + }, [token]); return ( - + - - + { + setDisplayLanguage(language); + }} + /> + + + ); }; -const CardsUtilityStrip = ({ editAccess }: { editAccess: boolean }) => { +const CardsUtilityStrip = ({ + token, + displayLanguage, + onChangeDisplayLanguage, +}: { + token: string; + displayLanguage: Language; + onChangeDisplayLanguage: (language: Language) => void; +}) => { + const [languageOptions, setLanguageOptions] = React.useState([]); + const [loadingLanguages, setLoadingLanguages] = React.useState(true); + React.useEffect(() => { + const fetchLanguages = async () => { + setLoadingLanguages(true); + try { + const languages = await apiCalls.getLanguageList(token); + setLanguageOptions(languages); + } catch (error) { + console.error("Failed to fetch language list:", error); + } finally { + setLoadingLanguages(false); + } + }; + fetchLanguages(); + onChangeDisplayLanguage(displayLanguage); + }, [token]); + const selectedValue = displayLanguage + ? displayLanguage.language_name + : ""; return ( - - + + + + Language + + + + ); }; @@ -74,12 +168,12 @@ const CardsGrid = ({ displayLanguage, searchTerm, }: { - displayLanguage: string; + displayLanguage: Language; searchTerm: string; }) => { const [page, setPage] = React.useState(1); const [max_pages, setMaxPages] = React.useState(1); - const [cards, setCards] = React.useState([]); + const [cards, setCards] = React.useState([]); const [isLoading, setIsLoading] = React.useState(true); const searchParams = useSearchParams(); @@ -92,10 +186,8 @@ const CardsGrid = ({ action: string | null, content_id: number | null, ): string | null => { - if (action === "edit") { - return `Content #${content_id} updated`; - } else if (action === "add") { - return `Content #${content_id} created`; + if (action === "delete") { + return `Content #${content_id} deleted successfully`; } return null; }; @@ -105,30 +197,37 @@ const CardsGrid = ({ ); const [refreshKey, setRefreshKey] = React.useState(0); - const onSuccessfulDelete = (content_id: number) => { + const onSuccessfulDelete = (content_id: number, language_id: number | null) => { setIsLoading(true); + setSnackMessage(getSnackMessage("delete", content_id)); setRefreshKey((prevKey) => prevKey + 1); - setSnackMessage(`Content #${content_id} deleted successfully`); - }; + }; + const handleDeleteLanguageVersion = (content_id: number, language_id: number | null) => { + return apiCalls.deleteContent(content_id, language_id, token!); + }; React.useEffect(() => { - apiCalls - .getContentList(token!) - .then((data) => { - const filteredData = data.filter( - (card: Content) => - card.content_title.includes(searchTerm) || - card.content_text.includes(searchTerm), - ); - setCards(filteredData); - setMaxPages(Math.ceil(filteredData.length / MAX_CARDS_PER_PAGE)); - setIsLoading(false); - }) - .catch((error) => { - console.error("Failed to fetch content:", error); - setIsLoading(false); - }); - }, [refreshKey, searchTerm, token]); + if (displayLanguage) { + setIsLoading(true); + apiCalls + .getContentListLanding( + displayLanguage ? displayLanguage.language_name : "", + token!, + ) + .then((data) => { + const filteredData = data.filter( + (card: ContentLanding) => + card.content_title.includes(searchTerm) || + card.content_text.includes(searchTerm), + ); + setCards(filteredData); + setMaxPages(Math.ceil(filteredData.length / MAX_CARDS_PER_PAGE)); + setIsLoading(false); + }) + .catch((error) => console.error("Failed to fetch content:", error)) + .finally(() => setIsLoading(false)); + } + }, [refreshKey, searchTerm, displayLanguage, token]); if (isLoading) { return ( @@ -197,7 +296,15 @@ const CardsGrid = ({ title={item.content_title} text={item.content_text} content_id={item.content_id} + language_id={displayLanguage.language_id} last_modified={item.updated_datetime_utc} + languages={item.languages} + getContentData={(content_id: number) => { + return apiCalls.getContent(content_id, null, token!); + }} + getLanguageList={() => { + return apiCalls.getLanguageList(token!); + }} onSuccessfulDelete={onSuccessfulDelete} onFailedDelete={(content_id: number) => { setSnackMessage( @@ -205,8 +312,10 @@ const CardsGrid = ({ ); }} deleteContent={(content_id: number) => { - return apiCalls.deleteContent(content_id, token!); + return apiCalls.deleteContent(content_id, null, token!); }} + deleteLanguageVersion={handleDeleteLanguageVersion} + setRefreshKey={setRefreshKey} editAccess={accessLevel === "fullaccess"} /> @@ -216,9 +325,33 @@ const CardsGrid = ({ - ); }; +const CardsBottomStrip = ({ editAccess, defaultLanguageId }: + { + editAccess: boolean; + defaultLanguageId: number | null; + }) => { + + return ( + + + + ); +}; + export default CardsPage; diff --git a/admin_app/src/components/ContentCard.tsx b/admin_app/src/components/ContentCard.tsx index 66c301dbc..5e1f9e91a 100644 --- a/admin_app/src/components/ContentCard.tsx +++ b/admin_app/src/components/ContentCard.tsx @@ -4,7 +4,8 @@ import { } from "@/components/ContentModal"; import { appColors, appStyles, sizes } from "@/utils"; import { Delete, Edit } from "@mui/icons-material"; -import { Button, Card, IconButton, Typography } from "@mui/material"; +import { Button, Card, IconButton, Typography, setRef } from "@mui/material"; +import TranslateIcon from '@mui/icons-material/Translate'; import Link from "next/link"; import React from "react"; import { Layout } from "./Layout"; @@ -13,24 +14,41 @@ const ContentCard = ({ title, text, content_id, + language_id, last_modified, + languages, + getContentData, + getLanguageList, onSuccessfulDelete, onFailedDelete, deleteContent, + deleteLanguageVersion, + setRefreshKey, editAccess, }: { title: string; text: string; content_id: number; + language_id: number; last_modified: string; - onSuccessfulDelete: (content_id: number) => void; + languages: string[]; + getContentData: (content_id: number) => Promise; + getLanguageList: () => Promise; + onSuccessfulDelete: (content_id: number, language_id: number | null) => void; onFailedDelete: (content_id: number) => void; deleteContent: (content_id: number) => Promise; + deleteLanguageVersion: + (content_id: number, language_id: number | null) => Promise; + setRefreshKey: React.Dispatch>; editAccess: boolean; }) => { const [openReadModal, setOpenReadModal] = React.useState(false); const [openDeleteModal, setOpenDeleteModal] = React.useState(false); + const handleCloseModal = () => { + setRefreshKey((prev) => prev + 1); + setOpenReadModal(false); + } return ( <> + + + + + {languages.join(', ')} + + @@ -103,16 +138,18 @@ const ContentCard = ({ setOpenReadModal(false)} + onClose={handleCloseModal} editAccess={editAccess} /> setOpenDeleteModal(false)} onSuccessfulDelete={onSuccessfulDelete} diff --git a/admin_app/src/components/ContentModal.tsx b/admin_app/src/components/ContentModal.tsx index c461da969..29f808684 100644 --- a/admin_app/src/components/ContentModal.tsx +++ b/admin_app/src/components/ContentModal.tsx @@ -7,33 +7,119 @@ import { ThumbDown, ThumbUp, } from "@mui/icons-material"; -import { Box, Button, Fade, Modal, Typography } from "@mui/material"; +import { Alert, Box, Button, Fade, IconButton, Modal, Snackbar, Typography } from "@mui/material"; import Dialog from "@mui/material/Dialog"; import DialogActions from "@mui/material/DialogActions"; import DialogContent from "@mui/material/DialogContent"; import DialogContentText from "@mui/material/DialogContentText"; import DialogTitle from "@mui/material/DialogTitle"; import Link from "next/link"; +import React from "react"; + import LanguageButtonBar from "./LanguageButtonBar"; import { Layout } from "./Layout"; +import { Content } from "@/app/content/edit/page"; +import { useRouter } from "next/navigation"; + const ContentViewModal = ({ - title, - text, content_id, - last_modified, + defaultLanguageId, + getContentData, + getLanguageList, + deleteLanguageVersion, open, onClose, editAccess, }: { - title: string; - text: string; content_id: number; - last_modified: string; + defaultLanguageId: number; + getContentData: (content_id: number) => Promise; + getLanguageList: () => Promise; + deleteLanguageVersion: + (content_id: number, language_id: number | null) => Promise; open: boolean; onClose: () => void; editAccess: boolean; }) => { + const [contentData, setContentData] = React.useState<{ [key: number]: any }>({}); + const [content, setContent] = React.useState(null); + const [enabledLanguages, setEnabledLanguages] = React.useState([]); + const [loading, setLoading] = React.useState(false); + const [error, setError] = React.useState(null); + const [openDeleteModal, setOpenDeleteModal] = React.useState(false); + const [reloadTrigger, setReloadTrigger] = React.useState(0); + + const getSnackMessage = ( + content_id: number | null, + language_id: number | null, + ): string | null => { + return `Content #${content_id} with language_id:#${language_id} deleted successfully`; + }; + const [snackMessage, setSnackMessage] = React.useState( + null, + ); + const handleLanguageSelect = (language_id: number) => { + setContent(contentData[language_id]); + }; + React.useEffect(() => { + const fetchData = async () => { + if (open) { + setLoading(true); + setError(null); + try { + getContentData(content_id).then((data) => { + const contentDic: { [key: number]: Content } = data.reduce( + (acc: { [key: number]: Content }, currentContent: Content) => { + acc[currentContent.language_id] = currentContent; + return acc; + }, + {} as { [key: string]: Content } + ); + setContentData(contentDic); + setEnabledLanguages(Object.keys(contentDic).map(Number)) + setContent( + contentDic[defaultLanguageId] ? contentDic[defaultLanguageId] : + Object.values(contentDic)[0]); + }); + setLoading(false); + } catch (err) { + setError((err as Error).message || "Something went wrong"); + setLoading(false); + } + } + }; + + fetchData(); + }, [open, content_id, reloadTrigger]); + const onSuccessfulDelete = (content_id: number, language_id: number | null) => { + setLoading(true); + if (language_id) { + setContentData(prevContentData => { + const updatedContentData = { ...prevContentData }; + delete updatedContentData[language_id]; + if (Object.keys(updatedContentData).length === 0) { + onClose(); + const router = useRouter(); + setTimeout(() => router + .push(`/content?content_id=${content_id}`), 0); + + } else { + setSnackMessage(getSnackMessage(content_id, language_id)); + setReloadTrigger(prev => prev + 1); + } + + return updatedContentData; + }); + } + }; + const onDeleteLanguageVersion = async () => { + if (content) { + const result = await deleteLanguageVersion(content_id, content.language_id); + + } + }; + return ( - + + - {title} + {content?.content_title} - {text} + {content?.content_text} Edit + setOpenDeleteModal(true)} + > + + + Last modified on{" "} - {new Date(last_modified).toLocaleString(undefined, { + {new Date(content?.updated_datetime_utc!).toLocaleString(undefined, { day: "numeric", month: "short", year: "numeric", hour: "numeric", minute: "numeric", - hour12: true, + hour12: false, })} @@ -142,6 +244,37 @@ const ContentViewModal = ({ _ + setOpenDeleteModal(false)} + onSuccessfulDelete={onSuccessfulDelete} + onFailedDelete={(content_id: number, language_id: number | null) => { + setSnackMessage( + `Failed to delete content #${content_id} with language_id: #${language_id}`, + ); + }} + deleteContent={onDeleteLanguageVersion} + /> + { + setSnackMessage(null); + }} + > + { + setSnackMessage(null); + }} + severity="success" + variant="filled" + sx={{ width: "100%" }} + > + {snackMessage} + + @@ -151,6 +284,7 @@ const ContentViewModal = ({ const DeleteContentModal = ({ content_id, + language_id, open, onClose, onSuccessfulDelete, @@ -158,11 +292,12 @@ const DeleteContentModal = ({ deleteContent, }: { content_id: number; + language_id: number | null; open: boolean; onClose: () => void; - onSuccessfulDelete: (content_id: number) => void; - onFailedDelete: (content_id: number) => void; - deleteContent: (content_id: number) => Promise; + onSuccessfulDelete: (content_id: number, language_id: number | null) => void; + onFailedDelete: (content_id: number, language_id: number | null) => void; + deleteContent: (content_id: number, language_id: number | null) => Promise; }) => { return ( Cancel