Compare commits

...

40 Commits

Author SHA1 Message Date
peter 7d7b291ea2 samesite
continuous-integration/drone/push Build is passing
2026-04-21 21:06:07 +01:00
peter c9e96d489d fix sameSite 2026-04-21 21:05:12 +01:00
peter 51a7e85858 add same origin to cookies
continuous-integration/drone/push Build is passing
2026-04-21 20:51:23 +01:00
peter ca0c2877b5 set sameSite to none
continuous-integration/drone/push Build is passing
2026-04-21 20:34:46 +01:00
peter 5832ca2d54 add env
continuous-integration/drone/push Build is passing
2026-04-21 20:30:13 +01:00
peter 29f89ea922 save
continuous-integration/drone/push Build is passing
2026-04-21 20:11:21 +01:00
peter b71a10a003 save
continuous-integration/drone/push Build is passing
2026-04-21 20:01:05 +01:00
peter d4ef46e327 add layout to admin routes
continuous-integration/drone/push Build is passing
2026-04-21 10:53:09 +01:00
peter 2952ad314b remove border
continuous-integration/drone/push Build is passing
2026-04-20 23:18:36 +01:00
peter 9391849795 hide editor 2026-04-20 23:13:12 +01:00
peter 233cf86fea reuse the same header and footer 2026-04-20 23:06:41 +01:00
peter eca4661cd6 enviroment
continuous-integration/drone/push Build is passing
2026-04-20 22:45:10 +01:00
peter 0cdfdd2962 add env to buildl time
continuous-integration/drone/push Build is failing
2026-04-20 22:33:59 +01:00
peter 326c57efab change
continuous-integration/drone/push Build is failing
2026-04-20 17:11:34 +01:00
peter 8e9c997f25 fix message
continuous-integration/drone/push Build is failing
2026-04-20 17:02:34 +01:00
peter dcccc2be47 fix base path
continuous-integration/drone/push Build was killed
continuous-integration/drone Build is failing
2026-04-20 16:30:12 +01:00
peter f4928bd9f9 change redirect 2026-04-20 16:29:58 +01:00
peter e166ad8a84 user env 2026-04-20 16:29:47 +01:00
peter 950fb32d27 rename 2026-04-20 16:29:32 +01:00
peter 96d3a86f3e load all variable from .env
continuous-integration/drone/push Build is failing
2026-04-20 11:45:13 +01:00
peter d408b74299 move to environment variable 2026-04-20 11:39:32 +01:00
peter ae61c8f661 remove afety: avoid setting cookie on known public suffix-like domains 2026-04-19 13:32:17 +01:00
peter 26cfe4eb5b set the right domain 2026-04-19 13:30:47 +01:00
peter 00e8f167f9 update redirect
continuous-integration/drone/push Build is passing
continuous-integration/drone Build is passing
2026-04-19 13:16:08 +01:00
peter b834b26087 make it dynamic 2026-04-19 13:13:35 +01:00
peter 80337ff874 add slider-photo api
continuous-integration/drone/push Build is passing
2026-04-19 11:26:33 +01:00
peter f2b62f29ba rollback slider-photo
continuous-integration/drone/push Build is passing
2026-04-19 11:15:54 +01:00
peter 189355174c comment refresh token
continuous-integration/drone/push Build is passing
2026-04-19 11:02:43 +01:00
peter 7a4ebad9e2 save refresh token 2026-04-19 03:52:51 +01:00
peter f220ee6c3e change link redirect 2026-04-19 03:39:38 +01:00
peter 31aac2db57 change route 2026-04-19 03:39:05 +01:00
peter 7d80c9c91b add permissision 2026-04-19 03:38:50 +01:00
peter 12639cb6f8 add middleware 2026-04-19 03:38:15 +01:00
peter 52dfe2defa change path 2026-04-19 03:38:07 +01:00
peter c4ffae44fe now sending cookies
continuous-integration/drone/push Build is failing
2026-04-19 02:33:07 +01:00
peter 662a8a400b optimize page 2026-04-19 00:53:01 +01:00
peter a6d475ed13 set settsion to http and https 2026-04-19 00:52:09 +01:00
peter 174febe986 login to next backend 2026-04-18 23:45:46 +01:00
peter 2131a34e33 simplefy keycloak 2026-04-18 15:25:18 +01:00
peter ba17904895 add auth moodule 2026-04-18 13:43:13 +01:00
43 changed files with 1626 additions and 1332 deletions
+10
View File
@@ -38,6 +38,16 @@ COPY . .
# Disable telemetry during build (optional) # Disable telemetry during build (optional)
ENV NEXT_TELEMETRY_DISABLED=1 ENV NEXT_TELEMETRY_DISABLED=1
# Required by prisma generate during image build
ENV API_URL="https://tvone-api.petermaquiran.xyz"
ENV KEYCLOAK_BASE_URL=""
ENV KEYCLOAK_REALM=""
ENV KEYCLOAK_CLIENT_ID=""
ENV KEYCLOAK_CLIENT_SECRET=""
ENV API_URL=""
ENV COOKIE_DOMAIN=".petermaquiran.xyz"
ENV NEXT_PUBLIC_API_URL="https://tvone-api.petermaquiran.xyz"
# Build Next.js app # Build Next.js app
RUN \ RUN \
if [ -f yarn.lock ]; then yarn build; \ if [ -f yarn.lock ]; then yarn build; \
@@ -0,0 +1,308 @@
"use client";
import React, { useState, useRef, useEffect } from 'react';
import Image from 'next/image';
import {
LayoutDashboard, Newspaper, Users, BarChart3,
Settings, HelpCircle, Image as ImageIcon,
Type, Calendar, Clock, Tag, User, Save, Eye, Send
} from 'lucide-react';
// 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 { keycloak } from '@/app/feature/auth/keycloak-config';
const Editor = dynamic(
() => import("@tinymce/tinymce-react").then((mod) => mod.Editor),
{ ssr: false }
);
export const CreateNewsPage = () => {
// Configuração do Design do Editor para combinar com seu layout
const editorConfig = {
height: 500,
menubar: false,
plugins: [
'advlist', 'autolink', 'lists', 'link', 'image', 'charmap', 'preview',
'anchor', 'searchreplace', 'visualblocks', 'code', 'fullscreen',
'insertdatetime', 'media', 'table', 'code', 'help', 'wordcount'
],
// Adicionei 'blockquote' na toolbar
toolbar: 'undo redo | blocks | ' +
'bold italic forecolor | alignleft aligncenter ' +
'alignright alignjustify | bullist numlist | ' +
'blockquote link image | removeformat | help',
// Customização do aspeto da citação dentro do editor
content_style: `
body {
font-family: Inter, Helvetica, Arial, sans-serif;
font-size: 14px;
color: #334155;
line-height: 1.6;
}
blockquote {
border-left: 4px solid #2563eb; /* Azul da TVone */
padding-left: 1.5rem;
color: #475569;
font-style: italic;
margin: 1.5rem 0;
background: #f8fafc;
padding: 1rem 1.5rem;
border-radius: 0 8px 8px 0;
}
`,
skin: 'oxide',
promotion: false, // Remove o botão "Upgrade" do Tiny
branding: false, // Remove o "Powered by Tiny"
};
// 1. Estados para o Crop
const [tempImage, setTempImage] = useState<string | null>(null);
const [isEditorOpen, setIsEditorOpen] = useState(false);
const [finalCrops, setFinalCrops] = useState<any>(null); // Guarda os Base64 finais
const [content, setContent] = useState('');
const [user, setUser] = useState<{
email?: string;
name?: string;
picture?: string;
} | null>(null);
const [categories, setCategories] = useState<Category[]>([]);
const [categoriesLoading, setCategoriesLoading] = useState(true);
const [categoryId, setCategoryId] = useState<string>("");
const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
let cancelled = false;
(async () => {
setCategoriesLoading(true);
try {
const list = await getFlat();
if (!cancelled) setCategories(list);
} catch {
if (!cancelled) setCategories([]);
} finally {
if (!cancelled) setCategoriesLoading(false);
}
})();
return () => {
cancelled = true;
};
}, []);
// 2. Lógica de Upload
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) {
const reader = new FileReader();
reader.readAsDataURL(e.target.files[0]);
reader.onload = () => {
setTempImage(reader.result as string);
setIsEditorOpen(true);
};
}
};
const triggerUpload = () => {
fileInputRef.current?.click();
};
// Avoid hydration mismatch by waiting for mount
// useEffect(() => {
// keycloak.init({
// onLoad: "check-sso",
// pkceMethod: "S256",
// }).then(async (authenticated) => {
// if (authenticated) {
// const token = keycloak.token!;
// localStorage.setItem("token", token);
// // 👉 send token to Next.js backend
// fetch("/api/session", {
// method: "POST",
// body: JSON.stringify({ token }),
// });
// const res = await fetch("http://localhost:3001/profile/", {
// headers: {
// //Authorization: `Bearer ${token}`,
// },
// });
// const profile = await res.json();
// var keycloakData : {
// email: string,
// email_verified: boolean,
// name: string,
// picture: string,
// roles: string[]
// } = profile.keycloak
// setUser(keycloakData);
// console.log("Profile:", keycloakData);
// }
// });
// }, []);
return (
<div className="p-8 max-w-6xl mx-auto">
<div className="flex justify-between items-center mb-8">
<h1 className="text-2xl font-bold tracking-tight">Criar Nova Notícia</h1>
<div className="flex gap-3">
<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">
<Send size={16}/> Publicar Artigo
</button>
</div>
</div>
<div className="grid grid-cols-3 gap-8">
{/* Coluna Principal */}
<div className="col-span-2 space-y-6">
<section className="bg-white rounded-2xl border border-slate-200 shadow-sm overflow-hidden">
{/* Título */}
<div className="p-6 pb-0">
<label className="block text-xs font-bold uppercase text-slate-400 mb-2">Título do Artigo</label>
<input
type="text"
placeholder="Insira o título principal da notícia..."
className="w-full text-xl font-bold border-none focus:ring-0 placeholder:text-slate-300 p-0"
/>
<hr className="my-6 border-slate-100" />
<label className="block text-xs font-bold uppercase text-slate-400 mb-2">Conteúdo Principal</label>
</div>
{/* TinyMCE Editor */}
<div className="border-t border-slate-50">
{/* <Editor
apiKey='dmg1hghyf25x09mtg04hik0034yeadt1h6ai2ou68zhdvw11' // Obtenha em tiny.cloud ou use 'no-api-key' para teste
init={editorConfig}
value={content}
onEditorChange={(newContent) => setContent(newContent)}
/> */}
</div>
</section>
{/* 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>
{/* Tags */}
<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">Tags</label>
<input type="text" placeholder="Insira as tags da notícia..." className="w-full border-none focus:ring-0 resize-none text-slate-600 text-sm italic p-0" />
</div>
</div>
{/* Coluna Lateral */}
<div className="space-y-6">
<section className="bg-white rounded-2xl p-6 border border-slate-200 shadow-sm space-y-6">
<div>
<label className="flex items-center gap-2 text-xs font-bold uppercase text-slate-400 mb-3">
<Tag size={14}/> Categoria
</label>
<select
value={categoryId}
onChange={(e) => setCategoryId(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) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</div>
{/* --- INPUT DE UPLOAD ATUALIZADO --- */}
<div>
<label className="flex items-center gap-2 text-xs font-bold uppercase text-slate-400 mb-3">
<ImageIcon size={14}/> Imagem de Capa
</label>
<input
type="file"
hidden
ref={fileInputRef}
accept="image/*"
onChange={handleFileChange}
/>
<div
onClick={triggerUpload}
className={`border-2 border-dashed rounded-xl p-8 flex flex-col items-center justify-center transition-all cursor-pointer group
${finalCrops ? 'border-emerald-200 bg-emerald-50/30' : 'border-slate-100 bg-slate-50/50 hover:bg-slate-50'}`}
>
{finalCrops ? (
<div className="text-center">
<div className="w-12 h-12 bg-emerald-100 text-emerald-600 rounded-full flex items-center justify-center mx-auto mb-2">
<ImageIcon size={24}/>
</div>
<p className="text-[10px] font-bold text-emerald-700 uppercase">3 Formatos Gerados</p>
<p className="text-[9px] text-emerald-500 mt-1">Clique para alterar</p>
</div>
) : (
<>
<div className="w-10 h-10 bg-white rounded-full shadow-sm flex items-center justify-center text-blue-500 mb-2 group-hover:scale-110 transition-transform">
<ImageIcon size={20}/>
</div>
<p className="text-[10px] font-medium text-slate-500 text-center">Clique para carregar e enquadrar</p>
<p className="text-[9px] text-slate-400">Suporta JPG, PNG</p>
</>
)}
</div>
</div>
<div>
<label className="flex items-center gap-2 text-xs font-bold uppercase text-slate-400 mb-3">
<Calendar size={14}/> Agendamento
</label>
<div className="grid grid-cols-2 gap-2">
<input type="date" className="bg-slate-50 border border-slate-100 rounded-lg px-2 py-2 text-[11px] outline-none" />
<input type="time" className="bg-slate-50 border border-slate-100 rounded-lg px-2 py-2 text-[11px] outline-none" />
</div>
</div>
</section>
<button className="w-full py-4 rounded-2xl border border-slate-200 bg-white text-sm font-bold flex items-center justify-center gap-2 hover:bg-slate-50 transition-all text-slate-600">
<Eye size={18}/> Pré-visualizar Notícia
</button>
</div>
</div>
{/* --- MODAL DO EDITOR (Renderiza fora do fluxo quando ativo) --- */}
{isEditorOpen && tempImage && (
<MultiAspectEditor
image={tempImage}
onClose={() => setIsEditorOpen(false)}
onExport={(data) => {
setFinalCrops(data); // Aqui tens o objeto com hero, news, square em Base64
setIsEditorOpen(false);
console.log("Imagens prontas para envio:", data);
}}
/>
)}
</div>
);
};
export default CreateNewsPage;
+7
View File
@@ -0,0 +1,7 @@
import CreateNewsPage from './create-news';
const CreateNews = () => {
return <CreateNewsPage />;
};
export default CreateNews;
+204
View File
@@ -0,0 +1,204 @@
import React from 'react';
import {
Newspaper,
TrendingUp,
Clock,
AlertCircle,
ChevronRight,
Edit3,
Trash2,
} from 'lucide-react';
const DashboardMain = () => {
return (
<div className="p-8 space-y-8 min-h-full bg-[#F1F5F9]">
<div className="grid grid-cols-4 gap-6">
<StatCard
label="Artigos Publicados"
value="14,352"
trend="+12% este mês"
icon={<Newspaper size={20} className="text-blue-600" />}
/>
<StatCard
label="Visualizações Totais"
value="2.1M"
trend="+5.4% vs ontem"
icon={<TrendingUp size={20} className="text-emerald-600" />}
/>
<StatCard
label="Tempo Médio"
value="4m 32s"
trend="-2s média"
icon={<Clock size={20} className="text-orange-600" />}
/>
<StatCard
label="Alertas Ativos"
value="3"
trend="Urgente"
icon={<AlertCircle size={20} className="text-red-600" />}
isAlert
/>
</div>
<div className="grid grid-cols-3 gap-8">
<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">
<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">
Ver Todos <ChevronRight size={14} />
</button>
</div>
<table className="w-full text-left border-collapse">
<thead className="bg-slate-50 text-[10px] uppercase font-bold text-slate-400">
<tr>
<th className="px-6 py-3">Título</th>
<th className="px-6 py-3">Estado</th>
<th className="px-6 py-3">Autor</th>
<th className="px-6 py-3">Visualizações</th>
<th className="px-6 py-3">Ações</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-50">
<TableRow
title="Volatilidade do Mercado: Impactos na Economia"
status="Publicado"
author="James Wilson"
views="1.2M"
/>
<TableRow
title="O Futuro da Inteligência Artificial em 2026"
status="Rascunho"
author="Sarah Johnson"
views="--"
/>
<TableRow
title="Novas Políticas de Sustentabilidade na UE"
status="Agendado"
author="Ricardo Silva"
views="0"
/>
</tbody>
</table>
</div>
<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>
<div className="space-y-6">
<CategoryBar label="Tecnologia" percentage={85} color="bg-blue-500" />
<CategoryBar label="Negócios" percentage={62} color="bg-emerald-500" />
<CategoryBar label="Política" percentage={45} color="bg-purple-500" />
<CategoryBar label="Desporto" percentage={30} color="bg-orange-500" />
</div>
<div className="mt-8 p-4 bg-slate-50 rounded-xl">
<p className="text-[11px] text-slate-500 text-center">
O tráfego orgânico cresceu <strong>15%</strong> nos últimos 7 dias.
</p>
</div>
</div>
</div>
</div>
);
};
const StatCard = ({
label,
value,
trend,
icon,
isAlert = false,
}: {
label: string;
value: string;
trend: string;
icon: React.ReactNode;
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="flex justify-between items-start mb-4">
<div className={`p-2 rounded-lg ${isAlert ? 'bg-red-100' : 'bg-slate-50'}`}>{icon}</div>
<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'
}`}
>
{trend}
</span>
</div>
<p className="text-xs text-slate-500 font-medium">{label}</p>
<h3 className="text-2xl font-bold tracking-tight">{value}</h3>
</div>
);
const TableRow = ({
title,
status,
author,
views,
}: {
title: string;
status: string;
author: string;
views: string;
}) => (
<tr className="hover:bg-slate-50/50 transition-colors group">
<td className="px-6 py-4">
<p className="text-sm font-semibold text-slate-800 truncate max-w-[200px]">{title}</p>
</td>
<td className="px-6 py-4">
<span
className={`text-[10px] font-bold px-2 py-1 rounded-md ${
status === 'Publicado'
? 'bg-emerald-50 text-emerald-600'
: status === 'Rascunho'
? 'bg-slate-100 text-slate-500'
: 'bg-blue-50 text-blue-600'
}`}
>
{status}
</span>
</td>
<td className="px-6 py-4 text-xs text-slate-600">{author}</td>
<td className="px-6 py-4 text-xs font-mono font-medium">{views}</td>
<td className="px-6 py-4">
<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-red-600 shadow-sm">
<Trash2 size={14} />
</button>
</div>
</td>
</tr>
);
const CategoryBar = ({
label,
percentage,
color,
}: {
label: string;
percentage: number;
color: string;
}) => (
<div className="space-y-2">
<div className="flex justify-between text-[11px] font-bold uppercase tracking-wide">
<span>{label}</span>
<span className="text-slate-400">{percentage}%</span>
</div>
<div className="w-full h-1.5 bg-slate-100 rounded-full overflow-hidden">
<div className={`h-full ${color} rounded-full`} style={{ width: `${percentage}%` }} />
</div>
</div>
);
export default DashboardMain;
+15
View File
@@ -0,0 +1,15 @@
import { AdminHeaderPage } from '@/app/components/layout/admin/header';
import { AdminSideBar } from '@/app/components/layout/admin/sidebar';
export default function AdminLayout({ children }: { children: React.ReactNode }) {
return (
<div className="flex h-screen bg-slate-100/50 text-slate-900 font-sans overflow-hidden">
<AdminSideBar />
<main className="flex-1 min-w-0 min-h-0 overflow-y-auto flex flex-col">
<AdminHeaderPage />
<div className="flex-1 min-h-0">{children}</div>
</main>
</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>
)}
</>
);
}
@@ -0,0 +1,5 @@
import ManageCategoryClient from './manage-category-client';
export default function ManageCategoryPage() {
return <ManageCategoryClient />;
}
+5
View File
@@ -0,0 +1,5 @@
import PanelEditor from './panel-editor';
export default function PanelPage() {
return <PanelEditor />;
}
@@ -8,7 +8,7 @@ const RATIOS = [
{ label: "Profile / Post (Square)", value: 1 / 1, text: "1/1" }, { label: "Profile / Post (Square)", value: 1 / 1, text: "1/1" },
]; ];
export default function FullPageEditor() { export default function PanelEditor() {
const [image, setImage] = useState<string | null>(null); const [image, setImage] = useState<string | null>(null);
const [crops, setCrops] = useState<Record<string, any>>( const [crops, setCrops] = useState<Record<string, any>>(
RATIOS.reduce((acc, r) => ({ ...acc, [r.text]: { x: 0, y: 0, zoom: 1 } }), {}) RATIOS.reduce((acc, r) => ({ ...acc, [r.text]: { x: 0, y: 0, zoom: 1 } }), {})
@@ -37,21 +37,22 @@ export default function FullPageEditor() {
}; };
return ( return (
<div className="min-h-screen bg-zinc-950 text-zinc-100 font-sans selection:bg-indigo-500"> <div className="min-h-full bg-zinc-950 text-zinc-100 font-sans selection:bg-indigo-500 flex flex-col">
{/* 1. Global Navigation */} <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">
<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 items-center gap-4">
<div className="bg-indigo-600 p-2 rounded-lg font-black text-xs">FIX</div> <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> <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>
<div className="flex gap-4"> <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"> <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"} {image ? "New Photo" : "Upload Source"}
<input type="file" accept="image/*" className="hidden" onChange={onSelectFile} /> <input type="file" accept="image/*" className="hidden" onChange={onSelectFile} />
</label> </label>
{image && ( {image && (
<button <button
onClick={handleExport} 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" 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"
> >
@@ -59,61 +60,81 @@ export default function FullPageEditor() {
</button> </button>
)} )}
</div> </div>
</nav> </div>
{/* 2. Workspace Area */} <main className="max-w-screen-xl mx-auto p-8 space-y-20 flex-1 overflow-y-auto">
<main className="max-w-screen-xl mx-auto p-8 space-y-20">
{!image ? ( {!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="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> <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> <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> <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>
) : ( ) : (
<div className="space-y-32 pb-40"> <div className="space-y-32 pb-40">
{RATIOS.map((ratio) => ( {RATIOS.map((ratio) => (
<section key={ratio.text} className="group"> <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 className="flex items-end justify-between mb-6 border-b border-zinc-800 pb-4">
<div> <div>
<span className="text-indigo-500 text-xs font-black uppercase tracking-[0.2em]">Aspect Ratio</span> <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> <h3 className="text-3xl font-bold mt-1">{ratio.label}</h3>
</div> </div>
<div className="text-right"> <div className="text-right">
<p className="text-zinc-500 text-xs font-mono uppercase">Current Zoom</p> <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> <p className="text-2xl font-black">
{Math.round((crops[ratio.text]?.zoom || 1) * 100)}%
</p>
</div> </div>
</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]"> <div className="relative w-full overflow-hidden rounded-[2rem] bg-zinc-900 shadow-2xl ring-1 ring-zinc-800 h-[600px]">
<Cropper <Cropper
image={image} image={image}
crop={{ x: crops[ratio.text].x, y: crops[ratio.text].y }} crop={{ x: crops[ratio.text].x, y: crops[ratio.text].y }}
zoom={crops[ratio.text].zoom} zoom={crops[ratio.text].zoom}
aspect={ratio.value} aspect={ratio.value}
onCropChange={(c) => setCrops(prev => ({ ...prev, [ratio.text]: { ...prev[ratio.text], ...c } }))} onCropChange={(c) =>
onZoomChange={(z) => setCrops(prev => ({ ...prev, [ratio.text]: { ...prev[ratio.text], zoom: z } }))} setCrops((prev) => ({ ...prev, [ratio.text]: { ...prev[ratio.text], ...c } }))
onCropComplete={(_, px) => setCompletedCrops(prev => ({ ...prev, [ratio.text]: px }))} }
onZoomChange={(z) =>
setCrops((prev) => ({ ...prev, [ratio.text]: { ...prev[ratio.text], zoom: z } }))
}
onCropComplete={(_, px) =>
setCompletedCrops((prev) => ({ ...prev, [ratio.text]: px }))
}
objectFit="cover" objectFit="cover"
showGrid={true} showGrid={true}
/> />
</div> </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"> <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> <span className="text-xs font-bold text-zinc-500 uppercase tracking-widest min-w-[80px]">
Adjust Zoom
</span>
<input <input
type="range" type="range"
min={1} min={1}
max={3} max={3}
step={0.01} step={0.01}
value={crops[ratio.text].zoom} value={crops[ratio.text].zoom}
onChange={(e) => setCrops(prev => ({ ...prev, [ratio.text]: { ...prev[ratio.text], zoom: Number(e.target.value) } }))} 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" className="flex-1 accent-indigo-500 h-2 bg-zinc-800 rounded-lg appearance-none cursor-pointer"
/> />
<button <button
onClick={() => setCrops(prev => ({ ...prev, [ratio.text]: { x:0, y:0, zoom: 1 } }))} 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" className="text-[10px] font-black uppercase tracking-widest text-zinc-600 hover:text-white"
> >
Reset Reset
@@ -125,29 +146,36 @@ export default function FullPageEditor() {
)} )}
</main> </main>
{/* 3. Global Results Modal/Area */}
{Object.keys(results).length > 0 && ( {Object.keys(results).length > 0 && (
<div className="fixed inset-0 z-[100] bg-zinc-950 flex flex-col"> <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"> <header className="p-8 border-b border-zinc-800 flex justify-between items-center">
<h2 className="text-2xl font-black">EXPORT BUNDLE</h2> <h2 className="text-2xl font-black">EXPORT BUNDLE</h2>
<button onClick={() => setResults({})} className="text-zinc-500 hover:text-white font-bold">Close Editor</button> <button
onClick={() => setResults({})}
className="text-zinc-500 hover:text-white font-bold"
>
Close Editor
</button>
</header> </header>
<div className="flex-1 overflow-y-auto p-8 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8"> <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]) => ( {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 key={ratio} className="bg-zinc-900 rounded-3xl p-6 space-y-4 border border-zinc-800">
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<span className="text-xs font-bold text-indigo-500 uppercase">{ratio} Result</span> <span className="text-xs font-bold text-indigo-500 uppercase">{ratio} Result</span>
<button <button
onClick={() => {navigator.clipboard.writeText(b64); alert('Copied!');}} onClick={() => {
void navigator.clipboard.writeText(b64);
alert('Copied!');
}}
className="text-[10px] bg-indigo-600 px-3 py-1 rounded-full font-bold" className="text-[10px] bg-indigo-600 px-3 py-1 rounded-full font-bold"
> >
Copy Base64 Copy Base64
</button> </button>
</div> </div>
<img src={b64} className="w-full rounded-xl border border-white/5 shadow-lg" alt="Crop preview" /> <img src={b64} className="w-full rounded-xl border border-white/5 shadow-lg" alt="Crop preview" />
<textarea <textarea
readOnly readOnly
value={b64} 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" 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>
@@ -159,7 +187,6 @@ export default function FullPageEditor() {
); );
} }
/* ---------------- CANVAS EXPORT UTILITY ---------------- */
async function getCroppedImg(imageSrc: string, pixelCrop: any): Promise<string> { async function getCroppedImg(imageSrc: string, pixelCrop: any): Promise<string> {
const image = new Image(); const image = new Image();
image.src = imageSrc; image.src = imageSrc;
@@ -185,4 +212,4 @@ async function getCroppedImg(imageSrc: string, pixelCrop: any): Promise<string>
); );
return canvas.toDataURL("image/jpeg", 0.9); return canvas.toDataURL("image/jpeg", 0.9);
} }
@@ -2,9 +2,9 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import Image from 'next/image'; import Image from 'next/image';
import { ThumbsUp, MessageCircle, Send, Share2, LinkIcon, Link2, ChevronRight } from 'lucide-react'; import { ThumbsUp, MessageCircle, Send, Share2, LinkIcon, Link2, ChevronRight } from 'lucide-react';
import { TvoneAdBanner, TvoneFooter } from '../components/tvone-content'; import { TvoneAdBanner, TvoneFooter } from '../../components/tvone-content';
import { TvonePromoStrip } from '../components/tvone-promo-strip'; import { TvonePromoStrip } from '../../components/tvone-promo-strip';
import { TvoneSiteNav } from '../components/tvone-site-nav'; import { TvoneSiteNav } from '../../components/tvone-site-nav';
import { SiWhatsapp, SiFacebook, SiX } from 'react-icons/si'; // Ícones oficiais de marca import { SiWhatsapp, SiFacebook, SiX } from 'react-icons/si'; // Ícones oficiais de marca
import { FiCopy } from 'react-icons/fi'; // Ícone de cópia mais limpo import { FiCopy } from 'react-icons/fi'; // Ícone de cópia mais limpo
import Link from 'next/link'; import Link from 'next/link';
-364
View File
@@ -1,364 +0,0 @@
"use client";
import React, { useState, useRef, useEffect } from 'react';
import Image from 'next/image';
import {
LayoutDashboard, Newspaper, Users, BarChart3,
Settings, HelpCircle, Image as ImageIcon,
Type, Calendar, Clock, Tag, User, Save, Eye, Send
} from 'lucide-react';
import Keycloak from "keycloak-js";
// Importe o componente que criámos (ajuste o caminho se necessário)
import MultiAspectEditor from '../../components/MultiAspectEditor';
import dynamic from "next/dynamic";
const Editor = dynamic(
() => import("@tinymce/tinymce-react").then((mod) => mod.Editor),
{ ssr: false }
);
const keycloak = new Keycloak({
url: "https://keycloak.petermaquiran.xyz",
realm: "tvone", // ✅ IMPORTANT
clientId: "tvone-web", // must match Keycloak client
});
interface GoogleAuthResponse {
access_token: string;
token_type: string;
expires_in: number;
scope: string;
authuser?: string;
prompt?: string;
}
interface KeycloakTokenResponse {
access_token: string;
expires_in: number;
refresh_expires_in: number;
refresh_token: string;
token_type: string;
id_token?: string;
"not-before-policy": number;
session_state: string;
scope: string;
}
const CreateNewsPage = () => {
// Configuração do Design do Editor para combinar com seu layout
const editorConfig = {
height: 500,
menubar: false,
plugins: [
'advlist', 'autolink', 'lists', 'link', 'image', 'charmap', 'preview',
'anchor', 'searchreplace', 'visualblocks', 'code', 'fullscreen',
'insertdatetime', 'media', 'table', 'code', 'help', 'wordcount'
],
// Adicionei 'blockquote' na toolbar
toolbar: 'undo redo | blocks | ' +
'bold italic forecolor | alignleft aligncenter ' +
'alignright alignjustify | bullist numlist | ' +
'blockquote link image | removeformat | help',
// Customização do aspeto da citação dentro do editor
content_style: `
body {
font-family: Inter, Helvetica, Arial, sans-serif;
font-size: 14px;
color: #334155;
line-height: 1.6;
}
blockquote {
border-left: 4px solid #2563eb; /* Azul da TVone */
padding-left: 1.5rem;
color: #475569;
font-style: italic;
margin: 1.5rem 0;
background: #f8fafc;
padding: 1rem 1.5rem;
border-radius: 0 8px 8px 0;
}
`,
skin: 'oxide',
promotion: false, // Remove o botão "Upgrade" do Tiny
branding: false, // Remove o "Powered by Tiny"
};
// 1. Estados para o Crop
const [tempImage, setTempImage] = useState<string | null>(null);
const [isEditorOpen, setIsEditorOpen] = useState(false);
const [finalCrops, setFinalCrops] = useState<any>(null); // Guarda os Base64 finais
const [content, setContent] = useState('');
const [user, setUser] = useState<{
email?: string;
name?: string;
picture?: string;
} | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
// 2. Lógica de Upload
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) {
const reader = new FileReader();
reader.readAsDataURL(e.target.files[0]);
reader.onload = () => {
setTempImage(reader.result as string);
setIsEditorOpen(true);
};
}
};
const triggerUpload = () => {
fileInputRef.current?.click();
};
// Avoid hydration mismatch by waiting for mount
useEffect(() => {
keycloak.init({
onLoad: "check-sso",
pkceMethod: "S256",
}).then(async (authenticated) => {
if (authenticated) {
const token = keycloak.token!;
localStorage.setItem("token", token);
const res = await fetch("http://localhost:3001/profile/", {
headers: {
Authorization: `Bearer ${token}`,
},
});
const profile = await res.json();
setUser(profile);
console.log("Profile:", profile);
}
});
}, []);
return (
<div className="flex h-screen bg-slate-100/50 text-slate-900 font-sans">
{/* --- MODAL DO EDITOR (Renderiza fora do fluxo quando ativo) --- */}
{isEditorOpen && tempImage && (
<MultiAspectEditor
image={tempImage}
onClose={() => setIsEditorOpen(false)}
onExport={(data) => {
setFinalCrops(data); // Aqui tens o objeto com hero, news, square em Base64
setIsEditorOpen(false);
console.log("Imagens prontas para envio:", data);
}}
/>
)}
{/* 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 Content Area */}
<main className="flex-1 overflow-y-auto">
<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..."
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"
/>
</div>
<div className="flex items-center gap-4">
<div className="text-right">
<p className="text-xs font-bold">{user?.name ?? "Loading..."}</p>
<p className="text-[10px] text-slate-500">Editor</p>
</div>
<div className="w-8 h-8 bg-slate-300 rounded-full border border-white shadow-sm overflow-hidden">
<img
src={user?.picture ?? "https://ui-avatars.com/api/?name=User"}
alt="User"
/>
</div>
</div>
</header>
<div className="p-8 max-w-6xl mx-auto">
<div className="flex justify-between items-center mb-8">
<h1 className="text-2xl font-bold tracking-tight">Criar Nova Notícia</h1>
<div className="flex gap-3">
<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">
<Send size={16}/> Publicar Artigo
</button>
</div>
</div>
<div className="grid grid-cols-3 gap-8">
{/* Coluna Principal */}
<div className="col-span-2 space-y-6">
<section className="bg-white rounded-2xl border border-slate-200 shadow-sm overflow-hidden">
{/* Título */}
<div className="p-6 pb-0">
<label className="block text-xs font-bold uppercase text-slate-400 mb-2">Título do Artigo</label>
<input
type="text"
placeholder="Insira o título principal da notícia..."
className="w-full text-xl font-bold border-none focus:ring-0 placeholder:text-slate-300 p-0"
/>
<hr className="my-6 border-slate-100" />
<label className="block text-xs font-bold uppercase text-slate-400 mb-2">Conteúdo Principal</label>
</div>
{/* TinyMCE Editor */}
<div className="border-t border-slate-50">
<Editor
apiKey='dmg1hghyf25x09mtg04hik0034yeadt1h6ai2ou68zhdvw11' // Obtenha em tiny.cloud ou use 'no-api-key' para teste
init={editorConfig}
value={content}
onEditorChange={(newContent) => setContent(newContent)}
/>
</div>
</section>
{/* 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>
{/* Tags */}
<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">Tags</label>
<input type="text" placeholder="Insira as tags da notícia..." className="w-full border-none focus:ring-0 resize-none text-slate-600 text-sm italic p-0" />
</div>
</div>
{/* Coluna Lateral */}
<div className="space-y-6">
<section className="bg-white rounded-2xl p-6 border border-slate-200 shadow-sm space-y-6">
<div>
<label className="flex items-center gap-2 text-xs font-bold uppercase text-slate-400 mb-3">
<Tag size={14}/> Categoria
</label>
<select className="w-full bg-slate-50 border border-slate-100 rounded-lg px-3 py-2 text-sm outline-none">
<option>Negócios</option>
<option>Tecnologia</option>
<option>Desporto</option>
</select>
</div>
{/* --- INPUT DE UPLOAD ATUALIZADO --- */}
<div>
<label className="flex items-center gap-2 text-xs font-bold uppercase text-slate-400 mb-3">
<ImageIcon size={14}/> Imagem de Capa
</label>
<input
type="file"
hidden
ref={fileInputRef}
accept="image/*"
onChange={handleFileChange}
/>
<div
onClick={triggerUpload}
className={`border-2 border-dashed rounded-xl p-8 flex flex-col items-center justify-center transition-all cursor-pointer group
${finalCrops ? 'border-emerald-200 bg-emerald-50/30' : 'border-slate-100 bg-slate-50/50 hover:bg-slate-50'}`}
>
{finalCrops ? (
<div className="text-center">
<div className="w-12 h-12 bg-emerald-100 text-emerald-600 rounded-full flex items-center justify-center mx-auto mb-2">
<ImageIcon size={24}/>
</div>
<p className="text-[10px] font-bold text-emerald-700 uppercase">3 Formatos Gerados</p>
<p className="text-[9px] text-emerald-500 mt-1">Clique para alterar</p>
</div>
) : (
<>
<div className="w-10 h-10 bg-white rounded-full shadow-sm flex items-center justify-center text-blue-500 mb-2 group-hover:scale-110 transition-transform">
<ImageIcon size={20}/>
</div>
<p className="text-[10px] font-medium text-slate-500 text-center">Clique para carregar e enquadrar</p>
<p className="text-[9px] text-slate-400">Suporta JPG, PNG</p>
</>
)}
</div>
</div>
<div>
<label className="flex items-center gap-2 text-xs font-bold uppercase text-slate-400 mb-3">
<Calendar size={14}/> Agendamento
</label>
<div className="grid grid-cols-2 gap-2">
<input type="date" className="bg-slate-50 border border-slate-100 rounded-lg px-2 py-2 text-[11px] outline-none" />
<input type="time" className="bg-slate-50 border border-slate-100 rounded-lg px-2 py-2 text-[11px] outline-none" />
</div>
</div>
</section>
<button className="w-full py-4 rounded-2xl border border-slate-200 bg-white text-sm font-bold flex items-center justify-center gap-2 hover:bg-slate-50 transition-all text-slate-600">
<Eye size={18}/> Pré-visualizar Notícia
</button>
</div>
</div>
</div>
</main>
</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>
);
export default CreateNewsPage;
-338
View File
@@ -1,338 +0,0 @@
import React from 'react';
import {
LayoutDashboard, Newspaper, Users, BarChart3,
Settings, HelpCircle, TrendingUp, Eye, Clock,
AlertCircle, ChevronRight, Edit3, Trash2, ExternalLink
} from 'lucide-react';
import Image from 'next/image';
const DashboardMain = () => {
return (
<div className="flex h-screen bg-[#F1F5F9] text-slate-900 font-sans">
{/* 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">
<NavItem icon={<LayoutDashboard size={18}/>} label="Painel Principal" active />
<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="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">
<StatCard
label="Artigos Publicados"
value="14,352"
trend="+12% este mês"
icon={<Newspaper size={20} className="text-blue-600"/>}
/>
<StatCard
label="Visualizações Totais"
value="2.1M"
trend="+5.4% vs ontem"
icon={<TrendingUp size={20} className="text-emerald-600"/>}
/>
<StatCard
label="Tempo Médio"
value="4m 32s"
trend="-2s média"
icon={<Clock size={20} className="text-orange-600"/>}
/>
<StatCard
label="Alertas Ativos"
value="3"
trend="Urgente"
icon={<AlertCircle size={20} className="text-red-600"/>}
isAlert
/>
</div>
<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="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>
<button className="text-blue-600 text-xs font-bold flex items-center gap-1 hover:underline">
Ver Todos <ChevronRight size={14}/>
</button>
</div>
<table className="w-full text-left border-collapse">
<thead className="bg-slate-50 text-[10px] uppercase font-bold text-slate-400">
<tr>
<th className="px-6 py-3">Título</th>
<th className="px-6 py-3">Estado</th>
<th className="px-6 py-3">Autor</th>
<th className="px-6 py-3">Visualizações</th>
<th className="px-6 py-3">Ações</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-50">
<TableRow
title="Volatilidade do Mercado: Impactos na Economia"
status="Publicado"
author="James Wilson"
views="1.2M"
/>
<TableRow
title="O Futuro da Inteligência Artificial em 2026"
status="Rascunho"
author="Sarah Johnson"
views="--"
/>
<TableRow
title="Novas Políticas de Sustentabilidade na UE"
status="Agendado"
author="Ricardo Silva"
views="0"
/>
</tbody>
</table>
</div>
{/* Performance por Categoria (Coluna Única) */}
<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>
<div className="space-y-6">
<CategoryBar label="Tecnologia" percentage={85} color="bg-blue-500" />
<CategoryBar label="Negócios" percentage={62} color="bg-emerald-500" />
<CategoryBar label="Política" percentage={45} color="bg-purple-500" />
<CategoryBar label="Desporto" percentage={30} color="bg-orange-500" />
</div>
<div className="mt-8 p-4 bg-slate-50 rounded-xl">
<p className="text-[11px] text-slate-500 text-center">
O tráfego orgânico cresceu <strong>15%</strong> nos últimos 7 dias.
</p>
</div>
</div>
</div>
</div>
</main>
</div>
);
};
// --- Subcomponentes para Organização ---
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-lg shadow-blue-200' : 'text-slate-500 hover:bg-slate-100'
}`}>
{icon}
<span className="truncate">{label}</span>
</div>
);
const StatCard = ({ label, value, trend, icon, isAlert = false }: any) => (
<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={`p-2 rounded-lg ${isAlert ? 'bg-red-100' : 'bg-slate-50'}`}>
{icon}
</div>
<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'}`}>
{trend}
</span>
</div>
<p className="text-xs text-slate-500 font-medium">{label}</p>
<h3 className="text-2xl font-bold tracking-tight">{value}</h3>
</div>
);
const TableRow = ({ title, status, author, views }: any) => (
<tr className="hover:bg-slate-50/50 transition-colors group">
<td className="px-6 py-4">
<p className="text-sm font-semibold text-slate-800 truncate max-w-[200px]">{title}</p>
</td>
<td className="px-6 py-4">
<span className={`text-[10px] font-bold px-2 py-1 rounded-md ${
status === 'Publicado' ? 'bg-emerald-50 text-emerald-600' :
status === 'Rascunho' ? 'bg-slate-100 text-slate-500' : 'bg-blue-50 text-blue-600'
}`}>
{status}
</span>
</td>
<td className="px-6 py-4 text-xs text-slate-600">{author}</td>
<td className="px-6 py-4 text-xs font-mono font-medium">{views}</td>
<td className="px-6 py-4">
<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-red-600 shadow-sm"><Trash2 size={14}/></button>
</div>
</td>
</tr>
);
const CategoryBar = ({ label, percentage, color }: any) => (
<div className="space-y-2">
<div className="flex justify-between text-[11px] font-bold uppercase tracking-wide">
<span>{label}</span>
<span className="text-slate-400">{percentage}%</span>
</div>
<div className="w-full h-1.5 bg-slate-100 rounded-full overflow-hidden">
<div className={`h-full ${color} rounded-full`} style={{ width: `${percentage}%` }} />
</div>
</div>
);
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>
);
+3 -90
View File
@@ -1,26 +1,9 @@
"use client"; "use client";
import Image from 'next/image'; import Image from 'next/image';
import React, { useState, useEffect } from "react"; import { useState } from "react";
import { useGoogleLogin } from "@react-oauth/google"; import { useGoogleLogin } from "@react-oauth/google";
import { useTheme } from 'next-themes'; import { useTheme } from 'next-themes';
import { Sun, Moon } from 'lucide-react'; // Optional: install lucide-react for clean icons import { Sun, Moon } from 'lucide-react';
import Keycloak from "keycloak-js";
const keycloak = new Keycloak({
url: "https://keycloak.petermaquiran.xyz",
realm: "tvone", // ✅ IMPORTANT
clientId: "tvone-web", // must match Keycloak client
});
interface GoogleAuthResponse {
access_token: string;
token_type: string;
expires_in: number;
scope: string;
authuser?: string;
prompt?: string;
}
interface KeycloakTokenResponse { interface KeycloakTokenResponse {
access_token: string; access_token: string;
@@ -40,71 +23,6 @@ export default function AppleStyleAuth() {
const { theme, setTheme } = useTheme(); const { theme, setTheme } = useTheme();
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
// Avoid hydration mismatch by waiting for mount
useEffect(() => {
keycloak.init({
onLoad: "check-sso", // or "login-required"
pkceMethod: "S256",
}).then((authenticated) => {
if (authenticated) {
localStorage.setItem("token", keycloak.token!);
console.log("Logged in", keycloak.token);
localStorage.setItem("token", keycloak.token as string);
fetch("http://localhost:3001/profile/", {
headers: {
Authorization: `Bearer ${localStorage.getItem("token")}`,
},
});
}
});
setMounted(true);
}, []);
const handleExchange = async (googleResponse: GoogleAuthResponse): Promise<void> => {
try {
const details: Record<string, string> = {
grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange',
client_id: 'tvone-web', // Replace with your actual Keycloak Client ID
subject_token: googleResponse.access_token,
subject_token_type: 'urn:ietf:params:oauth:token-type:access_token',
subject_issuer: 'google', // Ensure this matches your Keycloak IdP Alias
};
const formBody = new URLSearchParams(details).toString();
const response = await fetch(
'https://keycloak.petermaquiran.xyz/realms/tvone/protocol/openid-connect/token',
{
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: formBody,
}
);
if (!response.ok) {
throw new Error(`Keycloak exchange failed: ${response.statusText}`);
}
const data: KeycloakTokenResponse = await response.json();
// Store the Keycloak token to send to your NestJS API
localStorage.setItem("token", data.access_token);
console.log("Authenticated with Keycloak:", data.access_token);
// Redirect user or update Global Auth State here
} catch (error) {
console.error("Authentication Flow Error:", error);
}
};
const googleLogin = useGoogleLogin({
onSuccess: (res) => {
handleExchange(res)
console.log("Google Success", res)
},
onError: () => console.log("Google Failed"),
});
const handleManualLogin = async (): Promise<void> => { const handleManualLogin = async (): Promise<void> => {
const details: Record<string, string> = { const details: Record<string, string> = {
@@ -134,7 +52,6 @@ export default function AppleStyleAuth() {
} }
}; };
if (!mounted) return null;
return ( return (
<div className="relative flex min-h-screen items-center justify-center bg-[#f5f5f7] px-4 py-10 transition-colors duration-500 dark:bg-black"> <div className="relative flex min-h-screen items-center justify-center bg-[#f5f5f7] px-4 py-10 transition-colors duration-500 dark:bg-black">
@@ -197,11 +114,7 @@ export default function AppleStyleAuth() {
{/* 4. BOTÃO GOOGLE */} {/* 4. BOTÃO GOOGLE */}
<button <button
onClick={() => onClick={() => (window.location.href = "/api/auth/login")}
keycloak.login({
redirectUri: `${window.location.origin}/create-news`,
})
}
className="group flex w-full items-center justify-center gap-3 rounded-2xl border border-neutral-200 bg-white px-6 py-3.5 transition-all hover:bg-neutral-50 active:scale-[0.98] dark:border-neutral-800 dark:bg-transparent dark:hover:bg-neutral-900" className="group flex w-full items-center justify-center gap-3 rounded-2xl border border-neutral-200 bg-white px-6 py-3.5 transition-all hover:bg-neutral-50 active:scale-[0.98] dark:border-neutral-800 dark:bg-transparent dark:hover:bg-neutral-900"
> >
<svg className="h-5 w-5" viewBox="0 0 24 24"> <svg className="h-5 w-5" viewBox="0 0 24 24">
-343
View File
@@ -1,343 +0,0 @@
"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>
);
+59
View File
@@ -0,0 +1,59 @@
import { getCookieDomain } from "@/lib/getDomain";
import { env } from "@/lib/env";
import { NextResponse } from "next/server";
const BASE_URL = env.APP_URL;
export async function GET(req: Request) {
const url = new URL(req.url);
const code = url.searchParams.get("code");
const isHttps = url.protocol === "https:";
if (!code) {
return NextResponse.redirect(`${BASE_URL}/login?error=missing_code`);
}
const redirectUri = `${BASE_URL}/api/auth/callback`;
const tokenRes = await fetch(
`${env.KEYCLOAK_BASE_URL}/realms/${env.KEYCLOAK_REALM}/protocol/openid-connect/token`,
{
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
client_id: env.KEYCLOAK_CLIENT_ID,
client_secret: env.KEYCLOAK_CLIENT_SECRET,
grant_type: "authorization_code",
code,
redirect_uri: redirectUri,
}),
}
);
const text = await tokenRes.text();
let data: { access_token?: string; expires_in?: number };
try {
data = JSON.parse(text) as typeof data;
} catch {
return NextResponse.redirect(`${BASE_URL}/login?error=token_parse`);
}
if (!tokenRes.ok || !data.access_token) {
console.error("token exchange failed", tokenRes.status, text);
return NextResponse.redirect(`${BASE_URL}/login?error=token_exchange`);
}
const res = NextResponse.redirect(`${BASE_URL}/admin/create-news`);
// Secure cookies are ignored on http:// (e.g. localhost) — browser drops them.
res.cookies.set("access_token", data.access_token, {
httpOnly: true,
secure: isHttps,
sameSite: isHttps ? "none" : "lax",
path: "/",
...(env.COOKIE_DOMAIN ? { domain: env.COOKIE_DOMAIN } : {}),
maxAge: data.expires_in,
});
return res;
}
+19
View File
@@ -0,0 +1,19 @@
import { env } from "@/lib/env";
const BASE_URL = env.APP_URL;
export async function GET() {
const redirect = encodeURIComponent(
`${BASE_URL}/api/auth/callback`
);
const keycloakUrl =
`${env.KEYCLOAK_BASE_URL}/realms/${env.KEYCLOAK_REALM}/protocol/openid-connect/auth` +
`?client_id=${encodeURIComponent(env.KEYCLOAK_CLIENT_ID)}` +
`&response_type=code` +
`&scope=openid` +
`&redirect_uri=${redirect}`;
return Response.redirect(keycloakUrl);
}
+57
View File
@@ -0,0 +1,57 @@
import { NextResponse } from "next/server";
import { env } from "@/lib/env";
export async function GET(req: Request) {
const isHttps = new URL(req.url).protocol === "https:";
const cookie = req.headers.get("cookie");
const refreshToken = cookie
?.split("; ")
.find((c) => c.startsWith("refresh_token="))
?.split("=")[1];
if (!refreshToken) {
return NextResponse.json({ message: "No refresh token" }, { status: 401 });
}
try {
const res = await fetch(`${env.API_URL}/auth/refresh`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ refresh_token: refreshToken }),
});
if (!res.ok) {
throw new Error("Refresh failed");
}
const data = await res.json();
const response = NextResponse.json({ success: true });
// 🍪 Set new access token
response.cookies.set("access_token", data.access_token, {
httpOnly: true,
secure: isHttps,
sameSite: isHttps ? "none" : "lax",
path: "/",
...(env.COOKIE_DOMAIN ? { domain: env.COOKIE_DOMAIN } : {}),
maxAge: data.expires_in,
});
response.cookies.set("refresh_token", data.refresh_token, {
httpOnly: true,
secure: isHttps,
sameSite: isHttps ? "none" : "lax",
path: "/",
...(env.COOKIE_DOMAIN ? { domain: env.COOKIE_DOMAIN } : {}),
maxAge: data.expires_in,
});
return response;
} catch {
return NextResponse.json({ message: "Refresh failed" }, { status: 401 });
}
}
+18
View File
@@ -0,0 +1,18 @@
export async function GET(req: Request) {
const url = new URL(req.url);
const origin = url.origin;
const redirect = encodeURIComponent(
`${origin}/api/auth/callback`
);
const keycloakUrl =
`https://keycloak.petermaquiran.xyz/auth/realms/tvone/protocol/openid-connect/auth` +
`?client_id=tvone-web` +
`&response_type=code` +
`&scope=openid` +
`&redirect_uri=${redirect}`;
return Response.redirect(keycloakUrl);
}
+46
View File
@@ -0,0 +1,46 @@
import { NextResponse } from "next/server";
import { jwtVerify } from "jose";
const getTokenFromCookies = (cookieHeader: string | null) => {
if (!cookieHeader) return null;
return cookieHeader
.split("; ")
.find((c) => c.startsWith("access_token="))
?.split("=")[1];
};
export async function GET(req: Request) {
try {
const cookie = req.headers.get("cookie");
const token = getTokenFromCookies(cookie);
if (!token) {
return NextResponse.json(
{ message: "Unauthorized" },
{ status: 401 }
);
}
// ⚠️ For production: use Keycloak public key verification
// For now: decode safely (basic version)
const payload = JSON.parse(
Buffer.from(token.split(".")[1], "base64").toString()
);
return NextResponse.json({
id: payload.sub,
email: payload.email,
name: payload.name,
realm_access: payload.realm_access,
username: payload.preferred_username,
roles:
payload.realm_access?.roles || [],
});
} catch (err) {
return NextResponse.json(
{ message: "Invalid token" },
{ status: 401 }
);
}
}
+20
View File
@@ -0,0 +1,20 @@
import { env } from "@/lib/env";
import { NextResponse } from "next/server";
export async function POST(req: Request) {
const { token } = await req.json();
const isHttps = new URL(req.url).protocol === "https:";
const res = NextResponse.json({ ok: true });
res.cookies.set("auth_token", token, {
httpOnly: true,
secure: true,
sameSite: isHttps ? "none" : "lax",
...(env.COOKIE_DOMAIN ? { domain: env.COOKIE_DOMAIN } : {}),
path: "/",
});
return res;
}
+12 -12
View File
@@ -1,14 +1,14 @@
// import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
// import { readSliderPhotos } from "@/lib/slider-photos"; import { readSliderPhotos } from "@/lib/slider-photos";
// export const runtime = "nodejs"; export const runtime = "nodejs";
// export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
// export async function GET() { export async function GET() {
// try { try {
// const photos = readSliderPhotos(); const photos = readSliderPhotos();
// return NextResponse.json(photos); return NextResponse.json(photos);
// } catch { } catch {
// return NextResponse.json([]); return NextResponse.json([]);
// } }
// } }
@@ -0,0 +1,90 @@
"use client";
import type { ReactNode } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import {
BarChart3,
FolderPlus,
HelpCircle,
LayoutDashboard,
Newspaper,
Settings,
Users,
} from "lucide-react";
type NavLinkItem = {
kind: "link";
href: string;
label: string;
icon: ReactNode;
};
type NavDisabledItem = {
kind: "disabled";
label: string;
icon: ReactNode;
};
const navItems: (NavLinkItem | NavDisabledItem)[] = [
{ kind: "link", href: "/admin/dashboard", label: "Painel", icon: <LayoutDashboard size={18} /> },
{ kind: "disabled", label: "Meus Artigos", icon: <Newspaper size={18} /> },
{ kind: "disabled", label: "Equipa", icon: <Users size={18} /> },
{ kind: "disabled", label: "Análises", icon: <BarChart3 size={18} /> },
{ kind: "link", href: "/admin/create-news", label: "Adicionar Notícia", icon: <Newspaper size={18} /> },
{
kind: "link",
href: "/admin/manage-category",
label: "Adicionar categoria",
icon: <FolderPlus size={18} />,
},
{ kind: "disabled", label: "Definições", icon: <Settings size={18} /> },
{ kind: "disabled", label: "Ajuda", icon: <HelpCircle size={18} /> },
];
function pathIsActive(pathname: string, href: string) {
if (pathname === href) return true;
if (href === "/admin/dashboard") return false;
return pathname.startsWith(`${href}/`);
}
export function AdminSidebarNav() {
const pathname = usePathname();
return (
<nav className="flex-1 space-y-1">
{navItems.map((item) => {
if (item.kind === "disabled") {
return (
<div
key={item.label}
className="flex items-center gap-3 px-3 py-2 rounded-xl text-sm font-medium text-slate-400 cursor-not-allowed"
aria-disabled
>
{item.icon}
{item.label}
</div>
);
}
const active = pathIsActive(pathname, item.href);
return (
<Link
key={item.href}
href={item.href}
className={`flex items-center gap-3 px-3 py-2 rounded-xl text-sm font-medium transition-all ${
active
? "bg-blue-600 text-white shadow-md shadow-blue-200"
: "text-slate-500 hover:bg-slate-100"
}`}
aria-current={active ? "page" : undefined}
>
{item.icon}
{item.label}
</Link>
);
})}
</nav>
);
}
+34
View File
@@ -0,0 +1,34 @@
import { getUserProfile } from "@/src/lib/auth/get-user-profile";
import { redirect } from "next/navigation";
export const AdminHeaderPage = async () => {
const user = await getUserProfile();
if (!user) {
redirect("/login");
}
return (
<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..."
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"
/>
</div>
<div className="flex items-center gap-4">
<div className="text-right">
<p className="text-xs font-bold">{user?.name ?? "Loading..."}</p>
<p className="text-[10px] text-slate-500">Editor</p>
</div>
<div className="w-8 h-8 bg-slate-300 rounded-full border border-white shadow-sm overflow-hidden">
<img
src={user?.picture ?? "https://ui-avatars.com/api/?name=User"}
alt="User"
/>
</div>
</div>
</header>
);
};
+53
View File
@@ -0,0 +1,53 @@
import { getUserProfile } from "@/src/lib/auth/get-user-profile";
import { redirect } from "next/navigation";
import Image from "next/image";
import Link from "next/link";
import { AdminSidebarNav } from "./admin-sidebar-nav";
export const AdminSideBar = async () => {
const user = await getUserProfile();
if (!user) {
redirect("/login");
}
return (
<aside className="w-64 backdrop-blur-xl bg-white/80 p-6 flex flex-col">
<div className="flex items-center gap-2 mb-10 px-2">
<Link href="/admin/dashboard" className="flex items-center gap-2 group">
<div className="w-10 h-10 bg-white rounded-lg flex items-center justify-center font-bold italic group-hover:ring-blue-200 transition-shadow">
<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>
</Link>
</div>
<AdminSidebarNav />
<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>
);
};
function ActivityItem({ user, action }: { user: string; action: string }) {
return (
<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>
);
}
+29
View File
@@ -0,0 +1,29 @@
// import Keycloak from "keycloak-js";
// /**
// * KEYCLOAK CONFIGURATION
// * Logic: Environment variable validation and OIDC configuration.
// */
// export const keycloakConfig = {
// clientId: process.env.KEYCLOAK_CLIENT_ID!,
// clientSecret: process.env.KEYCLOAK_CLIENT_SECRET!,
// issuer: `${process.env.KEYCLOAK_ISSUER_URL}/realms/${process.env.KEYCLOAK_REALM}`,
// // Scopes needed for OIDC and profile access
// scope: 'openid profile email',
// // Endpoint for global logout
// endSessionEndpoint: `${process.env.KEYCLOAK_ISSUER_URL}/realms/${process.env.KEYCLOAK_REALM}/protocol/openid-connect/logout`,
// };
// // Simple check to ensure environment variables are present
// if (!process.env.KEYCLOAK_CLIENT_ID || !process.env.KEYCLOAK_ISSUER_URL) {
// console.warn("Auth Warning: Keycloak environment variables are missing.");
// }
// export const keycloak = new Keycloak({
// url: "https://keycloak.petermaquiran.xyz",
// realm: "tvone", // ✅ IMPORTANT
// clientId: "tvone-web", // must match Keycloak client
// });
+21
View File
@@ -0,0 +1,21 @@
/**
* SESSION MAPPER
* Logic: Data transformation (JWT -> Clean Profile).
* Purpose: Prevents leaking sensitive JWT metadata to the UI layer.
*/
import { UserProfile, RawKeycloakToken } from '../../types/auth.types';
export const mapKeycloakProfile = (token: RawKeycloakToken): UserProfile => {
return {
id: token.sub,
name: token.name || 'Guest User',
email: token.email,
username: token.preferred_username,
// Extracting roles for domain-specific logic (e.g., Editor, Admin)
roles: token.realm_access?.roles || [],
avatar: token.picture || null,
// Custom logic to check for premium status
isPremium: token.realm_access?.roles.includes('premium_subscriber') ?? false,
};
};
+41
View File
@@ -0,0 +1,41 @@
// // /**
// // * TOKEN REFRESHER
// // * Logic: Silent background token rotation.
// // * Role: Communicates with Keycloak to exchange a Refresh Token for a new Access Token.
// // */
// // import { keycloakConfig } from './keycloak-config';
// export const refreshAccessToken = async (token: any) => {
// try {
// const url = `${process.env.KEYCLOAK_ISSUER_URL}/realms/${process.env.KEYCLOAK_REALM}/protocol/openid-connect/token`;
// const response = await fetch(url, {
// method: 'POST',
// headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
// body: new URLSearchParams({
// client_id: keycloakConfig.clientId,
// client_secret: keycloakConfig.clientSecret,
// grant_type: 'refresh_token',
// refresh_token: token.refreshToken,
// }),
// });
// const refreshedTokens = await response.json();
// if (!response.ok) throw refreshedTokens;
// return {
// ...token,
// accessToken: refreshedTokens.access_token,
// accessTokenExpires: Date.now() + refreshedTokens.expires_in * 1000,
// refreshToken: refreshedTokens.refresh_token ?? token.refreshToken, // Fallback to old refresh token
// };
// } catch (error) {
// console.error('Error refreshing access token', error);
// return {
// ...token,
// error: 'RefreshAccessTokenError',
// };
// }
// };
+45
View File
@@ -0,0 +1,45 @@
/**
* RAW KEYCLOAK TOKEN
* Data Layer: Represents the uncleaned decoded JWT payload from Keycloak.
*/
export interface RawKeycloakToken {
sub: string; // Unique User ID
name?: string;
email?: string;
preferred_username: string;
given_name?: string;
family_name?: string;
email_verified: boolean;
picture?: string; // Avatar URL from Keycloak
realm_access?: {
roles: string[]; // Global roles (e.g., 'admin', 'editor')
};
resource_access?: {
[key: string]: {
roles: string[]; // Client-specific roles
};
};
exp: number; // Expiration Timestamp
iat: number; // Issued At Timestamp
}
/**
* USER PROFILE
* UI Layer: Represents the "Cleaned" object used by the frontend.
* Logic: Extracted from RawKeycloakToken via session-mapper.ts.
*/
export interface UserProfile {
id: string;
name: string;
email?: string;
username: string;
avatar: string | null;
roles: string[];
isPremium: boolean; // Derived state for the News Portal
}
/**
* AUTH STATUS
* Logic: Standardizes the possible states of the session.
*/
export type AuthStatus = 'loading' | 'authenticated' | 'unauthenticated';
-89
View File
@@ -1,89 +0,0 @@
import { useEffect, useState } from "react";
import {
Category,
getCategoriesTree,
getCategoriesFlat,
createCategory,
updateCategory,
deleteCategory,
} from "@/lib/categories.api";
import { slugify } from "@/lib/slug";
export function useCategories() {
const [tree, setTree] = useState<Category[]>([]);
const [flat, setFlat] = useState<Category[]>([]);
const [loading, setLoading] = 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, f] = await Promise.all([
getCategoriesTree(),
getCategoriesFlat(),
]);
setTree(t);
setFlat(f);
} 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);
}
resetForm();
load();
}
async function remove(id: string) {
await deleteCategory(id);
load();
}
function edit(cat: Category) {
setForm({
id: cat.id,
name: cat.name,
slug: cat.slug,
parentId: cat.parentId || null,
});
}
function resetForm() {
setForm({ id: null, name: "", slug: "", parentId: null });
}
return {
tree,
flat,
form,
setForm,
save,
remove,
edit,
resetForm,
loading,
};
}
+18 -6
View File
@@ -1,4 +1,4 @@
const API = "http://localhost:3001/categories"; const API = `${process.env.NEXT_PUBLIC_API_URL || "https://tvone-api.petermaquiran.xyz"}/categories`;
export interface Category { export interface Category {
id: string; id: string;
@@ -8,14 +8,20 @@ export interface Category {
children?: Category[]; children?: Category[];
} }
//var token = 'eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJzSnBBLWUtcTEyc3ZVUlpLLUpCbU9lVzQxVDhIcGRKQnlLYlVkbHQxVDNZIn0.eyJleHAiOjE3NzY1NjA0MzIsImlhdCI6MTc3NjU2MDEzMiwiYXV0aF90aW1lIjoxNzc2NTYwMTMyLCJqdGkiOiI4ZTUzYmY3YS0wNDMzLTQ4MzQtOGE4NS02NjQ4YTMyOTliYWIiLCJpc3MiOiJodHRwczovL2tleWNsb2FrLnBldGVybWFxdWlyYW4ueHl6L3JlYWxtcy90dm9uZSIsImF1ZCI6ImFjY291bnQiLCJzdWIiOiJiY2RkYTYwOS00OThhLTQxNzgtYTEwMy04N2QzN2IxN2U1YzMiLCJ0eXAiOiJCZWFyZXIiLCJhenAiOiJ0dm9uZS13ZWIiLCJzZXNzaW9uX3N0YXRlIjoiMWVlZDBhOTMtOGFlNi00ZDBlLTg0MjItMGJmYTA3ZmViYTBiIiwiYWNyIjoiMSIsImFsbG93ZWQtb3JpZ2lucyI6WyJodHRwczovL3R2b25lLnBldGVybWFxdWlyYW4ueHl6IiwiaHR0cDovL2xvY2FsaG9zdDozMDAwIl0sInJlYWxtX2FjY2VzcyI6eyJyb2xlcyI6WyJvZmZsaW5lX2FjY2VzcyIsInVtYV9hdXRob3JpemF0aW9uIiwiZGVmYXVsdC1yb2xlcy10dm9uZSJdfSwicmVzb3VyY2VfYWNjZXNzIjp7ImFjY291bnQiOnsicm9sZXMiOlsibWFuYWdlLWFjY291bnQiLCJtYW5hZ2UtYWNjb3VudC1saW5rcyIsInZpZXctcHJvZmlsZSJdfX0sInNjb3BlIjoib3BlbmlkIGVtYWlsIHByb2ZpbGUiLCJzaWQiOiIxZWVkMGE5My04YWU2LTRkMGUtODQyMi0wYmZhMDdmZWJhMGIiLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwibmFtZSI6IlBldGVyIE1hcXVpcmFuIiwicHJlZmVycmVkX3VzZXJuYW1lIjoicGV0ZXJtYXF1aXJhbjI5QGdtYWlsLmNvbSIsImdpdmVuX25hbWUiOiJQZXRlciIsImZhbWlseV9uYW1lIjoiTWFxdWlyYW4iLCJwaWN0dXJlIjoiaHR0cHM6Ly9saDMuZ29vZ2xldXNlcmNvbnRlbnQuY29tL2EvQUNnOG9jTG5QUmpKbllhZ2tDT0VIbVRGY1IyZVVOaWNSLU45bG5QQkZuV0RSa0hnVF9JeHpocz1zOTYtYyIsImVtYWlsIjoicGV0ZXJtYXF1aXJhbjI5QGdtYWlsLmNvbSJ9.K9uo2g2nK7VjkcCfVyex39iCAAV32ASTSsF0jicUAWlupv8IwYOv4wToyGyetC7yfAqpxaPqeHIOd_QJ3V60jQeJu10J_P78BHw01oe1ONczmAMm3Lt175-i70m8lOmPFXhVPbzGCLrUxWtOC1npS1to1y_QvtMmU11owcZvjy7InV4KpOUUmJkp2OMiSEpDV7tiVNBm7YtoXHhCeTN3-jpipV16yhBJuMfdyVhqK0gYT_z6bnbkvND6F1XG2D-A0cYwuc2NYeSwQT-F3Gxyw09JioZTEN_mn6sMRjy2zgm4oz0Owc1Qv6Exi2my32734e8Y7o-0RcFZpUFfkegdFA';
export async function getCategoriesTree(): Promise<Category[]> { export async function getCategoriesTree(): Promise<Category[]> {
const res = await fetch(`${API}/`); const res = await fetch(`${API}/`, {credentials: "include", headers: {
//Authorization: "Bearer "+token,
}});
const data = await res.json(); const data = await res.json();
return Array.isArray(data) ? data : data?.data ?? []; return Array.isArray(data) ? data : data?.data ?? [];
} }
export async function getCategoriesFlat(): Promise<Category[]> { export async function getCategoriesFlat(): Promise<Category[]> {
const res = await fetch(API); const res = await fetch(API, {credentials: "include",headers: {
// Authorization: "Bearer "+token,
}});
const data = await res.json(); const data = await res.json();
return Array.isArray(data) ? data : []; return Array.isArray(data) ? data : [];
} }
@@ -24,6 +30,7 @@ export async function createCategory(payload: Partial<Category>) {
return fetch(API, { return fetch(API, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify(payload), body: JSON.stringify(payload),
}); });
} }
@@ -32,23 +39,28 @@ export async function updateCategory(id: string, payload: Partial<Category>) {
return fetch(`${API}/${id}`, { return fetch(`${API}/${id}`, {
method: "PATCH", method: "PATCH",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify(payload), body: JSON.stringify(payload),
}); });
} }
export async function deleteCategory(id: string) { export async function deleteCategory(id: string) {
return fetch(`${API}/${id}`, { method: "DELETE" }); return fetch(`${API}/${id}`, { method: "DELETE", credentials: "include", });
} }
export async function getTree(): Promise<Category[]> { export async function getTree(): Promise<Category[]> {
const res = await fetch(`${API}/`); const res = await fetch(`${API}/`, {credentials: "include",headers: {
// Authorization: "Bearer "+token,
}});
const data = await res.json(); const data = await res.json();
return Array.isArray(data) ? data : data?.data ?? []; return Array.isArray(data) ? data : data?.data ?? [];
} }
export async function getFlat(): Promise<Category[]> { export async function getFlat(): Promise<Category[]> {
const res = await fetch(API); const res = await fetch(API, {credentials: "include",headers: {
// Authorization: "Bearer "+token,
}});
const data = await res.json(); const data = await res.json();
return Array.isArray(data) ? data : []; return Array.isArray(data) ? data : [];
} }
+22
View File
@@ -0,0 +1,22 @@
import { loadEnvConfig } from "@next/env";
loadEnvConfig(process.cwd());
function getRequiredEnv(name: string): string | null | undefined {
return process.env[name];
}
function getOptionalEnv(name: string): string | undefined {
const value = process.env[name];
return value && value.trim().length > 0 ? value : undefined;
}
export const env = {
APP_URL: getRequiredEnv("APP_URL") as string,
KEYCLOAK_BASE_URL: getRequiredEnv("KEYCLOAK_BASE_URL") as string,
KEYCLOAK_REALM: getRequiredEnv("KEYCLOAK_REALM") as string,
KEYCLOAK_CLIENT_ID: getRequiredEnv("KEYCLOAK_CLIENT_ID") as string,
KEYCLOAK_CLIENT_SECRET: getRequiredEnv("KEYCLOAK_CLIENT_SECRET") as string,
API_URL: getRequiredEnv("API_URL") as string,
COOKIE_DOMAIN: getOptionalEnv("COOKIE_DOMAIN") as string,
};
+41
View File
@@ -0,0 +1,41 @@
const PUBLIC_SUFFIX_BLOCKLIST = new Set([
"localhost",
"127.0.0.1",
]);
export function getCookieDomain(hostname: string): string | undefined {
if (!hostname) return undefined;
const cleanHost = hostname.toLowerCase().split(":")[0];
// 1. Local / dev environments → no domain
if (PUBLIC_SUFFIX_BLOCKLIST.has(cleanHost) || cleanHost.endsWith(".local")) {
return undefined;
}
const parts = cleanHost.split(".").filter(Boolean);
// 2. IP address → no domain cookies
const isIp = parts.every((p) => /^\d+$/.test(p));
if (isIp) return undefined;
// 3. Must have at least domain + tld
if (parts.length < 2) return undefined;
// 4. Handle common case: api.example.com → example.com
const rootDomain = parts.slice(-2).join(".");
// 5. Safety: avoid setting cookie on known public suffix-like domains
// const unsafeTlds = new Set([
// "vercel.app",
// "netlify.app",
// "github.io",
// "firebaseapp.com",
// ]);
// if (unsafeTlds.has(rootDomain)) {
// return undefined;
// }
return `.${rootDomain}`;
}
+32
View File
@@ -0,0 +1,32 @@
import { NextResponse } from "next/server";
import { jwtVerify } from "jose";
const getTokenFromCookies = (cookieHeader: string | null) => {
if (!cookieHeader) return null;
return cookieHeader
.split("; ")
.find((c) => c.startsWith("access_token="))
?.split("=")[1];
};
export function getPermision(req: Request): string[] {
try {
const cookie = req.headers.get("cookie");
const token = getTokenFromCookies(cookie);
if (!token) {
return []
}
// ⚠️ For production: use Keycloak public key verification
// For now: decode safely (basic version)
const payload = JSON.parse(
Buffer.from(token.split(".")[1], "base64").toString()
);
return payload.realm_access.roles || [];
} catch (err) {
return []
}
}
+55 -55
View File
@@ -1,63 +1,63 @@
// import fs from "fs"; import fs from "fs";
// import path from "path"; import path from "path";
// export type SliderPhoto = { src: string; alt: string }; export type SliderPhoto = { src: string; alt: string };
// function slugToAlt(filename: string): string { function slugToAlt(filename: string): string {
// const base = filename.replace(/\.[^.]+$/, ""); const base = filename.replace(/\.[^.]+$/, "");
// const words = base.replace(/[-_]+/g, " ").trim(); const words = base.replace(/[-_]+/g, " ").trim();
// return words || "Slide"; return words || "Slide";
// } }
// export function parseManifest(data: unknown): SliderPhoto[] { export function parseManifest(data: unknown): SliderPhoto[] {
// if (!Array.isArray(data)) return []; if (!Array.isArray(data)) return [];
// const out: SliderPhoto[] = []; const out: SliderPhoto[] = [];
// for (const item of data) { for (const item of data) {
// if (typeof item !== "object" || item === null || !("src" in item)) continue; if (typeof item !== "object" || item === null || !("src" in item)) continue;
// const src = (item as { src: unknown }).src; const src = (item as { src: unknown }).src;
// if (typeof src !== "string" || !src.startsWith("/")) continue; if (typeof src !== "string" || !src.startsWith("/")) continue;
// const altRaw = (item as { alt?: unknown }).alt; const altRaw = (item as { alt?: unknown }).alt;
// const alt = typeof altRaw === "string" && altRaw.trim() ? altRaw.trim() : slugToAlt(src.split("/").pop() ?? ""); const alt = typeof altRaw === "string" && altRaw.trim() ? altRaw.trim() : slugToAlt(src.split("/").pop() ?? "");
// out.push({ src, alt }); out.push({ src, alt });
// } }
// return out; return out;
// } }
// const IMAGE_EXT = /\.(jpe?g|png|webp|gif|avif)$/i; const IMAGE_EXT = /\.(jpe?g|png|webp|gif|avif)$/i;
// function scanSliderDirectory(dir: string): SliderPhoto[] { function scanSliderDirectory(dir: string): SliderPhoto[] {
// let names: string[] = []; let names: string[] = [];
// try { try {
// names = fs.readdirSync(dir); names = fs.readdirSync(dir);
// } catch { } catch {
// return []; return [];
// } }
// return names return names
// .filter((f) => IMAGE_EXT.test(f)) .filter((f) => IMAGE_EXT.test(f))
// .sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" })) .sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" }))
// .map((f) => ({ .map((f) => ({
// src: `/slider/${f}`, src: `/slider/${f}`,
// alt: slugToAlt(f), alt: slugToAlt(f),
// })); }));
// } }
// /** /**
// * Reads `public/slider/manifest.json` when present (full control: order + alt). * Reads `public/slider/manifest.json` when present (full control: order + alt).
// * Otherwise scans `public/slider` for image files (drop-in updates, no code edits). * Otherwise scans `public/slider` for image files (drop-in updates, no code edits).
// */ */
// export function readSliderPhotos(): SliderPhoto[] { export function readSliderPhotos(): SliderPhoto[] {
// const dir = path.join(process.cwd(), "public", "slider"); const dir = path.join(process.cwd(), "public", "slider");
// if (!fs.existsSync(dir)) return []; if (!fs.existsSync(dir)) return [];
// const manifestPath = path.join(dir, "manifest.json"); const manifestPath = path.join(dir, "manifest.json");
// if (fs.existsSync(manifestPath)) { if (fs.existsSync(manifestPath)) {
// try { try {
// const raw = JSON.parse(fs.readFileSync(manifestPath, "utf8")) as unknown; const raw = JSON.parse(fs.readFileSync(manifestPath, "utf8")) as unknown;
// return parseManifest(raw); return parseManifest(raw);
// } catch { } catch {
// return []; return [];
// } }
// } }
// return scanSliderDirectory(dir); return scanSliderDirectory(dir);
// } }
+8
View File
@@ -0,0 +1,8 @@
export const getTokenFromCookies = (cookieHeader: string | null) => {
if (!cookieHeader) return null;
return cookieHeader
.split("; ")
.find((c) => c.startsWith("access_token="))
?.split("=")[1];
};
+23
View File
@@ -0,0 +1,23 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { getPermision } from "./lib/getPermisions";
export function middleware(req: NextRequest) {
const token = req.cookies.get("access_token")?.value;
const { pathname } = req.nextUrl;
const isAdminRoute = pathname.startsWith("/admin");
const isLoginPage = pathname.startsWith("/login");
// 🚫 block user if not logged in or not admin
if (isAdminRoute && ( !token || !getPermision(req).includes("Admin") )) {
return NextResponse.redirect(new URL("/login", req.url));
}
// 🔁 prevent logged-in users from seeing login page
if (isLoginPage && token && getPermision(req).includes("Admin")) {
return NextResponse.redirect(new URL("/admin/create-news", req.url));
}
return NextResponse.next();
}
+9
View File
@@ -1,4 +1,5 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
import { env } from "@/lib/env";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
output: "standalone", output: "standalone",
@@ -15,6 +16,14 @@ const nextConfig: NextConfig = {
}, },
], ],
}, },
async rewrites() {
return [
{
source: "/api/:path*",
destination: env.API_URL+"/:path*",
},
];
},
}; };
export default nextConfig; export default nextConfig;
+2
View File
@@ -12,9 +12,11 @@
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@next/env": "^16.2.4",
"@react-oauth/google": "^0.13.5", "@react-oauth/google": "^0.13.5",
"@tinymce/tinymce-react": "^6.3.0", "@tinymce/tinymce-react": "^6.3.0",
"framer-motion": "^12.38.0", "framer-motion": "^12.38.0",
"jose": "^6.2.2",
"keycloak-js": "^26.2.3", "keycloak-js": "^26.2.3",
"lucide-react": "^1.8.0", "lucide-react": "^1.8.0",
"next": "16.2.1", "next": "16.2.1",
+16
View File
@@ -17,6 +17,9 @@ importers:
'@dnd-kit/utilities': '@dnd-kit/utilities':
specifier: ^3.2.2 specifier: ^3.2.2
version: 3.2.2(react@19.2.4) version: 3.2.2(react@19.2.4)
'@next/env':
specifier: ^16.2.4
version: 16.2.4
'@react-oauth/google': '@react-oauth/google':
specifier: ^0.13.5 specifier: ^0.13.5
version: 0.13.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4) version: 0.13.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@@ -26,6 +29,9 @@ importers:
framer-motion: framer-motion:
specifier: ^12.38.0 specifier: ^12.38.0
version: 12.38.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) version: 12.38.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
jose:
specifier: ^6.2.2
version: 6.2.2
keycloak-js: keycloak-js:
specifier: ^26.2.3 specifier: ^26.2.3
version: 26.2.3 version: 26.2.3
@@ -412,6 +418,9 @@ packages:
'@next/env@16.2.1': '@next/env@16.2.1':
resolution: {integrity: sha512-n8P/HCkIWW+gVal2Z8XqXJ6aB3J0tuM29OcHpCsobWlChH/SITBs1DFBk/HajgrwDkqqBXPbuUuzgDvUekREPg==} resolution: {integrity: sha512-n8P/HCkIWW+gVal2Z8XqXJ6aB3J0tuM29OcHpCsobWlChH/SITBs1DFBk/HajgrwDkqqBXPbuUuzgDvUekREPg==}
'@next/env@16.2.4':
resolution: {integrity: sha512-dKkkOzOSwFYe5RX6y26fZgkSpVAlIOJKQHIiydQcrWH6y/97+RceSOAdjZ14Qa3zLduVUy0TXcn+EiM6t4rPgw==}
'@next/eslint-plugin-next@16.2.1': '@next/eslint-plugin-next@16.2.1':
resolution: {integrity: sha512-r0epZGo24eT4g08jJlg2OEryBphXqO8aL18oajoTKLzHJ6jVr6P6FI58DLMug04MwD3j8Fj0YK0slyzneKVyzA==} resolution: {integrity: sha512-r0epZGo24eT4g08jJlg2OEryBphXqO8aL18oajoTKLzHJ6jVr6P6FI58DLMug04MwD3j8Fj0YK0slyzneKVyzA==}
@@ -1448,6 +1457,9 @@ packages:
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
hasBin: true hasBin: true
jose@6.2.2:
resolution: {integrity: sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==}
js-tokens@4.0.0: js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
@@ -2408,6 +2420,8 @@ snapshots:
'@next/env@16.2.1': {} '@next/env@16.2.1': {}
'@next/env@16.2.4': {}
'@next/eslint-plugin-next@16.2.1': '@next/eslint-plugin-next@16.2.1':
dependencies: dependencies:
fast-glob: 3.3.1 fast-glob: 3.3.1
@@ -3549,6 +3563,8 @@ snapshots:
jiti@2.6.1: {} jiti@2.6.1: {}
jose@6.2.2: {}
js-tokens@4.0.0: {} js-tokens@4.0.0: {}
js-yaml@4.1.1: js-yaml@4.1.1:
+34
View File
@@ -0,0 +1,34 @@
import { cookies } from "next/headers";
export type UserProfile = {
id: string;
email?: string;
name?: string;
username?: string;
roles: string[];
picture?: string
};
export async function getUserProfile(): Promise<UserProfile | null> {
const cookieStore = cookies();
const token = (await cookieStore).get("access_token")?.value;
if (!token) return null;
try {
const payload = JSON.parse(
Buffer.from(token.split(".")[1], "base64").toString()
);
return {
id: payload.sub,
email: payload.email,
name: payload.name,
username: payload.preferred_username,
picture: payload.picture,
roles: payload.realm_access?.roles || [],
};
} catch (err) {
return null;
}
}