mirror of
https://github.com/PeterMaquiran/tvone.git
synced 2026-04-18 15:27:52 +00:00
Compare commits
25 Commits
c46857514b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 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
|
||||
@@ -2,9 +2,9 @@
|
||||
import React, { useState } from 'react';
|
||||
import Image from 'next/image';
|
||||
import { ThumbsUp, MessageCircle, Send, Share2, LinkIcon, Link2, ChevronRight } from 'lucide-react';
|
||||
import { TvoneAdBanner, TvoneFooter } from '../components/tvone-content';
|
||||
import { TvonePromoStrip } from '../components/tvone-promo-strip';
|
||||
import { TvoneSiteNav } from '../components/tvone-site-nav';
|
||||
import { TvoneAdBanner, TvoneFooter } from '../../components/tvone-content';
|
||||
import { TvonePromoStrip } from '../../components/tvone-promo-strip';
|
||||
import { TvoneSiteNav } from '../../components/tvone-site-nav';
|
||||
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 Link from 'next/link';
|
||||
@@ -0,0 +1,364 @@
|
||||
"use client";
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import Image from 'next/image';
|
||||
import {
|
||||
LayoutDashboard, Newspaper, Users, BarChart3,
|
||||
Settings, HelpCircle, Image as ImageIcon,
|
||||
Type, Calendar, Clock, Tag, User, Save, Eye, Send
|
||||
} from 'lucide-react';
|
||||
import Keycloak from "keycloak-js";
|
||||
|
||||
// Importe o componente que criámos (ajuste o caminho se necessário)
|
||||
import MultiAspectEditor from '../../components/MultiAspectEditor';
|
||||
import dynamic from "next/dynamic";
|
||||
const Editor = dynamic(
|
||||
() => import("@tinymce/tinymce-react").then((mod) => mod.Editor),
|
||||
{ ssr: false }
|
||||
);
|
||||
|
||||
const keycloak = new Keycloak({
|
||||
url: "https://keycloak.petermaquiran.xyz",
|
||||
realm: "tvone", // ✅ IMPORTANT
|
||||
clientId: "tvone-web", // must match Keycloak client
|
||||
});
|
||||
|
||||
interface GoogleAuthResponse {
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
expires_in: number;
|
||||
scope: string;
|
||||
authuser?: string;
|
||||
prompt?: string;
|
||||
}
|
||||
|
||||
interface KeycloakTokenResponse {
|
||||
access_token: string;
|
||||
expires_in: number;
|
||||
refresh_expires_in: number;
|
||||
refresh_token: string;
|
||||
token_type: string;
|
||||
id_token?: string;
|
||||
"not-before-policy": number;
|
||||
session_state: string;
|
||||
scope: string;
|
||||
}
|
||||
|
||||
const CreateNewsPage = () => {
|
||||
|
||||
// Configuração do Design do Editor para combinar com seu layout
|
||||
const editorConfig = {
|
||||
height: 500,
|
||||
menubar: false,
|
||||
plugins: [
|
||||
'advlist', 'autolink', 'lists', 'link', 'image', 'charmap', 'preview',
|
||||
'anchor', 'searchreplace', 'visualblocks', 'code', 'fullscreen',
|
||||
'insertdatetime', 'media', 'table', 'code', 'help', 'wordcount'
|
||||
],
|
||||
// Adicionei 'blockquote' na toolbar
|
||||
toolbar: 'undo redo | blocks | ' +
|
||||
'bold italic forecolor | alignleft aligncenter ' +
|
||||
'alignright alignjustify | bullist numlist | ' +
|
||||
'blockquote link image | removeformat | help',
|
||||
|
||||
// Customização do aspeto da citação dentro do editor
|
||||
content_style: `
|
||||
body {
|
||||
font-family: Inter, Helvetica, Arial, sans-serif;
|
||||
font-size: 14px;
|
||||
color: #334155;
|
||||
line-height: 1.6;
|
||||
}
|
||||
blockquote {
|
||||
border-left: 4px solid #2563eb; /* Azul da TVone */
|
||||
padding-left: 1.5rem;
|
||||
color: #475569;
|
||||
font-style: italic;
|
||||
margin: 1.5rem 0;
|
||||
background: #f8fafc;
|
||||
padding: 1rem 1.5rem;
|
||||
border-radius: 0 8px 8px 0;
|
||||
}
|
||||
`,
|
||||
skin: 'oxide',
|
||||
promotion: false, // Remove o botão "Upgrade" do Tiny
|
||||
branding: false, // Remove o "Powered by Tiny"
|
||||
};
|
||||
|
||||
// 1. Estados para o Crop
|
||||
const [tempImage, setTempImage] = useState<string | null>(null);
|
||||
const [isEditorOpen, setIsEditorOpen] = useState(false);
|
||||
const [finalCrops, setFinalCrops] = useState<any>(null); // Guarda os Base64 finais
|
||||
const [content, setContent] = useState('');
|
||||
const [user, setUser] = useState<{
|
||||
email?: string;
|
||||
name?: string;
|
||||
picture?: string;
|
||||
} | null>(null);
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// 2. Lógica de Upload
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files && e.target.files.length > 0) {
|
||||
const reader = new FileReader();
|
||||
reader.readAsDataURL(e.target.files[0]);
|
||||
reader.onload = () => {
|
||||
setTempImage(reader.result as string);
|
||||
setIsEditorOpen(true);
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const triggerUpload = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
// Avoid hydration mismatch by waiting for mount
|
||||
useEffect(() => {
|
||||
keycloak.init({
|
||||
onLoad: "check-sso",
|
||||
pkceMethod: "S256",
|
||||
}).then(async (authenticated) => {
|
||||
if (authenticated) {
|
||||
const token = keycloak.token!;
|
||||
localStorage.setItem("token", token);
|
||||
|
||||
const res = await fetch("http://localhost:3001/profile/", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
const profile = await res.json();
|
||||
|
||||
setUser(profile);
|
||||
console.log("Profile:", profile);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-slate-100/50 text-slate-900 font-sans">
|
||||
|
||||
{/* --- MODAL DO EDITOR (Renderiza fora do fluxo quando ativo) --- */}
|
||||
{isEditorOpen && tempImage && (
|
||||
<MultiAspectEditor
|
||||
image={tempImage}
|
||||
onClose={() => setIsEditorOpen(false)}
|
||||
onExport={(data) => {
|
||||
setFinalCrops(data); // Aqui tens o objeto com hero, news, square em Base64
|
||||
setIsEditorOpen(false);
|
||||
console.log("Imagens prontas para envio:", data);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Sidebar Lateral */}
|
||||
<aside className="w-64 backdrop-blur-xl bg-white/80 border-r border-slate-200 p-6 flex flex-col">
|
||||
<div className="flex items-center gap-2 mb-10 px-2">
|
||||
<div className="w-10 h-10 bg-white rounded-lg flex items-center justify-center font-bold italic">
|
||||
<Image src="/logo.png" alt="TVone" width={50} height={50} />
|
||||
</div>
|
||||
<span className="font-bold text-sm tracking-tight leading-tight uppercase">
|
||||
TVone<br/><span className="text-blue-600 text-[10px]">de Notícias</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 space-y-1">
|
||||
<NavItem icon={<LayoutDashboard size={18}/>} label="Painel" />
|
||||
<NavItem icon={<Newspaper size={18}/>} label="Meus Artigos" />
|
||||
<NavItem icon={<Users size={18}/>} label="Equipa" />
|
||||
<NavItem icon={<BarChart3 size={18}/>} label="Análises" />
|
||||
<NavItem icon={<Newspaper size={18}/>} label="Adicionar Notícia" active />
|
||||
<NavItem icon={<Settings size={18}/>} label="Definições" />
|
||||
<NavItem icon={<HelpCircle size={18}/>} label="Ajuda" />
|
||||
</nav>
|
||||
|
||||
{/* User Activity Feed */}
|
||||
<div className="mt-auto pt-6 border-t border-slate-200">
|
||||
<h4 className="text-[10px] font-bold uppercase text-slate-400 mb-4 px-2 tracking-widest">Atividade Recente</h4>
|
||||
<div className="space-y-3 px-2">
|
||||
<ActivityItem user="James Wilson" action="atualizou 'Mercado'" />
|
||||
<ActivityItem user="Sarah Johnson" action="criou 'Startups'" />
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main Content Area */}
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
<header className="h-16 border-b border-slate-200 bg-white/50 backdrop-blur-md sticky top-0 z-10 px-8 flex items-center justify-between">
|
||||
<div className="w-1/3">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Pesquisar artigos..."
|
||||
className="w-full bg-slate-100 border-none rounded-full px-4 py-1.5 text-sm focus:ring-2 focus:ring-blue-500/20 outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-right">
|
||||
<p className="text-xs font-bold">{user?.name ?? "Loading..."}</p>
|
||||
<p className="text-[10px] text-slate-500">Editor</p>
|
||||
</div>
|
||||
<div className="w-8 h-8 bg-slate-300 rounded-full border border-white shadow-sm overflow-hidden">
|
||||
<img
|
||||
src={user?.picture ?? "https://ui-avatars.com/api/?name=User"}
|
||||
alt="User"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="p-8 max-w-6xl mx-auto">
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
<h1 className="text-2xl font-bold tracking-tight">Criar Nova Notícia</h1>
|
||||
<div className="flex gap-3">
|
||||
<button className="px-5 py-2 rounded-lg border border-slate-200 bg-white text-sm font-medium hover:bg-slate-50 transition-all flex items-center gap-2">
|
||||
<Save size={16}/> Salvar Rascunho
|
||||
</button>
|
||||
<button className="px-6 py-2 rounded-lg bg-blue-600 text-white text-sm font-medium hover:bg-blue-700 transition-all shadow-lg shadow-blue-200 flex items-center gap-2">
|
||||
<Send size={16}/> Publicar Artigo
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-8">
|
||||
{/* Coluna Principal */}
|
||||
<div className="col-span-2 space-y-6">
|
||||
<section className="bg-white rounded-2xl border border-slate-200 shadow-sm overflow-hidden">
|
||||
{/* Título */}
|
||||
<div className="p-6 pb-0">
|
||||
<label className="block text-xs font-bold uppercase text-slate-400 mb-2">Título do Artigo</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Insira o título principal da notícia..."
|
||||
className="w-full text-xl font-bold border-none focus:ring-0 placeholder:text-slate-300 p-0"
|
||||
/>
|
||||
<hr className="my-6 border-slate-100" />
|
||||
<label className="block text-xs font-bold uppercase text-slate-400 mb-2">Conteúdo Principal</label>
|
||||
</div>
|
||||
|
||||
{/* TinyMCE Editor */}
|
||||
<div className="border-t border-slate-50">
|
||||
<Editor
|
||||
apiKey='dmg1hghyf25x09mtg04hik0034yeadt1h6ai2ou68zhdvw11' // Obtenha em tiny.cloud ou use 'no-api-key' para teste
|
||||
init={editorConfig}
|
||||
value={content}
|
||||
onEditorChange={(newContent) => setContent(newContent)}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Resumo (Lead) */}
|
||||
<div className="bg-white rounded-2xl p-6 border border-slate-200 shadow-sm">
|
||||
<label className="block text-xs font-bold uppercase text-slate-400 mb-2 leading-8">Resumo (Lead)</label>
|
||||
<textarea
|
||||
rows={3}
|
||||
placeholder="Escreva um resumo curto para visualização..."
|
||||
className="w-full border-none focus:ring-0 resize-none text-slate-600 text-sm italic p-0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
<div className="bg-white rounded-2xl p-6 border border-slate-200 shadow-sm">
|
||||
<label className="block text-xs font-bold uppercase text-slate-400 mb-2 leading-8">Tags</label>
|
||||
<input type="text" placeholder="Insira as tags da notícia..." className="w-full border-none focus:ring-0 resize-none text-slate-600 text-sm italic p-0" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Coluna Lateral */}
|
||||
<div className="space-y-6">
|
||||
<section className="bg-white rounded-2xl p-6 border border-slate-200 shadow-sm space-y-6">
|
||||
<div>
|
||||
<label className="flex items-center gap-2 text-xs font-bold uppercase text-slate-400 mb-3">
|
||||
<Tag size={14}/> Categoria
|
||||
</label>
|
||||
<select className="w-full bg-slate-50 border border-slate-100 rounded-lg px-3 py-2 text-sm outline-none">
|
||||
<option>Negócios</option>
|
||||
<option>Tecnologia</option>
|
||||
<option>Desporto</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* --- INPUT DE UPLOAD ATUALIZADO --- */}
|
||||
<div>
|
||||
<label className="flex items-center gap-2 text-xs font-bold uppercase text-slate-400 mb-3">
|
||||
<ImageIcon size={14}/> Imagem de Capa
|
||||
</label>
|
||||
|
||||
<input
|
||||
type="file"
|
||||
hidden
|
||||
ref={fileInputRef}
|
||||
accept="image/*"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
|
||||
<div
|
||||
onClick={triggerUpload}
|
||||
className={`border-2 border-dashed rounded-xl p-8 flex flex-col items-center justify-center transition-all cursor-pointer group
|
||||
${finalCrops ? 'border-emerald-200 bg-emerald-50/30' : 'border-slate-100 bg-slate-50/50 hover:bg-slate-50'}`}
|
||||
>
|
||||
{finalCrops ? (
|
||||
<div className="text-center">
|
||||
<div className="w-12 h-12 bg-emerald-100 text-emerald-600 rounded-full flex items-center justify-center mx-auto mb-2">
|
||||
<ImageIcon size={24}/>
|
||||
</div>
|
||||
<p className="text-[10px] font-bold text-emerald-700 uppercase">3 Formatos Gerados</p>
|
||||
<p className="text-[9px] text-emerald-500 mt-1">Clique para alterar</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="w-10 h-10 bg-white rounded-full shadow-sm flex items-center justify-center text-blue-500 mb-2 group-hover:scale-110 transition-transform">
|
||||
<ImageIcon size={20}/>
|
||||
</div>
|
||||
<p className="text-[10px] font-medium text-slate-500 text-center">Clique para carregar e enquadrar</p>
|
||||
<p className="text-[9px] text-slate-400">Suporta JPG, PNG</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="flex items-center gap-2 text-xs font-bold uppercase text-slate-400 mb-3">
|
||||
<Calendar size={14}/> Agendamento
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<input type="date" className="bg-slate-50 border border-slate-100 rounded-lg px-2 py-2 text-[11px] outline-none" />
|
||||
<input type="time" className="bg-slate-50 border border-slate-100 rounded-lg px-2 py-2 text-[11px] outline-none" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<button className="w-full py-4 rounded-2xl border border-slate-200 bg-white text-sm font-bold flex items-center justify-center gap-2 hover:bg-slate-50 transition-all text-slate-600">
|
||||
<Eye size={18}/> Pré-visualizar Notícia
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Componentes Auxiliares para Limpeza de Código
|
||||
const NavItem = ({ icon, label, active = false }: { icon: any, label: string, active?: boolean }) => (
|
||||
<div className={`flex items-center gap-3 px-3 py-2 rounded-xl text-sm font-medium transition-all cursor-pointer ${
|
||||
active ? 'bg-blue-600 text-white shadow-md shadow-blue-200' : 'text-slate-500 hover:bg-slate-100'
|
||||
}`}>
|
||||
{icon}
|
||||
{label}
|
||||
</div>
|
||||
);
|
||||
|
||||
const ActivityItem = ({ user, action }: { user: string, action: string }) => (
|
||||
<div className="flex gap-2">
|
||||
<div className="w-6 h-6 bg-slate-200 rounded-full shrink-0" />
|
||||
<p className="text-[10px] leading-tight text-slate-600">
|
||||
<span className="font-bold block text-slate-900">{user}</span> {action}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default CreateNewsPage;
|
||||
@@ -0,0 +1,338 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
LayoutDashboard, Newspaper, Users, BarChart3,
|
||||
Settings, HelpCircle, TrendingUp, Eye, Clock,
|
||||
AlertCircle, ChevronRight, Edit3, Trash2, ExternalLink
|
||||
} from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
|
||||
const DashboardMain = () => {
|
||||
return (
|
||||
<div className="flex h-screen bg-[#F1F5F9] text-slate-900 font-sans">
|
||||
|
||||
{/* Sidebar Lateral - Consistente com a página de criação */}
|
||||
<aside className="w-64 backdrop-blur-xl bg-white/80 border-r border-slate-200 p-6 flex flex-col">
|
||||
<div className="flex items-center gap-2 mb-10 px-2">
|
||||
<div className="w-10 h-10 bg-white rounded-lg flex items-center justify-center font-bold italic">
|
||||
<Image src="/logo.png" alt="TVone" width={50} height={50} />
|
||||
</div>
|
||||
<span className="font-bold text-sm tracking-tight leading-tight uppercase">
|
||||
TVone<br/><span className="text-blue-600 text-[10px]">de Notícias</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 space-y-1">
|
||||
<NavItem icon={<LayoutDashboard size={18}/>} label="Painel Principal" active />
|
||||
<NavItem icon={<Newspaper size={18}/>} label="Meus Artigos" />
|
||||
<NavItem icon={<Users size={18}/>} label="Equipa" />
|
||||
<NavItem icon={<BarChart3 size={18}/>} label="Análises" />
|
||||
<NavItem icon={<Settings size={18}/>} label="Definições" />
|
||||
<NavItem icon={<HelpCircle size={18}/>} label="Ajuda" />
|
||||
</nav>
|
||||
|
||||
<div className="mt-auto pt-6 border-t border-slate-200">
|
||||
<div className="bg-blue-50 p-4 rounded-2xl">
|
||||
<p className="text-[10px] font-bold text-blue-600 uppercase mb-1">Dica do Dia</p>
|
||||
<p className="text-[11px] text-blue-800 leading-relaxed">
|
||||
Artigos com mais de 3 imagens têm 40% mais retenção.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Container do Fluxo de Atividade */}
|
||||
<div className="mt-auto pt-6 border-t border-slate-200">
|
||||
<h4 className="text-[10px] font-bold uppercase text-slate-400 mb-5 px-2 tracking-[0.15em]">
|
||||
Fluxo de Atividade
|
||||
</h4>
|
||||
|
||||
<div className="space-y-5 px-2">
|
||||
<ActivityItem
|
||||
name="James Wilson"
|
||||
action="atualizou"
|
||||
target="'Volatilidade do Mercado'"
|
||||
time="Agora mesmo"
|
||||
avatar="https://ui-avatars.com/api/?name=James+Wilson&background=E0F2FE&color=0369A1"
|
||||
/>
|
||||
|
||||
<ActivityItem
|
||||
name="Sarah Johnson"
|
||||
action="criou"
|
||||
target="'Financiamento de Startups'"
|
||||
time="Há 12 min"
|
||||
avatar="https://ui-avatars.com/api/?name=Sarah+Johnson&background=FEE2E2&color=B91C1C"
|
||||
/>
|
||||
|
||||
<ActivityItem
|
||||
name="Sarah Johnson"
|
||||
action="criou"
|
||||
target="'Tech Mercenary' em recentes"
|
||||
time="Há 45 min"
|
||||
avatar="https://ui-avatars.com/api/?name=Sarah+Johnson&background=FEE2E2&color=B91C1C"
|
||||
/>
|
||||
|
||||
<ActivityItem
|
||||
name="James Wilson"
|
||||
action="atualizou"
|
||||
target="'Volatilidade do Mercado'"
|
||||
time="Há 1h"
|
||||
avatar="https://ui-avatars.com/api/?name=James+Wilson&background=E0F2FE&color=0369A1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main Content Area */}
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
{/* Header Superior */}
|
||||
{/* Header Superior - Secção do Utilizador Atualizada */}
|
||||
<header className="h-16 border-b border-slate-200 bg-white/50 backdrop-blur-md sticky top-0 z-10 px-8 flex items-center justify-between">
|
||||
<div className="w-1/3">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Pesquisar artigos, autores..."
|
||||
className="w-full bg-slate-100 border-none rounded-full px-4 py-1.5 text-sm focus:ring-2 focus:ring-blue-500/20 outline-none transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-5">
|
||||
{/* Notificações (Opcional, mas completa o look) */}
|
||||
<button className="relative p-2 text-slate-400 hover:text-blue-600 transition-colors">
|
||||
<div className="absolute top-2 right-2.5 w-2 h-2 bg-red-500 border-2 border-white rounded-full"></div>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/></svg>
|
||||
</button>
|
||||
|
||||
{/* Menu do Utilizador */}
|
||||
<div className="flex items-center gap-3 pl-4 border-l border-slate-200 group cursor-pointer">
|
||||
<div className="text-right hidden sm:block">
|
||||
<p className="text-[11px] font-bold text-slate-900 leading-tight">James Wilson</p>
|
||||
<p className="text-[10px] text-emerald-600 font-medium">Online</p>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
{/* Container da Imagem com Efeito de Anel */}
|
||||
<div className="w-9 h-9 rounded-full border-2 border-white shadow-sm overflow-hidden ring-1 ring-slate-200 group-hover:ring-blue-400 transition-all">
|
||||
<img
|
||||
src="https://ui-avatars.com/api/?name=James+Wilson&background=0D8ABC&color=fff"
|
||||
alt="Avatar do utilizador"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Indicador de Status (Mobile) */}
|
||||
<div className="absolute bottom-0 right-0 w-2.5 h-2.5 bg-emerald-500 border-2 border-white rounded-full"></div>
|
||||
</div>
|
||||
|
||||
{/* Seta para indicar menu (Chevron) */}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="14" height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="text-slate-400 group-hover:text-slate-600 transition-transform group-hover:translate-y-0.5"
|
||||
>
|
||||
<path d="m6 9 6 6 6-6"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="p-8 space-y-8">
|
||||
|
||||
{/* Métricas Principais (Grid de 4 colunas) */}
|
||||
<div className="grid grid-cols-4 gap-6">
|
||||
<StatCard
|
||||
label="Artigos Publicados"
|
||||
value="14,352"
|
||||
trend="+12% este mês"
|
||||
icon={<Newspaper size={20} className="text-blue-600"/>}
|
||||
/>
|
||||
<StatCard
|
||||
label="Visualizações Totais"
|
||||
value="2.1M"
|
||||
trend="+5.4% vs ontem"
|
||||
icon={<TrendingUp size={20} className="text-emerald-600"/>}
|
||||
/>
|
||||
<StatCard
|
||||
label="Tempo Médio"
|
||||
value="4m 32s"
|
||||
trend="-2s média"
|
||||
icon={<Clock size={20} className="text-orange-600"/>}
|
||||
/>
|
||||
<StatCard
|
||||
label="Alertas Ativos"
|
||||
value="3"
|
||||
trend="Urgente"
|
||||
icon={<AlertCircle size={20} className="text-red-600"/>}
|
||||
isAlert
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-8">
|
||||
{/* Tabela de Artigos Recentes (Coluna Dupla) */}
|
||||
<div className="col-span-2 bg-white rounded-2xl border border-slate-200 shadow-sm overflow-hidden">
|
||||
<div className="p-6 border-b border-slate-100 flex justify-between items-center">
|
||||
<h3 className="font-bold text-slate-800 text-sm uppercase tracking-wider">Artigos Recentes</h3>
|
||||
<button className="text-blue-600 text-xs font-bold flex items-center gap-1 hover:underline">
|
||||
Ver Todos <ChevronRight size={14}/>
|
||||
</button>
|
||||
</div>
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead className="bg-slate-50 text-[10px] uppercase font-bold text-slate-400">
|
||||
<tr>
|
||||
<th className="px-6 py-3">Título</th>
|
||||
<th className="px-6 py-3">Estado</th>
|
||||
<th className="px-6 py-3">Autor</th>
|
||||
<th className="px-6 py-3">Visualizações</th>
|
||||
<th className="px-6 py-3">Ações</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-50">
|
||||
<TableRow
|
||||
title="Volatilidade do Mercado: Impactos na Economia"
|
||||
status="Publicado"
|
||||
author="James Wilson"
|
||||
views="1.2M"
|
||||
/>
|
||||
<TableRow
|
||||
title="O Futuro da Inteligência Artificial em 2026"
|
||||
status="Rascunho"
|
||||
author="Sarah Johnson"
|
||||
views="--"
|
||||
/>
|
||||
<TableRow
|
||||
title="Novas Políticas de Sustentabilidade na UE"
|
||||
status="Agendado"
|
||||
author="Ricardo Silva"
|
||||
views="0"
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Performance por Categoria (Coluna Única) */}
|
||||
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm p-6">
|
||||
<h3 className="font-bold text-slate-800 text-sm uppercase tracking-wider mb-6">Performance de Conteúdo</h3>
|
||||
<div className="space-y-6">
|
||||
<CategoryBar label="Tecnologia" percentage={85} color="bg-blue-500" />
|
||||
<CategoryBar label="Negócios" percentage={62} color="bg-emerald-500" />
|
||||
<CategoryBar label="Política" percentage={45} color="bg-purple-500" />
|
||||
<CategoryBar label="Desporto" percentage={30} color="bg-orange-500" />
|
||||
</div>
|
||||
|
||||
<div className="mt-8 p-4 bg-slate-50 rounded-xl">
|
||||
<p className="text-[11px] text-slate-500 text-center">
|
||||
O tráfego orgânico cresceu <strong>15%</strong> nos últimos 7 dias.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// --- Subcomponentes para Organização ---
|
||||
|
||||
const NavItem = ({ icon, label, active = false }: { icon: any, label: string, active?: boolean }) => (
|
||||
<div className={`flex items-center gap-3 px-3 py-2 rounded-xl text-sm font-medium transition-all cursor-pointer ${
|
||||
active ? 'bg-blue-600 text-white shadow-lg shadow-blue-200' : 'text-slate-500 hover:bg-slate-100'
|
||||
}`}>
|
||||
{icon}
|
||||
<span className="truncate">{label}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
const StatCard = ({ label, value, trend, icon, isAlert = false }: any) => (
|
||||
<div className={`p-5 rounded-2xl border bg-white shadow-sm transition-transform hover:scale-[1.02] cursor-default ${isAlert ? 'border-red-100 bg-red-50/20' : 'border-slate-200'}`}>
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div className={`p-2 rounded-lg ${isAlert ? 'bg-red-100' : 'bg-slate-50'}`}>
|
||||
{icon}
|
||||
</div>
|
||||
<span className={`text-[10px] font-bold px-2 py-0.5 rounded-full ${isAlert ? 'bg-red-500 text-white' : 'bg-emerald-50 text-emerald-600'}`}>
|
||||
{trend}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-500 font-medium">{label}</p>
|
||||
<h3 className="text-2xl font-bold tracking-tight">{value}</h3>
|
||||
</div>
|
||||
);
|
||||
|
||||
const TableRow = ({ title, status, author, views }: any) => (
|
||||
<tr className="hover:bg-slate-50/50 transition-colors group">
|
||||
<td className="px-6 py-4">
|
||||
<p className="text-sm font-semibold text-slate-800 truncate max-w-[200px]">{title}</p>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className={`text-[10px] font-bold px-2 py-1 rounded-md ${
|
||||
status === 'Publicado' ? 'bg-emerald-50 text-emerald-600' :
|
||||
status === 'Rascunho' ? 'bg-slate-100 text-slate-500' : 'bg-blue-50 text-blue-600'
|
||||
}`}>
|
||||
{status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-xs text-slate-600">{author}</td>
|
||||
<td className="px-6 py-4 text-xs font-mono font-medium">{views}</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button className="p-1.5 hover:bg-white rounded-md border border-slate-200 text-slate-400 hover:text-blue-600 shadow-sm"><Edit3 size={14}/></button>
|
||||
<button className="p-1.5 hover:bg-white rounded-md border border-slate-200 text-slate-400 hover:text-red-600 shadow-sm"><Trash2 size={14}/></button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
|
||||
const CategoryBar = ({ label, percentage, color }: any) => (
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-[11px] font-bold uppercase tracking-wide">
|
||||
<span>{label}</span>
|
||||
<span className="text-slate-400">{percentage}%</span>
|
||||
</div>
|
||||
<div className="w-full h-1.5 bg-slate-100 rounded-full overflow-hidden">
|
||||
<div className={`h-full ${color} rounded-full`} style={{ width: `${percentage}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default DashboardMain;
|
||||
|
||||
|
||||
{/* Subcomponente ActivityItem (Coloque fora do componente principal) */}
|
||||
const ActivityItem = ({ name, action, target, time, avatar }: {
|
||||
name: string,
|
||||
action: string,
|
||||
target: string,
|
||||
time: string,
|
||||
avatar: string
|
||||
}) => (
|
||||
<div className="flex gap-3 items-start group">
|
||||
{/* Avatar com Ring */}
|
||||
<div className="relative shrink-0">
|
||||
<img
|
||||
src={avatar}
|
||||
alt={name}
|
||||
className="w-7 h-7 rounded-full border border-slate-100 shadow-sm"
|
||||
/>
|
||||
<div className="absolute -bottom-0.5 -right-0.5 w-2 h-2 bg-emerald-500 border-2 border-white rounded-full"></div>
|
||||
</div>
|
||||
|
||||
{/* Texto da Atividade */}
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<p className="text-[11px] leading-snug text-slate-600">
|
||||
<span className="font-bold text-slate-900 hover:text-blue-600 cursor-pointer transition-colors">
|
||||
{name}
|
||||
</span>
|
||||
{" "}{action}{" "}
|
||||
<span className="font-medium text-slate-800 italic">{target}</span>
|
||||
</p>
|
||||
<span className="text-[9px] font-medium text-slate-400 uppercase tracking-tight">
|
||||
{time}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,233 @@
|
||||
"use client";
|
||||
import Image from 'next/image';
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useGoogleLogin } from "@react-oauth/google";
|
||||
import { useTheme } from 'next-themes';
|
||||
import { Sun, Moon } from 'lucide-react'; // Optional: install lucide-react for clean icons
|
||||
import Keycloak from "keycloak-js";
|
||||
|
||||
|
||||
const keycloak = new Keycloak({
|
||||
url: "https://keycloak.petermaquiran.xyz",
|
||||
realm: "tvone", // ✅ IMPORTANT
|
||||
clientId: "tvone-web", // must match Keycloak client
|
||||
});
|
||||
|
||||
interface GoogleAuthResponse {
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
expires_in: number;
|
||||
scope: string;
|
||||
authuser?: string;
|
||||
prompt?: string;
|
||||
}
|
||||
|
||||
interface KeycloakTokenResponse {
|
||||
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);
|
||||
|
||||
// Avoid hydration mismatch by waiting for mount
|
||||
useEffect(() => {
|
||||
keycloak.init({
|
||||
onLoad: "check-sso", // or "login-required"
|
||||
pkceMethod: "S256",
|
||||
}).then((authenticated) => {
|
||||
if (authenticated) {
|
||||
localStorage.setItem("token", keycloak.token!);
|
||||
console.log("Logged in", keycloak.token);
|
||||
localStorage.setItem("token", keycloak.token as string);
|
||||
|
||||
fetch("http://localhost:3001/profile/", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
const handleExchange = async (googleResponse: GoogleAuthResponse): Promise<void> => {
|
||||
try {
|
||||
const details: Record<string, string> = {
|
||||
grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange',
|
||||
client_id: 'tvone-web', // Replace with your actual Keycloak Client ID
|
||||
subject_token: googleResponse.access_token,
|
||||
subject_token_type: 'urn:ietf:params:oauth:token-type:access_token',
|
||||
subject_issuer: 'google', // Ensure this matches your Keycloak IdP Alias
|
||||
};
|
||||
|
||||
const formBody = new URLSearchParams(details).toString();
|
||||
|
||||
const response = await fetch(
|
||||
'https://keycloak.petermaquiran.xyz/realms/tvone/protocol/openid-connect/token',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: formBody,
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Keycloak exchange failed: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data: KeycloakTokenResponse = await response.json();
|
||||
|
||||
// Store the Keycloak token to send to your NestJS API
|
||||
localStorage.setItem("token", data.access_token);
|
||||
console.log("Authenticated with Keycloak:", data.access_token);
|
||||
|
||||
// Redirect user or update Global Auth State here
|
||||
} catch (error) {
|
||||
console.error("Authentication Flow Error:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const googleLogin = useGoogleLogin({
|
||||
onSuccess: (res) => {
|
||||
handleExchange(res)
|
||||
console.log("Google Success", res)
|
||||
},
|
||||
onError: () => console.log("Google Failed"),
|
||||
});
|
||||
|
||||
const handleManualLogin = async (): Promise<void> => {
|
||||
const 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);
|
||||
}
|
||||
};
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-screen items-center justify-center bg-[#f5f5f7] px-4 py-10 transition-colors duration-500 dark:bg-black">
|
||||
|
||||
{/* 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={() =>
|
||||
keycloak.login({
|
||||
redirectUri: `${window.location.origin}/create-news`,
|
||||
})
|
||||
}
|
||||
className="group flex w-full items-center justify-center gap-3 rounded-2xl border border-neutral-200 bg-white px-6 py-3.5 transition-all hover:bg-neutral-50 active:scale-[0.98] dark:border-neutral-800 dark:bg-transparent dark:hover:bg-neutral-900"
|
||||
>
|
||||
<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,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,343 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import Image from "next/image";
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Newspaper,
|
||||
Users,
|
||||
BarChart3,
|
||||
Settings,
|
||||
HelpCircle,
|
||||
Tag,
|
||||
FolderTree,
|
||||
Edit3,
|
||||
Trash2,
|
||||
Plus,
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
} from "lucide-react";
|
||||
import { createCategory, deleteCategory, getFlat, getTree, updateCategory } from "@/lib/categories.api";
|
||||
import { slugify } from "@/lib/slug";
|
||||
|
||||
/* ================= TYPES ================= */
|
||||
interface Category {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
parentId?: string | null;
|
||||
children?: Category[];
|
||||
}
|
||||
|
||||
/* ================= PAGE ================= */
|
||||
export default function CategoriesPage() {
|
||||
const [tree, setTree] = useState<Category[]>([]);
|
||||
const [flat, setFlat] = useState<Category[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
|
||||
const [form, setForm] = useState({
|
||||
id: null as string | null,
|
||||
name: "",
|
||||
slug: "",
|
||||
parentId: null as string | null,
|
||||
});
|
||||
|
||||
/* ================= LOAD ================= */
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [t, f] = await Promise.all([getTree(), getFlat()]);
|
||||
setTree(t);
|
||||
setFlat(f);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, []);
|
||||
|
||||
/* ================= CRUD ================= */
|
||||
async function save() {
|
||||
const payload = {
|
||||
name: form.name,
|
||||
slug: form.slug || slugify(form.name),
|
||||
parentId: form.parentId,
|
||||
};
|
||||
|
||||
if (form.id) {
|
||||
await updateCategory(form.id, payload);
|
||||
} else {
|
||||
await createCategory(payload);
|
||||
}
|
||||
|
||||
closeModal();
|
||||
load();
|
||||
}
|
||||
|
||||
async function remove(id: string) {
|
||||
if (!confirm("Delete this category?")) return;
|
||||
await deleteCategory(id);
|
||||
load();
|
||||
}
|
||||
|
||||
/* ================= MODAL ================= */
|
||||
function openCreate(parentId?: string) {
|
||||
setForm({
|
||||
id: null,
|
||||
name: "",
|
||||
slug: "",
|
||||
parentId: parentId || null,
|
||||
});
|
||||
setModalOpen(true);
|
||||
}
|
||||
|
||||
function openEdit(cat: Category) {
|
||||
setForm({
|
||||
id: cat.id,
|
||||
name: cat.name,
|
||||
slug: cat.slug,
|
||||
parentId: cat.parentId || null,
|
||||
});
|
||||
setModalOpen(true);
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
setModalOpen(false);
|
||||
setForm({ id: null, name: "", slug: "", parentId: null });
|
||||
}
|
||||
|
||||
/* ================= TREE ================= */
|
||||
function TreeNode({
|
||||
node,
|
||||
level = 0,
|
||||
}: {
|
||||
node: Category;
|
||||
level?: number;
|
||||
}) {
|
||||
const [open, setOpen] = useState(true);
|
||||
|
||||
return (
|
||||
<div style={{ marginLeft: level * 14 }} className="border-l pl-3">
|
||||
|
||||
<div className="flex items-center justify-between py-2 group">
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
<button onClick={() => setOpen(!open)}>
|
||||
{open ? (
|
||||
<ChevronDown size={14} />
|
||||
) : (
|
||||
<ChevronRight size={14} />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<FolderTree size={14} className="text-blue-500" />
|
||||
|
||||
<span
|
||||
onClick={() => openEdit(node)}
|
||||
className="text-sm font-medium cursor-pointer hover:text-blue-600"
|
||||
>
|
||||
{node.name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 opacity-0 group-hover:opacity-100 transition">
|
||||
|
||||
<button
|
||||
onClick={() => openCreate(node.id)}
|
||||
className="text-green-600"
|
||||
>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
|
||||
<button onClick={() => openEdit(node)}>
|
||||
<Edit3 size={14} />
|
||||
</button>
|
||||
|
||||
<button onClick={() => remove(node.id)}>
|
||||
<Trash2 size={14} className="text-red-500" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{open &&
|
||||
node.children?.map((child) => (
|
||||
<TreeNode
|
||||
key={child.id}
|
||||
node={child}
|
||||
level={level + 1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ================= UI ================= */
|
||||
return (
|
||||
<div className="flex h-screen bg-slate-50 text-slate-900">
|
||||
|
||||
{/* ================= SIDEBAR ================= */}
|
||||
{/* Sidebar Lateral */}
|
||||
<aside className="w-64 backdrop-blur-xl bg-white/80 border-r border-slate-200 p-6 flex flex-col">
|
||||
<div className="flex items-center gap-2 mb-10 px-2">
|
||||
<div className="w-10 h-10 bg-white rounded-lg flex items-center justify-center font-bold italic">
|
||||
<Image src="/logo.png" alt="TVone" width={50} height={50} />
|
||||
</div>
|
||||
<span className="font-bold text-sm tracking-tight leading-tight uppercase">
|
||||
TVone<br/><span className="text-blue-600 text-[10px]">de Notícias</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 space-y-1">
|
||||
<NavItem icon={<LayoutDashboard size={18}/>} label="Painel" />
|
||||
<NavItem icon={<Newspaper size={18}/>} label="Meus Artigos" />
|
||||
<NavItem icon={<Users size={18}/>} label="Equipa" />
|
||||
<NavItem icon={<BarChart3 size={18}/>} label="Análises" />
|
||||
<NavItem icon={<Newspaper size={18}/>} label="Adicionar Notícia" active />
|
||||
<NavItem icon={<Settings size={18}/>} label="Definições" />
|
||||
<NavItem icon={<HelpCircle size={18}/>} label="Ajuda" />
|
||||
</nav>
|
||||
|
||||
{/* User Activity Feed */}
|
||||
<div className="mt-auto pt-6 border-t border-slate-200">
|
||||
<h4 className="text-[10px] font-bold uppercase text-slate-400 mb-4 px-2 tracking-widest">Atividade Recente</h4>
|
||||
<div className="space-y-3 px-2">
|
||||
<ActivityItem user="James Wilson" action="atualizou 'Mercado'" />
|
||||
<ActivityItem user="Sarah Johnson" action="criou 'Startups'" />
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* ================= MAIN ================= */}
|
||||
<div className="flex-1 flex flex-col">
|
||||
|
||||
{/* HEADER */}
|
||||
<header className="h-14 bg-white border-b flex items-center justify-between px-6">
|
||||
|
||||
<input
|
||||
placeholder="Search categories..."
|
||||
className="bg-slate-100 px-4 py-1 rounded-full text-sm w-72 outline-none"
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
|
||||
<span className="text-sm text-slate-600">
|
||||
Admin
|
||||
</span>
|
||||
|
||||
<img
|
||||
src="https://ui-avatars.com/api/?name=Admin"
|
||||
className="w-8 h-8 rounded-full"
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* CONTENT */}
|
||||
<main className="p-6 overflow-y-auto">
|
||||
|
||||
{/* TOP BAR */}
|
||||
<div className="flex justify-between mb-6">
|
||||
|
||||
<h1 className="text-xl font-semibold">
|
||||
Categories
|
||||
</h1>
|
||||
|
||||
<button
|
||||
onClick={() => openCreate()}
|
||||
className="bg-blue-600 text-white px-4 py-2 rounded"
|
||||
>
|
||||
+ New Category
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* TREE */}
|
||||
<div className="bg-white border rounded-xl p-4">
|
||||
{loading ? (
|
||||
<p>Loading...</p>
|
||||
) : (
|
||||
tree.map((node) => (
|
||||
<TreeNode key={node.id} node={node} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* ================= MODAL ================= */}
|
||||
{modalOpen && (
|
||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center">
|
||||
|
||||
<div className="bg-white w-[420px] p-5 rounded-xl">
|
||||
|
||||
<h2 className="font-semibold mb-4">
|
||||
{form.id ? "Edit Category" : "Create Category"}
|
||||
</h2>
|
||||
|
||||
<input
|
||||
className="w-full border p-2 rounded mb-2"
|
||||
placeholder="Name"
|
||||
value={form.name}
|
||||
onChange={(e) =>
|
||||
setForm({
|
||||
...form,
|
||||
name: e.target.value,
|
||||
slug: slugify(e.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
||||
<input
|
||||
className="w-full border p-2 rounded mb-3"
|
||||
placeholder="Slug"
|
||||
value={form.slug}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, slug: e.target.value })
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
|
||||
<button onClick={closeModal}>
|
||||
Cancel
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={save}
|
||||
className="bg-blue-600 text-white px-3 py-1 rounded"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// Componentes Auxiliares para Limpeza de Código
|
||||
const NavItem = ({ icon, label, active = false }: { icon: any, label: string, active?: boolean }) => (
|
||||
<div className={`flex items-center gap-3 px-3 py-2 rounded-xl text-sm font-medium transition-all cursor-pointer ${
|
||||
active ? 'bg-blue-600 text-white shadow-md shadow-blue-200' : 'text-slate-500 hover:bg-slate-100'
|
||||
}`}>
|
||||
{icon}
|
||||
{label}
|
||||
</div>
|
||||
);
|
||||
|
||||
const ActivityItem = ({ user, action }: { user: string, action: string }) => (
|
||||
<div className="flex gap-2">
|
||||
<div className="w-6 h-6 bg-slate-200 rounded-full shrink-0" />
|
||||
<p className="text-[10px] leading-tight text-slate-600">
|
||||
<span className="font-bold block text-slate-900">{user}</span> {action}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
@@ -1,14 +1,14 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { readSliderPhotos } from "@/lib/slider-photos";
|
||||
// import { NextResponse } from "next/server";
|
||||
// import { readSliderPhotos } from "@/lib/slider-photos";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
// export const runtime = "nodejs";
|
||||
// export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const photos = readSliderPhotos();
|
||||
return NextResponse.json(photos);
|
||||
} catch {
|
||||
return NextResponse.json([]);
|
||||
}
|
||||
}
|
||||
// export async function GET() {
|
||||
// try {
|
||||
// const photos = readSliderPhotos();
|
||||
// return NextResponse.json(photos);
|
||||
// } catch {
|
||||
// return NextResponse.json([]);
|
||||
// }
|
||||
// }
|
||||
|
||||
+12
-2
@@ -209,10 +209,20 @@ export default function NewsArticlePage() {
|
||||
</div>
|
||||
|
||||
<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}
|
||||
</h3>
|
||||
<p className="mt-1.5 text-xs font-medium text-neutral-500">{item.date}</p>
|
||||
</div>
|
||||
</Link>
|
||||
</article>
|
||||
|
||||
@@ -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;
|
||||
@@ -3,6 +3,8 @@
|
||||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useId, useRef, useState } from "react";
|
||||
import Image from "next/image";
|
||||
import { useTheme } from "next-themes";
|
||||
import { Moon, Sun } from "lucide-react";
|
||||
|
||||
const primaryNav = [
|
||||
{ label: "Música", href: "#" },
|
||||
@@ -69,6 +71,9 @@ export function TvoneSiteNav() {
|
||||
|
||||
const closeMenu = useCallback(() => setMenuOpen(false), []);
|
||||
const toggleMenu = useCallback(() => setMenuOpen((o) => !o), []);
|
||||
const { theme, setTheme } = useTheme();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
const el = sentinelRef.current;
|
||||
@@ -82,12 +87,14 @@ export function TvoneSiteNav() {
|
||||
sync();
|
||||
window.addEventListener("scroll", sync, { passive: true });
|
||||
window.addEventListener("resize", sync);
|
||||
setMounted(true);
|
||||
return () => {
|
||||
window.removeEventListener("scroll", sync);
|
||||
window.removeEventListener("resize", sync);
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (!menuOpen) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
@@ -163,6 +170,16 @@ export function TvoneSiteNav() {
|
||||
</ul>
|
||||
|
||||
<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
|
||||
type="button"
|
||||
className="flex h-10 w-10 items-center justify-center rounded-md hover:bg-white/10 sm:h-11 sm:w-11"
|
||||
|
||||
+11
-4
@@ -1,6 +1,8 @@
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { Inter } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { GoogleOAuthProvider } from "@react-oauth/google";
|
||||
import { ThemeProvider } from 'next-themes'
|
||||
|
||||
const inter = Inter({
|
||||
variable: "--font-inter",
|
||||
@@ -36,11 +38,16 @@ export default function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="pt" className={`${inter.variable} h-full antialiased`}>
|
||||
<body
|
||||
className={`min-h-full flex flex-col bg-white text-neutral-900 ${inter.className}`}
|
||||
>
|
||||
// 1. We remove "light" from className so ThemeProvider can inject it
|
||||
// 2. We remove style={{ colorScheme: 'light' }}
|
||||
<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}
|
||||
</ThemeProvider>
|
||||
</GoogleOAuthProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -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,89 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Category,
|
||||
getCategoriesTree,
|
||||
getCategoriesFlat,
|
||||
createCategory,
|
||||
updateCategory,
|
||||
deleteCategory,
|
||||
} from "@/lib/categories.api";
|
||||
import { slugify } from "@/lib/slug";
|
||||
|
||||
export function useCategories() {
|
||||
const [tree, setTree] = useState<Category[]>([]);
|
||||
const [flat, setFlat] = useState<Category[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const [form, setForm] = useState({
|
||||
id: null as string | null,
|
||||
name: "",
|
||||
slug: "",
|
||||
parentId: null as string | null,
|
||||
});
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [t, f] = await Promise.all([
|
||||
getCategoriesTree(),
|
||||
getCategoriesFlat(),
|
||||
]);
|
||||
|
||||
setTree(t);
|
||||
setFlat(f);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, []);
|
||||
|
||||
async function save() {
|
||||
const payload = {
|
||||
name: form.name,
|
||||
slug: form.slug || slugify(form.name),
|
||||
parentId: form.parentId,
|
||||
};
|
||||
|
||||
if (form.id) {
|
||||
await updateCategory(form.id, payload);
|
||||
} else {
|
||||
await createCategory(payload);
|
||||
}
|
||||
|
||||
resetForm();
|
||||
load();
|
||||
}
|
||||
|
||||
async function remove(id: string) {
|
||||
await deleteCategory(id);
|
||||
load();
|
||||
}
|
||||
|
||||
function edit(cat: Category) {
|
||||
setForm({
|
||||
id: cat.id,
|
||||
name: cat.name,
|
||||
slug: cat.slug,
|
||||
parentId: cat.parentId || null,
|
||||
});
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
setForm({ id: null, name: "", slug: "", parentId: null });
|
||||
}
|
||||
|
||||
return {
|
||||
tree,
|
||||
flat,
|
||||
form,
|
||||
setForm,
|
||||
save,
|
||||
remove,
|
||||
edit,
|
||||
resetForm,
|
||||
loading,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
const API = "http://localhost:3001/categories";
|
||||
|
||||
export interface Category {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
parentId?: string | null;
|
||||
children?: Category[];
|
||||
}
|
||||
|
||||
export async function getCategoriesTree(): Promise<Category[]> {
|
||||
const res = await fetch(`${API}/`);
|
||||
const data = await res.json();
|
||||
return Array.isArray(data) ? data : data?.data ?? [];
|
||||
}
|
||||
|
||||
export async function getCategoriesFlat(): Promise<Category[]> {
|
||||
const res = await fetch(API);
|
||||
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" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateCategory(id: string, payload: Partial<Category>) {
|
||||
return fetch(`${API}/${id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteCategory(id: string) {
|
||||
return fetch(`${API}/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
|
||||
export async function getTree(): Promise<Category[]> {
|
||||
const res = await fetch(`${API}/`);
|
||||
const data = await res.json();
|
||||
return Array.isArray(data) ? data : data?.data ?? [];
|
||||
}
|
||||
|
||||
export async function getFlat(): Promise<Category[]> {
|
||||
const res = await fetch(API);
|
||||
const data = await res.json();
|
||||
return Array.isArray(data) ? data : [];
|
||||
}
|
||||
+55
-55
@@ -1,63 +1,63 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
// import fs from "fs";
|
||||
// import path from "path";
|
||||
|
||||
export type SliderPhoto = { src: string; alt: string };
|
||||
// export type SliderPhoto = { src: string; alt: string };
|
||||
|
||||
function slugToAlt(filename: string): string {
|
||||
const base = filename.replace(/\.[^.]+$/, "");
|
||||
const words = base.replace(/[-_]+/g, " ").trim();
|
||||
return words || "Slide";
|
||||
}
|
||||
// function slugToAlt(filename: string): string {
|
||||
// const base = filename.replace(/\.[^.]+$/, "");
|
||||
// const words = base.replace(/[-_]+/g, " ").trim();
|
||||
// return words || "Slide";
|
||||
// }
|
||||
|
||||
export function parseManifest(data: unknown): SliderPhoto[] {
|
||||
if (!Array.isArray(data)) return [];
|
||||
const out: SliderPhoto[] = [];
|
||||
for (const item of data) {
|
||||
if (typeof item !== "object" || item === null || !("src" in item)) continue;
|
||||
const src = (item as { src: unknown }).src;
|
||||
if (typeof src !== "string" || !src.startsWith("/")) continue;
|
||||
const altRaw = (item as { alt?: unknown }).alt;
|
||||
const alt = typeof altRaw === "string" && altRaw.trim() ? altRaw.trim() : slugToAlt(src.split("/").pop() ?? "");
|
||||
out.push({ src, alt });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
// export function parseManifest(data: unknown): SliderPhoto[] {
|
||||
// if (!Array.isArray(data)) return [];
|
||||
// const out: SliderPhoto[] = [];
|
||||
// for (const item of data) {
|
||||
// if (typeof item !== "object" || item === null || !("src" in item)) continue;
|
||||
// const src = (item as { src: unknown }).src;
|
||||
// if (typeof src !== "string" || !src.startsWith("/")) continue;
|
||||
// const altRaw = (item as { alt?: unknown }).alt;
|
||||
// const alt = typeof altRaw === "string" && altRaw.trim() ? altRaw.trim() : slugToAlt(src.split("/").pop() ?? "");
|
||||
// out.push({ src, alt });
|
||||
// }
|
||||
// return out;
|
||||
// }
|
||||
|
||||
const IMAGE_EXT = /\.(jpe?g|png|webp|gif|avif)$/i;
|
||||
// const IMAGE_EXT = /\.(jpe?g|png|webp|gif|avif)$/i;
|
||||
|
||||
function scanSliderDirectory(dir: string): SliderPhoto[] {
|
||||
let names: string[] = [];
|
||||
try {
|
||||
names = fs.readdirSync(dir);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
return names
|
||||
.filter((f) => IMAGE_EXT.test(f))
|
||||
.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" }))
|
||||
.map((f) => ({
|
||||
src: `/slider/${f}`,
|
||||
alt: slugToAlt(f),
|
||||
}));
|
||||
}
|
||||
// function scanSliderDirectory(dir: string): SliderPhoto[] {
|
||||
// let names: string[] = [];
|
||||
// try {
|
||||
// names = fs.readdirSync(dir);
|
||||
// } catch {
|
||||
// return [];
|
||||
// }
|
||||
// return names
|
||||
// .filter((f) => IMAGE_EXT.test(f))
|
||||
// .sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" }))
|
||||
// .map((f) => ({
|
||||
// src: `/slider/${f}`,
|
||||
// alt: slugToAlt(f),
|
||||
// }));
|
||||
// }
|
||||
|
||||
/**
|
||||
* Reads `public/slider/manifest.json` when present (full control: order + alt).
|
||||
* Otherwise scans `public/slider` for image files (drop-in updates, no code edits).
|
||||
*/
|
||||
export function readSliderPhotos(): SliderPhoto[] {
|
||||
const dir = path.join(process.cwd(), "public", "slider");
|
||||
if (!fs.existsSync(dir)) return [];
|
||||
// /**
|
||||
// * Reads `public/slider/manifest.json` when present (full control: order + alt).
|
||||
// * Otherwise scans `public/slider` for image files (drop-in updates, no code edits).
|
||||
// */
|
||||
// export function readSliderPhotos(): SliderPhoto[] {
|
||||
// const dir = path.join(process.cwd(), "public", "slider");
|
||||
// if (!fs.existsSync(dir)) return [];
|
||||
|
||||
const manifestPath = path.join(dir, "manifest.json");
|
||||
if (fs.existsSync(manifestPath)) {
|
||||
try {
|
||||
const raw = JSON.parse(fs.readFileSync(manifestPath, "utf8")) as unknown;
|
||||
return parseManifest(raw);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
// const manifestPath = path.join(dir, "manifest.json");
|
||||
// if (fs.existsSync(manifestPath)) {
|
||||
// try {
|
||||
// const raw = JSON.parse(fs.readFileSync(manifestPath, "utf8")) as unknown;
|
||||
// return parseManifest(raw);
|
||||
// } catch {
|
||||
// return [];
|
||||
// }
|
||||
// }
|
||||
|
||||
return scanSliderDirectory(dir);
|
||||
}
|
||||
// return scanSliderDirectory(dir);
|
||||
// }
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
export function slugify(text: string) {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/\s+/g, "-")
|
||||
.replace(/[^a-z0-9-]/g, "");
|
||||
}
|
||||
@@ -9,9 +9,16 @@
|
||||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@react-oauth/google": "^0.13.5",
|
||||
"@tinymce/tinymce-react": "^6.3.0",
|
||||
"framer-motion": "^12.38.0",
|
||||
"keycloak-js": "^26.2.3",
|
||||
"lucide-react": "^1.8.0",
|
||||
"next": "16.2.1",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"react-easy-crop": "^5.5.7",
|
||||
|
||||
Generated
+111
@@ -8,15 +8,36 @@ importers:
|
||||
|
||||
.:
|
||||
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)
|
||||
'@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:
|
||||
specifier: ^12.38.0
|
||||
version: 12.38.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
keycloak-js:
|
||||
specifier: ^26.2.3
|
||||
version: 26.2.3
|
||||
lucide-react:
|
||||
specifier: ^1.8.0
|
||||
version: 1.8.0(react@19.2.4)
|
||||
next:
|
||||
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)
|
||||
next-themes:
|
||||
specifier: ^0.4.6
|
||||
version: 0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
react:
|
||||
specifier: 19.2.4
|
||||
version: 19.2.4
|
||||
@@ -131,6 +152,28 @@ packages:
|
||||
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
|
||||
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':
|
||||
resolution: {integrity: sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==}
|
||||
|
||||
@@ -440,6 +483,12 @@ packages:
|
||||
resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==}
|
||||
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':
|
||||
resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==}
|
||||
|
||||
@@ -538,6 +587,16 @@ packages:
|
||||
'@tailwindcss/postcss@4.2.2':
|
||||
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':
|
||||
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
|
||||
|
||||
@@ -1423,6 +1482,9 @@ packages:
|
||||
resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==}
|
||||
engines: {node: '>=4.0'}
|
||||
|
||||
keycloak-js@26.2.3:
|
||||
resolution: {integrity: sha512-widjzw/9T6bHRgEp6H/Se3NCCarU7u5CwFKBcwtu7xfA1IfdZb+7Q7/KGusAnBo34Vtls8Oz9vzSqkQvQ7+b4Q==}
|
||||
|
||||
keyv@4.5.4:
|
||||
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
|
||||
|
||||
@@ -1577,6 +1639,12 @@ packages:
|
||||
natural-compare@1.4.0:
|
||||
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:
|
||||
resolution: {integrity: sha512-VaChzNL7o9rbfdt60HUj8tev4m6d7iC1igAy157526+cJlXOQu5LzsBXNT+xaJnTP/k+utSX5vMv7m0G+zKH+Q==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
@@ -2117,6 +2185,31 @@ snapshots:
|
||||
'@babel/helper-string-parser': 7.27.1
|
||||
'@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':
|
||||
dependencies:
|
||||
'@emnapi/wasi-threads': 1.2.0
|
||||
@@ -2357,6 +2450,11 @@ snapshots:
|
||||
|
||||
'@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': {}
|
||||
|
||||
'@swc/helpers@0.5.15':
|
||||
@@ -2432,6 +2530,12 @@ snapshots:
|
||||
postcss: 8.5.8
|
||||
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':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
@@ -3472,6 +3576,8 @@ snapshots:
|
||||
object.assign: 4.1.7
|
||||
object.values: 1.2.1
|
||||
|
||||
keycloak-js@26.2.3: {}
|
||||
|
||||
keyv@4.5.4:
|
||||
dependencies:
|
||||
json-buffer: 3.0.1
|
||||
@@ -3591,6 +3697,11 @@ snapshots:
|
||||
|
||||
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):
|
||||
dependencies:
|
||||
'@next/env': 16.2.1
|
||||
|
||||
Reference in New Issue
Block a user