feat: CMS com limites de caracteres, traduções auto e painel de notificações

This commit is contained in:
Erik
2025-11-27 12:05:23 -03:00
parent ea0c4ac5a6
commit 6e32ffdc95
40 changed files with 3665 additions and 278 deletions

View File

@@ -1,11 +1,17 @@
"use client";
import { useState, useEffect } from 'react';
import { useState, useEffect, useRef, useCallback } from 'react';
import Link from 'next/link';
import { usePathname, useRouter } from 'next/navigation';
import { useToast } from '@/contexts/ToastContext';
import { useConfirm } from '@/contexts/ConfirmContext';
type TranslationSummary = {
slug: string;
timestamps: Partial<Record<'pt' | 'en' | 'es', string>>;
pendingLocales: Array<'en' | 'es'>;
};
export default function AdminLayout({
children,
}: {
@@ -13,12 +19,65 @@ export default function AdminLayout({
}) {
const [isSidebarOpen, setIsSidebarOpen] = useState(true);
const [user, setUser] = useState<{ name: string; email: string; avatar?: string | null } | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [showAvatarModal, setShowAvatarModal] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const pathname = usePathname();
const router = useRouter();
const { success, error } = useToast();
const { confirm } = useConfirm();
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(() => {
const fetchUser = async () => {
@@ -27,13 +86,68 @@ export default function AdminLayout({
if (response.ok) {
const data = await response.json();
setUser(data.user);
} else {
// Não autenticado - redirecionar para login
router.push('/acesso');
return;
}
} catch (error) {
console.error('Erro ao buscar dados do usuário:', error);
} catch (err) {
console.error('Erro ao buscar dados do usuário:', err);
router.push('/acesso');
return;
} finally {
setIsLoading(false);
}
};
fetchUser();
}, []);
}, [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 {
@@ -113,6 +227,8 @@ export default function AdminLayout({
{ 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 */}
@@ -168,6 +284,68 @@ export default function AdminLayout({
</button>
<div className="flex items-center gap-4">
<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>