reuse the same header and footer

This commit is contained in:
2026-04-20 23:06:41 +01:00
parent eca4661cd6
commit 233cf86fea
6 changed files with 555 additions and 759 deletions
@@ -10,8 +10,6 @@ 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 { AdminHeaderPage } from '@/app/components/layout/admin/header';
import { AdminSideBar } from '@/app/components/layout/admin/sidebar';
// 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),
+117 -240
View File
@@ -1,184 +1,62 @@
import React from 'react'; import React from 'react';
import { import {
LayoutDashboard, Newspaper, Users, BarChart3, Newspaper,
Settings, HelpCircle, TrendingUp, Eye, Clock, TrendingUp,
AlertCircle, ChevronRight, Edit3, Trash2, ExternalLink Eye,
Clock,
AlertCircle,
ChevronRight,
Edit3,
Trash2,
} from 'lucide-react'; } from 'lucide-react';
import Image from 'next/image'; import { AdminHeaderPage } from '@/app/components/layout/admin/header';
import { AdminSideBar } from '@/app/components/layout/admin/sidebar';
const DashboardMain = () => { const DashboardMain = () => {
return ( return (
<div className="flex h-screen bg-[#F1F5F9] text-slate-900 font-sans"> <div className="flex h-screen bg-[#F1F5F9] text-slate-900 font-sans">
<AdminSideBar />
{/* Sidebar Lateral - Consistente com a página de criação */}
<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"> <main className="flex-1 overflow-y-auto flex flex-col min-h-0">
<NavItem icon={<LayoutDashboard size={18}/>} label="Painel Principal" active /> <AdminHeaderPage />
<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={<Settings size={18}/>} label="Definições" />
<NavItem icon={<HelpCircle size={18}/>} label="Ajuda" />
</nav>
<div className="mt-auto pt-6 border-t border-slate-200"> <div className="p-8 space-y-8 flex-1">
<div className="bg-blue-50 p-4 rounded-2xl">
<p className="text-[10px] font-bold text-blue-600 uppercase mb-1">Dica do Dia</p>
<p className="text-[11px] text-blue-800 leading-relaxed">
Artigos com mais de 3 imagens têm 40% mais retenção.
</p>
</div>
</div>
{/* Container do Fluxo de Atividade */}
<div className="mt-auto pt-6 border-t border-slate-200">
<h4 className="text-[10px] font-bold uppercase text-slate-400 mb-5 px-2 tracking-[0.15em]">
Fluxo de Atividade
</h4>
<div className="space-y-5 px-2">
<ActivityItem
name="James Wilson"
action="atualizou"
target="'Volatilidade do Mercado'"
time="Agora mesmo"
avatar="https://ui-avatars.com/api/?name=James+Wilson&background=E0F2FE&color=0369A1"
/>
<ActivityItem
name="Sarah Johnson"
action="criou"
target="'Financiamento de Startups'"
time="Há 12 min"
avatar="https://ui-avatars.com/api/?name=Sarah+Johnson&background=FEE2E2&color=B91C1C"
/>
<ActivityItem
name="Sarah Johnson"
action="criou"
target="'Tech Mercenary' em recentes"
time="Há 45 min"
avatar="https://ui-avatars.com/api/?name=Sarah+Johnson&background=FEE2E2&color=B91C1C"
/>
<ActivityItem
name="James Wilson"
action="atualizou"
target="'Volatilidade do Mercado'"
time="Há 1h"
avatar="https://ui-avatars.com/api/?name=James+Wilson&background=E0F2FE&color=0369A1"
/>
</div>
</div>
</aside>
{/* Main Content Area */}
<main className="flex-1 overflow-y-auto">
{/* Header Superior */}
{/* Header Superior - Secção do Utilizador Atualizada */}
<header className="h-16 border-b border-slate-200 bg-white/50 backdrop-blur-md sticky top-0 z-10 px-8 flex items-center justify-between">
<div className="w-1/3">
<input
type="text"
placeholder="Pesquisar artigos, autores..."
className="w-full bg-slate-100 border-none rounded-full px-4 py-1.5 text-sm focus:ring-2 focus:ring-blue-500/20 outline-none transition-all"
/>
</div>
<div className="flex items-center gap-5">
{/* Notificações (Opcional, mas completa o look) */}
<button className="relative p-2 text-slate-400 hover:text-blue-600 transition-colors">
<div className="absolute top-2 right-2.5 w-2 h-2 bg-red-500 border-2 border-white rounded-full"></div>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/></svg>
</button>
{/* Menu do Utilizador */}
<div className="flex items-center gap-3 pl-4 border-l border-slate-200 group cursor-pointer">
<div className="text-right hidden sm:block">
<p className="text-[11px] font-bold text-slate-900 leading-tight">James Wilson</p>
<p className="text-[10px] text-emerald-600 font-medium">Online</p>
</div>
<div className="relative">
{/* Container da Imagem com Efeito de Anel */}
<div className="w-9 h-9 rounded-full border-2 border-white shadow-sm overflow-hidden ring-1 ring-slate-200 group-hover:ring-blue-400 transition-all">
<img
src="https://ui-avatars.com/api/?name=James+Wilson&background=0D8ABC&color=fff"
alt="Avatar do utilizador"
className="w-full h-full object-cover"
/>
</div>
{/* Indicador de Status (Mobile) */}
<div className="absolute bottom-0 right-0 w-2.5 h-2.5 bg-emerald-500 border-2 border-white rounded-full"></div>
</div>
{/* Seta para indicar menu (Chevron) */}
<svg
xmlns="http://www.w3.org/2000/svg"
width="14" height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
className="text-slate-400 group-hover:text-slate-600 transition-transform group-hover:translate-y-0.5"
>
<path d="m6 9 6 6 6-6"/>
</svg>
</div>
</div>
</header>
<div className="p-8 space-y-8">
{/* Métricas Principais (Grid de 4 colunas) */}
<div className="grid grid-cols-4 gap-6"> <div className="grid grid-cols-4 gap-6">
<StatCard <StatCard
label="Artigos Publicados" label="Artigos Publicados"
value="14,352" value="14,352"
trend="+12% este mês" trend="+12% este mês"
icon={<Newspaper size={20} className="text-blue-600"/>} icon={<Newspaper size={20} className="text-blue-600" />}
/> />
<StatCard <StatCard
label="Visualizações Totais" label="Visualizações Totais"
value="2.1M" value="2.1M"
trend="+5.4% vs ontem" trend="+5.4% vs ontem"
icon={<TrendingUp size={20} className="text-emerald-600"/>} icon={<TrendingUp size={20} className="text-emerald-600" />}
/> />
<StatCard <StatCard
label="Tempo Médio" label="Tempo Médio"
value="4m 32s" value="4m 32s"
trend="-2s média" trend="-2s média"
icon={<Clock size={20} className="text-orange-600"/>} icon={<Clock size={20} className="text-orange-600" />}
/> />
<StatCard <StatCard
label="Alertas Ativos" label="Alertas Ativos"
value="3" value="3"
trend="Urgente" trend="Urgente"
icon={<AlertCircle size={20} className="text-red-600"/>} icon={<AlertCircle size={20} className="text-red-600" />}
isAlert isAlert
/> />
</div> </div>
<div className="grid grid-cols-3 gap-8"> <div className="grid grid-cols-3 gap-8">
{/* Tabela de Artigos Recentes (Coluna Dupla) */}
<div className="col-span-2 bg-white rounded-2xl border border-slate-200 shadow-sm overflow-hidden"> <div className="col-span-2 bg-white rounded-2xl border border-slate-200 shadow-sm overflow-hidden">
<div className="p-6 border-b border-slate-100 flex justify-between items-center"> <div className="p-6 border-b border-slate-100 flex justify-between items-center">
<h3 className="font-bold text-slate-800 text-sm uppercase tracking-wider">Artigos Recentes</h3> <h3 className="font-bold text-slate-800 text-sm uppercase tracking-wider">
Artigos Recentes
</h3>
<button className="text-blue-600 text-xs font-bold flex items-center gap-1 hover:underline"> <button className="text-blue-600 text-xs font-bold flex items-center gap-1 hover:underline">
Ver Todos <ChevronRight size={14}/> Ver Todos <ChevronRight size={14} />
</button> </button>
</div> </div>
<table className="w-full text-left border-collapse"> <table className="w-full text-left border-collapse">
@@ -192,38 +70,39 @@ const DashboardMain = () => {
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-slate-50"> <tbody className="divide-y divide-slate-50">
<TableRow <TableRow
title="Volatilidade do Mercado: Impactos na Economia" title="Volatilidade do Mercado: Impactos na Economia"
status="Publicado" status="Publicado"
author="James Wilson" author="James Wilson"
views="1.2M" views="1.2M"
/> />
<TableRow <TableRow
title="O Futuro da Inteligência Artificial em 2026" title="O Futuro da Inteligência Artificial em 2026"
status="Rascunho" status="Rascunho"
author="Sarah Johnson" author="Sarah Johnson"
views="--" views="--"
/> />
<TableRow <TableRow
title="Novas Políticas de Sustentabilidade na UE" title="Novas Políticas de Sustentabilidade na UE"
status="Agendado" status="Agendado"
author="Ricardo Silva" author="Ricardo Silva"
views="0" views="0"
/> />
</tbody> </tbody>
</table> </table>
</div> </div>
{/* Performance por Categoria (Coluna Única) */}
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm p-6"> <div className="bg-white rounded-2xl border border-slate-200 shadow-sm p-6">
<h3 className="font-bold text-slate-800 text-sm uppercase tracking-wider mb-6">Performance de Conteúdo</h3> <h3 className="font-bold text-slate-800 text-sm uppercase tracking-wider mb-6">
Performance de Conteúdo
</h3>
<div className="space-y-6"> <div className="space-y-6">
<CategoryBar label="Tecnologia" percentage={85} color="bg-blue-500" /> <CategoryBar label="Tecnologia" percentage={85} color="bg-blue-500" />
<CategoryBar label="Negócios" percentage={62} color="bg-emerald-500" /> <CategoryBar label="Negócios" percentage={62} color="bg-emerald-500" />
<CategoryBar label="Política" percentage={45} color="bg-purple-500" /> <CategoryBar label="Política" percentage={45} color="bg-purple-500" />
<CategoryBar label="Desporto" percentage={30} color="bg-orange-500" /> <CategoryBar label="Desporto" percentage={30} color="bg-orange-500" />
</div> </div>
<div className="mt-8 p-4 bg-slate-50 rounded-xl"> <div className="mt-8 p-4 bg-slate-50 rounded-xl">
<p className="text-[11px] text-slate-500 text-center"> <p className="text-[11px] text-slate-500 text-center">
O tráfego orgânico cresceu <strong>15%</strong> nos últimos 7 dias. O tráfego orgânico cresceu <strong>15%</strong> nos últimos 7 dias.
@@ -237,24 +116,31 @@ const DashboardMain = () => {
); );
}; };
// --- Subcomponentes para Organização --- const StatCard = ({
label,
const NavItem = ({ icon, label, active = false }: { icon: any, label: string, active?: boolean }) => ( value,
<div className={`flex items-center gap-3 px-3 py-2 rounded-xl text-sm font-medium transition-all cursor-pointer ${ trend,
active ? 'bg-blue-600 text-white shadow-lg shadow-blue-200' : 'text-slate-500 hover:bg-slate-100' icon,
}`}> isAlert = false,
{icon} }: {
<span className="truncate">{label}</span> label: string;
</div> value: string;
); trend: string;
icon: React.ReactNode;
const StatCard = ({ label, value, trend, icon, isAlert = false }: any) => ( isAlert?: boolean;
<div className={`p-5 rounded-2xl border bg-white shadow-sm transition-transform hover:scale-[1.02] cursor-default ${isAlert ? 'border-red-100 bg-red-50/20' : 'border-slate-200'}`}> }) => (
<div
className={`p-5 rounded-2xl border bg-white shadow-sm transition-transform hover:scale-[1.02] cursor-default ${
isAlert ? 'border-red-100 bg-red-50/20' : 'border-slate-200'
}`}
>
<div className="flex justify-between items-start mb-4"> <div className="flex justify-between items-start mb-4">
<div className={`p-2 rounded-lg ${isAlert ? 'bg-red-100' : 'bg-slate-50'}`}> <div className={`p-2 rounded-lg ${isAlert ? 'bg-red-100' : 'bg-slate-50'}`}>{icon}</div>
{icon} <span
</div> className={`text-[10px] font-bold px-2 py-0.5 rounded-full ${
<span className={`text-[10px] font-bold px-2 py-0.5 rounded-full ${isAlert ? 'bg-red-500 text-white' : 'bg-emerald-50 text-emerald-600'}`}> isAlert ? 'bg-red-500 text-white' : 'bg-emerald-50 text-emerald-600'
}`}
>
{trend} {trend}
</span> </span>
</div> </div>
@@ -263,16 +149,31 @@ const StatCard = ({ label, value, trend, icon, isAlert = false }: any) => (
</div> </div>
); );
const TableRow = ({ title, status, author, views }: any) => ( const TableRow = ({
title,
status,
author,
views,
}: {
title: string;
status: string;
author: string;
views: string;
}) => (
<tr className="hover:bg-slate-50/50 transition-colors group"> <tr className="hover:bg-slate-50/50 transition-colors group">
<td className="px-6 py-4"> <td className="px-6 py-4">
<p className="text-sm font-semibold text-slate-800 truncate max-w-[200px]">{title}</p> <p className="text-sm font-semibold text-slate-800 truncate max-w-[200px]">{title}</p>
</td> </td>
<td className="px-6 py-4"> <td className="px-6 py-4">
<span className={`text-[10px] font-bold px-2 py-1 rounded-md ${ <span
status === 'Publicado' ? 'bg-emerald-50 text-emerald-600' : className={`text-[10px] font-bold px-2 py-1 rounded-md ${
status === 'Rascunho' ? 'bg-slate-100 text-slate-500' : 'bg-blue-50 text-blue-600' status === 'Publicado'
}`}> ? 'bg-emerald-50 text-emerald-600'
: status === 'Rascunho'
? 'bg-slate-100 text-slate-500'
: 'bg-blue-50 text-blue-600'
}`}
>
{status} {status}
</span> </span>
</td> </td>
@@ -280,14 +181,26 @@ const TableRow = ({ title, status, author, views }: any) => (
<td className="px-6 py-4 text-xs font-mono font-medium">{views}</td> <td className="px-6 py-4 text-xs font-mono font-medium">{views}</td>
<td className="px-6 py-4"> <td className="px-6 py-4">
<div className="flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity"> <div className="flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<button className="p-1.5 hover:bg-white rounded-md border border-slate-200 text-slate-400 hover:text-blue-600 shadow-sm"><Edit3 size={14}/></button> <button className="p-1.5 hover:bg-white rounded-md border border-slate-200 text-slate-400 hover:text-blue-600 shadow-sm">
<button className="p-1.5 hover:bg-white rounded-md border border-slate-200 text-slate-400 hover:text-red-600 shadow-sm"><Trash2 size={14}/></button> <Edit3 size={14} />
</button>
<button className="p-1.5 hover:bg-white rounded-md border border-slate-200 text-slate-400 hover:text-red-600 shadow-sm">
<Trash2 size={14} />
</button>
</div> </div>
</td> </td>
</tr> </tr>
); );
const CategoryBar = ({ label, percentage, color }: any) => ( const CategoryBar = ({
label,
percentage,
color,
}: {
label: string;
percentage: number;
color: string;
}) => (
<div className="space-y-2"> <div className="space-y-2">
<div className="flex justify-between text-[11px] font-bold uppercase tracking-wide"> <div className="flex justify-between text-[11px] font-bold uppercase tracking-wide">
<span>{label}</span> <span>{label}</span>
@@ -300,39 +213,3 @@ const CategoryBar = ({ label, percentage, color }: any) => (
); );
export default DashboardMain; export default DashboardMain;
{/* Subcomponente ActivityItem (Coloque fora do componente principal) */}
const ActivityItem = ({ name, action, target, time, avatar }: {
name: string,
action: string,
target: string,
time: string,
avatar: string
}) => (
<div className="flex gap-3 items-start group">
{/* Avatar com Ring */}
<div className="relative shrink-0">
<img
src={avatar}
alt={name}
className="w-7 h-7 rounded-full border border-slate-100 shadow-sm"
/>
<div className="absolute -bottom-0.5 -right-0.5 w-2 h-2 bg-emerald-500 border-2 border-white rounded-full"></div>
</div>
{/* Texto da Atividade */}
<div className="flex flex-col gap-0.5">
<p className="text-[11px] leading-snug text-slate-600">
<span className="font-bold text-slate-900 hover:text-blue-600 cursor-pointer transition-colors">
{name}
</span>
{" "}{action}{" "}
<span className="font-medium text-slate-800 italic">{target}</span>
</p>
<span className="text-[9px] font-medium text-slate-400 uppercase tracking-tight">
{time}
</span>
</div>
</div>
);
@@ -0,0 +1,203 @@
"use client";
import React, { useEffect, useState } from "react";
import {
FolderTree,
Edit3,
Trash2,
Plus,
ChevronRight,
ChevronDown,
} from "lucide-react";
import { createCategory, deleteCategory, getTree, updateCategory } from "@/lib/categories.api";
import { slugify } from "@/lib/slug";
interface Category {
id: string;
name: string;
slug: string;
parentId?: string | null;
children?: Category[];
}
export default function ManageCategoryClient() {
const [tree, setTree] = 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,
});
async function load() {
setLoading(true);
try {
const t = await getTree();
setTree(t);
} finally {
setLoading(false);
}
}
useEffect(() => {
load();
}, []);
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();
}
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 });
}
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>
);
}
return (
<>
<main className="flex-1 p-6 overflow-y-auto min-h-0">
<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>
<div className="bg-white border rounded-xl p-4">
{loading ? (
<p>Loading...</p>
) : (
tree.map((node) => <TreeNode key={node.id} node={node} />)
)}
</div>
</main>
{modalOpen && (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50">
<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>
)}
</>
);
}
+8 -335
View File
@@ -1,343 +1,16 @@
"use client"; import { AdminHeaderPage } from '@/app/components/layout/admin/header';
import { AdminSideBar } from '@/app/components/layout/admin/sidebar';
import ManageCategoryClient from './manage-category-client';
import React, { useEffect, useState } from "react"; export default function ManageCategoryPage() {
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 ( return (
<div className="flex h-screen bg-slate-50 text-slate-900"> <div className="flex h-screen bg-slate-50 text-slate-900">
<AdminSideBar />
{/* ================= SIDEBAR ================= */} <div className="flex-1 flex flex-col min-h-0 overflow-hidden">
{/* Sidebar Lateral */} <AdminHeaderPage />
<aside className="w-64 backdrop-blur-xl bg-white/80 border-r border-slate-200 p-6 flex flex-col"> <ManageCategoryClient />
<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> </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> </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>
);
+12 -182
View File
@@ -1,188 +1,18 @@
"use client"; import { AdminHeaderPage } from '@/app/components/layout/admin/header';
import React, { useState, useCallback } from "react"; import { AdminSideBar } from '@/app/components/layout/admin/sidebar';
import Cropper from "react-easy-crop"; import PanelEditor from './panel-editor';
const RATIOS = [
{ label: "Hero Banner (Ultra Wide)", value: 21 / 9, text: "21/9" },
{ label: "News Feed (Widescreen)", value: 16 / 9, text: "16/9" },
{ label: "Profile / Post (Square)", value: 1 / 1, text: "1/1" },
];
export default function FullPageEditor() {
const [image, setImage] = useState<string | null>(null);
const [crops, setCrops] = useState<Record<string, any>>(
RATIOS.reduce((acc, r) => ({ ...acc, [r.text]: { x: 0, y: 0, zoom: 1 } }), {})
);
const [completedCrops, setCompletedCrops] = useState<Record<string, any>>({});
const [isExporting, setIsExporting] = useState(false);
const [results, setResults] = useState<Record<string, string>>({});
const onSelectFile = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) {
const reader = new FileReader();
reader.onload = () => setImage(reader.result as string);
reader.readAsDataURL(e.target.files[0]);
}
};
const handleExport = async () => {
setIsExporting(true);
const bundle: Record<string, string> = {};
for (const ratio of RATIOS) {
const crop = completedCrops[ratio.text];
if (crop) bundle[ratio.text] = await getCroppedImg(image!, crop);
}
setResults(bundle);
setIsExporting(false);
};
export default function PanelPage() {
return ( return (
<div className="min-h-screen bg-zinc-950 text-zinc-100 font-sans selection:bg-indigo-500"> <div className="flex h-screen bg-slate-100 text-slate-900 font-sans overflow-hidden">
{/* 1. Global Navigation */} <AdminSideBar />
<nav className="sticky top-0 z-50 border-b border-zinc-800 bg-zinc-950/80 backdrop-blur-md px-8 py-4 flex items-center justify-between">
<div className="flex items-center gap-4"> <div className="flex-1 flex flex-col min-h-0 min-w-0">
<div className="bg-indigo-600 p-2 rounded-lg font-black text-xs">FIX</div> <AdminHeaderPage />
<h1 className="text-lg font-bold tracking-tighter">IMAGE FRAMER <span className="text-zinc-500 font-medium text-sm ml-2">PRO v3.0</span></h1> <div className="flex-1 min-h-0 overflow-hidden">
<PanelEditor />
</div> </div>
</div>
<div className="flex gap-4">
<label className="cursor-pointer bg-zinc-800 hover:bg-zinc-700 text-sm font-bold px-5 py-2 rounded-full transition-all border border-zinc-700">
{image ? "New Photo" : "Upload Source"}
<input type="file" accept="image/*" className="hidden" onChange={onSelectFile} />
</label>
{image && (
<button
onClick={handleExport}
className="bg-indigo-600 hover:bg-indigo-500 text-white text-sm font-bold px-8 py-2 rounded-full shadow-lg shadow-indigo-900/20 transition-all active:scale-95"
>
{isExporting ? "Processing..." : "Generate Base64"}
</button>
)}
</div>
</nav>
{/* 2. Workspace Area */}
<main className="max-w-screen-xl mx-auto p-8 space-y-20">
{!image ? (
<div className="h-[70vh] flex flex-col items-center justify-center border-2 border-dashed border-zinc-800 rounded-[3rem] bg-zinc-900/30">
<div className="w-20 h-20 bg-zinc-800 rounded-full flex items-center justify-center text-3xl mb-6">📁</div>
<h2 className="text-2xl font-bold mb-2">Editor is Empty</h2>
<p className="text-zinc-500 max-w-xs text-center">Upload a high-resolution image to begin the multi-aspect framing process.</p>
</div>
) : (
<div className="space-y-32 pb-40">
{RATIOS.map((ratio) => (
<section key={ratio.text} className="group">
{/* Section Header */}
<div className="flex items-end justify-between mb-6 border-b border-zinc-800 pb-4">
<div>
<span className="text-indigo-500 text-xs font-black uppercase tracking-[0.2em]">Aspect Ratio</span>
<h3 className="text-3xl font-bold mt-1">{ratio.label}</h3>
</div>
<div className="text-right">
<p className="text-zinc-500 text-xs font-mono uppercase">Current Zoom</p>
<p className="text-2xl font-black">{Math.round((crops[ratio.text]?.zoom || 1) * 100)}%</p>
</div>
</div>
{/* BIG Editor Window */}
<div className="relative w-full overflow-hidden rounded-[2rem] bg-zinc-900 shadow-2xl ring-1 ring-zinc-800 h-[600px]">
<Cropper
image={image}
crop={{ x: crops[ratio.text].x, y: crops[ratio.text].y }}
zoom={crops[ratio.text].zoom}
aspect={ratio.value}
onCropChange={(c) => setCrops(prev => ({ ...prev, [ratio.text]: { ...prev[ratio.text], ...c } }))}
onZoomChange={(z) => setCrops(prev => ({ ...prev, [ratio.text]: { ...prev[ratio.text], zoom: z } }))}
onCropComplete={(_, px) => setCompletedCrops(prev => ({ ...prev, [ratio.text]: px }))}
objectFit="cover"
showGrid={true}
/>
</div>
{/* Floating Zoom Control (Large & Accessible) */}
<div className="mt-8 flex items-center gap-8 bg-zinc-900/50 p-6 rounded-2xl border border-zinc-800">
<span className="text-xs font-bold text-zinc-500 uppercase tracking-widest min-w-[80px]">Adjust Zoom</span>
<input
type="range"
min={1}
max={3}
step={0.01}
value={crops[ratio.text].zoom}
onChange={(e) => setCrops(prev => ({ ...prev, [ratio.text]: { ...prev[ratio.text], zoom: Number(e.target.value) } }))}
className="flex-1 accent-indigo-500 h-2 bg-zinc-800 rounded-lg appearance-none cursor-pointer"
/>
<button
onClick={() => setCrops(prev => ({ ...prev, [ratio.text]: { x:0, y:0, zoom: 1 } }))}
className="text-[10px] font-black uppercase tracking-widest text-zinc-600 hover:text-white"
>
Reset
</button>
</div>
</section>
))}
</div>
)}
</main>
{/* 3. Global Results Modal/Area */}
{Object.keys(results).length > 0 && (
<div className="fixed inset-0 z-[100] bg-zinc-950 flex flex-col">
<header className="p-8 border-b border-zinc-800 flex justify-between items-center">
<h2 className="text-2xl font-black">EXPORT BUNDLE</h2>
<button onClick={() => setResults({})} className="text-zinc-500 hover:text-white font-bold">Close Editor</button>
</header>
<div className="flex-1 overflow-y-auto p-8 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{Object.entries(results).map(([ratio, b64]) => (
<div key={ratio} className="bg-zinc-900 rounded-3xl p-6 space-y-4 border border-zinc-800">
<div className="flex justify-between items-center">
<span className="text-xs font-bold text-indigo-500 uppercase">{ratio} Result</span>
<button
onClick={() => {navigator.clipboard.writeText(b64); alert('Copied!');}}
className="text-[10px] bg-indigo-600 px-3 py-1 rounded-full font-bold"
>
Copy Base64
</button>
</div>
<img src={b64} className="w-full rounded-xl border border-white/5 shadow-lg" alt="Crop preview" />
<textarea
readOnly
value={b64}
className="w-full h-24 bg-zinc-950 text-[10px] font-mono p-4 rounded-xl border-none outline-none text-zinc-600"
/>
</div>
))}
</div>
</div>
)}
</div> </div>
); );
} }
/* ---------------- CANVAS EXPORT UTILITY ---------------- */
async function getCroppedImg(imageSrc: string, pixelCrop: any): Promise<string> {
const image = new Image();
image.src = imageSrc;
await new Promise((resolve) => (image.onload = resolve));
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
if (!ctx) return "";
canvas.width = pixelCrop.width;
canvas.height = pixelCrop.height;
ctx.drawImage(
image,
pixelCrop.x,
pixelCrop.y,
pixelCrop.width,
pixelCrop.height,
0,
0,
pixelCrop.width,
pixelCrop.height
);
return canvas.toDataURL("image/jpeg", 0.9);
}
+215
View File
@@ -0,0 +1,215 @@
"use client";
import React, { useState, useCallback } from "react";
import Cropper from "react-easy-crop";
const RATIOS = [
{ label: "Hero Banner (Ultra Wide)", value: 21 / 9, text: "21/9" },
{ label: "News Feed (Widescreen)", value: 16 / 9, text: "16/9" },
{ label: "Profile / Post (Square)", value: 1 / 1, text: "1/1" },
];
export default function PanelEditor() {
const [image, setImage] = useState<string | null>(null);
const [crops, setCrops] = useState<Record<string, any>>(
RATIOS.reduce((acc, r) => ({ ...acc, [r.text]: { x: 0, y: 0, zoom: 1 } }), {})
);
const [completedCrops, setCompletedCrops] = useState<Record<string, any>>({});
const [isExporting, setIsExporting] = useState(false);
const [results, setResults] = useState<Record<string, string>>({});
const onSelectFile = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) {
const reader = new FileReader();
reader.onload = () => setImage(reader.result as string);
reader.readAsDataURL(e.target.files[0]);
}
};
const handleExport = async () => {
setIsExporting(true);
const bundle: Record<string, string> = {};
for (const ratio of RATIOS) {
const crop = completedCrops[ratio.text];
if (crop) bundle[ratio.text] = await getCroppedImg(image!, crop);
}
setResults(bundle);
setIsExporting(false);
};
return (
<div className="min-h-full bg-zinc-950 text-zinc-100 font-sans selection:bg-indigo-500 flex flex-col">
<div className="flex-shrink-0 border-b border-zinc-800 bg-zinc-950/80 backdrop-blur-md px-8 py-4 flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="bg-indigo-600 p-2 rounded-lg font-black text-xs">FIX</div>
<h1 className="text-lg font-bold tracking-tighter">
IMAGE FRAMER <span className="text-zinc-500 font-medium text-sm ml-2">PRO v3.0</span>
</h1>
</div>
<div className="flex gap-4">
<label className="cursor-pointer bg-zinc-800 hover:bg-zinc-700 text-sm font-bold px-5 py-2 rounded-full transition-all border border-zinc-700">
{image ? "New Photo" : "Upload Source"}
<input type="file" accept="image/*" className="hidden" onChange={onSelectFile} />
</label>
{image && (
<button
onClick={handleExport}
className="bg-indigo-600 hover:bg-indigo-500 text-white text-sm font-bold px-8 py-2 rounded-full shadow-lg shadow-indigo-900/20 transition-all active:scale-95"
>
{isExporting ? "Processing..." : "Generate Base64"}
</button>
)}
</div>
</div>
<main className="max-w-screen-xl mx-auto p-8 space-y-20 flex-1 overflow-y-auto">
{!image ? (
<div className="h-[70vh] flex flex-col items-center justify-center border-2 border-dashed border-zinc-800 rounded-[3rem] bg-zinc-900/30">
<div className="w-20 h-20 bg-zinc-800 rounded-full flex items-center justify-center text-3xl mb-6">📁</div>
<h2 className="text-2xl font-bold mb-2">Editor is Empty</h2>
<p className="text-zinc-500 max-w-xs text-center">
Upload a high-resolution image to begin the multi-aspect framing process.
</p>
</div>
) : (
<div className="space-y-32 pb-40">
{RATIOS.map((ratio) => (
<section key={ratio.text} className="group">
<div className="flex items-end justify-between mb-6 border-b border-zinc-800 pb-4">
<div>
<span className="text-indigo-500 text-xs font-black uppercase tracking-[0.2em]">
Aspect Ratio
</span>
<h3 className="text-3xl font-bold mt-1">{ratio.label}</h3>
</div>
<div className="text-right">
<p className="text-zinc-500 text-xs font-mono uppercase">Current Zoom</p>
<p className="text-2xl font-black">
{Math.round((crops[ratio.text]?.zoom || 1) * 100)}%
</p>
</div>
</div>
<div className="relative w-full overflow-hidden rounded-[2rem] bg-zinc-900 shadow-2xl ring-1 ring-zinc-800 h-[600px]">
<Cropper
image={image}
crop={{ x: crops[ratio.text].x, y: crops[ratio.text].y }}
zoom={crops[ratio.text].zoom}
aspect={ratio.value}
onCropChange={(c) =>
setCrops((prev) => ({ ...prev, [ratio.text]: { ...prev[ratio.text], ...c } }))
}
onZoomChange={(z) =>
setCrops((prev) => ({ ...prev, [ratio.text]: { ...prev[ratio.text], zoom: z } }))
}
onCropComplete={(_, px) =>
setCompletedCrops((prev) => ({ ...prev, [ratio.text]: px }))
}
objectFit="cover"
showGrid={true}
/>
</div>
<div className="mt-8 flex items-center gap-8 bg-zinc-900/50 p-6 rounded-2xl border border-zinc-800">
<span className="text-xs font-bold text-zinc-500 uppercase tracking-widest min-w-[80px]">
Adjust Zoom
</span>
<input
type="range"
min={1}
max={3}
step={0.01}
value={crops[ratio.text].zoom}
onChange={(e) =>
setCrops((prev) => ({
...prev,
[ratio.text]: { ...prev[ratio.text], zoom: Number(e.target.value) },
}))
}
className="flex-1 accent-indigo-500 h-2 bg-zinc-800 rounded-lg appearance-none cursor-pointer"
/>
<button
onClick={() =>
setCrops((prev) => ({
...prev,
[ratio.text]: { x: 0, y: 0, zoom: 1 },
}))
}
className="text-[10px] font-black uppercase tracking-widest text-zinc-600 hover:text-white"
>
Reset
</button>
</div>
</section>
))}
</div>
)}
</main>
{Object.keys(results).length > 0 && (
<div className="fixed inset-0 z-[100] bg-zinc-950 flex flex-col">
<header className="p-8 border-b border-zinc-800 flex justify-between items-center">
<h2 className="text-2xl font-black">EXPORT BUNDLE</h2>
<button
onClick={() => setResults({})}
className="text-zinc-500 hover:text-white font-bold"
>
Close Editor
</button>
</header>
<div className="flex-1 overflow-y-auto p-8 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{Object.entries(results).map(([ratio, b64]) => (
<div key={ratio} className="bg-zinc-900 rounded-3xl p-6 space-y-4 border border-zinc-800">
<div className="flex justify-between items-center">
<span className="text-xs font-bold text-indigo-500 uppercase">{ratio} Result</span>
<button
onClick={() => {
void navigator.clipboard.writeText(b64);
alert('Copied!');
}}
className="text-[10px] bg-indigo-600 px-3 py-1 rounded-full font-bold"
>
Copy Base64
</button>
</div>
<img src={b64} className="w-full rounded-xl border border-white/5 shadow-lg" alt="Crop preview" />
<textarea
readOnly
value={b64}
className="w-full h-24 bg-zinc-950 text-[10px] font-mono p-4 rounded-xl border-none outline-none text-zinc-600"
/>
</div>
))}
</div>
</div>
)}
</div>
);
}
async function getCroppedImg(imageSrc: string, pixelCrop: any): Promise<string> {
const image = new Image();
image.src = imageSrc;
await new Promise((resolve) => (image.onload = resolve));
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
if (!ctx) return "";
canvas.width = pixelCrop.width;
canvas.height = pixelCrop.height;
ctx.drawImage(
image,
pixelCrop.x,
pixelCrop.y,
pixelCrop.width,
pixelCrop.height,
0,
0,
pixelCrop.width,
pixelCrop.height
);
return canvas.toDataURL("image/jpeg", 0.9);
}