Files
octto-engenharia/frontend/src/app/admin/layout.tsx
Erik e503069a86 feat: implementa sistema de logotipo dinâmico
- Adiciona campo 'logo' ao modelo Settings no Prisma
- Atualiza API /api/settings para lidar com upload de logo
- Cria aba Logotipo funcional no admin com upload de imagem
- Atualiza Header para exibir logo dinâmico (fallback para ícone)
- Atualiza Footer para exibir logo dinâmico
- Atualiza Admin Layout para exibir logo dinâmico
- Logo é atualizado em tempo real via evento settings:refresh
2025-11-29 16:36:25 -03:00

480 lines
19 KiB
TypeScript

"use client";
import { useState, useEffect, useRef, useCallback } from 'react';
import Link from 'next/link';
import Image from 'next/image';
import { usePathname, useRouter } from 'next/navigation';
import { useToast } from '@/contexts/ToastContext';
import { useConfirm } from '@/contexts/ConfirmContext';
import { useTheme } from 'next-themes';
type TranslationSummary = {
slug: string;
timestamps: Partial<Record<'pt' | 'en' | 'es', string>>;
pendingLocales: Array<'en' | 'es'>;
};
export default function AdminLayout({
children,
}: {
children: React.ReactNode;
}) {
const [isSidebarOpen, setIsSidebarOpen] = useState(true);
const [user, setUser] = useState<{ name: string; email: string; avatar?: string | null } | null>(null);
const [logo, setLogo] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [showAvatarModal, setShowAvatarModal] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const [mounted, setMounted] = useState(false);
const pathname = usePathname();
const router = useRouter();
const { success, error } = useToast();
const { confirm } = useConfirm();
const { theme, setTheme } = useTheme();
const [showNotifications, setShowNotifications] = useState(false);
const [translationSummary, setTranslationSummary] = useState<TranslationSummary[]>([]);
const [isFetchingTranslations, setIsFetchingTranslations] = useState(false);
const notificationsRef = useRef<HTMLDivElement | null>(null);
const pendingTranslationsRef = useRef<Set<string>>(new Set());
const fetchTranslationStatus = useCallback(async (withLoader = false) => {
if (withLoader) {
setIsFetchingTranslations(true);
}
try {
const response = await fetch('/api/admin/translate-pages');
if (!response.ok) return;
const data = await response.json();
const pages: Record<string, Partial<Record<'pt' | 'en' | 'es', string>>> = data.pages || {};
const summary: TranslationSummary[] = Object.entries(pages).map(([slug, timestamps]) => {
const pendingLocales: Array<'en' | 'es'> = [];
const ptDate = timestamps.pt ? new Date(timestamps.pt) : null;
(['en', 'es'] as const).forEach((locale) => {
const localeDate = timestamps[locale] ? new Date(timestamps[locale] as string) : null;
if (ptDate && (!localeDate || localeDate < ptDate)) {
pendingLocales.push(locale);
}
});
return { slug, timestamps, pendingLocales };
});
setTranslationSummary(summary);
const pendingSlugs = summary.filter((page) => page.pendingLocales.length > 0).map((page) => page.slug);
const previousPending = pendingTranslationsRef.current;
previousPending.forEach((slug) => {
if (!pendingSlugs.includes(slug)) {
success(`Tradução da página "${slug}" concluída!`);
}
});
pendingTranslationsRef.current = new Set(pendingSlugs);
} catch (err) {
console.error('Erro ao buscar status das traduções:', err);
} finally {
if (withLoader) {
setIsFetchingTranslations(false);
}
}
}, [success]);
useEffect(() => {
setMounted(true);
const fetchUser = async () => {
try {
const response = await fetch('/api/auth/me');
if (response.ok) {
const data = await response.json();
setUser(data.user);
} else {
// Não autenticado - redirecionar para login
router.push('/acesso');
return;
}
} catch (err) {
console.error('Erro ao buscar dados do usuário:', err);
router.push('/acesso');
return;
} finally {
setIsLoading(false);
}
};
fetchUser();
// Buscar logo das configurações
const fetchLogo = async () => {
try {
const response = await fetch('/api/settings');
if (response.ok) {
const data = await response.json();
if (data.logo) {
setLogo(data.logo);
}
}
} catch (err) {
console.error('Erro ao buscar logo:', err);
}
};
fetchLogo();
// Listener para atualização em tempo real
const handleSettingsRefresh = () => fetchLogo();
window.addEventListener('settings:refresh', handleSettingsRefresh);
return () => window.removeEventListener('settings:refresh', handleSettingsRefresh);
}, [router]);
useEffect(() => {
if (!user) {
return;
}
fetchTranslationStatus();
const interval = setInterval(() => fetchTranslationStatus(), 10000);
return () => clearInterval(interval);
}, [user, fetchTranslationStatus]);
useEffect(() => {
const handler = () => fetchTranslationStatus();
window.addEventListener('translation:refresh', handler);
return () => window.removeEventListener('translation:refresh', handler);
}, [fetchTranslationStatus]);
useEffect(() => {
if (!showNotifications) return;
const handleClick = (event: MouseEvent) => {
if (notificationsRef.current && !notificationsRef.current.contains(event.target as Node)) {
setShowNotifications(false);
}
};
document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
}, [showNotifications]);
// Mostrar loading enquanto verifica autenticação
if (isLoading) {
return (
<div className="min-h-screen bg-gray-50 dark:bg-[#121212] flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto mb-4"></div>
<p className="text-gray-500 dark:text-gray-400">Verificando autenticação...</p>
</div>
</div>
);
}
// Se não tem usuário após loading, não renderizar nada (está redirecionando)
if (!user) {
return null;
}
const handleLogout = async () => {
try {
await fetch('/api/auth/logout', { method: 'POST' });
router.push('/acesso');
} catch (error) {
console.error('Erro ao fazer logout:', error);
// Fallback: clear cookie manually
document.cookie = "auth_token=; path=/; expires=Thu, 01 Jan 1970 00:00:01 GMT";
router.push('/acesso');
}
};
const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setIsUploading(true);
try {
const formData = new FormData();
formData.append('avatar', file);
const response = await fetch('/api/auth/avatar', {
method: 'POST',
body: formData,
});
if (response.ok) {
const data = await response.json();
setUser(data.user);
setShowAvatarModal(false);
success('Foto atualizada com sucesso!');
} else {
const errorData = await response.json();
error(errorData.error || 'Erro ao fazer upload');
}
} catch (err) {
console.error('Erro ao fazer upload:', err);
error('Erro ao fazer upload do avatar');
} finally {
setIsUploading(false);
}
};
const handleRemoveAvatar = async () => {
const confirmed = await confirm({
title: 'Remover Foto',
message: 'Deseja remover sua foto de perfil?',
confirmText: 'Remover',
cancelText: 'Cancelar',
type: 'warning',
});
if (!confirmed) return;
try {
const response = await fetch('/api/auth/avatar', { method: 'DELETE' });
if (response.ok) {
const data = await response.json();
setUser(data.user);
setShowAvatarModal(false);
success('Foto removida com sucesso!');
}
} catch (err) {
console.error('Erro ao remover avatar:', err);
error('Erro ao remover avatar');
}
};
const menuItems = [
{ icon: 'ri-dashboard-line', label: 'Dashboard', href: '/admin' },
{ icon: 'ri-briefcase-line', label: 'Projetos', href: '/admin/projetos' },
{ icon: 'ri-tools-line', label: 'Serviços', href: '/admin/servicos' },
{ icon: 'ri-pages-line', label: 'Páginas', href: '/admin/paginas' },
{ icon: 'ri-message-3-line', label: 'Mensagens', href: '/admin/mensagens' },
{ icon: 'ri-user-settings-line', label: 'Usuários', href: '/admin/usuarios' },
{ icon: 'ri-settings-3-line', label: 'Configurações', href: '/admin/configuracoes' },
];
const pendingCount = translationSummary.filter((page) => page.pendingLocales.length > 0).length;
return (
<div className="min-h-screen bg-gray-50 dark:bg-[#121212] flex">
{/* Sidebar */}
<aside className={`fixed inset-y-0 left-0 z-50 bg-white dark:bg-secondary border-r border-gray-200 dark:border-white/10 transition-all duration-300 ${isSidebarOpen ? 'w-64' : 'w-20'} hidden md:flex flex-col`}>
<div className="h-20 flex items-center justify-center border-b border-gray-200 dark:border-white/10">
<Link href="/admin" className="flex items-center gap-3">
{logo ? (
<Image
src={logo}
alt="Logo"
width={32}
height={32}
className="object-contain"
unoptimized
/>
) : (
<i className="ri-building-2-fill text-3xl text-primary"></i>
)}
{isSidebarOpen && (
<div className="flex items-center gap-2 animate-in fade-in duration-300">
<span className="text-xl font-bold text-secondary dark:text-white font-headline leading-none">OCCTO</span>
<span className="text-[10px] font-bold text-primary bg-primary/10 px-2 py-1 rounded-md uppercase tracking-wider">ENG.</span>
</div>
)}
</Link>
</div>
<nav className="flex-1 py-6 px-3 space-y-2 overflow-y-auto">
{menuItems.map((item) => {
const isActive = pathname === item.href;
return (
<Link
key={item.href}
href={item.href}
className={`flex items-center gap-3 px-3 py-3 rounded-xl transition-all group ${isActive ? 'bg-primary text-white shadow-lg shadow-primary/20' : 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-white/5'}`}
>
<i className={`${item.icon} text-xl ${isActive ? 'text-white' : 'text-gray-500 dark:text-gray-400 group-hover:text-primary'}`}></i>
{isSidebarOpen && <span className="font-medium whitespace-nowrap animate-in fade-in duration-200">{item.label}</span>}
</Link>
);
})}
</nav>
<div className="p-4 border-t border-gray-200 dark:border-white/10">
<button
onClick={handleLogout}
className="w-full flex items-center gap-3 px-3 py-3 rounded-xl text-red-500 hover:bg-red-50 dark:hover:bg-red-900/10 transition-colors cursor-pointer"
>
<i className="ri-logout-box-line text-xl"></i>
{isSidebarOpen && <span className="font-medium">Sair</span>}
</button>
</div>
</aside>
{/* Main Content */}
<div className={`flex-1 flex flex-col min-h-screen transition-all duration-300 ${isSidebarOpen ? 'md:ml-64' : 'md:ml-20'}`}>
{/* Header */}
<header className="h-20 bg-white dark:bg-secondary border-b border-gray-200 dark:border-white/10 sticky top-0 z-40 px-6 flex items-center justify-between">
<button
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
className="w-10 h-10 rounded-lg hover:bg-gray-100 dark:hover:bg-white/5 flex items-center justify-center text-gray-600 dark:text-gray-300 transition-colors cursor-pointer"
>
<i className={isSidebarOpen ? "ri-menu-fold-line text-xl" : "ri-menu-unfold-line text-xl"}></i>
</button>
<div className="flex items-center gap-4">
{/* Dark Mode Toggle */}
<button
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
className="w-10 h-10 rounded-lg hover:bg-gray-100 dark:hover:bg-white/5 flex items-center justify-center text-gray-600 dark:text-yellow-400 transition-colors cursor-pointer"
aria-label="Alternar tema"
>
{mounted && theme === 'dark' ? (
<i className="ri-sun-line text-xl"></i>
) : (
<i className="ri-moon-line text-xl"></i>
)}
</button>
<div ref={notificationsRef} className="relative">
<button
onClick={() => {
setShowNotifications((prev) => {
const next = !prev;
if (!prev) {
fetchTranslationStatus();
}
return next;
});
}}
className="relative w-10 h-10 rounded-lg hover:bg-gray-100 dark:hover:bg-white/5 flex items-center justify-center text-gray-600 dark:text-gray-300 transition-colors cursor-pointer"
>
<i className="ri-notification-3-line text-xl"></i>
{pendingCount > 0 && (
<span className="absolute -top-1 -right-1 min-w-[18px] h-[18px] text-[11px] font-bold rounded-full bg-primary text-white flex items-center justify-center px-1">
{pendingCount}
</span>
)}
</button>
{showNotifications && (
<div className="absolute right-0 mt-3 w-80 bg-white dark:bg-secondary border border-gray-200 dark:border-white/10 rounded-xl shadow-xl z-50">
<div className="flex items-center justify-between px-4 py-2 border-b border-gray-100 dark:border-white/10">
<p className="font-semibold text-sm text-secondary dark:text-white">Traduções</p>
<button
onClick={() => fetchTranslationStatus(true)}
className="text-xs text-primary hover:text-secondary dark:hover:text-white font-semibold"
disabled={isFetchingTranslations}
>
{isFetchingTranslations ? 'Atualizando...' : 'Atualizar'}
</button>
</div>
<div className="max-h-72 overflow-y-auto divide-y divide-gray-100 dark:divide-white/10">
{translationSummary.length === 0 ? (
<p className="px-4 py-6 text-sm text-gray-500 dark:text-gray-400">Nenhuma tradução registrada.</p>
) : (
translationSummary.map((page) => (
<div key={page.slug} className="px-4 py-3 text-sm flex items-center justify-between gap-3">
<div>
<p className="font-semibold text-secondary dark:text-white capitalize">{page.slug}</p>
{page.pendingLocales.length > 0 ? (
<p className="text-xs text-gray-500 dark:text-gray-400">
Atualizando {page.pendingLocales.map((loc) => loc.toUpperCase()).join(', ')}
</p>
) : (
<p className="text-xs text-gray-500 dark:text-gray-400">Tudo traduzido</p>
)}
</div>
{page.pendingLocales.length > 0 ? (
<span className="text-xs font-semibold text-primary bg-primary/10 px-2.5 py-1 rounded-full">Em andamento</span>
) : (
<span className="text-xs font-semibold text-emerald-600 bg-emerald-100/80 dark:bg-emerald-900/30 px-2.5 py-1 rounded-full">Concluída</span>
)}
</div>
))
)}
</div>
</div>
)}
</div>
<div className="flex items-center gap-3 pl-4 border-l border-gray-200 dark:border-white/10">
<div className="text-right hidden sm:block">
<p className="text-sm font-bold text-secondary dark:text-white">{user?.name || 'Carregando...'}</p>
<p className="text-xs text-gray-500">{user?.email || ''}</p>
</div>
<button
onClick={() => setShowAvatarModal(true)}
className="w-10 h-10 rounded-full overflow-hidden hover:ring-2 hover:ring-primary transition-all cursor-pointer"
>
{user?.avatar ? (
<img src={user.avatar} alt={user.name} className="w-full h-full object-cover" />
) : (
<div className="w-full h-full bg-gray-200 dark:bg-white/10 flex items-center justify-center text-gray-500 dark:text-gray-400">
<i className="ri-user-3-line text-xl"></i>
</div>
)}
</button>
</div>
</div>
</header>
{/* Page Content */}
<main className="p-6 md:p-8">
{children}
</main>
</div>
{/* Avatar Modal */}
{showAvatarModal && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4" onClick={() => setShowAvatarModal(false)}>
<div className="bg-white dark:bg-secondary rounded-xl p-6 max-w-md w-full" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-bold text-secondary dark:text-white">Foto de Perfil</h2>
<button onClick={() => setShowAvatarModal(false)} className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200">
<i className="ri-close-line text-2xl"></i>
</button>
</div>
<div className="flex flex-col items-center gap-6">
<div className="w-32 h-32 rounded-full overflow-hidden">
{user?.avatar ? (
<img src={user.avatar} alt={user.name} className="w-full h-full object-cover" />
) : (
<div className="w-full h-full bg-gray-200 dark:bg-white/10 flex items-center justify-center">
<i className="ri-user-3-line text-5xl text-gray-400"></i>
</div>
)}
</div>
<div className="flex gap-3 w-full">
<label className="flex-1 px-4 py-2 bg-primary text-white rounded-lg font-medium hover-primary transition-colors text-center cursor-pointer">
{isUploading ? 'Enviando...' : 'Escolher Foto'}
<input
type="file"
accept="image/jpeg,image/png,image/webp"
onChange={handleAvatarUpload}
disabled={isUploading}
className="hidden"
/>
</label>
{user?.avatar && (
<button
onClick={handleRemoveAvatar}
disabled={isUploading}
className="px-4 py-2 border border-red-500 text-red-500 rounded-lg font-medium hover:bg-red-50 dark:hover:bg-red-900/10 transition-colors"
>
Remover
</button>
)}
</div>
<p className="text-xs text-gray-500 text-center">
Formatos: JPEG, PNG, WEBP Tamanho máximo: 5MB
</p>
</div>
</div>
</div>
)}
</div>
);
}