feat: add dynamic favicon, dynamic titles and public portal page

This commit is contained in:
Erik Silva
2026-01-21 01:23:48 -03:00
parent f21cd10c07
commit b1d9082701
9 changed files with 386 additions and 0 deletions

View File

@@ -15,6 +15,8 @@ import {
import Link from "next/link"; import Link from "next/link";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Sidebar } from "@/components/Sidebar"; import { Sidebar } from "@/components/Sidebar";
import { DynamicFavicon } from "@/components/DynamicFavicon";
import { DynamicTitle } from "@/components/DynamicTitle";
type Organization = { type Organization = {
id: string; id: string;
@@ -57,6 +59,8 @@ export default function DashboardClient({
return ( return (
<div className="min-h-screen bg-white flex flex-col lg:flex-row"> <div className="min-h-screen bg-white flex flex-col lg:flex-row">
<DynamicFavicon logoUrl={organization.logoUrl} orgName={organization.name} primaryColor={primaryColor} />
<DynamicTitle title="Dashboard" orgName={organization.name} />
<Sidebar user={user} organization={organization} /> <Sidebar user={user} organization={organization} />
{/* Main Content Area */} {/* Main Content Area */}

View File

@@ -21,6 +21,8 @@ import "react-pdf/dist/Page/AnnotationLayer.css";
import "react-pdf/dist/Page/TextLayer.css"; import "react-pdf/dist/Page/TextLayer.css";
import { incrementViewCount, incrementDownloadCount } from "@/app/actions/documents"; import { incrementViewCount, incrementDownloadCount } from "@/app/actions/documents";
import { DynamicFavicon } from "@/components/DynamicFavicon";
import { DynamicTitle } from "@/components/DynamicTitle";
// Configure PDF.js worker // Configure PDF.js worker
pdfjs.GlobalWorkerOptions.workerSrc = `//unpkg.com/pdfjs-dist@${pdfjs.version}/build/pdf.worker.min.mjs`; pdfjs.GlobalWorkerOptions.workerSrc = `//unpkg.com/pdfjs-dist@${pdfjs.version}/build/pdf.worker.min.mjs`;
@@ -165,6 +167,8 @@ export default function DocumentViewClient({
className="min-h-screen bg-slate-50" className="min-h-screen bg-slate-50"
onContextMenu={!doc.isDownloadable ? (e) => e.preventDefault() : undefined} onContextMenu={!doc.isDownloadable ? (e) => e.preventDefault() : undefined}
> >
<DynamicFavicon logoUrl={organization.logoUrl} orgName={organization.name} primaryColor={primaryColor} />
<DynamicTitle title={doc.title} orgName={organization.name} />
{/* Header */} {/* Header */}
<header <header
className="py-3 px-4 md:py-4 md:px-6 border-b border-white/10" className="py-3 px-4 md:py-4 md:px-6 border-b border-white/10"

View File

@@ -0,0 +1,219 @@
"use client";
import React, { useState } from "react";
import { motion } from "framer-motion";
import {
FolderOpen,
Search,
ShieldCheck,
Calendar,
FileText,
ChevronRight,
Building2,
} from "lucide-react";
import Link from "next/link";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { DynamicFavicon } from "@/components/DynamicFavicon";
import { DynamicTitle } from "@/components/DynamicTitle";
type Organization = {
name: string;
logoUrl: string | null;
primaryColor: string;
};
type FolderType = {
id: string;
name: string;
description: string | null;
color: string;
imageUrl?: string | null;
documentsCount: number;
foldersCount: number;
createdAt: Date;
};
export default function PortalClient({
organization,
folders,
}: {
organization: Organization;
folders: FolderType[];
}) {
const [searchTerm, setSearchTerm] = useState("");
const filteredFolders = folders.filter((folder) =>
folder.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
folder.description?.toLowerCase().includes(searchTerm.toLowerCase())
);
const formatDate = (date: Date) => {
return new Date(date).toLocaleDateString("pt-BR", {
day: "2-digit",
month: "short",
year: "numeric",
});
};
return (
<div className="min-h-screen bg-[#f9fafb] selection:bg-red-100 selection:text-red-900 flex flex-col">
<DynamicFavicon logoUrl={organization.logoUrl} orgName={organization.name} primaryColor={organization.primaryColor} />
<DynamicTitle title="Portal da Transparência" orgName={organization.name} />
{/* Header */}
<header className="bg-white border-b border-slate-100 sticky top-0 z-10">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-16 sm:h-20 flex items-center justify-between">
<div className="flex items-center gap-3 sm:gap-4 min-w-0">
{organization.logoUrl ? (
<img src={organization.logoUrl} alt={organization.name} className="h-8 sm:h-10 object-contain shrink-0" />
) : (
<div className="w-8 h-8 sm:w-10 sm:h-10 rounded-lg sm:rounded-xl bg-slate-100 flex items-center justify-center font-bold text-slate-800 shrink-0 text-sm">
{organization.name[0]}
</div>
)}
<div className="min-w-0">
<h1 className="text-xs sm:text-sm font-black text-slate-900 uppercase tracking-tight truncate">{organization.name}</h1>
<p className="text-[9px] sm:text-[10px] font-bold text-slate-400 uppercase tracking-widest hidden sm:block">Portal da Transparência</p>
</div>
</div>
<Badge variant="outline" className="border-green-200 bg-green-50 text-green-600 font-bold text-[9px] sm:text-[10px] uppercase py-1 px-2 sm:px-3 shrink-0">
<span className="hidden sm:inline">Ambiente </span>Seguro
</Badge>
</div>
</header>
<main className="w-full flex-1">
<div className="w-full max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 sm:py-8 lg:py-12">
{/* Portal Hero */}
<div className="mb-8 sm:mb-12">
<div className="flex items-center gap-3 sm:gap-4 mb-4 sm:mb-6">
<div className="w-12 h-12 sm:w-16 sm:h-16 rounded-xl sm:rounded-2xl flex items-center justify-center shrink-0 bg-slate-900 text-white">
<Building2 size={24} className="sm:hidden" />
<Building2 size={32} className="hidden sm:block" />
</div>
<div className="min-w-0">
<h2 className="text-xl sm:text-2xl lg:text-3xl font-black text-slate-900 uppercase tracking-tighter leading-tight mb-1">
Portal da Transparência
</h2>
<p className="text-xs sm:text-sm font-medium text-slate-500 line-clamp-2">
Central de documentos públicos e projetos de {organization.name}
</p>
</div>
</div>
<div className="h-[1px] w-full bg-slate-100 mt-6" />
</div>
{/* Search & Stats */}
<div className="flex flex-col sm:flex-row justify-between items-stretch sm:items-center gap-3 sm:gap-6 mb-6 sm:mb-8">
<div className="relative flex-1 max-w-full sm:max-w-sm group">
<div className="absolute inset-y-0 left-0 pl-3 sm:pl-4 flex items-center pointer-events-none">
<Search className="text-slate-400 group-focus-within:text-red-500 transition-colors" size={16} />
</div>
<Input
placeholder="Buscar projetos..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="h-10 sm:h-12 pl-10 sm:pl-12 pr-4 bg-white border-slate-200 rounded-xl text-sm font-medium focus:ring-0 focus:border-slate-300 transition-all shadow-none"
/>
</div>
<div className="flex items-center gap-3 bg-white px-4 sm:px-5 py-2 sm:py-2.5 rounded-xl border border-slate-100 self-start">
<span className="text-[9px] sm:text-[10px] font-bold text-slate-400 uppercase tracking-widest">Projetos</span>
<span className="text-sm font-black text-slate-900">{filteredFolders.length}</span>
<div className="w-[1px] h-5 bg-slate-100 mx-1" />
<ShieldCheck className="text-green-500" size={16} />
</div>
</div>
{/* Projects Grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-6">
{filteredFolders.map((folder, idx) => (
<motion.div
key={folder.id}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: idx * 0.05 }}
>
<Link
href={`/visualizar/pasta/${folder.id}`}
className="block bg-white border border-slate-100 rounded-2xl p-5 sm:p-6 hover:border-slate-200 hover:shadow-lg transition-all group"
>
<div className="flex items-start gap-4 mb-4">
{folder.imageUrl ? (
<div className="w-14 h-14 rounded-xl overflow-hidden shrink-0 border border-slate-100">
<img src={folder.imageUrl} alt={folder.name} className="w-full h-full object-cover" />
</div>
) : (
<div
className="w-14 h-14 rounded-xl flex items-center justify-center shrink-0"
style={{ backgroundColor: `${folder.color}15`, color: folder.color }}
>
<FolderOpen size={28} fill="currentColor" fillOpacity={0.2} strokeWidth={2} />
</div>
)}
<div className="min-w-0 flex-1">
<h3 className="text-sm font-bold text-slate-800 uppercase tracking-tight truncate group-hover:text-red-600 transition-colors mb-1">
{folder.name}
</h3>
<p className="text-[11px] text-slate-400 font-medium line-clamp-2">
{folder.description || "Projeto público com documentos oficiais."}
</p>
</div>
</div>
<div className="flex items-center justify-between pt-4 border-t border-slate-50">
<div className="flex items-center gap-3 text-[10px] font-bold text-slate-400 uppercase tracking-tight">
<span className="flex items-center gap-1">
<FileText size={12} />
{folder.documentsCount + folder.foldersCount} itens
</span>
<span className="flex items-center gap-1">
<Calendar size={12} />
{formatDate(folder.createdAt)}
</span>
</div>
<ChevronRight size={16} className="text-slate-300 group-hover:text-red-500 transition-colors" />
</div>
</Link>
</motion.div>
))}
{filteredFolders.length === 0 && (
<div className="col-span-full bg-white border border-dashed border-slate-200 p-12 sm:p-20 rounded-2xl flex flex-col items-center justify-center text-center">
<div className="w-16 h-16 sm:w-20 sm:h-20 bg-slate-50 rounded-full flex items-center justify-center mb-4 sm:mb-6">
<FolderOpen size={32} className="text-slate-300" />
</div>
<h3 className="text-base sm:text-lg font-bold text-slate-800 uppercase tracking-tight mb-2">Nenhum projeto encontrado</h3>
<p className="text-xs sm:text-sm text-slate-400 max-w-xs mx-auto font-medium">
Não existem projetos públicos disponíveis no momento ou nenhum corresponde à sua busca.
</p>
</div>
)}
</div>
</div>
</main>
{/* Footer */}
<footer className="bg-white border-t border-slate-100 mt-auto">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-16 sm:h-20 flex items-center justify-center">
{organization.logoUrl ? (
<img
src={organization.logoUrl}
alt={organization.name}
className="h-8 sm:h-10 object-contain opacity-60 grayscale hover:opacity-100 hover:grayscale-0 transition-all cursor-pointer"
/>
) : (
<div className="flex items-center gap-2 sm:gap-3 text-slate-400">
<div className="w-8 h-8 sm:w-10 sm:h-10 rounded-lg sm:rounded-xl bg-slate-100 flex items-center justify-center font-bold text-slate-500 text-sm">
{organization.name[0]}
</div>
<span className="text-xs sm:text-sm font-bold uppercase tracking-widest truncate max-w-[150px] sm:max-w-none">{organization.name}</span>
</div>
)}
</div>
</footer>
</div>
);
}

53
src/app/portal/page.tsx Normal file
View File

@@ -0,0 +1,53 @@
import { prisma } from "@/lib/prisma";
import { redirect } from "next/navigation";
import PortalClient from "./PortalClient";
export default async function PortalPage() {
const organization = await prisma.organization.findFirst();
if (!organization) {
redirect("/setup");
}
// Get all public root folders (projects)
const publicFolders = await prisma.folder.findMany({
where: {
organizationId: organization.id,
parentId: null,
isPublished: true,
},
orderBy: { createdAt: "desc" },
include: {
_count: {
select: {
documents: {
where: { isPublished: true }
},
children: {
where: { isPublished: true }
}
}
}
}
});
return (
<PortalClient
organization={{
name: organization.name,
logoUrl: organization.logoUrl,
primaryColor: organization.primaryColor,
}}
folders={publicFolders.map((f: any) => ({
id: f.id,
name: f.name,
description: f.description,
color: f.color,
imageUrl: f.imageUrl,
documentsCount: f._count.documents,
foldersCount: f._count.children,
createdAt: f.createdAt,
}))}
/>
);
}

View File

@@ -16,6 +16,8 @@ import Link from "next/link";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { DynamicFavicon } from "@/components/DynamicFavicon";
import { DynamicTitle } from "@/components/DynamicTitle";
type Organization = { type Organization = {
name: string; name: string;
@@ -98,6 +100,8 @@ export default function FolderViewClient({
return ( return (
<div className="min-h-screen bg-[#f9fafb] selection:bg-red-100 selection:text-red-900 flex flex-col"> <div className="min-h-screen bg-[#f9fafb] selection:bg-red-100 selection:text-red-900 flex flex-col">
<DynamicFavicon logoUrl={organization.logoUrl} orgName={organization.name} primaryColor={organization.primaryColor} />
<DynamicTitle title={folder.name} orgName={organization.name} />
{/* Header */} {/* Header */}
<header className="bg-white border-b border-slate-100 sticky top-0 z-10"> <header className="bg-white border-b border-slate-100 sticky top-0 z-10">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-16 sm:h-20 flex items-center justify-between"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-16 sm:h-20 flex items-center justify-between">

View File

@@ -0,0 +1,56 @@
"use client";
import { useEffect } from "react";
interface DynamicFaviconProps {
logoUrl: string | null;
orgName: string;
primaryColor: string;
}
export function DynamicFavicon({ logoUrl, orgName, primaryColor }: DynamicFaviconProps) {
useEffect(() => {
// If there's a logo URL, use it as favicon
if (logoUrl) {
// Remove any existing favicon links
const existingLinks = document.querySelectorAll("link[rel*='icon']");
existingLinks.forEach(link => link.remove());
// Create new favicon link
const link = document.createElement("link");
link.rel = "icon";
link.type = "image/png";
link.href = logoUrl;
document.head.appendChild(link);
// Also add apple touch icon
const appleLink = document.createElement("link");
appleLink.rel = "apple-touch-icon";
appleLink.href = logoUrl;
document.head.appendChild(appleLink);
} else {
// Generate a simple SVG favicon with the first letter
const svg = `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="6" fill="${primaryColor}"/>
<text x="16" y="22" text-anchor="middle" fill="white" font-family="Arial, sans-serif" font-size="18" font-weight="bold">
${orgName.charAt(0).toUpperCase()}
</text>
</svg>
`;
const blob = new Blob([svg], { type: "image/svg+xml" });
const url = URL.createObjectURL(blob);
const existingLinks = document.querySelectorAll("link[rel*='icon']");
existingLinks.forEach(link => link.remove());
const link = document.createElement("link");
link.rel = "icon";
link.type = "image/svg+xml";
link.href = url;
document.head.appendChild(link);
}
}, [logoUrl, orgName, primaryColor]);
return null;
}

View File

@@ -0,0 +1,17 @@
"use client";
import { useEffect } from "react";
interface DynamicTitleProps {
title: string;
orgName?: string;
}
export function DynamicTitle({ title, orgName }: DynamicTitleProps) {
useEffect(() => {
const fullTitle = orgName ? `${title} | ${orgName}` : title;
document.title = fullTitle;
}, [title, orgName]);
return null;
}

View File

@@ -9,6 +9,8 @@ import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Alert, AlertDescription } from "@/components/ui/alert"; import { Alert, AlertDescription } from "@/components/ui/alert";
import { DynamicFavicon } from "@/components/DynamicFavicon";
import { DynamicTitle } from "@/components/DynamicTitle";
type Organization = { type Organization = {
id: string; id: string;
@@ -42,6 +44,8 @@ export default function LoginClient({ organization }: { organization: Organizati
return ( return (
<div className="min-h-screen flex flex-col lg:grid lg:grid-cols-2 bg-white selection:bg-slate-900 selection:text-white"> <div className="min-h-screen flex flex-col lg:grid lg:grid-cols-2 bg-white selection:bg-slate-900 selection:text-white">
<DynamicFavicon logoUrl={organization.logoUrl} orgName={organization.name} primaryColor={primaryColor} />
<DynamicTitle title="Login" orgName={organization.name} />
{/* Left Side: Illustration & Info - Hidden on Mobile */} {/* Left Side: Illustration & Info - Hidden on Mobile */}
<div <div
className="hidden lg:flex flex-col justify-between p-16 text-white relative overflow-hidden" className="hidden lg:flex flex-col justify-between p-16 text-white relative overflow-hidden"

View File

@@ -9,11 +9,13 @@ import {
LogOut, LogOut,
Menu, Menu,
X, X,
Globe,
} from "lucide-react"; } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { logout } from "@/app/actions/auth"; import { logout } from "@/app/actions/auth";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { DynamicFavicon } from "@/components/DynamicFavicon";
type Organization = { type Organization = {
id: string; id: string;
@@ -47,6 +49,10 @@ export function Sidebar({
{ href: "/dashboard/configuracoes", label: "Configurações", icon: Settings }, { href: "/dashboard/configuracoes", label: "Configurações", icon: Settings },
]; ];
const externalItems = [
{ href: "/portal", label: "Portal Público", icon: Globe, external: true },
];
const SidebarContent = () => ( const SidebarContent = () => (
<> <>
<div className="p-6 lg:p-8"> <div className="p-6 lg:p-8">
@@ -104,6 +110,25 @@ export function Sidebar({
})} })}
</nav> </nav>
{/* External Links */}
<div className="px-3 lg:px-4 pt-2 pb-4">
<div className="h-px bg-slate-100 mb-3" />
<p className="text-[9px] font-bold text-slate-400 uppercase tracking-widest px-4 mb-2">Acesso Público</p>
{externalItems.map((item) => (
<a
key={item.href}
href={item.href}
target="_blank"
rel="noopener noreferrer"
onClick={() => setMobileOpen(false)}
className="flex items-center gap-3 lg:gap-3.5 px-4 lg:px-5 py-3 lg:py-3.5 rounded-xl lg:rounded-2xl transition-all duration-300 group text-slate-500 hover:text-green-600 hover:bg-green-50"
>
<item.icon size={18} className="transition-transform duration-300 group-hover:scale-110 opacity-70 group-hover:opacity-100" />
<span className="text-[13px] tracking-tight">{item.label}</span>
</a>
))}
</div>
<div className="p-4 lg:p-6 mt-auto"> <div className="p-4 lg:p-6 mt-auto">
<div className="flex flex-col gap-3 lg:gap-4 p-4 lg:p-5 rounded-2xl lg:rounded-[24px] bg-white border border-slate-200/80"> <div className="flex flex-col gap-3 lg:gap-4 p-4 lg:p-5 rounded-2xl lg:rounded-[24px] bg-white border border-slate-200/80">
<Link <Link