From 9d58259672960998f6f4d29cdae639d0328f1316 Mon Sep 17 00:00:00 2001 From: Peter Maquiran Date: Thu, 23 Apr 2026 10:09:44 +0100 Subject: [PATCH] add subcategory on creating news --- app/(page)/admin/create-news/create-news.tsx | 83 ++++++- lib/api-client.ts | 110 ++++++++++ lib/dtos.ts | 215 +++++++++++++++++++ lib/services.ts | 101 +++++++++ 4 files changed, 504 insertions(+), 5 deletions(-) create mode 100644 lib/api-client.ts create mode 100644 lib/dtos.ts create mode 100644 lib/services.ts diff --git a/app/(page)/admin/create-news/create-news.tsx b/app/(page)/admin/create-news/create-news.tsx index d16e4cb..0bf0258 100644 --- a/app/(page)/admin/create-news/create-news.tsx +++ b/app/(page)/admin/create-news/create-news.tsx @@ -10,7 +10,7 @@ import { // Importe o componente que criámos (ajuste o caminho se necessário) import MultiAspectEditor from '../../../components/MultiAspectEditor'; import dynamic from "next/dynamic"; -import { getFlat, type Category } from "@/lib/categories.api"; +import { getTree, type Category } from "@/lib/categories.api"; // import { keycloak } from '@/app/feature/auth/keycloak-config'; const Editor = dynamic( () => import("@tinymce/tinymce-react").then((mod) => mod.Editor), @@ -73,6 +73,8 @@ export const CreateNewsPage = () => { const [categories, setCategories] = useState([]); const [categoriesLoading, setCategoriesLoading] = useState(true); const [categoryId, setCategoryId] = useState(""); + const [subCategoryId, setSubCategoryId] = useState(""); + const [showSubCategoryError, setShowSubCategoryError] = useState(false); const fileInputRef = useRef(null); @@ -81,7 +83,7 @@ export const CreateNewsPage = () => { (async () => { setCategoriesLoading(true); try { - const list = await getFlat(); + const list = await getTree(); if (!cancelled) setCategories(list); } catch { if (!cancelled) setCategories([]); @@ -94,6 +96,26 @@ export const CreateNewsPage = () => { }; }, []); + const parentCategories = categories.filter((c) => !c.parentId); + const selectedParentCategory = parentCategories.find((c) => c.id === categoryId); + const subCategories = selectedParentCategory?.children ?? []; + const isSubCategoryRequired = Boolean(categoryId) && subCategories.length > 0; + + const handleCategoryChange = (value: string) => { + setCategoryId(value); + setSubCategoryId(""); + setShowSubCategoryError(false); + }; + + const handlePublish = () => { + if (isSubCategoryRequired && !subCategoryId) { + setShowSubCategoryError(true); + return; + } + setShowSubCategoryError(false); + // TODO: ligar ao endpoint de publicar quando o fluxo de submissão estiver pronto. + }; + // 2. Lógica de Upload const handleFileChange = (e: React.ChangeEvent) => { if (e.target.files && e.target.files.length > 0) { @@ -157,7 +179,10 @@ export const CreateNewsPage = () => { - @@ -216,19 +241,67 @@ export const CreateNewsPage = () => { +

+ Primeiro escolha a categoria principal. +

+ + +
+ + + {showSubCategoryError && ( +

+ Esta categoria possui subcategorias. Selecione uma subcategoria para publicar. +

+ )} + {!showSubCategoryError && categoryId && subCategories.length > 0 && ( +

+ Esta categoria tem {subCategories.length} subcategoria(s) disponivel(is). +

+ )}
{/* --- INPUT DE UPLOAD ATUALIZADO --- */} diff --git a/lib/api-client.ts b/lib/api-client.ts new file mode 100644 index 0000000..75c093f --- /dev/null +++ b/lib/api-client.ts @@ -0,0 +1,110 @@ +export type ApiMethod = "GET" | "POST" | "PATCH" | "DELETE"; + +export interface ApiClientConfig { + baseUrl: string; + getAccessToken?: () => string | Promise | undefined; +} + +export class ApiError extends Error { + status: number; + body: unknown; + + constructor(status: number, message: string, body: unknown) { + super(message); + this.status = status; + this.body = body; + } +} + +export class ApiClient { + private baseUrl: string; + private getAccessToken?: ApiClientConfig["getAccessToken"]; + + constructor(config: ApiClientConfig) { + this.baseUrl = config.baseUrl.replace(/\/+$/, ""); + this.getAccessToken = config.getAccessToken; + } + + private async buildHeaders(extra?: HeadersInit): Promise { + const token = this.getAccessToken ? await this.getAccessToken() : undefined; + return { + ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...extra, + }; + } + + private buildUrl(path: string, query?: Record) { + const url = new URL(`${this.baseUrl}${path}`); + if (query) { + for (const [k, v] of Object.entries(query)) { + if (v !== undefined && v !== null) url.searchParams.set(k, String(v)); + } + } + return url.toString(); + } + + async request(params: { + method: ApiMethod; + path: string; + query?: Record; + body?: TBody; + headers?: HeadersInit; + isFormData?: boolean; + }): Promise { + const { method, path, query, body, headers, isFormData } = params; + const finalHeaders = await this.buildHeaders(headers); + + const response = await fetch(this.buildUrl(path, query), { + method, + headers: isFormData ? finalHeaders : { "Content-Type": "application/json", ...finalHeaders }, + body: body + ? isFormData + ? (body as unknown as FormData) + : JSON.stringify(body) + : undefined, + }); + + const text = await response.text(); + const data = text ? safeJsonParse(text) : null; + + if (!response.ok) { + throw new ApiError(response.status, response.statusText, data); + } + + return data as TResponse; + } + + get(path: string, query?: Record) { + return this.request({ method: "GET", path, query }); + } + + post( + path: string, + body?: TBody, + options?: { headers?: HeadersInit; isFormData?: boolean } + ) { + return this.request({ + method: "POST", + path, + body, + headers: options?.headers, + isFormData: options?.isFormData, + }); + } + + patch(path: string, body?: TBody) { + return this.request({ method: "PATCH", path, body }); + } + + delete(path: string) { + return this.request({ method: "DELETE", path }); + } +} + +function safeJsonParse(text: string) { + try { + return JSON.parse(text); + } catch { + return text; + } +} \ No newline at end of file diff --git a/lib/dtos.ts b/lib/dtos.ts new file mode 100644 index 0000000..0c0c084 --- /dev/null +++ b/lib/dtos.ts @@ -0,0 +1,215 @@ +export type UserRole = "ADMIN" | "EDITOR" | "AUTHOR" | "READER"; +export type ArticleStatus = "DRAFT" | "PUBLISHED" | "ARCHIVED"; + +export interface DeleteResponseDto { + deleted: true; +} + +export interface UserOutputDto { + id: string; + keycloakId: string; + email: string; + displayName: string | null; + avatarKey: string | null; + role: UserRole; + createdAt: string; + updatedAt: string; +} + +export interface CategoryOutputDto { + id: string; + name: string; + slug: string; + parentId: string | null; + createdAt: string; + updatedAt: string; +} + +export interface CategoryTreeOutputDto extends CategoryOutputDto { + children: CategoryTreeOutputDto[]; +} + +export interface CreateCategoryInputDto { + name: string; + slug?: string; + parentId?: string | null; +} + +export interface UpdateCategoryInputDto { + name?: string; + slug?: string; + parentId?: string | null; +} + +export interface TagOutputDto { + id: string; + name: string; + slug: string; + createdAt: string; + updatedAt: string; +} + +export interface CreateTagInputDto { + name: string; + slug?: string; +} + +export interface UpdateTagInputDto { + name?: string; + slug?: string; +} + +export interface ImageOutputDto { + id: string; + fileKey: string; + createdAt: string; +} + +export interface UploadedImageOutputDto extends ImageOutputDto { + urls: string; +} + +export interface ArticleAuthorOutputDto { + id: string; + displayName: string | null; + email?: string; + avatarKey: string | null; +} + +export interface ArticleCategoryLinkOutputDto { + articleId: string; + categoryId: string; + category: CategoryOutputDto; +} + +export interface ArticleTagLinkOutputDto { + articleId: string; + tagId: string; + tag: TagOutputDto; +} + +export interface ArticleImageLinkOutputDto { + articleId: string; + imageId: string; + sortOrder: number; + image: ImageOutputDto; +} + +export interface ArticleOutputDto { + id: string; + title: string; + slug: string; + content: string; + excerpt: string | null; + status: ArticleStatus; + publishedAt: string | null; + authorId: string; + author: ArticleAuthorOutputDto; + categories: ArticleCategoryLinkOutputDto[]; + tags: ArticleTagLinkOutputDto[]; + images: ArticleImageLinkOutputDto[]; + createdAt: string; + updatedAt: string; +} + +export interface ListArticlesInputDto { + page?: number; + limit?: number; + search?: string; + categoryId?: string; + tagId?: string; +} + +export interface ManageArticlesInputDto extends ListArticlesInputDto { + status?: ArticleStatus; +} + +export interface CreateArticleInputDto { + title: string; + slug?: string; + content: string; + excerpt?: string; + status?: ArticleStatus; + categoryIds?: string[]; + tagIds?: string[]; + imageIds?: string[]; +} + +export interface UpdateArticleInputDto { + title?: string; + slug?: string; + content?: string; + excerpt?: string; + status?: ArticleStatus; + categoryIds?: string[]; + tagIds?: string[]; + imageIds?: string[]; +} + +export interface AttachImageInputDto { + imageId: string; + sortOrder?: number; +} + +export interface PaginatedArticlesOutputDto { + items: ArticleOutputDto[]; + total: number; + page: number; + limit: number; +} + +export interface CommentUserOutputDto { + id: string; + displayName: string | null; + avatarKey: string | null; +} + +export interface CommentOutputDto { + id: string; + content: string; + articleId: string; + userId: string; + parentId: string | null; + user: CommentUserOutputDto; + createdAt: string; + updatedAt: string; +} + +export interface CommentTreeOutputDto extends CommentOutputDto { + replies: CommentTreeOutputDto[]; +} + +export interface CreateCommentInputDto { + content: string; + parentId?: string; +} + +export interface BookmarkOutputDto { + userId: string; + articleId: string; + createdAt: string; + article: ArticleOutputDto; +} + +export interface PaginatedBookmarksOutputDto { + items: BookmarkOutputDto[]; + total: number; + page: number; + limit: number; +} + +export interface ProfileOutputDto { + keycloak: { + email?: string; + name?: string; + picture?: string; + email_verified?: boolean; + roles: string[]; + }; + user: UserOutputDto | null; +} + +export interface UpdateMeInputDto { + displayName?: string; + avatarKey?: string; +} \ No newline at end of file diff --git a/lib/services.ts b/lib/services.ts new file mode 100644 index 0000000..a71a0b7 --- /dev/null +++ b/lib/services.ts @@ -0,0 +1,101 @@ +import { ApiClient } from "./api-client"; +import type { + AttachImageInputDto, + ArticleOutputDto, + CategoryOutputDto, + CategoryTreeOutputDto, + CommentOutputDto, + CommentTreeOutputDto, + CreateArticleInputDto, + CreateCategoryInputDto, + CreateCommentInputDto, + CreateTagInputDto, + DeleteResponseDto, + ListArticlesInputDto, + ManageArticlesInputDto, + PaginatedArticlesOutputDto, + PaginatedBookmarksOutputDto, + ProfileOutputDto, + TagOutputDto, + UpdateArticleInputDto, + UpdateCategoryInputDto, + UpdateMeInputDto, + UpdateTagInputDto, + UploadedImageOutputDto, + UserOutputDto, + BookmarkOutputDto, +} from "./dtos"; + +type ApiQuery = Record; + +export function createTvOneApiServices(api: ApiClient) { + return { + users: { + me: () => api.get("/users/me"), + updateMe: (dto: UpdateMeInputDto) => api.patch("/users/me", dto), + }, + + profile: { + get: () => api.get("/profile"), + }, + + categories: { + tree: () => api.get("/categories"), + flat: () => api.get("/categories/flat"), + create: (dto: CreateCategoryInputDto) => + api.post("/categories", dto), + update: (id: string, dto: UpdateCategoryInputDto) => + api.patch(`/categories/${id}`, dto), + remove: (id: string) => api.delete(`/categories/${id}`), + }, + + tags: { + list: () => api.get("/tags"), + create: (dto: CreateTagInputDto) => api.post("/tags", dto), + update: (id: string, dto: UpdateTagInputDto) => + api.patch(`/tags/${id}`, dto), + remove: (id: string) => api.delete(`/tags/${id}`), + }, + + articles: { + listPublished: (query?: ListArticlesInputDto) => + api.get("/articles", query as ApiQuery | undefined), + findPublishedBySlug: (slug: string) => api.get(`/articles/by-slug/${slug}`), + listManage: (query?: ManageArticlesInputDto) => + api.get("/articles/manage", query as ApiQuery | undefined), + findById: (id: string) => api.get(`/articles/by-id/${id}`), + create: (dto: CreateArticleInputDto) => + api.post("/articles", dto), + update: (id: string, dto: UpdateArticleInputDto) => + api.patch(`/articles/${id}`, dto), + remove: (id: string) => api.delete(`/articles/${id}`), + attachImage: (id: string, dto: AttachImageInputDto) => + api.post(`/articles/${id}/images`, dto), + }, + + comments: { + listForArticle: (articleId: string) => + api.get(`/comments/article/${articleId}`), + create: (articleId: string, dto: CreateCommentInputDto) => + api.post(`/comments/article/${articleId}`, dto), + remove: (id: string) => api.delete(`/comments/${id}`), + }, + + bookmarks: { + listMine: (query?: { page?: number; limit?: number }) => + api.get("/bookmarks/me", query), + add: (articleId: string) => api.post(`/bookmarks/${articleId}`), + remove: (articleId: string) => api.delete(`/bookmarks/${articleId}`), + }, + + images: { + upload: async (file: File) => { + const form = new FormData(); + form.append("file", file); + return api.post("/images/upload", form, { + isFormData: true, + }); + }, + }, + }; +} \ No newline at end of file