mirror of
https://github.com/PeterMaquiran/tvone.git
synced 2026-04-23 12:35:51 +00:00
This commit is contained in:
@@ -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<Category[]>([]);
|
||||
const [categoriesLoading, setCategoriesLoading] = useState(true);
|
||||
const [categoryId, setCategoryId] = useState<string>("");
|
||||
const [subCategoryId, setSubCategoryId] = useState<string>("");
|
||||
const [showSubCategoryError, setShowSubCategoryError] = useState(false);
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(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<HTMLInputElement>) => {
|
||||
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">
|
||||
<Save size={16}/> Salvar Rascunho
|
||||
</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
|
||||
</button>
|
||||
</div>
|
||||
@@ -216,19 +241,67 @@ export const CreateNewsPage = () => {
|
||||
</label>
|
||||
<select
|
||||
value={categoryId}
|
||||
onChange={(e) => setCategoryId(e.target.value)}
|
||||
onChange={(e) => handleCategoryChange(e.target.value)}
|
||||
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"
|
||||
>
|
||||
<option value="">
|
||||
{categoriesLoading ? "A carregar categorias…" : "Selecione uma categoria"}
|
||||
</option>
|
||||
{categories.map((c) => (
|
||||
{parentCategories.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</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>
|
||||
|
||||
{/* --- INPUT DE UPLOAD ATUALIZADO --- */}
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user