add subcategory on creating news
continuous-integration/drone/push Build is passing

This commit is contained in:
2026-04-23 10:09:44 +01:00
parent 7d7b291ea2
commit 9d58259672
4 changed files with 504 additions and 5 deletions
+78 -5
View File
@@ -10,7 +10,7 @@ import {
// Importe o componente que criámos (ajuste o caminho se necessário) // Importe o componente que criámos (ajuste o caminho se necessário)
import MultiAspectEditor from '../../../components/MultiAspectEditor'; import MultiAspectEditor from '../../../components/MultiAspectEditor';
import dynamic from "next/dynamic"; 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'; // import { keycloak } from '@/app/feature/auth/keycloak-config';
const Editor = dynamic( const Editor = dynamic(
() => import("@tinymce/tinymce-react").then((mod) => mod.Editor), () => import("@tinymce/tinymce-react").then((mod) => mod.Editor),
@@ -73,6 +73,8 @@ export const CreateNewsPage = () => {
const [categories, setCategories] = useState<Category[]>([]); const [categories, setCategories] = useState<Category[]>([]);
const [categoriesLoading, setCategoriesLoading] = useState(true); const [categoriesLoading, setCategoriesLoading] = useState(true);
const [categoryId, setCategoryId] = useState<string>(""); const [categoryId, setCategoryId] = useState<string>("");
const [subCategoryId, setSubCategoryId] = useState<string>("");
const [showSubCategoryError, setShowSubCategoryError] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
@@ -81,7 +83,7 @@ export const CreateNewsPage = () => {
(async () => { (async () => {
setCategoriesLoading(true); setCategoriesLoading(true);
try { try {
const list = await getFlat(); const list = await getTree();
if (!cancelled) setCategories(list); if (!cancelled) setCategories(list);
} catch { } catch {
if (!cancelled) setCategories([]); 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 // 2. Lógica de Upload
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) { if (e.target.files && e.target.files.length > 0) {
@@ -157,7 +179,10 @@ export const CreateNewsPage = () => {
<button className="px-5 py-2 rounded-lg border border-slate-200 bg-white text-sm font-medium hover:bg-slate-50 transition-all flex items-center gap-2"> <button className="px-5 py-2 rounded-lg border border-slate-200 bg-white text-sm font-medium hover:bg-slate-50 transition-all flex items-center gap-2">
<Save size={16}/> Salvar Rascunho <Save size={16}/> Salvar Rascunho
</button> </button>
<button className="px-6 py-2 rounded-lg bg-blue-600 text-white text-sm font-medium hover:bg-blue-700 transition-all shadow-lg shadow-blue-200 flex items-center gap-2"> <button
onClick={handlePublish}
className="px-6 py-2 rounded-lg bg-blue-600 text-white text-sm font-medium hover:bg-blue-700 transition-all shadow-lg shadow-blue-200 flex items-center gap-2"
>
<Send size={16}/> Publicar Artigo <Send size={16}/> Publicar Artigo
</button> </button>
</div> </div>
@@ -216,19 +241,67 @@ export const CreateNewsPage = () => {
</label> </label>
<select <select
value={categoryId} value={categoryId}
onChange={(e) => setCategoryId(e.target.value)} onChange={(e) => handleCategoryChange(e.target.value)}
disabled={categoriesLoading} disabled={categoriesLoading}
className="w-full bg-slate-50 border border-slate-100 rounded-lg px-3 py-2 text-sm outline-none disabled:opacity-60" className="w-full bg-slate-50 border border-slate-100 rounded-lg px-3 py-2 text-sm outline-none disabled:opacity-60"
> >
<option value=""> <option value="">
{categoriesLoading ? "A carregar categorias…" : "Selecione uma categoria"} {categoriesLoading ? "A carregar categorias…" : "Selecione uma categoria"}
</option> </option>
{categories.map((c) => ( {parentCategories.map((c) => (
<option key={c.id} value={c.id}> <option key={c.id} value={c.id}>
{c.name} {c.name}
</option> </option>
))} ))}
</select> </select>
<p className="mt-2 text-[11px] text-slate-400">
Primeiro escolha a categoria principal.
</p>
</div>
<div>
<label className="flex items-center gap-2 text-xs font-bold uppercase text-slate-400 mb-3">
<Tag size={14}/> Subcategoria
{isSubCategoryRequired && (
<span className="text-[9px] text-red-500 font-bold tracking-wide">obrigatório</span>
)}
</label>
<select
value={subCategoryId}
onChange={(e) => {
setSubCategoryId(e.target.value);
setShowSubCategoryError(false);
}}
disabled={!categoryId || subCategories.length === 0}
className={`w-full bg-slate-50 border rounded-lg px-3 py-2 text-sm outline-none disabled:opacity-60 ${
showSubCategoryError ? "border-red-300 bg-red-50/40" : "border-slate-100"
}`}
>
{!categoryId ? (
<option value="">Selecione primeiro uma categoria</option>
) : subCategories.length === 0 ? (
<option value="">Sem subcategorias para esta categoria</option>
) : (
<>
<option value="">Selecione uma subcategoria</option>
{subCategories.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</>
)}
</select>
{showSubCategoryError && (
<p className="mt-2 text-[11px] text-red-600">
Esta categoria possui subcategorias. Selecione uma subcategoria para publicar.
</p>
)}
{!showSubCategoryError && categoryId && subCategories.length > 0 && (
<p className="mt-2 text-[11px] text-slate-400">
Esta categoria tem {subCategories.length} subcategoria(s) disponivel(is).
</p>
)}
</div> </div>
{/* --- INPUT DE UPLOAD ATUALIZADO --- */} {/* --- INPUT DE UPLOAD ATUALIZADO --- */}
+110
View File
@@ -0,0 +1,110 @@
export type ApiMethod = "GET" | "POST" | "PATCH" | "DELETE";
export interface ApiClientConfig {
baseUrl: string;
getAccessToken?: () => string | Promise<string | undefined> | 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<HeadersInit> {
const token = this.getAccessToken ? await this.getAccessToken() : undefined;
return {
...(token ? { Authorization: `Bearer ${token}` } : {}),
...extra,
};
}
private buildUrl(path: string, query?: Record<string, string | number | boolean | undefined>) {
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<TResponse, TBody = undefined>(params: {
method: ApiMethod;
path: string;
query?: Record<string, string | number | boolean | undefined>;
body?: TBody;
headers?: HeadersInit;
isFormData?: boolean;
}): Promise<TResponse> {
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<TResponse>(path: string, query?: Record<string, string | number | boolean | undefined>) {
return this.request<TResponse>({ method: "GET", path, query });
}
post<TResponse, TBody = undefined>(
path: string,
body?: TBody,
options?: { headers?: HeadersInit; isFormData?: boolean }
) {
return this.request<TResponse, TBody>({
method: "POST",
path,
body,
headers: options?.headers,
isFormData: options?.isFormData,
});
}
patch<TResponse, TBody = undefined>(path: string, body?: TBody) {
return this.request<TResponse, TBody>({ method: "PATCH", path, body });
}
delete<TResponse>(path: string) {
return this.request<TResponse>({ method: "DELETE", path });
}
}
function safeJsonParse(text: string) {
try {
return JSON.parse(text);
} catch {
return text;
}
}
+215
View File
@@ -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;
}
+101
View File
@@ -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<string, string | number | boolean | undefined>;
export function createTvOneApiServices(api: ApiClient) {
return {
users: {
me: () => api.get<UserOutputDto>("/users/me"),
updateMe: (dto: UpdateMeInputDto) => api.patch<UserOutputDto, UpdateMeInputDto>("/users/me", dto),
},
profile: {
get: () => api.get<ProfileOutputDto>("/profile"),
},
categories: {
tree: () => api.get<CategoryTreeOutputDto[]>("/categories"),
flat: () => api.get<CategoryOutputDto[]>("/categories/flat"),
create: (dto: CreateCategoryInputDto) =>
api.post<CategoryOutputDto, CreateCategoryInputDto>("/categories", dto),
update: (id: string, dto: UpdateCategoryInputDto) =>
api.patch<CategoryOutputDto, UpdateCategoryInputDto>(`/categories/${id}`, dto),
remove: (id: string) => api.delete<DeleteResponseDto>(`/categories/${id}`),
},
tags: {
list: () => api.get<TagOutputDto[]>("/tags"),
create: (dto: CreateTagInputDto) => api.post<TagOutputDto, CreateTagInputDto>("/tags", dto),
update: (id: string, dto: UpdateTagInputDto) =>
api.patch<TagOutputDto, UpdateTagInputDto>(`/tags/${id}`, dto),
remove: (id: string) => api.delete<DeleteResponseDto>(`/tags/${id}`),
},
articles: {
listPublished: (query?: ListArticlesInputDto) =>
api.get<PaginatedArticlesOutputDto>("/articles", query as ApiQuery | undefined),
findPublishedBySlug: (slug: string) => api.get<ArticleOutputDto>(`/articles/by-slug/${slug}`),
listManage: (query?: ManageArticlesInputDto) =>
api.get<PaginatedArticlesOutputDto>("/articles/manage", query as ApiQuery | undefined),
findById: (id: string) => api.get<ArticleOutputDto>(`/articles/by-id/${id}`),
create: (dto: CreateArticleInputDto) =>
api.post<ArticleOutputDto, CreateArticleInputDto>("/articles", dto),
update: (id: string, dto: UpdateArticleInputDto) =>
api.patch<ArticleOutputDto, UpdateArticleInputDto>(`/articles/${id}`, dto),
remove: (id: string) => api.delete<DeleteResponseDto>(`/articles/${id}`),
attachImage: (id: string, dto: AttachImageInputDto) =>
api.post<ArticleOutputDto, AttachImageInputDto>(`/articles/${id}/images`, dto),
},
comments: {
listForArticle: (articleId: string) =>
api.get<CommentTreeOutputDto[]>(`/comments/article/${articleId}`),
create: (articleId: string, dto: CreateCommentInputDto) =>
api.post<CommentOutputDto, CreateCommentInputDto>(`/comments/article/${articleId}`, dto),
remove: (id: string) => api.delete<DeleteResponseDto>(`/comments/${id}`),
},
bookmarks: {
listMine: (query?: { page?: number; limit?: number }) =>
api.get<PaginatedBookmarksOutputDto>("/bookmarks/me", query),
add: (articleId: string) => api.post<BookmarkOutputDto>(`/bookmarks/${articleId}`),
remove: (articleId: string) => api.delete<DeleteResponseDto>(`/bookmarks/${articleId}`),
},
images: {
upload: async (file: File) => {
const form = new FormData();
form.append("file", file);
return api.post<UploadedImageOutputDto, FormData>("/images/upload", form, {
isFormData: true,
});
},
},
};
}