mirror of
https://github.com/PeterMaquiran/tvone.git
synced 2026-04-21 19:51:47 +00:00
Compare commits
63 Commits
c46857514b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 51a7e85858 | |||
| ca0c2877b5 | |||
| 5832ca2d54 | |||
| 29f89ea922 | |||
| b71a10a003 | |||
| d4ef46e327 | |||
| 2952ad314b | |||
| 9391849795 | |||
| 233cf86fea | |||
| eca4661cd6 | |||
| 0cdfdd2962 | |||
| 326c57efab | |||
| 8e9c997f25 | |||
| dcccc2be47 | |||
| f4928bd9f9 | |||
| e166ad8a84 | |||
| 950fb32d27 | |||
| 96d3a86f3e | |||
| d408b74299 | |||
| ae61c8f661 | |||
| 26cfe4eb5b | |||
| 00e8f167f9 | |||
| b834b26087 | |||
| 80337ff874 | |||
| f2b62f29ba | |||
| 189355174c | |||
| 7a4ebad9e2 | |||
| f220ee6c3e | |||
| 31aac2db57 | |||
| 7d80c9c91b | |||
| 12639cb6f8 | |||
| 52dfe2defa | |||
| c4ffae44fe | |||
| 662a8a400b | |||
| a6d475ed13 | |||
| 174febe986 | |||
| 2131a34e33 | |||
| ba17904895 | |||
| f163f22987 | |||
| 5e5a43094f | |||
| 661784a73d | |||
| 7854e3dd44 | |||
| 2ab775514e | |||
| c8383955d5 | |||
| 0a645744f0 | |||
| 4e7794476b | |||
| 6555a171ee | |||
| 8454abea36 | |||
| 73e0834d18 | |||
| 006305ca3f | |||
| 1aceeafd72 | |||
| 09f74d2439 | |||
| e9cbf91e91 | |||
| eec32932e7 | |||
| 9fb75d8db6 | |||
| 95a80a72c7 | |||
| 88759d56cf | |||
| 6e7fd74a31 | |||
| 30f2152524 | |||
| b30b8503f9 | |||
| f8d5e45673 | |||
| b84d6dd162 | |||
| 1beda1f8f6 |
@@ -0,0 +1,13 @@
|
|||||||
|
# editorconfig.org
|
||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
end_of_line = lf
|
||||||
|
charset = utf-8
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
insert_final_newline = true
|
||||||
|
|
||||||
|
[*.md]
|
||||||
|
trim_trailing_whitespace = false
|
||||||
+10
@@ -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;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import CreateNewsPage from './create-news';
|
||||||
|
|
||||||
|
const CreateNews = () => {
|
||||||
|
return <CreateNewsPage />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CreateNews;
|
||||||
@@ -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;
|
||||||
@@ -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,264 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
FolderTree,
|
||||||
|
Edit3,
|
||||||
|
Trash2,
|
||||||
|
Plus,
|
||||||
|
ChevronRight,
|
||||||
|
ChevronDown,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { getTree, getFlat, updateCategory, createCategory, deleteCategory } from "@/lib/categories.api";
|
||||||
|
|
||||||
|
/* ================= TYPES ================= */
|
||||||
|
interface Category {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
parentId?: string | null;
|
||||||
|
children?: Category[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ================= API ================= */
|
||||||
|
|
||||||
|
/* ================= UTIL ================= */
|
||||||
|
function slugify(text: string) {
|
||||||
|
return text
|
||||||
|
.toLowerCase()
|
||||||
|
.trim()
|
||||||
|
.replace(/\s+/g, "-")
|
||||||
|
.replace(/[^a-z0-9-]/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ================= PAGE ================= */
|
||||||
|
export default function CategoriesPage() {
|
||||||
|
const [tree, setTree] = useState<Category[]>([]);
|
||||||
|
const [flat, setFlat] = useState<Category[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
id: null as string | null,
|
||||||
|
name: "",
|
||||||
|
slug: "",
|
||||||
|
parentId: null as string | null,
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ================= LOAD ================= */
|
||||||
|
async function load() {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const [t, f] = await Promise.all([getTree(), getFlat()]);
|
||||||
|
setTree(t);
|
||||||
|
setFlat(f);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/* ================= CRUD ================= */
|
||||||
|
async function save() {
|
||||||
|
const payload = {
|
||||||
|
name: form.name,
|
||||||
|
slug: form.slug || slugify(form.name),
|
||||||
|
parentId: form.parentId,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (form.id) {
|
||||||
|
await updateCategory(form.id, payload);
|
||||||
|
} else {
|
||||||
|
await createCategory(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
closeModal();
|
||||||
|
load();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(id: string) {
|
||||||
|
if (!confirm("Delete this category?")) return;
|
||||||
|
await deleteCategory(id);
|
||||||
|
load();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ================= MODAL ================= */
|
||||||
|
function openCreate(parentId?: string) {
|
||||||
|
setForm({
|
||||||
|
id: null,
|
||||||
|
name: "",
|
||||||
|
slug: "",
|
||||||
|
parentId: parentId || null,
|
||||||
|
});
|
||||||
|
setModalOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(cat: Category) {
|
||||||
|
setForm({
|
||||||
|
id: cat.id,
|
||||||
|
name: cat.name,
|
||||||
|
slug: cat.slug,
|
||||||
|
parentId: cat.parentId || null,
|
||||||
|
});
|
||||||
|
setModalOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
setModalOpen(false);
|
||||||
|
setForm({ id: null, name: "", slug: "", parentId: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ================= TREE ================= */
|
||||||
|
function TreeNode({
|
||||||
|
node,
|
||||||
|
level = 0,
|
||||||
|
}: {
|
||||||
|
node: Category;
|
||||||
|
level?: number;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(true);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ marginLeft: level * 14 }} className="border-l pl-3">
|
||||||
|
|
||||||
|
{/* NODE */}
|
||||||
|
<div className="flex items-center justify-between py-2 group">
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
|
||||||
|
<button onClick={() => setOpen(!open)}>
|
||||||
|
{open ? (
|
||||||
|
<ChevronDown size={14} />
|
||||||
|
) : (
|
||||||
|
<ChevronRight size={14} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<FolderTree size={14} className="text-blue-500" />
|
||||||
|
|
||||||
|
<span
|
||||||
|
onClick={() => openEdit(node)}
|
||||||
|
className="text-sm font-medium cursor-pointer hover:text-blue-600"
|
||||||
|
>
|
||||||
|
{node.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ACTIONS */}
|
||||||
|
<div className="flex gap-2 opacity-0 group-hover:opacity-100 transition">
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => openCreate(node.id)}
|
||||||
|
className="text-green-600"
|
||||||
|
>
|
||||||
|
<Plus size={14} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button onClick={() => openEdit(node)}>
|
||||||
|
<Edit3 size={14} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button onClick={() => remove(node.id)}>
|
||||||
|
<Trash2 size={14} className="text-red-500" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* CHILDREN */}
|
||||||
|
{open &&
|
||||||
|
node.children?.map((child) => (
|
||||||
|
<TreeNode
|
||||||
|
key={child.id}
|
||||||
|
node={child}
|
||||||
|
level={level + 1}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ================= UI ================= */
|
||||||
|
return (
|
||||||
|
<div className="p-8 bg-slate-50 min-h-screen">
|
||||||
|
|
||||||
|
{/* HEADER */}
|
||||||
|
<div className="flex justify-between mb-6">
|
||||||
|
<h1 className="text-xl font-semibold">
|
||||||
|
Category Manager
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => openCreate()}
|
||||||
|
className="bg-blue-600 text-white px-4 py-2 rounded"
|
||||||
|
>
|
||||||
|
+ New Category
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* TREE */}
|
||||||
|
<div className="bg-white border rounded-xl p-4">
|
||||||
|
{loading ? (
|
||||||
|
<p>Loading...</p>
|
||||||
|
) : (
|
||||||
|
tree.map((node) => (
|
||||||
|
<TreeNode key={node.id} node={node} />
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* MODAL */}
|
||||||
|
{modalOpen && (
|
||||||
|
<div className="fixed inset-0 bg-black/40 flex items-center justify-center">
|
||||||
|
|
||||||
|
<div className="bg-white w-[420px] p-5 rounded-xl">
|
||||||
|
|
||||||
|
<h2 className="font-semibold mb-4">
|
||||||
|
{form.id ? "Edit Category" : "Create Category"}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<input
|
||||||
|
className="w-full border p-2 rounded mb-2"
|
||||||
|
placeholder="Name"
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm({
|
||||||
|
...form,
|
||||||
|
name: e.target.value,
|
||||||
|
slug: slugify(e.target.value),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<input
|
||||||
|
className="w-full border p-2 rounded mb-3"
|
||||||
|
placeholder="Slug"
|
||||||
|
value={form.slug}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm({ ...form, slug: e.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
|
||||||
|
<button onClick={closeModal}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={save}
|
||||||
|
className="bg-blue-600 text-white px-3 py-1 rounded"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 />;
|
||||||
|
}
|
||||||
@@ -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,12 +37,13 @@ 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">
|
||||||
@@ -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,12 +146,16 @@ 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]) => (
|
||||||
@@ -138,7 +163,10 @@ export default function FullPageEditor() {
|
|||||||
<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
|
||||||
@@ -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;
|
||||||
@@ -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';
|
||||||
@@ -209,10 +209,20 @@ export default function NewsArticlePage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="py-4">
|
<div className="py-4">
|
||||||
<h3 className="line-clamp-2 text-base font-bold leading-snug text-neutral-900 transition-colors group-hover:text-blue-600">
|
<div className="flex items-center gap-3 pb-2">
|
||||||
|
<span className="text-[10px] font-bold uppercase tracking-wider text-blue-600">
|
||||||
|
{item.cat}
|
||||||
|
</span>
|
||||||
|
<span className="h-1 w-1 rounded-2xl bg-neutral-300" />
|
||||||
|
{/* Data de Publicação em destaque suave */}
|
||||||
|
<span className="text-[10px] font-semibold text-neutral-500 uppercase tracking-tight">
|
||||||
|
{item.date}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 className="line-clamp-4 text-base font-bold leading-snug text-neutral-900 transition-colors group-hover:text-blue-600">
|
||||||
{item.title}
|
{item.title}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="mt-1.5 text-xs font-medium text-neutral-500">{item.date}</p>
|
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
</article>
|
</article>
|
||||||
@@ -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';
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
"use client";
|
||||||
|
import Image from 'next/image';
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useGoogleLogin } from "@react-oauth/google";
|
||||||
|
import { useTheme } from 'next-themes';
|
||||||
|
import { Sun, Moon } from 'lucide-react';
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AppleStyleAuth() {
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const { theme, setTheme } = useTheme();
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
|
|
||||||
|
const handleManualLogin = async (): Promise<void> => {
|
||||||
|
const details: Record<string, string> = {
|
||||||
|
grant_type: 'password',
|
||||||
|
client_id: 'tvone-web-client',
|
||||||
|
username: email,
|
||||||
|
password: password,
|
||||||
|
scope: 'openid',
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
'https://keycloak.petermaquiran.xyz/realms/<realm>/protocol/openid-connect/token',
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: new URLSearchParams(details).toString(),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const data: KeycloakTokenResponse = await response.json();
|
||||||
|
if (data.access_token) {
|
||||||
|
localStorage.setItem("token", data.access_token);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Login failed", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
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">
|
||||||
|
|
||||||
|
{/* THEME TOGGLE BUTTON */}
|
||||||
|
<button
|
||||||
|
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
|
||||||
|
className="absolute right-6 top-6 flex h-10 w-10 items-center justify-center rounded-full bg-white/80 shadow-sm backdrop-blur-md transition-all hover:scale-110 active:scale-95 dark:bg-neutral-800/80"
|
||||||
|
aria-label="Toggle Theme"
|
||||||
|
>
|
||||||
|
{theme === 'dark' ? (
|
||||||
|
<Sun className="h-5 w-5 text-yellow-400" />
|
||||||
|
) : (
|
||||||
|
<Moon className="h-5 w-5 text-neutral-600" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="w-full max-w-[400px] space-y-8 text-center">
|
||||||
|
|
||||||
|
{/* 1. LOGOTIPO */}
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<div className="flex h-16 w-16 items-center justify-center rounded-[1.2rem] bg-white text-white shadow-2xl transition-transform duration-700 hover:rotate-[360deg] dark:bg-white dark:text-black">
|
||||||
|
<Image src="/logo.png" alt="logo" width={100} height={100} />
|
||||||
|
</div>
|
||||||
|
<h1 className="mt-6 text-3xl font-bold tracking-tight text-neutral-900 dark:text-white">
|
||||||
|
Iniciar sessão
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 text-sm text-neutral-500">Usa a tua conta TVone.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 2. FORMULÁRIO */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
placeholder="E-mail ou Telefone"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
className="w-full rounded-2xl border border-neutral-200 bg-white/50 px-5 py-4 text-sm outline-none transition-all focus:border-blue-500 focus:bg-white focus:text-black focus:ring-4 focus:ring-blue-500/10 dark:border-neutral-800 dark:bg-neutral-900 dark:text-white"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
placeholder="Palavra-passe"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
className="w-full rounded-2xl border border-neutral-200 bg-white/50 px-5 py-4 text-sm outline-none transition-all focus:border-blue-500 focus:bg-white focus:text-black focus:ring-4 focus:ring-blue-500/10 dark:border-neutral-800 dark:bg-neutral-900 dark:text-white"
|
||||||
|
/>
|
||||||
|
<button className="w-full rounded-2xl bg-blue-600 py-4 text-sm font-bold text-white transition-all hover:bg-blue-700 active:scale-[0.98]">
|
||||||
|
Continuar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 3. DIVISOR */}
|
||||||
|
<div className="relative flex items-center py-4">
|
||||||
|
<div className="flex-grow border-t border-neutral-200 dark:border-neutral-800"></div>
|
||||||
|
<span className="mx-4 flex-shrink text-[11px] font-bold uppercase tracking-widest text-neutral-400">
|
||||||
|
ou
|
||||||
|
</span>
|
||||||
|
<div className="flex-grow border-t border-neutral-200 dark:border-neutral-800"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 4. BOTÃO GOOGLE */}
|
||||||
|
<button
|
||||||
|
onClick={() => (window.location.href = "/api/auth/login")}
|
||||||
|
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">
|
||||||
|
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" fill="#4285F4" />
|
||||||
|
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853" />
|
||||||
|
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05" />
|
||||||
|
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.66l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335" />
|
||||||
|
</svg>
|
||||||
|
<span className="text-sm font-semibold tracking-tight text-neutral-700 dark:text-neutral-300">
|
||||||
|
Continuar com o Google
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 5. LINKS */}
|
||||||
|
<div className="pt-4 text-center">
|
||||||
|
<a href="#" className="text-xs font-medium text-blue-600 hover:underline">
|
||||||
|
Esqueceste-te da palavra-passe?
|
||||||
|
</a>
|
||||||
|
{/* <p className="mt-8 text-xs text-neutral-400">
|
||||||
|
Não tens conta?{" "}
|
||||||
|
<a href="#" className="font-bold text-neutral-900 dark:text-white">
|
||||||
|
Cria uma agora.
|
||||||
|
</a>
|
||||||
|
</p> */}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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: "none",
|
||||||
|
path: "/",
|
||||||
|
...(env.COOKIE_DOMAIN ? { domain: env.COOKIE_DOMAIN } : {}),
|
||||||
|
maxAge: data.expires_in,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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: "none",
|
||||||
|
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: "none",
|
||||||
|
path: "/",
|
||||||
|
...(env.COOKIE_DOMAIN ? { domain: env.COOKIE_DOMAIN } : {}),
|
||||||
|
maxAge: data.expires_in,
|
||||||
|
});
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ message: "Refresh failed" }, { status: 401 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { env } from "@/lib/env";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
const { token } = await req.json();
|
||||||
|
|
||||||
|
const res = NextResponse.json({ ok: true });
|
||||||
|
|
||||||
|
res.cookies.set("auth_token", token, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: true,
|
||||||
|
sameSite: "none",
|
||||||
|
...(env.COOKIE_DOMAIN ? { domain: env.COOKIE_DOMAIN } : {}),
|
||||||
|
path: "/",
|
||||||
|
});
|
||||||
|
|
||||||
|
return res;
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
// export function CategoryModal({
|
||||||
|
// open,
|
||||||
|
// onClose,
|
||||||
|
// form,
|
||||||
|
// setForm,
|
||||||
|
// onSave,
|
||||||
|
// }: any) {
|
||||||
|
// if (!open) return null;
|
||||||
|
|
||||||
|
// return (
|
||||||
|
// <div className="fixed inset-0 bg-black/30 flex items-center justify-center">
|
||||||
|
|
||||||
|
// <div className="bg-white p-5 rounded-xl w-[400px]">
|
||||||
|
|
||||||
|
// <h2 className="font-semibold mb-3">
|
||||||
|
// {form.id ? "Edit Category" : "New 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 })
|
||||||
|
// }
|
||||||
|
// />
|
||||||
|
|
||||||
|
// <input
|
||||||
|
// className="w-full border p-2 rounded mb-3"
|
||||||
|
// placeholder="Slug (auto)"
|
||||||
|
// value={form.slug}
|
||||||
|
// onChange={(e) =>
|
||||||
|
// setForm({ ...form, slug: e.target.value })
|
||||||
|
// }
|
||||||
|
// />
|
||||||
|
|
||||||
|
// <div className="flex justify-end gap-2">
|
||||||
|
// <button onClick={onClose}>Cancel</button>
|
||||||
|
|
||||||
|
// <button
|
||||||
|
// onClick={onSave}
|
||||||
|
// className="bg-blue-600 text-white px-3 py-1 rounded"
|
||||||
|
// >
|
||||||
|
// Save
|
||||||
|
// </button>
|
||||||
|
// </div>
|
||||||
|
// </div>
|
||||||
|
// </div>
|
||||||
|
// );
|
||||||
|
// }
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
// "use client";
|
||||||
|
|
||||||
|
// import React, { useState } from "react";
|
||||||
|
// import {
|
||||||
|
// FolderTree,
|
||||||
|
// Edit3,
|
||||||
|
// Trash2,
|
||||||
|
// Plus,
|
||||||
|
// ChevronRight,
|
||||||
|
// ChevronDown,
|
||||||
|
// } from "lucide-react";
|
||||||
|
// import { Category } from "@/lib/categories.api";
|
||||||
|
|
||||||
|
// export function CategoryTree({
|
||||||
|
// nodes,
|
||||||
|
// onEdit,
|
||||||
|
// onDelete,
|
||||||
|
// onAddChild,
|
||||||
|
// }: {
|
||||||
|
// nodes: Category[];
|
||||||
|
// onEdit: (c: Category) => void;
|
||||||
|
// onDelete: (id: string) => void;
|
||||||
|
// onAddChild: (parentId: string) => void;
|
||||||
|
// }) {
|
||||||
|
// return (
|
||||||
|
// <div>
|
||||||
|
// {nodes.map((node) => (
|
||||||
|
// <TreeNode
|
||||||
|
// key={node.id}
|
||||||
|
// node={node}
|
||||||
|
// onEdit={onEdit}
|
||||||
|
// onDelete={onDelete}
|
||||||
|
// onAddChild={onAddChild}
|
||||||
|
// />
|
||||||
|
// ))}
|
||||||
|
// </div>
|
||||||
|
// );
|
||||||
|
// }
|
||||||
|
|
||||||
|
// function TreeNode({
|
||||||
|
// node,
|
||||||
|
// onEdit,
|
||||||
|
// onDelete,
|
||||||
|
// onAddChild,
|
||||||
|
// }: {
|
||||||
|
// node: Category;
|
||||||
|
// onEdit: (c: Category) => void;
|
||||||
|
// onDelete: (id: string) => void;
|
||||||
|
// onAddChild: (parentId: string) => void;
|
||||||
|
// }) {
|
||||||
|
// const [open, setOpen] = useState(true);
|
||||||
|
|
||||||
|
// return (
|
||||||
|
// <div className="ml-2 border-l pl-3">
|
||||||
|
|
||||||
|
// {/* NODE ROW */}
|
||||||
|
// <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" />
|
||||||
|
|
||||||
|
// {/* INLINE EDIT TRIGGER */}
|
||||||
|
// <span
|
||||||
|
// onClick={() => onEdit(node)}
|
||||||
|
// className="text-sm font-medium cursor-pointer hover:text-blue-600"
|
||||||
|
// >
|
||||||
|
// {node.name}
|
||||||
|
// </span>
|
||||||
|
// </div>
|
||||||
|
|
||||||
|
// {/* ACTIONS (SHOW ON HOVER) */}
|
||||||
|
// <div className="flex gap-2 opacity-0 group-hover:opacity-100 transition">
|
||||||
|
|
||||||
|
// <button
|
||||||
|
// onClick={() => onAddChild(node.id)}
|
||||||
|
// className="text-green-600"
|
||||||
|
// >
|
||||||
|
// <Plus size={14} />
|
||||||
|
// </button>
|
||||||
|
|
||||||
|
// <button onClick={() => onEdit(node)}>
|
||||||
|
// <Edit3 size={14} />
|
||||||
|
// </button>
|
||||||
|
|
||||||
|
// <button onClick={() => onDelete(node.id)}>
|
||||||
|
// <Trash2 size={14} className="text-red-500" />
|
||||||
|
// </button>
|
||||||
|
// </div>
|
||||||
|
// </div>
|
||||||
|
|
||||||
|
// {/* CHILDREN */}
|
||||||
|
// {open && node.children?.length ? (
|
||||||
|
// <div>
|
||||||
|
// {node.children.map((child) => (
|
||||||
|
// <TreeNode
|
||||||
|
// key={child.id}
|
||||||
|
// node={child}
|
||||||
|
// onEdit={onEdit}
|
||||||
|
// onDelete={onDelete}
|
||||||
|
// onAddChild={onAddChild}
|
||||||
|
// />
|
||||||
|
// ))}
|
||||||
|
// </div>
|
||||||
|
// ) : null}
|
||||||
|
// </div>
|
||||||
|
// );
|
||||||
|
// }
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import React, { useState, useCallback } from 'react';
|
||||||
|
import Cropper from 'react-easy-crop';
|
||||||
|
import { ImageIcon, Maximize2, RefreshCw, Copy, Check, X } from 'lucide-react';
|
||||||
|
|
||||||
|
const ASPECT_RATIOS = [
|
||||||
|
{ id: 'hero', label: 'Hero Banner (21:9)', ratio: 21 / 9 },
|
||||||
|
{ id: 'news', label: 'Notícia/Media (16:9)', ratio: 16 / 9 },
|
||||||
|
{ id: 'square', label: 'Quadrado/Social (1:1)', ratio: 1 / 1 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const MultiAspectEditor = ({ image, onClose, onExport }: { image: string, onClose: () => void, onExport: (results: Record<string, string>) => void }) => {
|
||||||
|
const [crops, setCrops] = useState(
|
||||||
|
ASPECT_RATIOS.reduce((acc, curr) => ({
|
||||||
|
...acc,
|
||||||
|
[curr.id]: { crop: { x: 0, y: 0 }, zoom: 1, croppedAreaPixels: null }
|
||||||
|
}), {})
|
||||||
|
);
|
||||||
|
|
||||||
|
const onCropChange = (id: string, crop: any) => {
|
||||||
|
setCrops(prev => ({ ...prev, [id]: { ...(prev as any)[id], crop } }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const onZoomChange = (id: string, zoom: number) => {
|
||||||
|
setCrops(prev => ({ ...prev, [id]: { ...(prev as any)[id], zoom } }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const onCropComplete = useCallback((id: string, _: any, croppedAreaPixels: any) => {
|
||||||
|
setCrops(prev => ({ ...prev, [id]: { ...(prev as any)[id], croppedAreaPixels } }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const generateBase64 = async () => {
|
||||||
|
const results: Record<string, string> = {};
|
||||||
|
for (const ratio of ASPECT_RATIOS) {
|
||||||
|
const pixelCrop = (crops as any)[ratio.id].croppedAreaPixels;
|
||||||
|
if (pixelCrop) {
|
||||||
|
results[ratio.id as keyof typeof results] = await getCroppedImg(image, pixelCrop);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onExport(results);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-[100] bg-slate-900/60 backdrop-blur-sm flex items-center justify-center p-4">
|
||||||
|
<div className="bg-white w-full max-w-6xl h-[90vh] rounded-3xl shadow-2xl flex flex-col overflow-hidden border border-white/20">
|
||||||
|
|
||||||
|
{/* Header do Modal */}
|
||||||
|
<div className="p-6 border-b border-slate-100 flex justify-between items-center bg-white/50 backdrop-blur-md">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-bold text-slate-900">Editor de Enquadramento</h2>
|
||||||
|
<p className="text-xs text-slate-500">Ajuste a imagem para os diferentes formatos do portal</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={onClose} className="p-2 hover:bg-slate-100 rounded-full transition-colors">
|
||||||
|
<X size={20} className="text-slate-400" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Área de Edição - Grid de Croppers */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-8 bg-slate-50/50">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||||
|
{ASPECT_RATIOS.map((r) => (
|
||||||
|
<div key={r.id} className="flex flex-col gap-4">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="text-[11px] font-bold uppercase tracking-wider text-slate-400">{r.label}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => onZoomChange(r.id, 1)}
|
||||||
|
className="text-blue-600 p-1 hover:bg-blue-50 rounded text-[10px] flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<RefreshCw size={12}/> Reset
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Container do Cropper */}
|
||||||
|
<div className="relative h-64 bg-slate-200 rounded-2xl overflow-hidden border border-slate-200 shadow-inner">
|
||||||
|
<Cropper
|
||||||
|
image={image}
|
||||||
|
crop={(crops as any)[r.id].crop}
|
||||||
|
zoom={(crops as any)[r.id].zoom}
|
||||||
|
aspect={r.ratio}
|
||||||
|
onCropChange={(c) => onCropChange(r.id, c)}
|
||||||
|
onCropComplete={(_, pix) => onCropComplete(r.id, _, pix)}
|
||||||
|
onZoomChange={(z) => onZoomChange(r.id, z)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Slider de Zoom Customizado */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-slate-400"><Maximize2 size={14}/></span>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
value={(crops as any)[r.id].zoom}
|
||||||
|
min={1}
|
||||||
|
max={3}
|
||||||
|
step={0.1}
|
||||||
|
aria-labelledby="Zoom"
|
||||||
|
onChange={(e) => onZoomChange(r.id, Number(e.target.value))}
|
||||||
|
className="w-full h-1.5 bg-slate-200 rounded-lg appearance-none cursor-pointer accent-blue-600"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer com Ações */}
|
||||||
|
<div className="p-6 border-t border-slate-100 bg-white flex justify-end gap-4">
|
||||||
|
<button onClick={onClose} className="px-6 py-2.5 text-sm font-semibold text-slate-500 hover:text-slate-700">
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={generateBase64}
|
||||||
|
className="px-8 py-2.5 bg-blue-600 text-white rounded-xl text-sm font-bold shadow-lg shadow-blue-200 hover:bg-blue-700 transition-all flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Check size={18}/> Finalizar e Exportar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Função Utilitária para Canvas -> Base64
|
||||||
|
async function getCroppedImg(imageSrc: string, pixelCrop: any) {
|
||||||
|
const image = await new Promise((resolve, reject) => {
|
||||||
|
const img = new Image();
|
||||||
|
img.addEventListener('load', () => resolve(img));
|
||||||
|
img.addEventListener('error', (error) => reject(error));
|
||||||
|
img.setAttribute('crossOrigin', 'anonymous');
|
||||||
|
img.src = imageSrc;
|
||||||
|
});
|
||||||
|
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
const ctx = canvas.getContext('2d') as CanvasRenderingContext2D | null;
|
||||||
|
if (!ctx) return '';
|
||||||
|
|
||||||
|
canvas.width = pixelCrop.width;
|
||||||
|
canvas.height = pixelCrop.height;
|
||||||
|
|
||||||
|
ctx.drawImage(
|
||||||
|
image as CanvasImageSource,
|
||||||
|
pixelCrop.x,
|
||||||
|
pixelCrop.y,
|
||||||
|
pixelCrop.width,
|
||||||
|
pixelCrop.height,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
pixelCrop.width,
|
||||||
|
pixelCrop.height
|
||||||
|
);
|
||||||
|
|
||||||
|
return canvas.toDataURL('image/jpeg', 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MultiAspectEditor;
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,8 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useCallback, useEffect, useId, useRef, useState } from "react";
|
import { useCallback, useEffect, useId, useRef, useState } from "react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
|
import { useTheme } from "next-themes";
|
||||||
|
import { Moon, Sun } from "lucide-react";
|
||||||
|
|
||||||
const primaryNav = [
|
const primaryNav = [
|
||||||
{ label: "Música", href: "#" },
|
{ label: "Música", href: "#" },
|
||||||
@@ -69,6 +71,9 @@ export function TvoneSiteNav() {
|
|||||||
|
|
||||||
const closeMenu = useCallback(() => setMenuOpen(false), []);
|
const closeMenu = useCallback(() => setMenuOpen(false), []);
|
||||||
const toggleMenu = useCallback(() => setMenuOpen((o) => !o), []);
|
const toggleMenu = useCallback(() => setMenuOpen((o) => !o), []);
|
||||||
|
const { theme, setTheme } = useTheme();
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = sentinelRef.current;
|
const el = sentinelRef.current;
|
||||||
@@ -82,12 +87,14 @@ export function TvoneSiteNav() {
|
|||||||
sync();
|
sync();
|
||||||
window.addEventListener("scroll", sync, { passive: true });
|
window.addEventListener("scroll", sync, { passive: true });
|
||||||
window.addEventListener("resize", sync);
|
window.addEventListener("resize", sync);
|
||||||
|
setMounted(true);
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener("scroll", sync);
|
window.removeEventListener("scroll", sync);
|
||||||
window.removeEventListener("resize", sync);
|
window.removeEventListener("resize", sync);
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!menuOpen) return;
|
if (!menuOpen) return;
|
||||||
const onKey = (e: KeyboardEvent) => {
|
const onKey = (e: KeyboardEvent) => {
|
||||||
@@ -163,6 +170,16 @@ export function TvoneSiteNav() {
|
|||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div className="ml-auto flex shrink-0 items-center gap-1 sm:gap-2">
|
<div className="ml-auto flex shrink-0 items-center gap-1 sm:gap-2">
|
||||||
|
{/* Theme Toggle */}
|
||||||
|
{mounted && (
|
||||||
|
<button
|
||||||
|
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
|
||||||
|
className="flex h-10 w-10 items-center justify-center rounded-md hover:bg-white/10"
|
||||||
|
>
|
||||||
|
{theme === "dark" ? <Moon size={18} /> : <Sun size={18} />}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex h-10 w-10 items-center justify-center rounded-md hover:bg-white/10 sm:h-11 sm:w-11"
|
className="flex h-10 w-10 items-center justify-center rounded-md hover:bg-white/10 sm:h-11 sm:w-11"
|
||||||
|
|||||||
@@ -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
|
||||||
|
// });
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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',
|
||||||
|
// };
|
||||||
|
// }
|
||||||
|
// };
|
||||||
+11
-4
@@ -1,6 +1,8 @@
|
|||||||
import type { Metadata, Viewport } from "next";
|
import type { Metadata, Viewport } from "next";
|
||||||
import { Inter } from "next/font/google";
|
import { Inter } from "next/font/google";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
import { GoogleOAuthProvider } from "@react-oauth/google";
|
||||||
|
import { ThemeProvider } from 'next-themes'
|
||||||
|
|
||||||
const inter = Inter({
|
const inter = Inter({
|
||||||
variable: "--font-inter",
|
variable: "--font-inter",
|
||||||
@@ -36,11 +38,16 @@ export default function RootLayout({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html lang="pt" className={`${inter.variable} h-full antialiased`}>
|
// 1. We remove "light" from className so ThemeProvider can inject it
|
||||||
<body
|
// 2. We remove style={{ colorScheme: 'light' }}
|
||||||
className={`min-h-full flex flex-col bg-white text-neutral-900 ${inter.className}`}
|
<html lang="pt" className={`${inter.variable} h-full antialiased`} suppressHydrationWarning>
|
||||||
>
|
<body className={`min-h-full flex flex-col bg-[#f5f5f7] text-neutral-900 dark:bg-black dark:text-white ${inter.className}`}>
|
||||||
|
<GoogleOAuthProvider clientId="618391854803-gtdbtnf5t78stsmd1724s8c456tfq4lr.apps.googleusercontent.com">
|
||||||
|
{/* Ensure attribute="class" is set here */}
|
||||||
|
<ThemeProvider attribute="class" defaultTheme="light" enableSystem={true}>
|
||||||
{children}
|
{children}
|
||||||
|
</ThemeProvider>
|
||||||
|
</GoogleOAuthProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
const API = `${process.env.NEXT_PUBLIC_API_URL || "https://tvone-api.petermaquiran.xyz"}/categories`;
|
||||||
|
|
||||||
|
export interface Category {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
parentId?: string | null;
|
||||||
|
children?: Category[];
|
||||||
|
}
|
||||||
|
|
||||||
|
//var token = 'eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJzSnBBLWUtcTEyc3ZVUlpLLUpCbU9lVzQxVDhIcGRKQnlLYlVkbHQxVDNZIn0.eyJleHAiOjE3NzY1NjA0MzIsImlhdCI6MTc3NjU2MDEzMiwiYXV0aF90aW1lIjoxNzc2NTYwMTMyLCJqdGkiOiI4ZTUzYmY3YS0wNDMzLTQ4MzQtOGE4NS02NjQ4YTMyOTliYWIiLCJpc3MiOiJodHRwczovL2tleWNsb2FrLnBldGVybWFxdWlyYW4ueHl6L3JlYWxtcy90dm9uZSIsImF1ZCI6ImFjY291bnQiLCJzdWIiOiJiY2RkYTYwOS00OThhLTQxNzgtYTEwMy04N2QzN2IxN2U1YzMiLCJ0eXAiOiJCZWFyZXIiLCJhenAiOiJ0dm9uZS13ZWIiLCJzZXNzaW9uX3N0YXRlIjoiMWVlZDBhOTMtOGFlNi00ZDBlLTg0MjItMGJmYTA3ZmViYTBiIiwiYWNyIjoiMSIsImFsbG93ZWQtb3JpZ2lucyI6WyJodHRwczovL3R2b25lLnBldGVybWFxdWlyYW4ueHl6IiwiaHR0cDovL2xvY2FsaG9zdDozMDAwIl0sInJlYWxtX2FjY2VzcyI6eyJyb2xlcyI6WyJvZmZsaW5lX2FjY2VzcyIsInVtYV9hdXRob3JpemF0aW9uIiwiZGVmYXVsdC1yb2xlcy10dm9uZSJdfSwicmVzb3VyY2VfYWNjZXNzIjp7ImFjY291bnQiOnsicm9sZXMiOlsibWFuYWdlLWFjY291bnQiLCJtYW5hZ2UtYWNjb3VudC1saW5rcyIsInZpZXctcHJvZmlsZSJdfX0sInNjb3BlIjoib3BlbmlkIGVtYWlsIHByb2ZpbGUiLCJzaWQiOiIxZWVkMGE5My04YWU2LTRkMGUtODQyMi0wYmZhMDdmZWJhMGIiLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwibmFtZSI6IlBldGVyIE1hcXVpcmFuIiwicHJlZmVycmVkX3VzZXJuYW1lIjoicGV0ZXJtYXF1aXJhbjI5QGdtYWlsLmNvbSIsImdpdmVuX25hbWUiOiJQZXRlciIsImZhbWlseV9uYW1lIjoiTWFxdWlyYW4iLCJwaWN0dXJlIjoiaHR0cHM6Ly9saDMuZ29vZ2xldXNlcmNvbnRlbnQuY29tL2EvQUNnOG9jTG5QUmpKbllhZ2tDT0VIbVRGY1IyZVVOaWNSLU45bG5QQkZuV0RSa0hnVF9JeHpocz1zOTYtYyIsImVtYWlsIjoicGV0ZXJtYXF1aXJhbjI5QGdtYWlsLmNvbSJ9.K9uo2g2nK7VjkcCfVyex39iCAAV32ASTSsF0jicUAWlupv8IwYOv4wToyGyetC7yfAqpxaPqeHIOd_QJ3V60jQeJu10J_P78BHw01oe1ONczmAMm3Lt175-i70m8lOmPFXhVPbzGCLrUxWtOC1npS1to1y_QvtMmU11owcZvjy7InV4KpOUUmJkp2OMiSEpDV7tiVNBm7YtoXHhCeTN3-jpipV16yhBJuMfdyVhqK0gYT_z6bnbkvND6F1XG2D-A0cYwuc2NYeSwQT-F3Gxyw09JioZTEN_mn6sMRjy2zgm4oz0Owc1Qv6Exi2my32734e8Y7o-0RcFZpUFfkegdFA';
|
||||||
|
|
||||||
|
export async function getCategoriesTree(): Promise<Category[]> {
|
||||||
|
const res = await fetch(`${API}/`, {credentials: "include", headers: {
|
||||||
|
//Authorization: "Bearer "+token,
|
||||||
|
}});
|
||||||
|
const data = await res.json();
|
||||||
|
return Array.isArray(data) ? data : data?.data ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCategoriesFlat(): Promise<Category[]> {
|
||||||
|
const res = await fetch(API, {credentials: "include",headers: {
|
||||||
|
// Authorization: "Bearer "+token,
|
||||||
|
}});
|
||||||
|
const data = await res.json();
|
||||||
|
return Array.isArray(data) ? data : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createCategory(payload: Partial<Category>) {
|
||||||
|
return fetch(API, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateCategory(id: string, payload: Partial<Category>) {
|
||||||
|
return fetch(`${API}/${id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteCategory(id: string) {
|
||||||
|
return fetch(`${API}/${id}`, { method: "DELETE", credentials: "include", });
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export async function getTree(): Promise<Category[]> {
|
||||||
|
const res = await fetch(`${API}/`, {credentials: "include",headers: {
|
||||||
|
// Authorization: "Bearer "+token,
|
||||||
|
}});
|
||||||
|
const data = await res.json();
|
||||||
|
return Array.isArray(data) ? data : data?.data ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getFlat(): Promise<Category[]> {
|
||||||
|
const res = await fetch(API, {credentials: "include",headers: {
|
||||||
|
// Authorization: "Bearer "+token,
|
||||||
|
}});
|
||||||
|
const data = await res.json();
|
||||||
|
return Array.isArray(data) ? data : [];
|
||||||
|
}
|
||||||
+22
@@ -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,
|
||||||
|
};
|
||||||
@@ -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}`;
|
||||||
|
}
|
||||||
@@ -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 []
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export function slugify(text: string) {
|
||||||
|
return text
|
||||||
|
.toLowerCase()
|
||||||
|
.trim()
|
||||||
|
.replace(/\s+/g, "-")
|
||||||
|
.replace(/[^a-z0-9-]/g, "");
|
||||||
|
}
|
||||||
@@ -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];
|
||||||
|
};
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -9,9 +9,18 @@
|
|||||||
"lint": "eslint"
|
"lint": "eslint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"@next/env": "^16.2.4",
|
||||||
|
"@react-oauth/google": "^0.13.5",
|
||||||
|
"@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",
|
||||||
"lucide-react": "^1.8.0",
|
"lucide-react": "^1.8.0",
|
||||||
"next": "16.2.1",
|
"next": "16.2.1",
|
||||||
|
"next-themes": "^0.4.6",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
"react-easy-crop": "^5.5.7",
|
"react-easy-crop": "^5.5.7",
|
||||||
|
|||||||
Generated
+127
@@ -8,15 +8,42 @@ importers:
|
|||||||
|
|
||||||
.:
|
.:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@dnd-kit/core':
|
||||||
|
specifier: ^6.3.1
|
||||||
|
version: 6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||||
|
'@dnd-kit/sortable':
|
||||||
|
specifier: ^10.0.0
|
||||||
|
version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)
|
||||||
|
'@dnd-kit/utilities':
|
||||||
|
specifier: ^3.2.2
|
||||||
|
version: 3.2.2(react@19.2.4)
|
||||||
|
'@next/env':
|
||||||
|
specifier: ^16.2.4
|
||||||
|
version: 16.2.4
|
||||||
|
'@react-oauth/google':
|
||||||
|
specifier: ^0.13.5
|
||||||
|
version: 0.13.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||||
|
'@tinymce/tinymce-react':
|
||||||
|
specifier: ^6.3.0
|
||||||
|
version: 6.3.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||||
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:
|
||||||
|
specifier: ^26.2.3
|
||||||
|
version: 26.2.3
|
||||||
lucide-react:
|
lucide-react:
|
||||||
specifier: ^1.8.0
|
specifier: ^1.8.0
|
||||||
version: 1.8.0(react@19.2.4)
|
version: 1.8.0(react@19.2.4)
|
||||||
next:
|
next:
|
||||||
specifier: 16.2.1
|
specifier: 16.2.1
|
||||||
version: 16.2.1(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
version: 16.2.1(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||||
|
next-themes:
|
||||||
|
specifier: ^0.4.6
|
||||||
|
version: 0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||||
react:
|
react:
|
||||||
specifier: 19.2.4
|
specifier: 19.2.4
|
||||||
version: 19.2.4
|
version: 19.2.4
|
||||||
@@ -131,6 +158,28 @@ packages:
|
|||||||
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
|
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
|
||||||
engines: {node: '>=6.9.0'}
|
engines: {node: '>=6.9.0'}
|
||||||
|
|
||||||
|
'@dnd-kit/accessibility@3.1.1':
|
||||||
|
resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==}
|
||||||
|
peerDependencies:
|
||||||
|
react: '>=16.8.0'
|
||||||
|
|
||||||
|
'@dnd-kit/core@6.3.1':
|
||||||
|
resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==}
|
||||||
|
peerDependencies:
|
||||||
|
react: '>=16.8.0'
|
||||||
|
react-dom: '>=16.8.0'
|
||||||
|
|
||||||
|
'@dnd-kit/sortable@10.0.0':
|
||||||
|
resolution: {integrity: sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==}
|
||||||
|
peerDependencies:
|
||||||
|
'@dnd-kit/core': ^6.3.0
|
||||||
|
react: '>=16.8.0'
|
||||||
|
|
||||||
|
'@dnd-kit/utilities@3.2.2':
|
||||||
|
resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==}
|
||||||
|
peerDependencies:
|
||||||
|
react: '>=16.8.0'
|
||||||
|
|
||||||
'@emnapi/core@1.9.1':
|
'@emnapi/core@1.9.1':
|
||||||
resolution: {integrity: sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==}
|
resolution: {integrity: sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==}
|
||||||
|
|
||||||
@@ -369,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==}
|
||||||
|
|
||||||
@@ -440,6 +492,12 @@ packages:
|
|||||||
resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==}
|
resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==}
|
||||||
engines: {node: '>=12.4.0'}
|
engines: {node: '>=12.4.0'}
|
||||||
|
|
||||||
|
'@react-oauth/google@0.13.5':
|
||||||
|
resolution: {integrity: sha512-xQWri2s/3nNekZJ4uuov2aAfQYu83bN3864KcFqw2pK1nNbFurQIjPFDXhWaKH3IjYJ2r/9yyIIpsn5lMqrheQ==}
|
||||||
|
peerDependencies:
|
||||||
|
react: '>=16.8.0'
|
||||||
|
react-dom: '>=16.8.0'
|
||||||
|
|
||||||
'@rtsao/scc@1.1.0':
|
'@rtsao/scc@1.1.0':
|
||||||
resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==}
|
resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==}
|
||||||
|
|
||||||
@@ -538,6 +596,16 @@ packages:
|
|||||||
'@tailwindcss/postcss@4.2.2':
|
'@tailwindcss/postcss@4.2.2':
|
||||||
resolution: {integrity: sha512-n4goKQbW8RVXIbNKRB/45LzyUqN451deQK0nzIeauVEqjlI49slUlgKYJM2QyUzap/PcpnS7kzSUmPb1sCRvYQ==}
|
resolution: {integrity: sha512-n4goKQbW8RVXIbNKRB/45LzyUqN451deQK0nzIeauVEqjlI49slUlgKYJM2QyUzap/PcpnS7kzSUmPb1sCRvYQ==}
|
||||||
|
|
||||||
|
'@tinymce/tinymce-react@6.3.0':
|
||||||
|
resolution: {integrity: sha512-E++xnn0XzDzpKr40jno2Kj7umfAE6XfINZULEBBeNjTMvbACWzA6CjiR6V8eTDc9yVmdVhIPqVzV4PqD5TZ/4g==}
|
||||||
|
peerDependencies:
|
||||||
|
react: ^19.0.0 || ^18.0.0 || ^17.0.1 || ^16.7.0
|
||||||
|
react-dom: ^19.0.0 || ^18.0.0 || ^17.0.1 || ^16.7.0
|
||||||
|
tinymce: ^8.0.0 || ^7.0.0 || ^6.0.0 || ^5.5.1
|
||||||
|
peerDependenciesMeta:
|
||||||
|
tinymce:
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@tybys/wasm-util@0.10.1':
|
'@tybys/wasm-util@0.10.1':
|
||||||
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
|
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
|
||||||
|
|
||||||
@@ -1389,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==}
|
||||||
|
|
||||||
@@ -1423,6 +1494,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==}
|
resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==}
|
||||||
engines: {node: '>=4.0'}
|
engines: {node: '>=4.0'}
|
||||||
|
|
||||||
|
keycloak-js@26.2.3:
|
||||||
|
resolution: {integrity: sha512-widjzw/9T6bHRgEp6H/Se3NCCarU7u5CwFKBcwtu7xfA1IfdZb+7Q7/KGusAnBo34Vtls8Oz9vzSqkQvQ7+b4Q==}
|
||||||
|
|
||||||
keyv@4.5.4:
|
keyv@4.5.4:
|
||||||
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
|
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
|
||||||
|
|
||||||
@@ -1577,6 +1651,12 @@ packages:
|
|||||||
natural-compare@1.4.0:
|
natural-compare@1.4.0:
|
||||||
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
|
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
|
||||||
|
|
||||||
|
next-themes@0.4.6:
|
||||||
|
resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==}
|
||||||
|
peerDependencies:
|
||||||
|
react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
|
||||||
|
react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
|
||||||
|
|
||||||
next@16.2.1:
|
next@16.2.1:
|
||||||
resolution: {integrity: sha512-VaChzNL7o9rbfdt60HUj8tev4m6d7iC1igAy157526+cJlXOQu5LzsBXNT+xaJnTP/k+utSX5vMv7m0G+zKH+Q==}
|
resolution: {integrity: sha512-VaChzNL7o9rbfdt60HUj8tev4m6d7iC1igAy157526+cJlXOQu5LzsBXNT+xaJnTP/k+utSX5vMv7m0G+zKH+Q==}
|
||||||
engines: {node: '>=20.9.0'}
|
engines: {node: '>=20.9.0'}
|
||||||
@@ -2117,6 +2197,31 @@ snapshots:
|
|||||||
'@babel/helper-string-parser': 7.27.1
|
'@babel/helper-string-parser': 7.27.1
|
||||||
'@babel/helper-validator-identifier': 7.28.5
|
'@babel/helper-validator-identifier': 7.28.5
|
||||||
|
|
||||||
|
'@dnd-kit/accessibility@3.1.1(react@19.2.4)':
|
||||||
|
dependencies:
|
||||||
|
react: 19.2.4
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@dnd-kit/core@6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||||
|
dependencies:
|
||||||
|
'@dnd-kit/accessibility': 3.1.1(react@19.2.4)
|
||||||
|
'@dnd-kit/utilities': 3.2.2(react@19.2.4)
|
||||||
|
react: 19.2.4
|
||||||
|
react-dom: 19.2.4(react@19.2.4)
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)':
|
||||||
|
dependencies:
|
||||||
|
'@dnd-kit/core': 6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||||
|
'@dnd-kit/utilities': 3.2.2(react@19.2.4)
|
||||||
|
react: 19.2.4
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@dnd-kit/utilities@3.2.2(react@19.2.4)':
|
||||||
|
dependencies:
|
||||||
|
react: 19.2.4
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@emnapi/core@1.9.1':
|
'@emnapi/core@1.9.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@emnapi/wasi-threads': 1.2.0
|
'@emnapi/wasi-threads': 1.2.0
|
||||||
@@ -2315,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
|
||||||
@@ -2357,6 +2464,11 @@ snapshots:
|
|||||||
|
|
||||||
'@nolyfill/is-core-module@1.0.39': {}
|
'@nolyfill/is-core-module@1.0.39': {}
|
||||||
|
|
||||||
|
'@react-oauth/google@0.13.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||||
|
dependencies:
|
||||||
|
react: 19.2.4
|
||||||
|
react-dom: 19.2.4(react@19.2.4)
|
||||||
|
|
||||||
'@rtsao/scc@1.1.0': {}
|
'@rtsao/scc@1.1.0': {}
|
||||||
|
|
||||||
'@swc/helpers@0.5.15':
|
'@swc/helpers@0.5.15':
|
||||||
@@ -2432,6 +2544,12 @@ snapshots:
|
|||||||
postcss: 8.5.8
|
postcss: 8.5.8
|
||||||
tailwindcss: 4.2.2
|
tailwindcss: 4.2.2
|
||||||
|
|
||||||
|
'@tinymce/tinymce-react@6.3.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||||
|
dependencies:
|
||||||
|
prop-types: 15.8.1
|
||||||
|
react: 19.2.4
|
||||||
|
react-dom: 19.2.4(react@19.2.4)
|
||||||
|
|
||||||
'@tybys/wasm-util@0.10.1':
|
'@tybys/wasm-util@0.10.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
@@ -3445,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:
|
||||||
@@ -3472,6 +3592,8 @@ snapshots:
|
|||||||
object.assign: 4.1.7
|
object.assign: 4.1.7
|
||||||
object.values: 1.2.1
|
object.values: 1.2.1
|
||||||
|
|
||||||
|
keycloak-js@26.2.3: {}
|
||||||
|
|
||||||
keyv@4.5.4:
|
keyv@4.5.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
json-buffer: 3.0.1
|
json-buffer: 3.0.1
|
||||||
@@ -3591,6 +3713,11 @@ snapshots:
|
|||||||
|
|
||||||
natural-compare@1.4.0: {}
|
natural-compare@1.4.0: {}
|
||||||
|
|
||||||
|
next-themes@0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
|
||||||
|
dependencies:
|
||||||
|
react: 19.2.4
|
||||||
|
react-dom: 19.2.4(react@19.2.4)
|
||||||
|
|
||||||
next@16.2.1(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
|
next@16.2.1(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@next/env': 16.2.1
|
'@next/env': 16.2.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