feat: add dynamic favicon, dynamic titles and public portal page
This commit is contained in:
@@ -15,6 +15,8 @@ import {
|
||||
import Link from "next/link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Sidebar } from "@/components/Sidebar";
|
||||
import { DynamicFavicon } from "@/components/DynamicFavicon";
|
||||
import { DynamicTitle } from "@/components/DynamicTitle";
|
||||
|
||||
type Organization = {
|
||||
id: string;
|
||||
@@ -57,6 +59,8 @@ export default function DashboardClient({
|
||||
|
||||
return (
|
||||
<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} />
|
||||
|
||||
{/* Main Content Area */}
|
||||
|
||||
@@ -21,6 +21,8 @@ import "react-pdf/dist/Page/AnnotationLayer.css";
|
||||
import "react-pdf/dist/Page/TextLayer.css";
|
||||
|
||||
import { incrementViewCount, incrementDownloadCount } from "@/app/actions/documents";
|
||||
import { DynamicFavicon } from "@/components/DynamicFavicon";
|
||||
import { DynamicTitle } from "@/components/DynamicTitle";
|
||||
|
||||
// Configure PDF.js worker
|
||||
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"
|
||||
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
|
||||
className="py-3 px-4 md:py-4 md:px-6 border-b border-white/10"
|
||||
|
||||
219
src/app/portal/PortalClient.tsx
Normal file
219
src/app/portal/PortalClient.tsx
Normal 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
53
src/app/portal/page.tsx
Normal 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,
|
||||
}))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -16,6 +16,8 @@ import Link from "next/link";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DynamicFavicon } from "@/components/DynamicFavicon";
|
||||
import { DynamicTitle } from "@/components/DynamicTitle";
|
||||
|
||||
type Organization = {
|
||||
name: string;
|
||||
@@ -98,6 +100,8 @@ export default function FolderViewClient({
|
||||
|
||||
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={folder.name} 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">
|
||||
|
||||
56
src/components/DynamicFavicon.tsx
Normal file
56
src/components/DynamicFavicon.tsx
Normal 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;
|
||||
}
|
||||
17
src/components/DynamicTitle.tsx
Normal file
17
src/components/DynamicTitle.tsx
Normal 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;
|
||||
}
|
||||
@@ -9,6 +9,8 @@ import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { DynamicFavicon } from "@/components/DynamicFavicon";
|
||||
import { DynamicTitle } from "@/components/DynamicTitle";
|
||||
|
||||
type Organization = {
|
||||
id: string;
|
||||
@@ -42,6 +44,8 @@ export default function LoginClient({ organization }: { organization: Organizati
|
||||
|
||||
return (
|
||||
<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 */}
|
||||
<div
|
||||
className="hidden lg:flex flex-col justify-between p-16 text-white relative overflow-hidden"
|
||||
|
||||
@@ -9,11 +9,13 @@ import {
|
||||
LogOut,
|
||||
Menu,
|
||||
X,
|
||||
Globe,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { logout } from "@/app/actions/auth";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DynamicFavicon } from "@/components/DynamicFavicon";
|
||||
|
||||
type Organization = {
|
||||
id: string;
|
||||
@@ -47,6 +49,10 @@ export function Sidebar({
|
||||
{ href: "/dashboard/configuracoes", label: "Configurações", icon: Settings },
|
||||
];
|
||||
|
||||
const externalItems = [
|
||||
{ href: "/portal", label: "Portal Público", icon: Globe, external: true },
|
||||
];
|
||||
|
||||
const SidebarContent = () => (
|
||||
<>
|
||||
<div className="p-6 lg:p-8">
|
||||
@@ -104,6 +110,25 @@ export function Sidebar({
|
||||
})}
|
||||
</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="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
|
||||
|
||||
Reference in New Issue
Block a user