Compare commits

...

14 Commits

Author SHA1 Message Date
peter f163f22987 cooment code
continuous-integration/drone/push Build is failing
2026-04-18 13:22:22 +01:00
peter 5e5a43094f reorganize page 2026-04-18 13:21:13 +01:00
peter 661784a73d add page 2026-04-18 11:28:23 +01:00
peter 7854e3dd44 show profile picture and name
continuous-integration/drone/push Build is passing
2026-04-17 16:15:00 +01:00
peter 2ab775514e make the link dynamic 2026-04-17 16:03:17 +01:00
peter c8383955d5 get token inside the app
continuous-integration/drone/push Build is passing
2026-04-17 15:57:13 +01:00
peter 0a645744f0 fetch profile 2026-04-17 15:08:40 +01:00
peter 4e7794476b login with gmail with keyclock 2026-04-17 10:48:36 +01:00
peter 6555a171ee add google 2026-04-17 10:08:18 +01:00
peter 8454abea36 add interface 2026-04-16 21:13:17 +01:00
peter 73e0834d18 remove text
continuous-integration/drone/push Build is passing
continuous-integration/drone Build is passing
2026-04-15 15:39:51 +01:00
peter 006305ca3f add tiny
continuous-integration/drone/push Build is passing
2026-04-15 15:14:34 +01:00
peter 1aceeafd72 improve crete news scren
continuous-integration/drone/push Build is passing
2026-04-15 15:00:10 +01:00
peter 09f74d2439 add 2026-04-15 14:52:57 +01:00
17 changed files with 1722 additions and 262 deletions
@@ -2,9 +2,9 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import Image from 'next/image'; import Image from 'next/image';
import { ThumbsUp, MessageCircle, Send, Share2, LinkIcon, Link2, ChevronRight } from 'lucide-react'; import { ThumbsUp, MessageCircle, Send, Share2, LinkIcon, Link2, ChevronRight } from 'lucide-react';
import { TvoneAdBanner, TvoneFooter } from '../components/tvone-content'; import { TvoneAdBanner, TvoneFooter } from '../../components/tvone-content';
import { TvonePromoStrip } from '../components/tvone-promo-strip'; import { TvonePromoStrip } from '../../components/tvone-promo-strip';
import { TvoneSiteNav } from '../components/tvone-site-nav'; import { TvoneSiteNav } from '../../components/tvone-site-nav';
import { SiWhatsapp, SiFacebook, SiX } from 'react-icons/si'; // Ícones oficiais de marca import { SiWhatsapp, SiFacebook, SiX } from 'react-icons/si'; // Ícones oficiais de marca
import { FiCopy } from 'react-icons/fi'; // Ícone de cópia mais limpo import { FiCopy } from 'react-icons/fi'; // Ícone de cópia mais limpo
import Link from 'next/link'; import Link from 'next/link';
+364
View File
@@ -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;
@@ -4,6 +4,35 @@ import React, { useState, useEffect } from "react";
import { useGoogleLogin } from "@react-oauth/google"; import { useGoogleLogin } from "@react-oauth/google";
import { useTheme } from 'next-themes'; import { useTheme } from 'next-themes';
import { Sun, Moon } from 'lucide-react'; // Optional: install lucide-react for clean icons import { Sun, Moon } from 'lucide-react'; // 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() { export default function AppleStyleAuth() {
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
@@ -13,14 +42,98 @@ export default function AppleStyleAuth() {
// Avoid hydration mismatch by waiting for mount // Avoid hydration mismatch by waiting for mount
useEffect(() => { 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); 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({ const googleLogin = useGoogleLogin({
onSuccess: (res) => console.log("Google Success", res), onSuccess: (res) => {
handleExchange(res)
console.log("Google Success", res)
},
onError: () => console.log("Google Failed"), 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; if (!mounted) return null;
return ( return (
@@ -83,10 +196,14 @@ export default function AppleStyleAuth() {
</div> </div>
{/* 4. BOTÃO GOOGLE */} {/* 4. BOTÃO GOOGLE */}
<button <button
onClick={() => googleLogin()} onClick={() =>
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" 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"> <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="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="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" />
@@ -103,12 +220,12 @@ export default function AppleStyleAuth() {
<a href="#" className="text-xs font-medium text-blue-600 hover:underline"> <a href="#" className="text-xs font-medium text-blue-600 hover:underline">
Esqueceste-te da palavra-passe? Esqueceste-te da palavra-passe?
</a> </a>
<p className="mt-8 text-xs text-neutral-400"> {/* <p className="mt-8 text-xs text-neutral-400">
Não tens conta?{" "} Não tens conta?{" "}
<a href="#" className="font-bold text-neutral-900 dark:text-white"> <a href="#" className="font-bold text-neutral-900 dark:text-white">
Cria uma agora. Cria uma agora.
</a> </a>
</p> </p> */}
</div> </div>
</div> </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>
);
}
+343
View File
@@ -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>
);
+12 -12
View File
@@ -1,14 +1,14 @@
import { NextResponse } from "next/server"; // import { NextResponse } from "next/server";
import { readSliderPhotos } from "@/lib/slider-photos"; // import { readSliderPhotos } from "@/lib/slider-photos";
export const runtime = "nodejs"; // export const runtime = "nodejs";
export const dynamic = "force-dynamic"; // export const dynamic = "force-dynamic";
export async function GET() { // export async function GET() {
try { // try {
const photos = readSliderPhotos(); // const photos = readSliderPhotos();
return NextResponse.json(photos); // return NextResponse.json(photos);
} catch { // } catch {
return NextResponse.json([]); // return NextResponse.json([]);
} // }
} // }
+153
View File
@@ -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;
-185
View File
@@ -1,185 +0,0 @@
import React 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';
const CreateNewsPage = () => {
return (
<div className="flex h-screen bg-slate-100/50 text-slate-900 font-sans">
{/* Sidebar Lateral - Glassmorphism */}
<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 text-white 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 - Sidebar Bottom */}
<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 Superior */}
<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"
/>
</div>
<div className="flex items-center gap-4">
<div className="text-right">
<p className="text-xs font-bold">James Wilson</p>
<p className="text-[10px] text-slate-500">Editor</p>
</div>
<div className="w-8 h-8 bg-slate-300 rounded-full"></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-600 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 (Editor) */}
<div className="col-span-2 space-y-6">
<section 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">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 leading-8">Conteúdo Principal</label>
{/* Toolbar Simplificada */}
<div className="flex gap-4 mb-4 text-slate-400 border-b border-slate-50 pb-2">
<Type size={18} className="cursor-pointer hover:text-blue-600" />
<ImageIcon size={18} className="cursor-pointer hover:text-blue-600" />
<div className="w-px h-5 bg-slate-200" />
<span className="font-serif font-bold cursor-pointer hover:text-blue-600">B</span>
<span className="italic cursor-pointer hover:text-blue-600">I</span>
</div>
<textarea
rows={12}
placeholder="Comece a escrever a sua notícia aqui..."
className="w-full border-none focus:ring-0 resize-none text-slate-700 leading-relaxed p-0"
/>
</section>
<section 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"
/>
</section>
</div>
{/* Coluna Lateral (Meta-dados) */}
<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 focus:ring-2 focus:ring-blue-500/10">
<option>Negócios</option>
<option>Tecnologia</option>
<option>Desporto</option>
<option>Política</option>
</select>
</div>
<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>
<div className="border-2 border-dashed border-slate-100 rounded-xl p-8 flex flex-col items-center justify-center bg-slate-50/50 hover:bg-slate-50 transition-colors cursor-pointer group">
<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">Clique para carregar</p>
<p className="text-[9px] text-slate-400">JPG, PNG (Máx 5MB)</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-xs outline-none" />
<input type="time" className="bg-slate-50 border border-slate-100 rounded-lg px-2 py-2 text-xs 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;
+50
View File
@@ -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>
// );
// }
+116
View File
@@ -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>
// );
// }
+89
View File
@@ -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,
};
}
+54
View File
@@ -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
View File
@@ -1,63 +1,63 @@
import fs from "fs"; // import fs from "fs";
import path from "path"; // import path from "path";
export type SliderPhoto = { src: string; alt: string }; // export type SliderPhoto = { src: string; alt: string };
function slugToAlt(filename: string): string { // function slugToAlt(filename: string): string {
const base = filename.replace(/\.[^.]+$/, ""); // const base = filename.replace(/\.[^.]+$/, "");
const words = base.replace(/[-_]+/g, " ").trim(); // const words = base.replace(/[-_]+/g, " ").trim();
return words || "Slide"; // return words || "Slide";
} // }
export function parseManifest(data: unknown): SliderPhoto[] { // export function parseManifest(data: unknown): SliderPhoto[] {
if (!Array.isArray(data)) return []; // if (!Array.isArray(data)) return [];
const out: SliderPhoto[] = []; // const out: SliderPhoto[] = [];
for (const item of data) { // for (const item of data) {
if (typeof item !== "object" || item === null || !("src" in item)) continue; // if (typeof item !== "object" || item === null || !("src" in item)) continue;
const src = (item as { src: unknown }).src; // const src = (item as { src: unknown }).src;
if (typeof src !== "string" || !src.startsWith("/")) continue; // if (typeof src !== "string" || !src.startsWith("/")) continue;
const altRaw = (item as { alt?: unknown }).alt; // const altRaw = (item as { alt?: unknown }).alt;
const alt = typeof altRaw === "string" && altRaw.trim() ? altRaw.trim() : slugToAlt(src.split("/").pop() ?? ""); // const alt = typeof altRaw === "string" && altRaw.trim() ? altRaw.trim() : slugToAlt(src.split("/").pop() ?? "");
out.push({ src, alt }); // out.push({ src, alt });
} // }
return out; // return out;
} // }
const IMAGE_EXT = /\.(jpe?g|png|webp|gif|avif)$/i; // const IMAGE_EXT = /\.(jpe?g|png|webp|gif|avif)$/i;
function scanSliderDirectory(dir: string): SliderPhoto[] { // function scanSliderDirectory(dir: string): SliderPhoto[] {
let names: string[] = []; // let names: string[] = [];
try { // try {
names = fs.readdirSync(dir); // names = fs.readdirSync(dir);
} catch { // } catch {
return []; // return [];
} // }
return names // return names
.filter((f) => IMAGE_EXT.test(f)) // .filter((f) => IMAGE_EXT.test(f))
.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" })) // .sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" }))
.map((f) => ({ // .map((f) => ({
src: `/slider/${f}`, // src: `/slider/${f}`,
alt: slugToAlt(f), // alt: slugToAlt(f),
})); // }));
} // }
/** // /**
* Reads `public/slider/manifest.json` when present (full control: order + alt). // * Reads `public/slider/manifest.json` when present (full control: order + alt).
* Otherwise scans `public/slider` for image files (drop-in updates, no code edits). // * Otherwise scans `public/slider` for image files (drop-in updates, no code edits).
*/ // */
export function readSliderPhotos(): SliderPhoto[] { // export function readSliderPhotos(): SliderPhoto[] {
const dir = path.join(process.cwd(), "public", "slider"); // const dir = path.join(process.cwd(), "public", "slider");
if (!fs.existsSync(dir)) return []; // if (!fs.existsSync(dir)) return [];
const manifestPath = path.join(dir, "manifest.json"); // const manifestPath = path.join(dir, "manifest.json");
if (fs.existsSync(manifestPath)) { // if (fs.existsSync(manifestPath)) {
try { // try {
const raw = JSON.parse(fs.readFileSync(manifestPath, "utf8")) as unknown; // const raw = JSON.parse(fs.readFileSync(manifestPath, "utf8")) as unknown;
return parseManifest(raw); // return parseManifest(raw);
} catch { // } catch {
return []; // return [];
} // }
} // }
return scanSliderDirectory(dir); // return scanSliderDirectory(dir);
} // }
+7
View File
@@ -0,0 +1,7 @@
export function slugify(text: string) {
return text
.toLowerCase()
.trim()
.replace(/\s+/g, "-")
.replace(/[^a-z0-9-]/g, "");
}
+5
View File
@@ -9,8 +9,13 @@
"lint": "eslint" "lint": "eslint"
}, },
"dependencies": { "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", "@react-oauth/google": "^0.13.5",
"@tinymce/tinymce-react": "^6.3.0",
"framer-motion": "^12.38.0", "framer-motion": "^12.38.0",
"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", "next-themes": "^0.4.6",
+83
View File
@@ -8,12 +8,27 @@ 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)
'@react-oauth/google': '@react-oauth/google':
specifier: ^0.13.5 specifier: ^0.13.5
version: 0.13.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4) version: 0.13.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@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)
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)
@@ -137,6 +152,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==}
@@ -550,6 +587,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==}
@@ -1435,6 +1482,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==}
@@ -2135,6 +2185,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
@@ -2455,6 +2530,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
@@ -3495,6 +3576,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