From 198eee07c6693a54677f7fafb59919d8a0ae9d80 Mon Sep 17 00:00:00 2001 From: David Katrinka Date: Wed, 7 Jan 2026 19:00:42 +0100 Subject: [PATCH] publication CRUD and small tweaks --- src/MainLayout/MainLayout.tsx | 4 +- src/api/publications.ts | 123 +++++++++ src/components/Header/Header.tsx | 25 +- src/context/AuthContext.tsx | 2 +- .../publications/useCreatePublication.ts | 32 +++ .../publications/useDeletePublication.ts | 17 ++ .../publications/useGetPublicationById.ts | 10 + src/hooks/publications/useGetPublications.ts | 8 + .../publications/useUpdatePublication.ts | 38 +++ src/pages/IndexesPage/IndexesPage.tsx | 8 +- src/pages/LoginPage/LoginPage.tsx | 2 +- src/pages/ProfilePage/ProfilePage.tsx | 7 - .../PublicationsForm/PublicationsForm.tsx | 254 ++++++++++++++++++ .../PublicationsPage/PublicationsPage.tsx | 157 +++++++++++ src/router/router.tsx | 13 +- 15 files changed, 657 insertions(+), 43 deletions(-) create mode 100644 src/hooks/publications/useCreatePublication.ts create mode 100644 src/hooks/publications/useDeletePublication.ts create mode 100644 src/hooks/publications/useGetPublicationById.ts create mode 100644 src/hooks/publications/useGetPublications.ts create mode 100644 src/hooks/publications/useUpdatePublication.ts create mode 100644 src/pages/PublicationsForm/PublicationsForm.tsx create mode 100644 src/pages/PublicationsPage/PublicationsPage.tsx diff --git a/src/MainLayout/MainLayout.tsx b/src/MainLayout/MainLayout.tsx index 4af7691..62ad6b1 100644 --- a/src/MainLayout/MainLayout.tsx +++ b/src/MainLayout/MainLayout.tsx @@ -4,14 +4,16 @@ import { LayoutWrapper } from "./Layout.styles"; import { StyledDistance } from "../components/shared/StyledDistance"; import Header from "../components/Header/Header"; import { AdminSidebar } from "../components/AdminSidebar/AdminSidebar"; +import { useAuth } from "../context/AuthContext"; const MainLayout = () => { + const { user } = useAuth(); return ( <>
- + {user && } diff --git a/src/api/publications.ts b/src/api/publications.ts index e69de29..da2ee04 100644 --- a/src/api/publications.ts +++ b/src/api/publications.ts @@ -0,0 +1,123 @@ +import type { User } from "../components/shared/types/AuthTypes"; +import axiosInstance from "./axiosInstance"; +import type { CategoryType } from "./categories"; + +export type PublicationType = { + pk: number; + image?: string; + video?: string; + content_type: "image" | "video"; + status: "public" | "pending"; + average_score: number; + description: string; + is_pinned: boolean; + user: number; + user_detail: User; + category: number; + category_detail: CategoryType; + time_created: string; + time_updated: string; +}; + +export type PublicationsResponse = { + count: number; + page_size: number; + page: number; + total_pages: number; + results: PublicationType[]; +}; + +export type PublicationPayload = { + file: File; + content_type: "image" | "video"; + status: "public" | "pending"; + description: string; + is_pinned: boolean; + category: number; +}; + +export type UpdatePublicationPayload = { + file?: File; + content_type: "image" | "video"; + status: "public" | "pending"; + description: string; + is_pinned: boolean; + category: number; +}; + +export const getPublications = async (page: number) => { + const res = await axiosInstance.get( + "/api/admin/publications", + { params: { page } } + ); + return res.data; +}; + +export const getPublicationById = async (id: number) => { + const res = await axiosInstance.get( + `/api/admin/publications/${id}` + ); + return res.data; +}; + +export const createPublication = async (payload: PublicationPayload) => { + const formData = new FormData(); + + if (payload.content_type === "image") { + formData.append("image", payload.file); + } else { + formData.append("video", payload.file); + } + + formData.append("content_type", payload.content_type); + formData.append("status", payload.status); + formData.append("description", payload.description); + formData.append("is_pinned", String(payload.is_pinned)); + formData.append("category", String(payload.category)); + + const res = await axiosInstance.post( + "/api/admin/publications/", + formData, + { + headers: { "Content-Type": "multipart/form-data" }, + } + ); + + return res.data; +}; + +export const updatePublication = async ( + id: number, + payload: UpdatePublicationPayload +) => { + const formData = new FormData(); + + if (payload.file) { + if (payload.content_type === "image") { + formData.append("image", payload.file); + } else { + formData.append("video", payload.file); + } + } + + formData.append("content_type", payload.content_type); + formData.append("status", payload.status); + formData.append("description", payload.description); + formData.append("is_pinned", String(payload.is_pinned)); + formData.append("category", String(payload.category)); + + const res = await axiosInstance.patch( + `/api/admin/publications/${id}`, + formData, + { + headers: { "Content-Type": "multipart/form-data" }, + } + ); + + return res.data; +}; + +export const deletePublication = async (id: number) => { + const res = await axiosInstance.delete(`/api/admin/publications/${id}`); + return res.data; +}; diff --git a/src/components/Header/Header.tsx b/src/components/Header/Header.tsx index 710dcbf..c53df56 100644 --- a/src/components/Header/Header.tsx +++ b/src/components/Header/Header.tsx @@ -31,7 +31,6 @@ function Header() { setAnchorElNav(null); }; - const goToAdmin = () => navigate("/admin"); return ( @@ -42,7 +41,7 @@ function Header() { variant="h6" noWrap component="a" - href="/" + href= {user ? "/publications": "/"} sx={{ mr: 2, display: { xs: "none", md: "flex" }, @@ -100,16 +99,7 @@ function Header() { )} - {user && user.is_superuser && ( - { - handleCloseNavMenu(); - goToAdmin(); - }} - > - Admin Dashboard - - )} + @@ -131,7 +121,7 @@ function Header() { textDecoration: "none", }} > - HoshiAI + Gallery @@ -153,14 +143,7 @@ function Header() { )} - {user?.is_superuser && ( - - )} + diff --git a/src/context/AuthContext.tsx b/src/context/AuthContext.tsx index ce79d52..cd49662 100644 --- a/src/context/AuthContext.tsx +++ b/src/context/AuthContext.tsx @@ -34,7 +34,7 @@ export const AuthProvider = ({ children }: OnlyChildrenProps) => { localStorage.removeItem("access_token"); queryClient.removeQueries({ queryKey: ["me"] }); toast.info("Logged out"); - window.location.href = "/login"; + window.location.href = "/"; }; return ( diff --git a/src/hooks/publications/useCreatePublication.ts b/src/hooks/publications/useCreatePublication.ts new file mode 100644 index 0000000..1bd4d71 --- /dev/null +++ b/src/hooks/publications/useCreatePublication.ts @@ -0,0 +1,32 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { + createPublication, + type PublicationPayload, +} from "../../api/publications"; +import { toast } from "react-toastify"; + +export const useCreatePublication = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (payload: PublicationPayload) => createPublication(payload), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["publications"] }); + }, + onError: (error: any) => { + const data = error?.response?.data; + + if (data?.image?.length) { + toast.error(data.image[0]); + return; + } + + if (data?.video?.length) { + toast.error(data.video[0]); + return; + } + + toast.error("Failed to create publication"); + }, + }); +}; diff --git a/src/hooks/publications/useDeletePublication.ts b/src/hooks/publications/useDeletePublication.ts new file mode 100644 index 0000000..a2dd88d --- /dev/null +++ b/src/hooks/publications/useDeletePublication.ts @@ -0,0 +1,17 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { deletePublication } from "../../api/publications"; +import { toast } from "react-toastify"; + +export const useDeletePublication = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: number) => deletePublication(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["publications"] }); + toast.success("Publication Deleted"); + }, + onError: (error) => { + toast.error(error.message); + }, + }); +}; diff --git a/src/hooks/publications/useGetPublicationById.ts b/src/hooks/publications/useGetPublicationById.ts new file mode 100644 index 0000000..7893a88 --- /dev/null +++ b/src/hooks/publications/useGetPublicationById.ts @@ -0,0 +1,10 @@ +import { useQuery } from "@tanstack/react-query"; +import { getPublicationById } from "../../api/publications"; + +export const useGetPublicationsById = (id?: number) => { + return useQuery({ + queryKey: ["publication", id], + queryFn: () => getPublicationById(id!), + enabled: !!id, + }); +}; diff --git a/src/hooks/publications/useGetPublications.ts b/src/hooks/publications/useGetPublications.ts new file mode 100644 index 0000000..5c61b4b --- /dev/null +++ b/src/hooks/publications/useGetPublications.ts @@ -0,0 +1,8 @@ +import { useQuery } from "@tanstack/react-query"; +import { getPublications } from "../../api/publications"; + +export const useGetPublications = (page: number) => + useQuery({ + queryKey: ["publications", page], + queryFn: () => getPublications(page), + }); diff --git a/src/hooks/publications/useUpdatePublication.ts b/src/hooks/publications/useUpdatePublication.ts new file mode 100644 index 0000000..3ab43af --- /dev/null +++ b/src/hooks/publications/useUpdatePublication.ts @@ -0,0 +1,38 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { + updatePublication, + type UpdatePublicationPayload, +} from "../../api/publications"; +import { toast } from "react-toastify"; + +export const useUpdatePublication = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ + id, + payload, + }: { + id: number; + payload: UpdatePublicationPayload; + }) => updatePublication(id, payload), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["publications"] }); + }, + onError: (error: any) => { + const data = error?.response?.data; + + if (data?.image?.length) { + toast.error(data.image[0]); + return; + } + + if (data?.video?.length) { + toast.error(data.video[0]); + return; + } + + toast.error("Failed to create publication"); + }, + }); +}; diff --git a/src/pages/IndexesPage/IndexesPage.tsx b/src/pages/IndexesPage/IndexesPage.tsx index 9e99d13..b359358 100644 --- a/src/pages/IndexesPage/IndexesPage.tsx +++ b/src/pages/IndexesPage/IndexesPage.tsx @@ -30,7 +30,7 @@ const IndexesPage = () => { const deleteMutation = useDeleteIndex(); const navigate = useNavigate(); - const handleCreateIndex = () => { + const handleCreateIndex = () => { navigate("/indexes/create"); }; @@ -54,11 +54,7 @@ const IndexesPage = () => { Indexes - diff --git a/src/pages/LoginPage/LoginPage.tsx b/src/pages/LoginPage/LoginPage.tsx index 6fd0966..99dae62 100644 --- a/src/pages/LoginPage/LoginPage.tsx +++ b/src/pages/LoginPage/LoginPage.tsx @@ -14,7 +14,7 @@ const LoginPage = () => { useEffect(() => { if (user) { - navigate("/"); + navigate("/profile"); } }, [user, navigate]); diff --git a/src/pages/ProfilePage/ProfilePage.tsx b/src/pages/ProfilePage/ProfilePage.tsx index 001732e..552b26e 100644 --- a/src/pages/ProfilePage/ProfilePage.tsx +++ b/src/pages/ProfilePage/ProfilePage.tsx @@ -43,13 +43,6 @@ const ProfilePage = () => { Logout - ); diff --git a/src/pages/PublicationsForm/PublicationsForm.tsx b/src/pages/PublicationsForm/PublicationsForm.tsx new file mode 100644 index 0000000..9dba173 --- /dev/null +++ b/src/pages/PublicationsForm/PublicationsForm.tsx @@ -0,0 +1,254 @@ +import { useState, useEffect } from "react"; +import { useNavigate, useParams } from "react-router-dom"; +import { + Box, + Button, + CircularProgress, + TextField, + Typography, + FormControl, + InputLabel, + Select, + MenuItem, + Checkbox, + FormControlLabel, +} from "@mui/material"; +import { useGetPublicationsById } from "../../hooks/publications/useGetPublicationById"; +import { useCreatePublication } from "../../hooks/publications/useCreatePublication"; +import { useUpdatePublication } from "../../hooks/publications/useUpdatePublication"; +import type { + PublicationPayload, + UpdatePublicationPayload, +} from "../../api/publications"; +import { useCategories } from "../../hooks/categories/useCategories"; + +const PublicationForm = () => { + const { id } = useParams<{ id: string }>(); + const isUpdate = Boolean(id); + const navigate = useNavigate(); + + const [file, setFile] = useState(null); + const [contentType, setContentType] = useState<"image" | "video">("image"); + const [status, setStatus] = useState<"public" | "pending">("public"); + const [description, setDescription] = useState(""); + const [isPinned, setIsPinned] = useState(false); + const [category, setCategory] = useState(0); + + const { data: publication, isLoading } = useGetPublicationsById( + id ? Number(id) : undefined + ); + const { data: categories } = useCategories(); + const createMutation = useCreatePublication(); + const updateMutation = useUpdatePublication(); + const previewUrl = file ? URL.createObjectURL(file) : null; + + useEffect(() => { + return () => { + if (previewUrl) URL.revokeObjectURL(previewUrl); + }; + }, [previewUrl]); + + useEffect(() => { + if (publication) { + setContentType(publication.content_type); + setStatus(publication.status); + setDescription(publication.description); + setIsPinned(publication.is_pinned); + setCategory(publication.category); + } + }, [publication]); + + if (isUpdate && isLoading) return ; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + + if (isUpdate && id) { + const payload: UpdatePublicationPayload = { + content_type: contentType, + status, + description, + is_pinned: isPinned, + category, + file: file ?? undefined, + }; + + updateMutation.mutate( + { id: Number(id), payload }, + { onSuccess: () => navigate("/publications") } + ); + } else { + if (!file) { + alert("File is required"); + return; + } + + const payload: PublicationPayload = { + file, + content_type: contentType, + status, + description, + is_pinned: isPinned, + category, + }; + + createMutation.mutate(payload, { + onSuccess: () => navigate("/publications"), + }); + } + }; + + return ( + + + {isUpdate ? "Update Publication" : "Create Publication"} + + +
+ + Content Type + + + + + {file && + (contentType === "image" ? ( + + ) : ( + + ))} + + {!file && + isUpdate && + publication && + (contentType === publication.content_type ? ( + publication.content_type === "image" && publication.image ? ( + + ) : publication.video ? ( + + ) : null + ) : ( + + Current publication is a {publication.content_type}. +
+ Select a new {contentType} file to replace it. +
+ ))} +
+ + + + setDescription(e.target.value)} + fullWidth + required + sx={{ mb: 3 }} + /> + + + Status + + + + setIsPinned(e.target.checked)} + /> + } + label="Pinned" + sx={{ mb: 3 }} + /> + + setCategory(Number(e.target.value))} + fullWidth + required + sx={{ mb: 3 }} + > + {categories?.map((cat) => ( + + {cat.name} + + ))} + + + + +
+ ); +}; + +export default PublicationForm; diff --git a/src/pages/PublicationsPage/PublicationsPage.tsx b/src/pages/PublicationsPage/PublicationsPage.tsx new file mode 100644 index 0000000..89918ca --- /dev/null +++ b/src/pages/PublicationsPage/PublicationsPage.tsx @@ -0,0 +1,157 @@ +import { useState } from "react"; +import { + Box, + Button, + CircularProgress, + Paper, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Typography, + Pagination, + Dialog, + DialogTitle, + DialogContent, + DialogActions, +} from "@mui/material"; +import { useNavigate } from "react-router-dom"; + +import Container from "../../components/shared/Container/Container"; +import { useGetPublications } from "../../hooks/publications/useGetPublications"; +import { useDeletePublication } from "../../hooks/publications/useDeletePublication"; +import type { PublicationType } from "../../api/publications"; + +const PublicationsPage = () => { + const [currentPage, setCurrentPage] = useState(1); + const [deletePublicationId, setDeletePublicationId] = useState( + null + ); + + const { data, isLoading, isError } = useGetPublications(currentPage); + const deleteMutation = useDeletePublication(); + const navigate = useNavigate(); + + const handleCreatePublication = () => { + navigate("/publications/create"); + }; + + const handleUpdate = (id: number) => { + navigate(`/publications/${id}/update`); + }; + + const handleDelete = () => { + if (deletePublicationId !== null) { + deleteMutation.mutate(deletePublicationId, { + onSuccess: () => setDeletePublicationId(null), + }); + } + }; + + if (isLoading) return ; + if (isError) + return Error loading publications.; + + return ( + + + Publications + + + + + + + + ID + Type + Description + Status + Average Score + Pinned + Category + User + Created + Actions + + + + + {data?.results.map((pub: PublicationType) => ( + + {pub.pk} + {pub.content_type} + {pub.description} + {pub.status} + {pub.average_score} + {pub.is_pinned ? "Yes" : "No"} + {pub.category_detail.name} + {pub.user_detail.username} + + {new Date(pub.time_created).toLocaleDateString()} + + + + + + + ))} + +
+
+ + setCurrentPage(value)} + sx={{ mt: 3, mb: 3, display: "flex", justifyContent: "center" }} + /> + + setDeletePublicationId(null)} + > + Confirm Delete + + Are you sure you want to delete this publication? + + + + + + +
+ ); +}; + +export default PublicationsPage; diff --git a/src/router/router.tsx b/src/router/router.tsx index 30abd1f..5640033 100644 --- a/src/router/router.tsx +++ b/src/router/router.tsx @@ -1,6 +1,5 @@ import { createBrowserRouter } from "react-router-dom"; import MainLayout from "../MainLayout/MainLayout"; -import { Container } from "@mui/material"; import LoginPage from "../pages/LoginPage/LoginPage"; import ProfilePage from "../pages/ProfilePage/ProfilePage"; import UsersPage from "../pages/UsersPage/UsersPage"; @@ -9,6 +8,8 @@ import IndexesPage from "../pages/IndexesPage/IndexesPage"; import IndexForm from "../pages/IndexForm/IndexForm"; import CategoriesPage from "../pages/CategoriesPage/CategoriesPage"; import CategoryForm from "../pages/CategoryForm/CategoryForm"; +import PublicationsPage from "../pages/PublicationsPage/PublicationsPage"; +import PublicationForm from "../pages/PublicationsForm/PublicationsForm"; const router = createBrowserRouter([ { @@ -17,10 +18,6 @@ const router = createBrowserRouter([ children: [ { index: true, - element: index page, - }, - { - path: "/login", element: , }, { @@ -44,7 +41,11 @@ const router = createBrowserRouter([ }, { path: "/publications", - element: Publications Page, + children: [ + { index: true, element: }, + { path: "create", element: }, + { path: ":id/update", element: }, + ], }, { path: "/categories",