This commit is contained in:
2026-04-18 11:28:23 +01:00
parent 7854e3dd44
commit 661784a73d
11 changed files with 1040 additions and 58 deletions
+3 -3
View File
@@ -248,15 +248,15 @@ const CreateNewsPage = () => {
</div>
</section>
{/* Resumo (Lead) */}
<div className="bg-white rounded-2xl p-6 border border-slate-200 shadow-sm">
{/* Resumo (Lead) */}
<div className="bg-white rounded-2xl p-6 border border-slate-200 shadow-sm">
<label className="block text-xs font-bold uppercase text-slate-400 mb-2 leading-8">Resumo (Lead)</label>
<textarea
rows={3}
placeholder="Escreva um resumo curto para visualização..."
className="w-full border-none focus:ring-0 resize-none text-slate-600 text-sm italic p-0"
/>
</div>
</div>
{/* Tags */}
<div className="bg-white rounded-2xl p-6 border border-slate-200 shadow-sm">
@@ -0,0 +1,264 @@
"use client";
import React, { useEffect, useState } from "react";
import {
FolderTree,
Edit3,
Trash2,
Plus,
ChevronRight,
ChevronDown,
} from "lucide-react";
import { getTree, getFlat, updateCategory, createCategory, deleteCategory } from "@/lib/categories.api";
/* ================= TYPES ================= */
interface Category {
id: string;
name: string;
slug: string;
parentId?: string | null;
children?: Category[];
}
/* ================= API ================= */
/* ================= UTIL ================= */
function slugify(text: string) {
return text
.toLowerCase()
.trim()
.replace(/\s+/g, "-")
.replace(/[^a-z0-9-]/g, "");
}
/* ================= PAGE ================= */
export default function CategoriesPage() {
const [tree, setTree] = useState<Category[]>([]);
const [flat, setFlat] = useState<Category[]>([]);
const [loading, setLoading] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const [form, setForm] = useState({
id: null as string | null,
name: "",
slug: "",
parentId: null as string | null,
});
/* ================= LOAD ================= */
async function load() {
setLoading(true);
try {
const [t, f] = await Promise.all([getTree(), getFlat()]);
setTree(t);
setFlat(f);
} finally {
setLoading(false);
}
}
useEffect(() => {
load();
}, []);
/* ================= CRUD ================= */
async function save() {
const payload = {
name: form.name,
slug: form.slug || slugify(form.name),
parentId: form.parentId,
};
if (form.id) {
await updateCategory(form.id, payload);
} else {
await createCategory(payload);
}
closeModal();
load();
}
async function remove(id: string) {
if (!confirm("Delete this category?")) return;
await deleteCategory(id);
load();
}
/* ================= MODAL ================= */
function openCreate(parentId?: string) {
setForm({
id: null,
name: "",
slug: "",
parentId: parentId || null,
});
setModalOpen(true);
}
function openEdit(cat: Category) {
setForm({
id: cat.id,
name: cat.name,
slug: cat.slug,
parentId: cat.parentId || null,
});
setModalOpen(true);
}
function closeModal() {
setModalOpen(false);
setForm({ id: null, name: "", slug: "", parentId: null });
}
/* ================= TREE ================= */
function TreeNode({
node,
level = 0,
}: {
node: Category;
level?: number;
}) {
const [open, setOpen] = useState(true);
return (
<div style={{ marginLeft: level * 14 }} className="border-l pl-3">
{/* NODE */}
<div className="flex items-center justify-between py-2 group">
<div className="flex items-center gap-2">
<button onClick={() => setOpen(!open)}>
{open ? (
<ChevronDown size={14} />
) : (
<ChevronRight size={14} />
)}
</button>
<FolderTree size={14} className="text-blue-500" />
<span
onClick={() => openEdit(node)}
className="text-sm font-medium cursor-pointer hover:text-blue-600"
>
{node.name}
</span>
</div>
{/* ACTIONS */}
<div className="flex gap-2 opacity-0 group-hover:opacity-100 transition">
<button
onClick={() => openCreate(node.id)}
className="text-green-600"
>
<Plus size={14} />
</button>
<button onClick={() => openEdit(node)}>
<Edit3 size={14} />
</button>
<button onClick={() => remove(node.id)}>
<Trash2 size={14} className="text-red-500" />
</button>
</div>
</div>
{/* CHILDREN */}
{open &&
node.children?.map((child) => (
<TreeNode
key={child.id}
node={child}
level={level + 1}
/>
))}
</div>
);
}
/* ================= UI ================= */
return (
<div className="p-8 bg-slate-50 min-h-screen">
{/* HEADER */}
<div className="flex justify-between mb-6">
<h1 className="text-xl font-semibold">
Category Manager
</h1>
<button
onClick={() => openCreate()}
className="bg-blue-600 text-white px-4 py-2 rounded"
>
+ New Category
</button>
</div>
{/* TREE */}
<div className="bg-white border rounded-xl p-4">
{loading ? (
<p>Loading...</p>
) : (
tree.map((node) => (
<TreeNode key={node.id} node={node} />
))
)}
</div>
{/* MODAL */}
{modalOpen && (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center">
<div className="bg-white w-[420px] p-5 rounded-xl">
<h2 className="font-semibold mb-4">
{form.id ? "Edit Category" : "Create Category"}
</h2>
<input
className="w-full border p-2 rounded mb-2"
placeholder="Name"
value={form.name}
onChange={(e) =>
setForm({
...form,
name: e.target.value,
slug: slugify(e.target.value),
})
}
/>
<input
className="w-full border p-2 rounded mb-3"
placeholder="Slug"
value={form.slug}
onChange={(e) =>
setForm({ ...form, slug: e.target.value })
}
/>
<div className="flex justify-end gap-2">
<button onClick={closeModal}>
Cancel
</button>
<button
onClick={save}
className="bg-blue-600 text-white px-3 py-1 rounded"
>
Save
</button>
</div>
</div>
</div>
)}
</div>
);
}
+343
View File
@@ -0,0 +1,343 @@
"use client";
import React, { useEffect, useState } from "react";
import Image from "next/image";
import {
LayoutDashboard,
Newspaper,
Users,
BarChart3,
Settings,
HelpCircle,
Tag,
FolderTree,
Edit3,
Trash2,
Plus,
ChevronRight,
ChevronDown,
} from "lucide-react";
import { createCategory, deleteCategory, getFlat, getTree, updateCategory } from "@/lib/categories.api";
import { slugify } from "@/lib/slug";
/* ================= TYPES ================= */
interface Category {
id: string;
name: string;
slug: string;
parentId?: string | null;
children?: Category[];
}
/* ================= PAGE ================= */
export default function CategoriesPage() {
const [tree, setTree] = useState<Category[]>([]);
const [flat, setFlat] = useState<Category[]>([]);
const [loading, setLoading] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const [form, setForm] = useState({
id: null as string | null,
name: "",
slug: "",
parentId: null as string | null,
});
/* ================= LOAD ================= */
async function load() {
setLoading(true);
try {
const [t, f] = await Promise.all([getTree(), getFlat()]);
setTree(t);
setFlat(f);
} finally {
setLoading(false);
}
}
useEffect(() => {
load();
}, []);
/* ================= CRUD ================= */
async function save() {
const payload = {
name: form.name,
slug: form.slug || slugify(form.name),
parentId: form.parentId,
};
if (form.id) {
await updateCategory(form.id, payload);
} else {
await createCategory(payload);
}
closeModal();
load();
}
async function remove(id: string) {
if (!confirm("Delete this category?")) return;
await deleteCategory(id);
load();
}
/* ================= MODAL ================= */
function openCreate(parentId?: string) {
setForm({
id: null,
name: "",
slug: "",
parentId: parentId || null,
});
setModalOpen(true);
}
function openEdit(cat: Category) {
setForm({
id: cat.id,
name: cat.name,
slug: cat.slug,
parentId: cat.parentId || null,
});
setModalOpen(true);
}
function closeModal() {
setModalOpen(false);
setForm({ id: null, name: "", slug: "", parentId: null });
}
/* ================= TREE ================= */
function TreeNode({
node,
level = 0,
}: {
node: Category;
level?: number;
}) {
const [open, setOpen] = useState(true);
return (
<div style={{ marginLeft: level * 14 }} className="border-l pl-3">
<div className="flex items-center justify-between py-2 group">
<div className="flex items-center gap-2">
<button onClick={() => setOpen(!open)}>
{open ? (
<ChevronDown size={14} />
) : (
<ChevronRight size={14} />
)}
</button>
<FolderTree size={14} className="text-blue-500" />
<span
onClick={() => openEdit(node)}
className="text-sm font-medium cursor-pointer hover:text-blue-600"
>
{node.name}
</span>
</div>
<div className="flex gap-2 opacity-0 group-hover:opacity-100 transition">
<button
onClick={() => openCreate(node.id)}
className="text-green-600"
>
<Plus size={14} />
</button>
<button onClick={() => openEdit(node)}>
<Edit3 size={14} />
</button>
<button onClick={() => remove(node.id)}>
<Trash2 size={14} className="text-red-500" />
</button>
</div>
</div>
{open &&
node.children?.map((child) => (
<TreeNode
key={child.id}
node={child}
level={level + 1}
/>
))}
</div>
);
}
/* ================= UI ================= */
return (
<div className="flex h-screen bg-slate-50 text-slate-900">
{/* ================= SIDEBAR ================= */}
{/* Sidebar Lateral */}
<aside className="w-64 backdrop-blur-xl bg-white/80 border-r border-slate-200 p-6 flex flex-col">
<div className="flex items-center gap-2 mb-10 px-2">
<div className="w-10 h-10 bg-white rounded-lg flex items-center justify-center font-bold italic">
<Image src="/logo.png" alt="TVone" width={50} height={50} />
</div>
<span className="font-bold text-sm tracking-tight leading-tight uppercase">
TVone<br/><span className="text-blue-600 text-[10px]">de Notícias</span>
</span>
</div>
<nav className="flex-1 space-y-1">
<NavItem icon={<LayoutDashboard size={18}/>} label="Painel" />
<NavItem icon={<Newspaper size={18}/>} label="Meus Artigos" />
<NavItem icon={<Users size={18}/>} label="Equipa" />
<NavItem icon={<BarChart3 size={18}/>} label="Análises" />
<NavItem icon={<Newspaper size={18}/>} label="Adicionar Notícia" active />
<NavItem icon={<Settings size={18}/>} label="Definições" />
<NavItem icon={<HelpCircle size={18}/>} label="Ajuda" />
</nav>
{/* User Activity Feed */}
<div className="mt-auto pt-6 border-t border-slate-200">
<h4 className="text-[10px] font-bold uppercase text-slate-400 mb-4 px-2 tracking-widest">Atividade Recente</h4>
<div className="space-y-3 px-2">
<ActivityItem user="James Wilson" action="atualizou 'Mercado'" />
<ActivityItem user="Sarah Johnson" action="criou 'Startups'" />
</div>
</div>
</aside>
{/* ================= MAIN ================= */}
<div className="flex-1 flex flex-col">
{/* HEADER */}
<header className="h-14 bg-white border-b flex items-center justify-between px-6">
<input
placeholder="Search categories..."
className="bg-slate-100 px-4 py-1 rounded-full text-sm w-72 outline-none"
/>
<div className="flex items-center gap-3">
<span className="text-sm text-slate-600">
Admin
</span>
<img
src="https://ui-avatars.com/api/?name=Admin"
className="w-8 h-8 rounded-full"
/>
</div>
</header>
{/* CONTENT */}
<main className="p-6 overflow-y-auto">
{/* TOP BAR */}
<div className="flex justify-between mb-6">
<h1 className="text-xl font-semibold">
Categories
</h1>
<button
onClick={() => openCreate()}
className="bg-blue-600 text-white px-4 py-2 rounded"
>
+ New Category
</button>
</div>
{/* TREE */}
<div className="bg-white border rounded-xl p-4">
{loading ? (
<p>Loading...</p>
) : (
tree.map((node) => (
<TreeNode key={node.id} node={node} />
))
)}
</div>
</main>
</div>
{/* ================= MODAL ================= */}
{modalOpen && (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center">
<div className="bg-white w-[420px] p-5 rounded-xl">
<h2 className="font-semibold mb-4">
{form.id ? "Edit Category" : "Create Category"}
</h2>
<input
className="w-full border p-2 rounded mb-2"
placeholder="Name"
value={form.name}
onChange={(e) =>
setForm({
...form,
name: e.target.value,
slug: slugify(e.target.value),
})
}
/>
<input
className="w-full border p-2 rounded mb-3"
placeholder="Slug"
value={form.slug}
onChange={(e) =>
setForm({ ...form, slug: e.target.value })
}
/>
<div className="flex justify-end gap-2">
<button onClick={closeModal}>
Cancel
</button>
<button
onClick={save}
className="bg-blue-600 text-white px-3 py-1 rounded"
>
Save
</button>
</div>
</div>
</div>
)}
</div>
);
}
// Componentes Auxiliares para Limpeza de Código
const NavItem = ({ icon, label, active = false }: { icon: any, label: string, active?: boolean }) => (
<div className={`flex items-center gap-3 px-3 py-2 rounded-xl text-sm font-medium transition-all cursor-pointer ${
active ? 'bg-blue-600 text-white shadow-md shadow-blue-200' : 'text-slate-500 hover:bg-slate-100'
}`}>
{icon}
{label}
</div>
);
const ActivityItem = ({ user, action }: { user: string, action: string }) => (
<div className="flex gap-2">
<div className="w-6 h-6 bg-slate-200 rounded-full shrink-0" />
<p className="text-[10px] leading-tight text-slate-600">
<span className="font-bold block text-slate-900">{user}</span> {action}
</p>
</div>
);