feat: CMS com limites de caracteres, traduções auto e painel de notificações
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user