mirror of
https://github.com/PeterMaquiran/tvone.git
synced 2026-04-22 20:15:51 +00:00
optimize page
This commit is contained in:
@@ -0,0 +1,277 @@
|
||||
"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 { AdminHeaderPage } from '@/app/components/layout/admin/header';
|
||||
import { AdminSideBar } from '@/app/components/layout/admin/sidebar';
|
||||
// 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 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);
|
||||
|
||||
// // 👉 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 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>
|
||||
|
||||
{/* --- 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;
|
||||
@@ -1,365 +1,22 @@
|
||||
"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 { keycloak } from '@/app/feature/auth/keycloak-config';
|
||||
const Editor = dynamic(
|
||||
() => import("@tinymce/tinymce-react").then((mod) => mod.Editor),
|
||||
{ ssr: false }
|
||||
);
|
||||
import CreateNewsPage from './create-news';
|
||||
import { AdminHeaderPage } from '@/app/components/layout/admin/header';
|
||||
import { AdminSideBar } from '@/app/components/layout/admin/sidebar';
|
||||
|
||||
|
||||
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);
|
||||
|
||||
// // 👉 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);
|
||||
// }
|
||||
// });
|
||||
// }, []);
|
||||
|
||||
const CreateNews = () => {
|
||||
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);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div className="flex h-screen bg-slate-100/50 text-slate-900 font-sans">
|
||||
|
||||
{/* 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>
|
||||
<AdminSideBar />
|
||||
|
||||
{/* 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>
|
||||
<AdminHeaderPage />
|
||||
<CreateNewsPage />
|
||||
</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;
|
||||
export default CreateNews;
|
||||
@@ -1,19 +1,9 @@
|
||||
"use client";
|
||||
import Image from 'next/image';
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useState } from "react";
|
||||
import { useGoogleLogin } from "@react-oauth/google";
|
||||
import { useTheme } from 'next-themes';
|
||||
import { Sun, Moon } from 'lucide-react'; // Optional: install lucide-react for clean icons
|
||||
//import { keycloak } from '@/app/feature/auth/keycloak-config';
|
||||
|
||||
interface GoogleAuthResponse {
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
expires_in: number;
|
||||
scope: string;
|
||||
authuser?: string;
|
||||
prompt?: string;
|
||||
}
|
||||
import { Sun, Moon } from 'lucide-react';
|
||||
|
||||
interface KeycloakTokenResponse {
|
||||
access_token: string;
|
||||
@@ -33,50 +23,6 @@ export default function AppleStyleAuth() {
|
||||
const { theme, setTheme } = useTheme();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
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 details: Record<string, string> = {
|
||||
@@ -106,7 +52,6 @@ export default function AppleStyleAuth() {
|
||||
}
|
||||
};
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
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">
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
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,
|
||||
username: payload.preferred_username,
|
||||
roles:
|
||||
payload.realm_access?.roles || [],
|
||||
});
|
||||
} catch (err) {
|
||||
return NextResponse.json(
|
||||
{ message: "Invalid token" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
import { getUserProfile } from "@/src/lib/auth/get-user-profile";
|
||||
import { BarChart3, HelpCircle, LayoutDashboard, Newspaper, Settings, Users } from "lucide-react";
|
||||
import { redirect } from "next/navigation";
|
||||
import Image from 'next/image';
|
||||
|
||||
export const AdminSideBar = async () => {
|
||||
const user = await getUserProfile();
|
||||
|
||||
if (!user) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
// 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>
|
||||
);
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"@react-oauth/google": "^0.13.5",
|
||||
"@tinymce/tinymce-react": "^6.3.0",
|
||||
"framer-motion": "^12.38.0",
|
||||
"jose": "^6.2.2",
|
||||
"keycloak-js": "^26.2.3",
|
||||
"lucide-react": "^1.8.0",
|
||||
"next": "16.2.1",
|
||||
|
||||
Generated
+8
@@ -26,6 +26,9 @@ importers:
|
||||
framer-motion:
|
||||
specifier: ^12.38.0
|
||||
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:
|
||||
specifier: ^26.2.3
|
||||
version: 26.2.3
|
||||
@@ -1448,6 +1451,9 @@ packages:
|
||||
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
|
||||
hasBin: true
|
||||
|
||||
jose@6.2.2:
|
||||
resolution: {integrity: sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==}
|
||||
|
||||
js-tokens@4.0.0:
|
||||
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
||||
|
||||
@@ -3549,6 +3555,8 @@ snapshots:
|
||||
|
||||
jiti@2.6.1: {}
|
||||
|
||||
jose@6.2.2: {}
|
||||
|
||||
js-tokens@4.0.0: {}
|
||||
|
||||
js-yaml@4.1.1:
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user