- 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
480 lines
19 KiB
TypeScript
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>
|
|
);
|
|
}
|