feat: setup docker and push project to remote

This commit is contained in:
Erik Silva
2026-01-20 13:44:32 -03:00
parent 45bac0c990
commit 261fd429d5
74 changed files with 12876 additions and 101 deletions

47
.agent/workflows/brand.md Normal file
View File

@@ -0,0 +1,47 @@
---
description: Descricao do Projeto
---
# Cores
Azul
Branco
Preto
Cinza
# Fonte
Inter
# Projeto
Contruir uma plataforma de Portal de Transparencia que facilmente e instalada em subdonio de ongs e organizacaoes
# Iconografia
- Lucide Icons
Contruir uma plataforma de Portal de Transparencia que facilmente e instalada em subdonio de ongs e organizacaoes
# Solucoes
- Documentacoes compartilhadas em paginas criadas pelo usuario e sistema automaticamente cria uma pagina com essa documentacao.
# Questoes técnicas
- Next.js - ultima versao
- Postgress
- Docker
- Prisma ORM
- Minio S3
# Páginas
- Super Cadastro - pagina destinada ao inicico onde nos criamos a ong com cores logo e mais relacionado a ong
- Dashboard - No momento sem nada
- Documentos - lista completa com data de cricao se esta publicado ou nao o documento e link de compartilhamento

2
.gitignore vendored
View File

@@ -39,3 +39,5 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
/src/generated/prisma

56
Dockerfile Normal file
View File

@@ -0,0 +1,56 @@
# Stage 1: Install dependencies
FROM node:20-alpine AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
# Install dependencies based on the preferred package manager
COPY package.json package-lock.json* ./
RUN npm ci
# Stage 2: Rebuild the source code only when needed
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Next.js collects completely anonymous telemetry data about general usage.
# Learn more here: https://nextjs.org/telemetry
# Uncomment the following line in case you want to disable telemetry during the build.
# ENV NEXT_TELEMETRY_DISABLED 1
RUN npx prisma generate
RUN npm run build
# Stage 3: Production image, copy all the files and run next
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV production
# Uncomment the following line in case you want to disable telemetry during runtime.
# ENV NEXT_TELEMETRY_DISABLED 1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
# Set the correct permission for prerender cache
RUN mkdir .next
RUN chown nextjs:nodejs .next
# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT 3000
# set hostname to localhost
ENV HOSTNAME "0.0.0.0"
# server.js is created by next build from the standalone output
# https://nextjs.org/docs/pages/api-reference/next-config-js/output
CMD ["node", "server.js"]

22
components.json Normal file
View File

@@ -0,0 +1,22 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
}

43
docker-compose.yml Normal file
View File

@@ -0,0 +1,43 @@
services:
postgres:
image: postgres:15-alpine
container_name: portal_transparencia_db
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
POSTGRES_DB: portal_db
ports:
- "5433:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
minio:
image: minio/minio
container_name: portal_transparencia_minio
ports:
- "9000:9000"
- "9001:9001"
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-admin}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-password123}
command: server /data --console-address ":9001"
volumes:
- minio_data:/data
app:
build:
context: .
dockerfile: Dockerfile
container_name: portal_transparencia_app
ports:
- "3000:3000"
environment:
DATABASE_URL: postgres://postgres:password@postgres:5432/portal_db
NEXT_PUBLIC_APP_URL: http://localhost:3000
depends_on:
- postgres
- minio
volumes:
postgres_data:
minio_data:

View File

@@ -1,7 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
output: 'standalone',
};
export default nextConfig;

3957
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -9,18 +9,47 @@
"lint": "eslint"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.968.0",
"@aws-sdk/s3-request-presigner": "^3.968.0",
"@prisma/client": "^6.19.2",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@types/bcryptjs": "^2.4.6",
"@types/react-pdf": "^6.2.0",
"bcryptjs": "^3.0.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cookies-next": "^6.1.1",
"framer-motion": "^12.26.2",
"jose": "^6.1.3",
"lucide-react": "^0.562.0",
"next": "16.1.1",
"react": "19.2.3",
"react-dom": "19.2.3"
"react-dom": "19.2.3",
"react-pdf": "^10.3.0",
"tailwind-merge": "^3.4.0",
"uuid": "^13.0.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/uuid": "^10.0.0",
"eslint": "^9",
"eslint-config-next": "16.1.1",
"prisma": "^6.19.2",
"tailwindcss": "^4",
"tw-animate-css": "^1.4.0",
"typescript": "^5"
}
}

View File

@@ -0,0 +1,63 @@
-- CreateEnum
CREATE TYPE "Role" AS ENUM ('SUPER_ADMIN', 'ADMIN', 'VIEWER');
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL,
"name" TEXT,
"email" TEXT NOT NULL,
"password" TEXT NOT NULL,
"role" "Role" NOT NULL DEFAULT 'ADMIN',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"organizationId" TEXT,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Organization" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"cnpj" TEXT,
"logoUrl" TEXT,
"primaryColor" TEXT NOT NULL DEFAULT '#2563eb',
"subdomain" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Organization_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Document" (
"id" TEXT NOT NULL,
"title" TEXT NOT NULL,
"description" TEXT,
"fileUrl" TEXT NOT NULL,
"fileName" TEXT NOT NULL,
"fileType" TEXT NOT NULL,
"category" TEXT NOT NULL,
"isPublished" BOOLEAN NOT NULL DEFAULT true,
"publishedAt" TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"organizationId" TEXT NOT NULL,
CONSTRAINT "Document_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- CreateIndex
CREATE UNIQUE INDEX "Organization_cnpj_key" ON "Organization"("cnpj");
-- CreateIndex
CREATE UNIQUE INDEX "Organization_subdomain_key" ON "Organization"("subdomain");
-- AddForeignKey
ALTER TABLE "User" ADD CONSTRAINT "User_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Document" ADD CONSTRAINT "Document_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@@ -0,0 +1,36 @@
/*
Warnings:
- You are about to drop the column `category` on the `Document` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE "Document" DROP COLUMN "category",
ADD COLUMN "fileSize" INTEGER NOT NULL DEFAULT 0,
ADD COLUMN "folderId" TEXT,
ADD COLUMN "isDownloadable" BOOLEAN NOT NULL DEFAULT true,
ALTER COLUMN "isPublished" SET DEFAULT false,
ALTER COLUMN "publishedAt" DROP DEFAULT;
-- CreateTable
CREATE TABLE "Folder" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"color" TEXT NOT NULL DEFAULT '#6366f1',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"organizationId" TEXT NOT NULL,
"parentId" TEXT,
CONSTRAINT "Folder_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "Folder" ADD CONSTRAINT "Folder_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Folder" ADD CONSTRAINT "Folder_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "Folder"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Document" ADD CONSTRAINT "Document_folderId_fkey" FOREIGN KEY ("folderId") REFERENCES "Folder"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"

90
prisma/schema.prisma Normal file
View File

@@ -0,0 +1,90 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id String @id @default(cuid())
name String?
email String @unique
password String
role Role @default(ADMIN)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
organization Organization? @relation(fields: [organizationId], references: [id])
organizationId String?
}
enum Role {
SUPER_ADMIN
ADMIN
VIEWER
}
model Organization {
id String @id @default(cuid())
name String
cnpj String? @unique
logoUrl String?
primaryColor String @default("#2563eb")
subdomain String @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
users User[]
documents Document[]
folders Folder[]
}
model Folder {
id String @id @default(cuid())
name String
description String?
color String @default("#6366f1")
imageUrl String? // Imagem de capa da pasta
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
isPublished Boolean @default(false)
publishedAt DateTime?
organization Organization @relation(fields: [organizationId], references: [id])
organizationId String
parentId String?
parent Folder? @relation("FolderToFolder", fields: [parentId], references: [id])
children Folder[] @relation("FolderToFolder")
documents Document[]
}
model Document {
id String @id @default(cuid())
title String
description String?
fileUrl String
fileName String
fileType String
fileSize Int @default(0)
isPublished Boolean @default(false)
isDownloadable Boolean @default(true)
viewCount Int @default(0)
downloadCount Int @default(0)
publishedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
organization Organization @relation(fields: [organizationId], references: [id])
organizationId String
folder Folder? @relation(fields: [folderId], references: [id])
folderId String?
}

View File

@@ -0,0 +1,41 @@
const { S3Client, PutBucketPolicyCommand } = require("@aws-sdk/client-s3");
const BUCKET_NAME = process.env.MINIO_BUCKET || "portal-transparencia";
const s3Client = new S3Client({
endpoint: `http://${process.env.MINIO_ENDPOINT || "localhost"}:${process.env.MINIO_PORT || 9000}`,
region: "us-east-1",
credentials: {
accessKeyId: process.env.MINIO_ACCESS_KEY || "admin",
secretAccessKey: process.env.MINIO_SECRET_KEY || "password123",
},
forcePathStyle: true,
});
const publicPolicy = {
Version: "2012-10-17",
Statement: [
{
Effect: "Allow",
Principal: "*",
Action: ["s3:GetObject"],
Resource: [`arn:aws:s3:::${BUCKET_NAME}/*`],
},
],
};
async function main() {
try {
await s3Client.send(
new PutBucketPolicyCommand({
Bucket: BUCKET_NAME,
Policy: JSON.stringify(publicPolicy),
})
);
console.log(`✅ Bucket ${BUCKET_NAME} configurado como público!`);
} catch (error) {
console.error("Erro:", error.message);
}
}
main();

18
scripts/fix-password.js Normal file
View File

@@ -0,0 +1,18 @@
const { PrismaClient } = require('@prisma/client');
const bcrypt = require('bcryptjs');
async function main() {
const prisma = new PrismaClient();
const hash = await bcrypt.hash('Android@2020', 10);
await prisma.user.updateMany({
where: { role: 'SUPER_ADMIN' },
data: { password: hash }
});
console.log('Senha atualizada com sucesso!');
await prisma.$disconnect();
}
main();

View File

@@ -0,0 +1,88 @@
"use client";
import Link from "next/link";
import { motion } from "framer-motion";
import { FileQuestion, Home, ArrowLeft } from "lucide-react";
interface NotFoundClientProps {
primaryColor: string;
orgName: string;
}
export default function NotFoundClient({ primaryColor, orgName }: NotFoundClientProps) {
// Gerar cor mais clara para backgrounds
const lightColor = `${primaryColor}15`;
const mediumColor = `${primaryColor}30`;
return (
<div className="min-h-screen bg-slate-50 flex items-center justify-center p-4">
<div className="max-w-md w-full text-center">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
className="glass-card p-12 rounded-3xl border border-slate-200 shadow-2xl bg-white/40 backdrop-blur-xl"
>
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ delay: 0.2, type: "spring", stiffness: 200 }}
className="w-24 h-24 rounded-2xl flex items-center justify-center mx-auto mb-8"
style={{ backgroundColor: lightColor }}
>
<FileQuestion className="w-12 h-12" style={{ color: primaryColor }} />
</motion.div>
<h1 className="text-6xl font-black text-slate-900 mb-4 tracking-tighter">404</h1>
<h2 className="text-2xl font-bold text-slate-800 mb-4">Página não encontrada</h2>
<p className="text-slate-600 mb-8 leading-relaxed">
Ops! Parece que você se perdeu pelo caminho. A página que você está procurando não existe ou foi movida.
</p>
<div className="flex flex-col gap-3">
<Link
href="/dashboard"
className="flex items-center justify-center gap-2 group px-6 py-3 rounded-xl text-white font-semibold transition-all shadow-lg active:scale-95 hover:opacity-90"
style={{
backgroundColor: primaryColor,
boxShadow: `0 10px 25px -5px ${mediumColor}`
}}
>
<Home className="w-5 h-5 transition-transform group-hover:-translate-y-1" />
Voltar ao Início
</Link>
<button
onClick={() => window.history.back()}
className="px-6 py-3 rounded-xl font-semibold text-slate-600 hover:bg-slate-100 transition-all flex items-center justify-center gap-2"
>
<ArrowLeft className="w-5 h-5" />
Voltar Página
</button>
</div>
</motion.div>
<motion.p
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.8 }}
className="mt-8 text-slate-400 text-sm font-medium"
>
{orgName} © {new Date().getFullYear()}
</motion.p>
</div>
{/* Background Decorative Elements */}
<div className="fixed top-0 left-0 w-full h-full -z-10 overflow-hidden pointer-events-none">
<div
className="absolute top-[-10%] left-[-10%] w-[40%] h-[40%] rounded-full blur-[120px]"
style={{ backgroundColor: `${primaryColor}15` }}
/>
<div
className="absolute bottom-[-10%] right-[-10%] w-[40%] h-[40%] rounded-full blur-[120px]"
style={{ backgroundColor: `${primaryColor}10` }}
/>
</div>
</div>
);
}

81
src/app/actions/auth.ts Normal file
View File

@@ -0,0 +1,81 @@
"use server";
import { prisma } from "@/lib/db";
import bcrypt from "bcryptjs";
import { SignJWT, jwtVerify } from "jose";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
const JWT_SECRET = new TextEncoder().encode(
process.env.JWT_SECRET || "supersecretkey123456789"
);
// Login
export async function login(email: string, password: string) {
try {
const user = await prisma.user.findUnique({
where: { email },
include: { organization: true },
});
if (!user) {
return { success: false, error: "E-mail não encontrado." };
}
const isValid = await bcrypt.compare(password, user.password);
if (!isValid) {
return { success: false, error: "Senha incorreta." };
}
// Criar JWT
const token = await new SignJWT({
userId: user.id,
email: user.email,
role: user.role,
organizationId: user.organizationId,
})
.setProtectedHeader({ alg: "HS256" })
.setExpirationTime("7d")
.sign(JWT_SECRET);
// Salvar cookie
(await cookies()).set("auth-token", token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: 60 * 60 * 24 * 7, // 7 dias
});
return { success: true };
} catch (error) {
console.error("Login error:", error);
return { success: false, error: "Erro ao fazer login." };
}
}
// Logout
export async function logout() {
(await cookies()).delete("auth-token");
redirect("/");
}
// Verificar sessão
export async function getSession() {
try {
const cookieStore = await cookies();
const token = cookieStore.get("auth-token")?.value;
if (!token) return null;
const { payload } = await jwtVerify(token, JWT_SECRET);
const user = await prisma.user.findUnique({
where: { id: payload.userId as string },
include: { organization: true },
});
return user;
} catch {
return null;
}
}

View File

@@ -0,0 +1,47 @@
"use server";
import { prisma } from "@/lib/db";
import { getSession } from "./auth";
export async function getDashboardStats() {
try {
const session = await getSession();
if (!session) return null;
const [docCount, viewsData, downloadsData, recentDocs] = await Promise.all([
// Total documents
prisma.document.count({
where: { organizationId: session.organizationId || undefined }
}),
// Total views
prisma.document.aggregate({
where: { organizationId: session.organizationId || undefined },
_sum: { viewCount: true }
}),
// Total downloads
prisma.document.aggregate({
where: { organizationId: session.organizationId || undefined },
_sum: { downloadCount: true }
}),
// Recent documents
prisma.document.findMany({
where: { organizationId: session.organizationId || undefined },
orderBy: { createdAt: "desc" },
take: 5,
include: {
folder: true
}
})
]);
return {
docCount,
viewCount: (viewsData._sum as any).viewCount || 0,
downloadCount: (downloadsData._sum as any).downloadCount || 0,
recentDocs
};
} catch (error) {
console.error("Dashboard stats error:", error);
return null;
}
}

View File

@@ -0,0 +1,217 @@
"use server";
import { prisma } from "@/lib/db";
import { getSession } from "./auth";
// Criar documento
export async function createDocument(data: {
title: string;
description?: string;
fileUrl: string;
fileName: string;
fileType: string;
fileSize: number;
folderId?: string;
isPublished?: boolean;
isDownloadable?: boolean;
}) {
const session = await getSession();
if (!session?.organizationId) {
return { success: false, error: "Não autenticado." };
}
try {
const doc = await prisma.document.create({
data: {
title: data.title,
description: data.description,
fileUrl: data.fileUrl,
fileName: data.fileName,
fileType: data.fileType,
fileSize: data.fileSize,
folderId: data.folderId,
isPublished: data.isPublished ?? false,
isDownloadable: data.isDownloadable ?? true,
publishedAt: data.isPublished ? new Date() : null,
organizationId: session.organizationId,
},
});
return { success: true, document: doc };
} catch (error) {
console.error("Create document error:", error);
return { success: false, error: "Erro ao criar documento." };
}
}
// Listar documentos
export async function getDocuments(folderId?: string | null) {
const session = await getSession();
if (!session?.organizationId) {
return [];
}
return prisma.document.findMany({
where: {
organizationId: session.organizationId,
folderId: folderId === undefined ? null : folderId,
},
orderBy: { createdAt: "desc" },
});
}
// Atualizar documento
export async function updateDocument(
id: string,
data: {
title?: string;
description?: string;
isPublished?: boolean;
isDownloadable?: boolean;
folderId?: string | null;
}
) {
const session = await getSession();
if (!session?.organizationId) {
return { success: false, error: "Não autenticado." };
}
try {
const updateData: any = { ...data };
// Se está publicando agora
if (data.isPublished === true) {
const current = await prisma.document.findUnique({ where: { id } });
if (!current?.publishedAt) {
updateData.publishedAt = new Date();
}
}
await prisma.document.update({
where: { id },
data: updateData,
});
return { success: true };
} catch (error) {
console.error("Update document error:", error);
return { success: false, error: "Erro ao atualizar documento." };
}
}
// Deletar documento (também remove arquivo do MinIO)
export async function deleteDocument(id: string) {
const session = await getSession();
if (!session?.organizationId) {
return { success: false, error: "Não autenticado." };
}
try {
// Buscar o documento para obter a URL do arquivo
const doc = await prisma.document.findUnique({
where: { id },
select: { fileUrl: true }
});
// Deletar do banco
await prisma.document.delete({ where: { id } });
// Deletar do MinIO
if (doc?.fileUrl) {
const { deleteFile } = await import("@/lib/s3");
await deleteFile(doc.fileUrl);
}
return { success: true };
} catch (error) {
console.error("Delete document error:", error);
return { success: false, error: "Erro ao deletar documento." };
}
}
// Buscar documento por ID
export async function getDocument(id: string) {
const session = await getSession();
if (!session?.organizationId) {
return null;
}
return prisma.document.findUnique({
where: { id },
include: { folder: true },
});
}
// Bulk delete (também remove arquivos do MinIO)
export async function bulkDeleteDocuments(ids: string[]) {
const session = await getSession();
if (!session?.organizationId) return { success: false };
try {
// Buscar URLs dos arquivos antes de deletar
const docs = await prisma.document.findMany({
where: { id: { in: ids }, organizationId: session.organizationId },
select: { fileUrl: true }
});
// Deletar do banco
await prisma.document.deleteMany({
where: { id: { in: ids }, organizationId: session.organizationId }
});
// Deletar do MinIO
const { deleteFile } = await import("@/lib/s3");
for (const doc of docs) {
if (doc.fileUrl) {
await deleteFile(doc.fileUrl);
}
}
return { success: true };
} catch (error) {
console.error("Bulk delete documents error:", error);
return { success: false };
}
}
// Bulk move
export async function bulkMoveDocuments(ids: string[], folderId: string | null) {
const session = await getSession();
if (!session?.organizationId) return { success: false };
try {
await prisma.document.updateMany({
where: { id: { in: ids }, organizationId: session.organizationId },
data: { folderId }
});
return { success: true };
} catch (error) {
return { success: false };
}
}
// Incrementar visualização
export async function incrementViewCount(id: string) {
try {
await prisma.document.update({
where: { id },
data: { viewCount: { increment: 1 } }
});
return { success: true };
} catch (error) {
return { success: false };
}
}
// Incrementar download
export async function incrementDownloadCount(id: string) {
try {
await prisma.document.update({
where: { id },
data: { downloadCount: { increment: 1 } }
});
return { success: true };
} catch (error) {
return { success: false };
}
}

347
src/app/actions/folders.ts Normal file
View File

@@ -0,0 +1,347 @@
"use server";
import { prisma } from "@/lib/db";
import { getSession } from "./auth";
// Criar pasta
export async function createFolder(data: {
name: string;
description?: string;
color?: string;
imageUrl?: string;
parentId?: string | null;
isPublished?: boolean;
}) {
const session = await getSession();
if (!session?.organizationId) {
return { success: false, error: "Não autenticado." };
}
try {
const folder = await (prisma.folder as any).create({
data: {
name: data.name,
description: data.description,
color: data.color || "#6366f1",
imageUrl: data.imageUrl || null,
parentId: data.parentId || null,
organizationId: session.organizationId,
isPublished: data.isPublished || false,
publishedAt: data.isPublished ? new Date() : null,
},
});
return { success: true, folder };
} catch (error) {
console.error("Create folder error:", error);
return { success: false, error: "Erro ao criar pasta." };
}
}
// Listar pastas
export async function getFolders(parentId?: string | null) {
const session = await getSession();
if (!session?.organizationId) {
return [];
}
return (prisma.folder as any).findMany({
where: {
organizationId: session.organizationId,
parentId: parentId === undefined ? null : parentId,
},
include: {
_count: { select: { documents: true, children: true } },
},
orderBy: { name: "asc" },
});
}
// Listar TODAS as pastas (para mover documentos)
export async function getAllFolders() {
const session = await getSession();
if (!session?.organizationId) {
return [];
}
return (prisma.folder as any).findMany({
where: {
organizationId: session.organizationId,
},
select: {
id: true,
name: true,
parentId: true,
},
orderBy: { name: "asc" },
});
}
// Buscar caminho (breadcrumb) de uma pasta
export async function getFolderBreadcrumb(folderId: string | null) {
if (!folderId) return [];
const session = await getSession();
if (!session?.organizationId) {
return [];
}
const breadcrumb: { id: string; name: string }[] = [];
let currentId: string | null = folderId;
while (currentId) {
const folder: { id: string; name: string; parentId: string | null } | null = await (prisma.folder as any).findUnique({
where: { id: currentId },
select: { id: true, name: true, parentId: true }
});
if (!folder) break;
breadcrumb.unshift({ id: folder.id, name: folder.name });
currentId = folder.parentId;
}
return breadcrumb;
}
// Buscar pasta por ID (com informações de publicação)
export async function getFolder(id: string) {
const session = await getSession();
if (!session?.organizationId) {
return null;
}
return (prisma.folder as any).findUnique({
where: { id },
select: {
id: true,
name: true,
isPublished: true,
parentId: true,
color: true,
imageUrl: true,
description: true
}
});
}
// Atualizar pasta
export async function updateFolder(id: string, data: {
name?: string;
description?: string;
color?: string;
imageUrl?: string | null;
}) {
const session = await getSession();
if (!session?.organizationId) {
return { success: false, error: "Não autenticado." };
}
try {
await (prisma.folder as any).update({
where: { id },
data,
});
return { success: true };
} catch (error) {
console.error("Update folder error:", error);
return { success: false, error: "Erro ao atualizar pasta." };
}
}
// Função auxiliar para deletar pasta recursivamente (com documentos e subpastas)
async function deleteFolderRecursive(id: string) {
// Primeiro, buscar todas as subpastas
const subfolders = await prisma.folder.findMany({
where: { parentId: id },
select: { id: true }
});
// Deletar subpastas recursivamente
for (const subfolder of subfolders) {
await deleteFolderRecursive(subfolder.id);
}
// Buscar todos os documentos para deletar do MinIO
const docs = await prisma.document.findMany({
where: { folderId: id },
select: { fileUrl: true }
});
// Deletar todos os documentos do banco
await prisma.document.deleteMany({
where: { folderId: id }
});
// Deletar arquivos do MinIO
const { deleteFile } = await import("@/lib/s3");
for (const doc of docs) {
if (doc.fileUrl) {
await deleteFile(doc.fileUrl);
}
}
// Buscar imagem da pasta (se existir)
const folder = await (prisma.folder as any).findUnique({
where: { id },
select: { imageUrl: true }
});
// Finalmente, deletar a pasta
await prisma.folder.delete({ where: { id } });
// Deletar imagem da pasta do MinIO (se existir)
if ((folder as any)?.imageUrl) {
await deleteFile((folder as any).imageUrl);
}
}
// Deletar pasta
export async function deleteFolder(id: string) {
const session = await getSession();
if (!session?.organizationId) {
return { success: false, error: "Não autenticado." };
}
try {
await deleteFolderRecursive(id);
return { success: true };
} catch (error) {
console.error("Delete folder error:", error);
return { success: false, error: "Erro ao deletar pasta." };
}
}
// Deletar várias pastas
export async function bulkDeleteFolders(ids: string[]) {
const session = await getSession();
if (!session?.organizationId) {
return { success: false, error: "Não autenticado." };
}
try {
for (const id of ids) {
await deleteFolderRecursive(id);
}
return { success: true };
} catch (error) {
console.error("Bulk delete folders error:", error);
return { success: false, error: "Erro ao deletar pastas." };
}
}
// Função auxiliar para publicar recursivamente todos os filhos
async function publishChildrenRecursive(folderId: string, isPublished: boolean) {
const publishedAt = isPublished ? new Date() : null;
// Publicar todos os documentos da pasta
await prisma.document.updateMany({
where: { folderId },
data: { isPublished, publishedAt },
});
// Buscar subpastas e publicar recursivamente
const subfolders = await prisma.folder.findMany({
where: { parentId: folderId },
select: { id: true },
});
for (const subfolder of subfolders) {
await (prisma.folder as any).update({
where: { id: subfolder.id },
data: { isPublished, publishedAt },
});
await publishChildrenRecursive(subfolder.id, isPublished);
}
}
// Alternar status de publicação da pasta
export async function toggleFolderPublish(id: string) {
const session = await getSession();
if (!session?.organizationId) {
return { success: false, error: "Não autenticado." };
}
try {
const folder = await (prisma.folder as any).findUnique({ where: { id } });
if (!folder) return { success: false, error: "Pasta não encontrada." };
const newStatus = !folder.isPublished;
// Atualizar a pasta
await (prisma.folder as any).update({
where: { id },
data: {
isPublished: newStatus,
publishedAt: newStatus ? new Date() : null,
},
});
// Se está publicando, publicar todos os filhos recursivamente
if (newStatus) {
await publishChildrenRecursive(id, true);
}
return { success: true, published: newStatus };
} catch (error) {
console.error("Toggle folder publish error:", error);
return { success: false, error: "Erro ao atualizar status." };
}
}
// Buscar pasta pública com ancestrais
export async function getPublicFolder(id: string) {
try {
const folder = await (prisma.folder as any).findUnique({
where: { id },
include: {
organization: true,
documents: {
where: { isPublished: true },
orderBy: { createdAt: "desc" },
},
children: {
where: { isPublished: true },
orderBy: { name: "asc" },
},
parent: true
},
});
if (!folder || !folder.isPublished) {
return null;
}
// Construir breadcrumbs (ancestrais)
let breadcrumbs = [];
let currentParent = folder.parent;
let rootFolder = folder; // Assumimos que a atual é a raiz se não tiver pai
while (currentParent) {
// Se o pai não estiver publicado, quebra a cadeia (segurança)
if (!currentParent.isPublished) {
return null;
}
breadcrumbs.unshift(currentParent);
rootFolder = currentParent; // O último pai encontrado será a raiz
// Buscar o próximo pai
const parentOfParent = await (prisma.folder as any).findUnique({
where: { id: currentParent.id },
include: { parent: true }
});
currentParent = parentOfParent?.parent || null;
}
return {
...folder,
breadcrumbs,
rootFolder: breadcrumbs.length > 0 ? rootFolder : null // Se não tem breadcrumbs, a própria pasta é a raiz (null aqui para identificar no front)
};
} catch (error) {
console.error("Get public folder error:", error);
return null;
}
}

View File

@@ -0,0 +1,61 @@
"use server";
import { prisma } from "@/lib/db";
import { getSession } from "./auth";
export async function updateOrganization(data: {
name?: string;
logoUrl?: string;
primaryColor?: string;
}) {
try {
const session = await getSession();
if (!session) {
return { success: false, error: "Não autorizado." };
}
if (session.role !== "SUPER_ADMIN" && session.role !== "ADMIN") {
return { success: false, error: "Sem permissão para alterar configurações." };
}
if (!session.organizationId) {
return { success: false, error: "Usuário não vinculado a uma organização." };
}
await prisma.organization.update({
where: { id: session.organizationId as string },
data: {
name: data.name,
logoUrl: data.logoUrl,
primaryColor: data.primaryColor,
},
});
return { success: true };
} catch (error) {
console.error("Erro ao atualizar organização:", error);
return { success: false, error: "Erro ao salvar configurações." };
}
}
export async function getOrganization() {
try {
const session = await getSession();
if (!session) {
return null;
}
if (!session.organizationId) {
return null;
}
const org = await prisma.organization.findUnique({
where: { id: session.organizationId as string },
});
return org;
} catch (error) {
console.error("Erro ao buscar organização:", error);
return null;
}
}

View File

@@ -0,0 +1,46 @@
"use server";
import { prisma } from "@/lib/db";
import bcrypt from "bcryptjs";
import { getSession } from "./auth";
export async function updateProfile(data: {
name: string;
email: string;
currentPassword?: string;
newPassword?: string;
}) {
const session = await getSession();
if (!session) {
return { success: false, error: "Não autenticado." };
}
try {
// Se está alterando senha, verifica a atual
if (data.newPassword && data.currentPassword) {
const isValid = await bcrypt.compare(data.currentPassword, session.password);
if (!isValid) {
return { success: false, error: "Senha atual incorreta." };
}
}
const updateData: any = {
name: data.name,
email: data.email,
};
if (data.newPassword) {
updateData.password = await bcrypt.hash(data.newPassword, 10);
}
await prisma.user.update({
where: { id: session.id },
data: updateData,
});
return { success: true };
} catch (error) {
console.error("Update profile error:", error);
return { success: false, error: "Erro ao atualizar perfil." };
}
}

73
src/app/actions/setup.ts Normal file
View File

@@ -0,0 +1,73 @@
"use server";
import { prisma } from "@/lib/db";
import { initMinio } from "@/lib/init-minio";
import bcrypt from "bcryptjs";
import { SignJWT } from "jose";
import { cookies } from "next/headers";
const JWT_SECRET = new TextEncoder().encode(
process.env.JWT_SECRET || "supersecretkey123456789"
);
export async function createOrganization(data: {
name: string;
cnpj?: string;
logoUrl?: string;
primaryColor: string;
adminName: string;
adminEmail: string;
adminPassword: string;
}) {
try {
await initMinio();
// Hash da senha
const hashedPassword = await bcrypt.hash(data.adminPassword, 10);
// 1. Criar a organização
const org = await prisma.organization.create({
data: {
name: data.name,
cnpj: data.cnpj,
logoUrl: data.logoUrl,
primaryColor: data.primaryColor,
subdomain: data.name.toLowerCase().replace(/[^a-z0-9]/g, "-"),
},
});
// 2. Criar o usuário administrador master
const user = await prisma.user.create({
data: {
name: data.adminName,
email: data.adminEmail,
password: hashedPassword,
role: "SUPER_ADMIN",
organizationId: org.id,
},
});
// 3. Criar JWT e logar automaticamente
const token = await new SignJWT({
userId: user.id,
email: user.email,
role: user.role,
organizationId: user.organizationId,
})
.setProtectedHeader({ alg: "HS256" })
.setExpirationTime("7d")
.sign(JWT_SECRET);
(await cookies()).set("auth-token", token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: 60 * 60 * 24 * 7,
});
return { success: true };
} catch (error) {
console.error("Setup error:", error);
return { success: false, error: "Falha ao realizar a instalação inicial." };
}
}

33
src/app/actions/upload.ts Normal file
View File

@@ -0,0 +1,33 @@
"use server";
import { PutObjectCommand } from "@aws-sdk/client-s3";
import { s3Client, BUCKET_NAME } from "@/lib/s3";
import { v4 as uuidv4 } from "uuid";
export async function uploadFile(formData: FormData) {
try {
const file = formData.get("file") as File;
if (!file) {
throw new Error("No file provided");
}
const buffer = Buffer.from(await file.arrayBuffer());
const fileName = `${uuidv4()}-${file.name}`;
const command = new PutObjectCommand({
Bucket: BUCKET_NAME,
Key: fileName,
Body: buffer,
ContentType: file.type,
});
await s3Client.send(command);
// Return the URL to access the file (in MinIO local development)
const url = `http://localhost:9000/${BUCKET_NAME}/${fileName}`;
return { success: true, url, fileName };
} catch (error) {
console.error("Upload error:", error);
return { success: false, error: "Failed to upload file" };
}
}

171
src/app/actions/users.ts Normal file
View File

@@ -0,0 +1,171 @@
"use server";
import { prisma } from "@/lib/db";
import { getSession } from "./auth";
import bcrypt from "bcryptjs";
import { Role } from "@prisma/client";
export async function getUsers() {
try {
const session = await getSession();
if (!session) {
return [];
}
const users = await prisma.user.findMany({
where: { organizationId: session.organizationId },
select: {
id: true,
name: true,
email: true,
role: true,
createdAt: true,
},
orderBy: { createdAt: "desc" },
});
return users;
} catch (error) {
console.error("Erro ao buscar usuários:", error);
return [];
}
}
export async function createUser(data: {
name: string;
email: string;
password: string;
role: Role;
}) {
try {
const session = await getSession();
if (!session) {
return { success: false, error: "Não autorizado." };
}
if (session.role !== "SUPER_ADMIN" && session.role !== "ADMIN") {
return { success: false, error: "Sem permissão para criar usuários." };
}
// Verificar se email já existe
const existing = await prisma.user.findUnique({
where: { email: data.email },
});
if (existing) {
return { success: false, error: "Este e-mail já está em uso." };
}
const hashedPassword = await bcrypt.hash(data.password, 10);
await prisma.user.create({
data: {
name: data.name,
email: data.email,
password: hashedPassword,
role: data.role,
organizationId: session.organizationId,
},
});
return { success: true };
} catch (error) {
console.error("Erro ao criar usuário:", error);
return { success: false, error: "Erro ao criar usuário." };
}
}
export async function updateUser(
userId: string,
data: {
name?: string;
email?: string;
role?: Role;
password?: string;
}
) {
try {
const session = await getSession();
if (!session) {
return { success: false, error: "Não autorizado." };
}
if (session.role !== "SUPER_ADMIN" && session.role !== "ADMIN") {
return { success: false, error: "Sem permissão para editar usuários." };
}
// Verificar se usuário pertence à mesma organização
const user = await prisma.user.findFirst({
where: { id: userId, organizationId: session.organizationId },
});
if (!user) {
return { success: false, error: "Usuário não encontrado." };
}
// Não permitir alterar SUPER_ADMIN
if (user.role === "SUPER_ADMIN" && session.role !== "SUPER_ADMIN") {
return { success: false, error: "Não é possível alterar o administrador master." };
}
const updateData: Record<string, unknown> = {};
if (data.name) updateData.name = data.name;
if (data.email) updateData.email = data.email;
if (data.role) updateData.role = data.role;
if (data.password) {
updateData.password = await bcrypt.hash(data.password, 10);
}
await prisma.user.update({
where: { id: userId },
data: updateData,
});
return { success: true };
} catch (error) {
console.error("Erro ao atualizar usuário:", error);
return { success: false, error: "Erro ao atualizar usuário." };
}
}
export async function deleteUser(userId: string) {
try {
const session = await getSession();
if (!session) {
return { success: false, error: "Não autorizado." };
}
if (session.role !== "SUPER_ADMIN" && session.role !== "ADMIN") {
return { success: false, error: "Sem permissão para excluir usuários." };
}
// Verificar se usuário pertence à mesma organização
const user = await prisma.user.findFirst({
where: { id: userId, organizationId: session.organizationId },
});
if (!user) {
return { success: false, error: "Usuário não encontrado." };
}
// Não permitir excluir SUPER_ADMIN
if (user.role === "SUPER_ADMIN") {
return { success: false, error: "Não é possível excluir o administrador master." };
}
// Não permitir auto-exclusão
if (userId === session.id) {
return { success: false, error: "Você não pode excluir a si mesmo." };
}
await prisma.user.delete({
where: { id: userId },
});
return { success: true };
} catch (error) {
console.error("Erro ao excluir usuário:", error);
return { success: false, error: "Erro ao excluir usuário." };
}
}

View File

@@ -0,0 +1,85 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
// Função simples de XOR para ofuscar os dados
function xorEncrypt(data: Uint8Array, key: string): Uint8Array {
const keyBytes = new TextEncoder().encode(key);
const result = new Uint8Array(data.length);
for (let i = 0; i < data.length; i++) {
result[i] = data[i] ^ keyBytes[i % keyBytes.length];
}
return result;
}
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
// 1. Buscar o documento no banco
const doc = await prisma.document.findUnique({
where: { id },
select: {
fileUrl: true,
fileType: true,
fileName: true,
isDownloadable: true,
isPublished: true
}
});
if (!doc || !doc.isPublished) {
return new NextResponse("Documento não encontrado ou não publicado.", { status: 404 });
}
// 2. Buscar o arquivo original
const fileResponse = await fetch(doc.fileUrl);
if (!fileResponse.ok) {
return new NextResponse("Erro ao buscar arquivo na origem.", { status: 500 });
}
const fileBuffer = await fileResponse.arrayBuffer();
const fileData = new Uint8Array(fileBuffer);
// 3. Configurar Headers de Proteção
const headers = new Headers();
if (doc.isDownloadable) {
// Se pode baixar, enviamos normalmente
headers.set("Content-Type", doc.fileType);
headers.set("Content-Disposition", `attachment; filename="${doc.fileName}"`);
return new NextResponse(fileData, {
status: 200,
headers,
});
} else {
// SE NÃO PODE BAIXAR: Encriptar os dados!
// Gerar uma chave única baseada no ID (estática para permitir cache do PDF.js)
const encryptionKey = `PORTAL_SECURE_${id}_KEY`;
// Encriptar os dados com XOR
const encryptedData = xorEncrypt(fileData, encryptionKey);
// Enviar dados encriptados com a chave no header
headers.set("Content-Type", "application/octet-stream");
headers.set("Content-Disposition", "inline");
headers.set("X-Encryption-Key", encryptionKey);
// IMPORTANTE: Expor o header customizado ao JavaScript
headers.set("Access-Control-Expose-Headers", "X-Encryption-Key");
headers.set("X-Content-Type-Options", "nosniff");
headers.set("Cache-Control", "no-store, no-cache, must-revalidate");
return new NextResponse(encryptedData.buffer as ArrayBuffer, {
status: 200,
headers,
});
}
} catch (error) {
console.error("Erro no Proxy de Visualização:", error);
return new NextResponse("Erro interno do servidor.", { status: 500 });
}
}

View File

@@ -0,0 +1,278 @@
"use client";
import React from "react";
import { motion } from "framer-motion";
import {
FileText,
Plus,
TrendingUp,
Eye,
Calendar,
ChevronRight,
ArrowUpRight,
ArrowRight,
} from "lucide-react";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Sidebar } from "@/components/Sidebar";
type Organization = {
id: string;
name: string;
logoUrl: string | null;
primaryColor: string;
};
type UserType = {
id: string;
name: string | null;
email: string;
role: string;
};
type DashboardStats = {
docCount: number;
viewCount: number;
downloadCount: number;
recentDocs: any[];
};
export default function DashboardClient({
user,
organization,
stats,
}: {
user: UserType;
organization: Organization;
stats: DashboardStats;
}) {
const primaryColor = organization.primaryColor || "#2563eb";
const formatDate = (date: Date) => {
return new Date(date).toLocaleDateString("pt-BR", {
day: "2-digit",
month: "short",
});
};
return (
<div className="min-h-screen bg-white flex">
<Sidebar user={user} organization={organization} />
{/* Main Content Area */}
<main className="flex-1 overflow-y-auto">
{/* Top Banner / Hero - Integrated Background */}
<div className="relative border-b border-slate-100 bg-slate-50/40 p-8 lg:p-10">
<div className="flex flex-col md:flex-row md:items-end justify-between gap-6">
<div>
<div className="flex items-center gap-2 mb-2">
<span className="px-2 py-0.5 bg-white border border-slate-200 rounded-full text-[9px] font-bold uppercase tracking-[0.1em] text-slate-500">Overview Panel</span>
</div>
<h2 className="text-2xl font-black text-slate-900 tracking-tight mb-1">Dashboard</h2>
<p className="text-sm text-slate-500 font-medium">
Bem-vindo, Administrador <span className="text-slate-900">{user.name?.split(' ')[0] || "Administrador"}</span>.
</p>
</div>
<div className="flex items-center gap-3">
<Button
asChild
style={{ backgroundColor: primaryColor }}
className="h-10 px-6 rounded-lg font-bold text-xs shadow-none hover:opacity-90 active:scale-95 transition-all text-white"
>
<Link href="/dashboard/documentos?upload=true">
<Plus size={18} className="mr-2 stroke-[3]" />
Novo Documento
</Link>
</Button>
</div>
</div>
{/* Faded accent circle */}
<div
className="absolute top-0 right-0 w-[300px] h-[300px] rounded-full blur-[80px] opacity-[0.03] pointer-events-none"
style={{ backgroundColor: primaryColor }}
/>
</div>
<div className="p-8 lg:p-10 w-full">
{/* Stats Grid - Large and minimalist */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-5 mb-10">
<motion.div initial={{ opacity: 0, y: 15 }} animate={{ opacity: 1, y: 0 }}>
<div className="group relative p-5 rounded-[20px] bg-slate-50 hover:bg-white border-2 border-transparent hover:border-slate-100 transition-all duration-500">
<div className="flex flex-col gap-3.5">
<div className="flex items-center justify-between">
<div className="w-10 h-10 rounded-lg bg-white flex items-center justify-center text-slate-800 border border-slate-100 group-hover:border-blue-100 transition-colors">
<FileText size={20} className="stroke-[2]" />
</div>
<div className="p-1 rounded-full bg-blue-50 text-blue-600">
<ArrowUpRight size={14} />
</div>
</div>
<div>
<p className="text-[9px] font-bold text-slate-400 uppercase tracking-widest mb-0.5">Total de Documentos</p>
<h3 className="text-2xl font-black text-slate-900">{stats.docCount}</h3>
</div>
</div>
</div>
</motion.div>
<motion.div initial={{ opacity: 0, y: 15 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.1 }}>
<div className="group relative p-5 rounded-[20px] bg-slate-50 hover:bg-white border-2 border-transparent hover:border-slate-100 transition-all duration-500">
<div className="flex flex-col gap-3.5">
<div className="flex items-center justify-between">
<div className="w-10 h-10 rounded-lg bg-white flex items-center justify-center text-slate-800 border border-slate-100 group-hover:border-green-100 transition-colors">
<Eye size={20} className="stroke-[2]" />
</div>
<div className="p-1 rounded-full bg-green-50 text-green-600">
<ArrowUpRight size={14} />
</div>
</div>
<div>
<p className="text-[9px] font-bold text-slate-400 uppercase tracking-widest mb-0.5">Visualizações Totais</p>
<h3 className="text-2xl font-black text-slate-900">{stats.viewCount.toLocaleString()}</h3>
</div>
</div>
</div>
</motion.div>
<motion.div initial={{ opacity: 0, y: 15 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.2 }}>
<div className="group relative p-5 rounded-[20px] bg-slate-50 hover:bg-white border-2 border-transparent hover:border-slate-100 transition-all duration-500">
<div className="flex flex-col gap-3.5">
<div className="flex items-center justify-between">
<div className="w-10 h-10 rounded-lg bg-white flex items-center justify-center text-slate-800 border border-slate-100 group-hover:border-purple-100 transition-colors">
<TrendingUp size={20} className="stroke-[2]" />
</div>
<div className="p-1 rounded-full bg-purple-50 text-purple-600">
<ArrowUpRight size={14} />
</div>
</div>
<div>
<p className="text-[9px] font-bold text-slate-400 uppercase tracking-widest mb-0.5">Downloads Totais</p>
<h3 className="text-2xl font-black text-slate-900">{stats.downloadCount.toLocaleString()}</h3>
</div>
</div>
</div>
</motion.div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8">
{/* Recent Documents Table - More integrated */}
<motion.div initial={{ opacity: 0, y: 15 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.3 }} className="lg:col-span-8">
<div className="flex items-center justify-between mb-4 px-1">
<h3 className="text-lg font-black text-slate-900 tracking-tight">Atividade Recente</h3>
<Link href="/dashboard/documentos" className="group flex items-center gap-2 text-[9px] font-bold uppercase tracking-widest text-slate-400 hover:text-slate-900 transition-colors">
Ver Todos <ArrowRight size={12} className="group-hover:translate-x-0.5 transition-transform" />
</Link>
</div>
<div className="bg-white rounded-[24px] border-2 border-slate-100 overflow-hidden">
{stats.recentDocs.length === 0 ? (
<div className="p-14 text-center">
<div className="w-14 h-14 bg-slate-50 rounded-full flex items-center justify-center mx-auto mb-3">
<FileText size={24} className="text-slate-300" />
</div>
<p className="text-slate-500 font-medium mb-4 text-sm">Nenhum documento encontrado.</p>
<Button asChild variant="outline" className="rounded-lg border-2 font-bold px-6 h-9 text-[10px] uppercase tracking-widest">
<Link href="/dashboard/documentos/novo">Começar Agora</Link>
</Button>
</div>
) : (
<div className="divide-y-2 divide-slate-50">
{stats.recentDocs.map((doc) => (
<Link
key={doc.id}
href={`/dashboard/documentos/${doc.id}`}
className="flex items-center gap-4 py-3.5 px-6 hover:bg-slate-50/50 transition-all group"
>
<div className="w-10 h-10 rounded-lg bg-slate-50 group-hover:bg-white border-2 border-transparent group-hover:border-slate-100 flex items-center justify-center transition-all shrink-0">
<FileText size={18} className="text-slate-500 group-hover:text-slate-900 transition-colors" />
</div>
<div className="flex-1 min-w-0">
<h4 className="font-black text-slate-900 group-hover:text-blue-600 transition-colors truncate mb-0.5 text-sm">{doc.title}</h4>
<div className="flex items-center gap-3 text-[9px] font-bold text-slate-400 uppercase tracking-widest">
<span>{doc.folder?.name || "Sem categoria"}</span>
<span className="w-1 h-1 rounded-full bg-slate-200" />
<span>{formatDate(doc.createdAt)}</span>
</div>
</div>
<div className="text-right shrink-0">
<div className="flex items-center gap-2 text-slate-400 group-hover:text-slate-900 transition-colors">
<span className="text-[10px] font-bold uppercase tracking-widest">Detalhes</span>
<ChevronRight size={14} className="stroke-[3]" />
</div>
</div>
</Link>
))}
</div>
)}
</div>
</motion.div>
{/* Summary Column */}
<motion.div initial={{ opacity: 0, x: 20 }} animate={{ opacity: 1, x: 0 }} transition={{ delay: 0.4 }} className="lg:col-span-4 space-y-6">
<div className="bg-slate-900 rounded-[24px] p-6 text-white relative overflow-hidden group">
<div className="relative z-10">
<div className="w-10 h-10 bg-white/10 rounded-lg flex items-center justify-center mb-4">
<Calendar size={20} className="text-white" />
</div>
<h4 className="text-xl font-black tracking-tight mb-1">Status do Portal</h4>
<p className="text-white/60 text-xs font-medium leading-relaxed mb-6">
O portal está online e sincronizado com os últimos envios de documentos.
</p>
<Button variant="ghost" className="w-full bg-white/10 hover:bg-white/20 text-white font-bold text-[10px] uppercase tracking-widest h-10 rounded-lg border-none">
Ver Log de Atividades
</Button>
</div>
<div className="absolute -right-4 -bottom-4 w-32 h-32 bg-white/5 rounded-full blur-2xl group-hover:scale-150 transition-transform duration-700" />
</div>
<div className="bg-white border-2 border-slate-100 rounded-[24px] p-6">
<h4 className="text-base font-black text-slate-900 tracking-tight mb-4">Informações Rápidas</h4>
<div className="space-y-4">
<div className="flex items-center justify-between">
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-widest">Organização</span>
<span className="text-xs font-black text-slate-900">{organization.name}</span>
</div>
<div className="h-px bg-slate-100" />
<div className="flex items-center justify-between">
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-widest">Seu Perfil</span>
<span className="text-xs font-black text-slate-900 uppercase tracking-tighter">{user.role}</span>
</div>
<div className="h-px bg-slate-100" />
<div className="flex items-center justify-between">
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-widest">Data</span>
<span className="text-xs font-black text-slate-900">{new Date().toLocaleDateString('pt-BR')}</span>
</div>
</div>
</div>
</motion.div>
</div>
</div>
</main>
</div>
);
}
function Users(props: any) {
return (
<svg
{...props}
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M22 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
)
}

View File

@@ -0,0 +1,255 @@
"use client";
import React, { useState } from "react";
import { motion } from "framer-motion";
import {
ArrowLeft,
Save,
CloudUpload,
Loader2,
Palette,
Image,
ChevronRight,
} from "lucide-react";
import Link from "next/link";
import { uploadFile } from "@/app/actions/upload";
import { updateOrganization } from "@/app/actions/organization";
import { useRouter } from "next/navigation";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Sidebar } from "@/components/Sidebar";
type Organization = {
id: string;
name: string;
logoUrl: string | null;
primaryColor: string;
cnpj: string | null;
subdomain: string;
};
type UserType = {
id: string;
name: string | null;
email: string;
role: string;
};
export default function ConfiguracoesClient({
user,
organization,
}: {
user: UserType;
organization: Organization;
}) {
const router = useRouter();
const [primaryColor, setPrimaryColor] = useState(organization.primaryColor || "#2563eb");
const [name, setName] = useState(organization.name);
const [logoUrl, setLogoUrl] = useState(organization.logoUrl || "");
const [isUploading, setIsUploading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [message, setMessage] = useState({ type: "", text: "" });
const handleLogoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setIsUploading(true);
const formData = new FormData();
formData.append("file", file);
const result = await uploadFile(formData);
if (result.success && result.url) {
setLogoUrl(result.url);
} else {
setMessage({ type: "error", text: "Erro ao fazer upload da logo." });
}
setIsUploading(false);
};
const handleSave = async () => {
setIsSaving(true);
setMessage({ type: "", text: "" });
const result = await updateOrganization({
name,
logoUrl: logoUrl || undefined,
primaryColor,
});
if (result.success) {
setMessage({ type: "success", text: "Configurações salvas com sucesso!" });
router.refresh();
} else {
setMessage({ type: "error", text: result.error || "Erro ao salvar." });
}
setIsSaving(false);
};
return (
<div className="min-h-screen bg-white flex">
<Sidebar user={user} organization={organization} />
<main className="flex-1 overflow-y-auto flex flex-col">
{/* Header Section - Integrated */}
<div className="relative border-b border-slate-100 bg-slate-50/40 p-10 lg:p-12">
<div className="flex flex-col md:flex-row md:items-end justify-between gap-6">
<div>
<div className="flex items-center gap-2 mb-3">
<span className="px-2.5 py-0.5 bg-white border border-slate-200 rounded-full text-[10px] font-black uppercase tracking-[0.15em] text-slate-500">Corporate Branding</span>
</div>
<h2 className="text-3xl font-black text-slate-900 tracking-tight mb-1">Configurações</h2>
<p className="text-base text-slate-500 font-medium">Personalize a identidade visual do seu portal de transparência.</p>
</div>
</div>
</div>
<div className="p-10 lg:p-12 w-full">
{message.text && (
<Alert variant={message.type === "success" ? "default" : "destructive"} className="mb-6 rounded-xl border-2">
<AlertDescription className="font-bold text-xs">{message.text}</AlertDescription>
</Alert>
)}
<div className="space-y-10">
{/* Identity Section */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-10">
<div>
<h3 className="text-lg font-black text-slate-900 tracking-tight mb-2">Identidade</h3>
<p className="text-xs text-slate-500 font-medium leading-relaxed">
Defina o nome oficial da organização e a marca que será exibida para o cidadão.
</p>
</div>
<div className="lg:col-span-2 bg-slate-50 p-8 rounded-[32px] space-y-6">
<div className="space-y-2">
<Label htmlFor="name" className="text-[9px] font-black uppercase tracking-widest text-slate-600 ml-1">Nome da Organização</Label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
className="h-11 bg-white border-2 border-slate-100 rounded-xl text-sm font-bold px-6 focus:border-blue-100 focus:ring-4 focus:ring-blue-50/50 transition-all shadow-none"
/>
</div>
<div className="space-y-2">
<Label className="text-[9px] font-black uppercase tracking-widest text-slate-600 ml-1">Logotipo Oficial</Label>
<div className="flex flex-col md:flex-row gap-6 items-start md:items-center">
<div className="shrink-0 group relative">
{logoUrl ? (
<div className="w-24 h-24 rounded-2xl bg-white border-2 border-slate-100 p-3 flex items-center justify-center transition-all group-hover:scale-105">
<img src={logoUrl} alt="Logo" className="max-w-full max-h-full object-contain" />
</div>
) : (
<div
className="w-24 h-24 rounded-2xl flex items-center justify-center text-white text-2xl font-black border-4 border-white/20 shadow-none transition-all group-hover:scale-105"
style={{ backgroundColor: primaryColor }}
>
{name.charAt(0)}
</div>
)}
</div>
<div className="flex-1 w-full">
<div className="relative border-2 border-dashed border-slate-200 rounded-2xl p-6 flex flex-col items-center justify-center text-slate-400 hover:border-blue-400 hover:bg-white transition-all cursor-pointer group">
<input
type="file"
className="absolute inset-0 opacity-0 cursor-pointer"
onChange={handleLogoUpload}
accept="image/*"
/>
{isUploading ? (
<Loader2 size={32} className="text-blue-500 animate-spin" />
) : (
<div className="text-center">
<CloudUpload size={32} className="mx-auto mb-2.5 group-hover:scale-110 transition-transform" />
<p className="text-[10px] font-black text-slate-900 uppercase tracking-widest leading-none">Alterar Logotipo</p>
<p className="text-[9px] font-bold text-slate-400 mt-1.5">Clique ou arraste (PNG, JPG até 5MB)</p>
</div>
)}
</div>
</div>
</div>
</div>
</div>
</div>
{/* Colors Section */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-10">
<div>
<h3 className="text-lg font-black text-slate-900 tracking-tight mb-2">Cores e Estilo</h3>
<p className="text-xs text-slate-500 font-medium leading-relaxed">
Escolha a cor primária que define a identidade do portal administrativo e público.
</p>
</div>
<div className="lg:col-span-2 bg-slate-50 p-8 rounded-[32px] space-y-8">
<div className="space-y-2">
<Label className="text-[9px] font-black uppercase tracking-widest text-slate-600 ml-1">Cor de Destaque</Label>
<div className="flex flex-col md:flex-row items-center gap-6">
<div className="relative">
<input
type="color"
value={primaryColor}
onChange={(e) => setPrimaryColor(e.target.value)}
className="w-20 h-20 rounded-2xl border-4 border-white cursor-pointer overflow-hidden p-0"
/>
</div>
<div className="flex-1 w-full">
<Input
value={primaryColor}
onChange={(e) => setPrimaryColor(e.target.value)}
className="h-11 bg-white border-2 border-slate-100 rounded-xl text-lg font-black px-6 uppercase focus:border-blue-100 transition-all shadow-none"
/>
<p className="text-[10px] font-black text-slate-400 uppercase tracking-widest mt-2 ml-1">Código HEX / Cor Primária</p>
</div>
</div>
</div>
{/* Live Preview Card */}
<div className="p-6 bg-white rounded-2xl border-2 border-slate-100 italic">
<h4 className="text-[9px] font-black text-slate-300 uppercase tracking-widest mb-4 not-italic">Visualização em Tempo Real</h4>
<div className="flex flex-wrap items-center gap-4">
<Button style={{ backgroundColor: primaryColor }} className="h-10 px-6 rounded-lg font-black uppercase text-[10px] tracking-widest shadow-none text-white hover:opacity-90">
Botão de Ação
</Button>
<Button variant="outline" style={{ borderColor: primaryColor, color: primaryColor }} className="h-10 px-6 rounded-lg font-black uppercase text-[10px] tracking-widest border-2 shadow-none hover:bg-slate-50">
Borda Colorida
</Button>
<div className="flex items-center gap-2.5 ml-2 not-italic">
<div className="w-8 h-8 rounded-lg flex items-center justify-center text-white" style={{ backgroundColor: primaryColor }}>
<Palette size={16} />
</div>
<span className="font-black text-xs uppercase tracking-tight" style={{ color: primaryColor }}>Texto em Destaque</span>
</div>
</div>
</div>
</div>
</div>
{/* Actions */}
<div className="pt-8 border-t-2 border-slate-50 flex justify-end">
<Button
onClick={handleSave}
disabled={isSaving}
className="h-11 px-10 rounded-xl font-black text-xs uppercase tracking-widest transition-all active:scale-95 shadow-none text-white"
style={{ backgroundColor: primaryColor }}
>
{isSaving ? (
<Loader2 className="mr-3 h-4 w-4 animate-spin text-white" />
) : (
<Save size={18} className="mr-2.5 stroke-[3]" />
)}
{isSaving ? "Gravando..." : "Salvar Configurações"}
</Button>
</div>
</div>
</div>
</main>
</div>
);
}

View File

@@ -0,0 +1,26 @@
import React from "react";
import { redirect } from "next/navigation";
import { getSession } from "@/app/actions/auth";
import { getOrganization } from "@/app/actions/organization";
import ConfiguracoesClient from "./ConfiguracoesClient";
export default async function ConfiguracoesPage() {
const session = await getSession();
if (!session) {
redirect("/");
}
const organization = await getOrganization();
if (!organization) {
redirect("/dashboard");
}
return (
<ConfiguracoesClient
user={session}
organization={organization}
/>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,370 @@
"use client";
import React, { useState } from "react";
import { motion } from "framer-motion";
import {
ArrowLeft,
Save,
Trash2,
Eye,
Download,
Copy,
Check,
ChevronLeft,
FileText,
Settings,
Share2,
Info,
} from "lucide-react";
import Link from "next/link";
import { updateDocument, deleteDocument } from "@/app/actions/documents";
import { useRouter } from "next/navigation";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Badge } from "@/components/ui/badge";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Sidebar } from "@/components/Sidebar";
type Organization = {
id: string;
name: string;
logoUrl: string | null;
primaryColor: string;
};
type UserType = {
id: string;
name: string | null;
email: string;
role: string;
};
type DocumentType = {
id: string;
title: string;
description: string | null;
fileName: string;
fileUrl: string;
fileType: string;
fileSize: number;
isPublished: boolean;
isDownloadable: boolean;
folderId: string | null;
};
type FolderType = {
id: string;
name: string;
};
export default function DocumentDetailClient({
user,
organization,
document,
folders,
}: {
user: UserType;
organization: Organization;
document: DocumentType;
folders: FolderType[];
}) {
const router = useRouter();
const primaryColor = organization.primaryColor || "#2563eb";
const [title, setTitle] = useState(document.title);
const [description, setDescription] = useState(document.description || "");
const [isPublished, setIsPublished] = useState(document.isPublished);
const [isDownloadable, setIsDownloadable] = useState(document.isDownloadable);
const [folderId, setFolderId] = useState(document.folderId || "__none__");
const [isSaving, setIsSaving] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [message, setMessage] = useState({ type: "", text: "" });
const [copied, setCopied] = useState(false);
const publicUrl = `${typeof window !== "undefined" ? window.location.origin : ""}/documento/${document.id}`;
const handleSave = async (e: React.FormEvent) => {
e.preventDefault();
setIsSaving(true);
setMessage({ type: "", text: "" });
const result = await updateDocument(document.id, {
title,
description: description || undefined,
isPublished,
isDownloadable,
folderId: folderId === "__none__" ? null : folderId,
});
if (result.success) {
setMessage({ type: "success", text: "Documento atualizado com sucesso!" });
router.refresh();
} else {
setMessage({ type: "error", text: result.error || "Erro ao salvar." });
}
setIsSaving(false);
};
const handleDelete = async () => {
if (!confirm("Tem certeza que deseja excluir permanentemente este documento?")) return;
setIsDeleting(true);
const result = await deleteDocument(document.id);
if (result.success) {
router.push("/dashboard/documentos");
router.refresh();
} else {
setMessage({ type: "error", text: result.error || "Erro ao excluir." });
setIsDeleting(false);
}
};
const copyToClipboard = () => {
navigator.clipboard.writeText(publicUrl);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
const formatFileSize = (bytes: number) => {
if (bytes < 1024) return bytes + " B";
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB";
return (bytes / (1024 * 1024)).toFixed(1) + " MB";
};
return (
<div className="min-h-screen bg-white flex">
<Sidebar user={user} organization={organization} />
<main className="flex-1 overflow-y-auto flex flex-col">
{/* Header Section - Integrated */}
<div className="relative border-b border-slate-100 bg-slate-50/40 p-8 lg:p-10">
<div className="flex flex-col md:flex-row md:items-end justify-between gap-6">
<div className="flex-1 min-w-0">
<nav className="flex items-center gap-2 mb-3 text-[10px] font-bold uppercase tracking-widest text-slate-400">
<Link href="/dashboard/documentos" className="hover:text-slate-900 transition-colors">Documentos</Link>
<ChevronLeft size={10} className="rotate-180" />
<span className="text-slate-900 truncate tracking-[0.1em]">Gerenciar Arquivo</span>
</nav>
<h2 className="text-2xl font-black text-slate-900 tracking-tight mb-1 truncate">{document.title}</h2>
<div className="flex items-center gap-3">
<p className="text-sm text-slate-500 font-medium truncate">{document.fileName}</p>
<Badge className={`h-5 rounded-full px-2 text-[8px] uppercase font-bold tracking-widest ${isPublished ? 'bg-green-50 text-green-600 border-green-100' : 'bg-slate-100 text-slate-500 border-transparent shadow-none'}`}>
{isPublished ? 'Publicado' : 'Rascunho'}
</Badge>
</div>
</div>
<div className="flex items-center gap-2">
<Button variant="ghost" asChild className="h-9 px-4 rounded-lg font-bold text-[10px] uppercase tracking-widest border-2 border-slate-100 hover:bg-white transition-all shadow-none">
<a href={document.fileUrl} target="_blank" rel="noreferrer">
<Eye size={16} className="mr-2 stroke-[3]" />
Visualizar
</a>
</Button>
<Button
variant="ghost"
onClick={handleDelete}
disabled={isDeleting}
className="h-9 px-4 rounded-lg font-bold text-[10px] uppercase tracking-widest border-2 border-transparent hover:bg-red-50 text-red-500 hover:text-red-600 transition-all shadow-none"
>
<Trash2 size={16} className="mr-2 stroke-[3]" />
Excluir
</Button>
</div>
</div>
</div>
<div className="p-8 lg:p-10 w-full">
{message.text && (
<Alert variant={message.type === "success" ? "default" : "destructive"} className="mb-6 rounded-xl border-2">
<AlertDescription className="font-bold text-xs">{message.text}</AlertDescription>
</Alert>
)}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Left Side: Form */}
<div className="lg:col-span-2 space-y-8">
{/* Metadata Editor */}
<div className="space-y-4">
<div className="flex items-center gap-2">
<div className="w-6 h-6 rounded-lg bg-slate-900 flex items-center justify-center text-white">
<Settings size={14} />
</div>
<h3 className="text-lg font-black text-slate-900 tracking-tight">Metadados e Visibilidade</h3>
</div>
<form onSubmit={handleSave} className="bg-slate-50 p-6 rounded-[24px] space-y-5">
<div className="space-y-1.5">
<Label htmlFor="title" className="text-[9px] font-bold uppercase tracking-widest text-slate-400 ml-0.5">Título de Exibição</Label>
<Input
id="title"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="h-10 bg-white border-2 border-slate-100 rounded-xl text-sm font-bold px-4 focus:border-blue-200 focus:ring-4 focus:ring-blue-50/50 transition-all shadow-none"
required
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="description" className="text-[9px] font-bold uppercase tracking-widest text-slate-400 ml-0.5">Descrição do Conteúdo</Label>
<Textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
className="bg-white border-2 border-slate-100 rounded-xl text-sm font-medium p-4 focus:border-blue-200 focus:ring-4 focus:ring-blue-50/50 transition-all shadow-none min-h-[100px]"
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
<div className="space-y-1.5">
<Label className="text-[9px] font-bold uppercase tracking-widest text-slate-400 ml-0.5">Pasta Destino</Label>
<Select value={folderId} onValueChange={setFolderId}>
<SelectTrigger className="h-10 bg-white border-2 border-slate-100 rounded-xl text-sm font-bold px-4 focus:border-blue-200 focus:ring-4 focus:ring-blue-50/50 transition-all shadow-none text-slate-900">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-white border-2 border-slate-100 rounded-xl shadow-xl">
<SelectItem value="__none__" className="font-bold text-[10px] uppercase p-2 text-slate-600 hover:bg-slate-50 cursor-pointer">Pasta Raiz</SelectItem>
{folders.map((f) => (
<SelectItem key={f.id} value={f.id} className="font-bold text-[10px] uppercase p-2 text-slate-600 hover:bg-slate-50 cursor-pointer">{f.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="bg-white p-5 rounded-xl border-2 border-slate-100 flex flex-col justify-center gap-3">
<div className="flex items-center justify-between">
<span className="text-[10px] font-bold uppercase tracking-tight text-slate-900">Visível ao Público</span>
<Switch checked={isPublished} onCheckedChange={setIsPublished} />
</div>
<div className="h-px bg-slate-50" />
<div className="flex items-center justify-between">
<span className="text-[10px] font-bold uppercase tracking-tight text-slate-900">Download</span>
<Switch checked={isDownloadable} onCheckedChange={setIsDownloadable} />
</div>
</div>
</div>
<div className="pt-2">
<Button
type="submit"
disabled={isSaving}
className="h-10 px-8 rounded-lg font-bold text-[10px] uppercase tracking-widest transition-all active:scale-95 shadow-none w-full md:w-auto text-white"
style={{ backgroundColor: primaryColor }}
>
{isSaving ? (
<Loader2 className="mr-3 h-4 w-4 animate-spin text-white" />
) : (
<Save size={16} className="mr-2 stroke-[3]" />
)}
{isSaving ? "Gravando..." : "Salvar Alterações"}
</Button>
</div>
</form>
</div>
</div>
{/* Right Side: Sidebar Info */}
<div className="space-y-8">
{/* Technical Specs */}
<div className="space-y-4">
<div className="flex items-center gap-2">
<div className="w-6 h-6 rounded-lg bg-slate-100 flex items-center justify-center text-slate-600">
<Info size={14} />
</div>
<h3 className="text-[10px] font-bold text-slate-900 tracking-tight uppercase tracking-widest">Especificações</h3>
</div>
<div className="bg-white rounded-xl border-2 border-slate-100 p-5 space-y-3.5 shadow-none">
<div className="flex justify-between items-center group">
<span className="text-[9px] font-bold uppercase tracking-widest text-slate-400">Dimensões</span>
<span className="text-[11px] font-bold text-slate-900">{formatFileSize(document.fileSize)}</span>
</div>
<div className="h-px bg-slate-50" />
<div className="flex justify-between items-center group">
<span className="text-[9px] font-bold uppercase tracking-widest text-slate-400">Extensão</span>
<span className="text-[11px] font-bold text-slate-900 uppercase">{document.fileType.split("/")[1] || "Arquivo"}</span>
</div>
<div className="h-px bg-slate-50" />
<div className="flex justify-between items-center group">
<span className="text-[9px] font-bold uppercase tracking-widest text-slate-400">ID Digital</span>
<span className="text-[9px] font-medium text-slate-400 font-mono truncate max-w-[100px]">{document.id}</span>
</div>
</div>
</div>
{/* Public Share Box */}
{isPublished && (
<div className="space-y-4">
<div className="flex items-center gap-2">
<div className="w-6 h-6 rounded-lg bg-blue-50 flex items-center justify-center text-blue-600">
<Share2 size={14} />
</div>
<h3 className="text-[10px] font-bold text-slate-900 tracking-tight uppercase tracking-widest">Compartilhamento</h3>
</div>
<div className="bg-slate-900 rounded-xl p-5 space-y-3.5 text-white overflow-hidden relative">
<div className="relative z-10">
<p className="text-[9px] font-bold uppercase tracking-widest text-white/40 mb-2.5">Link Público de Acesso</p>
<div className="flex gap-2">
<Input
readOnly
value={publicUrl}
className="bg-white/10 border-white/10 h-9 text-[9px] font-medium rounded-lg text-white/80 focus:ring-0 shadow-none border-0"
/>
<Button
size="icon"
variant="secondary"
className={`shrink-0 h-9 w-9 rounded-lg transition-all ${copied ? 'bg-green-500 text-white' : 'bg-white text-slate-900'}`}
onClick={copyToClipboard}
>
{copied ? <Check size={16} className="stroke-[3]" /> : <Copy size={16} className="stroke-[3]" />}
</Button>
</div>
<p className="text-[8px] font-medium text-white/30 mt-2.5 leading-relaxed">
Cidadãos poderão visualizar este documento sem login através deste link permanente.
</p>
</div>
{/* Bg decoration */}
<div className="absolute top-0 right-0 w-20 h-20 bg-blue-500/20 blur-3xl rounded-full -mr-10 -mt-10" />
</div>
</div>
)}
</div>
</div>
</div>
</main>
</div>
);
}
function Loader2(props: any) {
return (
<svg
{...props}
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
</svg>
)
}

View File

@@ -0,0 +1,36 @@
import { getSession } from "@/app/actions/auth";
import { redirect } from "next/navigation";
import { getDocument } from "@/app/actions/documents";
import { getFolders } from "@/app/actions/folders";
import DocumentDetailClient from "./DocumentDetailClient";
export default async function DocumentDetailPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const session = await getSession();
if (!session) {
redirect("/");
}
const { id } = await params;
const [document, folders] = await Promise.all([
getDocument(id),
getFolders(),
]);
if (!document) {
redirect("/dashboard/documentos");
}
return (
<DocumentDetailClient
user={session}
organization={session.organization!}
document={document}
folders={folders}
/>
);
}

View File

@@ -0,0 +1,291 @@
"use client";
import React, { useState } from "react";
import { motion } from "framer-motion";
import {
ArrowLeft,
CloudUpload,
Loader2,
Save,
FileText,
ChevronLeft,
} from "lucide-react";
import Link from "next/link";
import { uploadFile } from "@/app/actions/upload";
import { createDocument } from "@/app/actions/documents";
import { useRouter } from "next/navigation";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Sidebar } from "@/components/Sidebar";
import { Alert, AlertDescription } from "@/components/ui/alert";
type Organization = {
id: string;
name: string;
logoUrl: string | null;
primaryColor: string;
};
type UserType = {
id: string;
name: string | null;
email: string;
role: string;
};
type FolderType = {
id: string;
name: string;
};
export default function NewDocumentClient({
user,
organization,
folders,
defaultFolderId,
}: {
user: UserType;
organization: Organization;
folders: FolderType[];
defaultFolderId?: string;
}) {
const router = useRouter();
const primaryColor = organization.primaryColor || "#2563eb";
const [file, setFile] = useState<File | null>(null);
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [folderId, setFolderId] = useState(defaultFolderId || "__none__");
const [isPublished, setIsPublished] = useState(false);
const [isDownloadable, setIsDownloadable] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState("");
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const selectedFile = e.target.files?.[0];
if (selectedFile) {
setFile(selectedFile);
if (!title) setTitle(selectedFile.name.split(".")[0]);
}
};
const handleSave = async (e: React.FormEvent) => {
e.preventDefault();
if (!file || !title) return;
setIsSaving(true);
setError("");
const formData = new FormData();
formData.append("file", file);
const uploadResult = await uploadFile(formData);
if (!uploadResult.success) {
setError(uploadResult.error || "Erro no upload.");
setIsSaving(false);
return;
}
const result = await createDocument({
title,
description: description || undefined,
fileUrl: uploadResult.url!,
fileName: file.name,
fileType: file.type,
fileSize: file.size,
folderId: folderId === "__none__" ? undefined : folderId,
isPublished,
isDownloadable,
});
if (result.success) {
router.push("/dashboard/documentos");
router.refresh();
} else {
setError(result.error || "Erro ao salvar.");
setIsSaving(false);
}
};
return (
<div className="flex-1 flex flex-col bg-white overflow-hidden selection:bg-slate-900 selection:text-white">
{/* Header Section - Integrated */}
<div className="relative border-b border-slate-100 bg-slate-50/40 p-8 lg:p-10">
<div className="flex flex-col md:flex-row md:items-end justify-between gap-6">
<div className="flex-1 min-w-0">
<nav className="flex items-center gap-2 mb-3 text-[10px] font-bold uppercase tracking-widest text-slate-400">
<Link href="/dashboard/documentos" className="hover:text-slate-900 transition-colors">Documentos</Link>
<ChevronLeft size={10} className="rotate-180" />
<span className="text-slate-900 truncate tracking-[0.1em]">Upload de Arquivo</span>
</nav>
<h2 className="text-2xl font-black text-slate-900 tracking-tight leading-none italic">Novo Documento.</h2>
<p className="text-sm text-slate-500 font-medium mt-1">Carregue arquivos oficiais para o portal público.</p>
</div>
</div>
</div>
<main className="flex-1 overflow-y-auto">
<div className="p-8 lg:p-10 w-full">
{error && (
<Alert variant="destructive" className="mb-6 rounded-xl border-2">
<AlertDescription className="font-bold text-xs">{error}</AlertDescription>
</Alert>
)}
<div className="space-y-8">
{/* File Upload Section */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
<div>
<h3 className="text-lg font-black text-slate-900 tracking-tight mb-2">Arquivo</h3>
<p className="text-xs text-slate-500 font-medium leading-relaxed">
Selecione o arquivo que será publicado. Formatos aceitos: PDF e Imagens.
</p>
</div>
<div className="lg:col-span-2">
<div className={`relative border-2 border-dashed rounded-[24px] p-8 transition-all duration-500 flex flex-col items-center justify-center text-center group ${file ? 'border-green-100 bg-green-50/30' : 'border-slate-100 bg-slate-50 hover:border-blue-200 hover:bg-white'}`}>
<input
type="file"
onChange={handleFileChange}
className="absolute inset-0 opacity-0 cursor-pointer z-10"
/>
{file ? (
<div className="space-y-3.5">
<div className="w-14 h-14 bg-white rounded-xl flex items-center justify-center mx-auto border-2 border-green-100 shadow-none scale-105">
<FileText size={24} className="text-green-500" />
</div>
<div>
<p className="text-base font-black text-slate-900 leading-tight">{file.name}</p>
<p className="text-[9px] font-bold text-slate-400 uppercase tracking-widest mt-1">{(file.size / 1024 / 1024).toFixed(2)} MB Pronto para envio</p>
</div>
<Button
type="button"
variant="ghost"
onClick={(e) => { e.stopPropagation(); setFile(null); }}
className="relative z-20 text-red-500 hover:text-red-600 hover:bg-red-50 font-bold uppercase text-[9px] tracking-widest h-8 px-4 rounded-lg"
>
Trocar Arquivo
</Button>
</div>
) : (
<div className="space-y-3.5">
<div className="w-14 h-14 bg-white rounded-xl flex items-center justify-center mx-auto border-2 border-slate-50 group-hover:scale-105 transition-transform">
<CloudUpload size={24} className="text-slate-300" />
</div>
<div>
<p className="text-base font-black text-slate-900 leading-tight">Clique ou arraste o arquivo</p>
<p className="text-[9px] font-bold text-slate-400 uppercase tracking-widest mt-1">Até 50MB permitidos por documento</p>
</div>
<Button
type="button"
className="h-9 px-5 rounded-lg font-bold uppercase text-[9px] tracking-widest pointer-events-none text-white font-bold"
style={{ backgroundColor: primaryColor }}
>
Procurar no Computador
</Button>
</div>
)}
</div>
</div>
</div>
{/* Details Section */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
<div>
<h3 className="text-lg font-black text-slate-900 tracking-tight mb-2">Informações</h3>
<p className="text-xs text-slate-500 font-medium leading-relaxed">
Defina como o documento será identificado e em qual pasta ele ficará organizado.
</p>
</div>
<form onSubmit={handleSave} className="lg:col-span-2 bg-slate-50 p-6 rounded-[24px] space-y-5">
<div className="space-y-1.5">
<Label htmlFor="title" className="text-[9px] font-bold uppercase tracking-widest text-slate-400 ml-0.5">Título do Documento</Label>
<Input
id="title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Ex: Balanço Anual 2023"
className="h-10 bg-white border-none rounded-lg text-sm font-bold px-4 focus:ring-4 focus:ring-slate-100 transition-all shadow-none"
required
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="description" className="text-[9px] font-bold uppercase tracking-widest text-slate-400 ml-0.5">Descrição Contextual (Opcional)</Label>
<Textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Descreva brevemente para que serve este arquivo..."
className="bg-white border-none rounded-lg text-sm font-medium p-4 focus:ring-4 focus:ring-slate-100 transition-all shadow-none min-h-[100px]"
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
<div className="space-y-1.5">
<Label className="text-[9px] font-bold uppercase tracking-widest text-slate-400 ml-0.5">Organizar na Pasta</Label>
<Select value={folderId} onValueChange={setFolderId}>
<SelectTrigger className="h-10 bg-white border-none rounded-lg text-sm font-bold px-4 focus:ring-4 focus:ring-slate-100 transition-all shadow-none text-slate-900">
<SelectValue />
</SelectTrigger>
<SelectContent className="rounded-lg border-none shadow-none bg-slate-900 text-white">
<SelectItem value="__none__" className="font-bold text-[10px] uppercase p-2">Pasta Raiz</SelectItem>
{folders.map((f) => (
<SelectItem key={f.id} value={f.id} className="font-bold text-[10px] uppercase p-2">
{f.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="bg-white p-5 rounded-xl border-2 border-slate-100 flex flex-col justify-center gap-3">
<div className="flex items-center justify-between">
<span className="text-[10px] font-bold uppercase tracking-tight text-slate-900">Publicar Imediatamente</span>
<Switch checked={isPublished} onCheckedChange={setIsPublished} />
</div>
<div className="h-px bg-slate-50" />
<div className="flex items-center justify-between">
<span className="text-[10px] font-bold uppercase tracking-tight text-slate-900">Permitir Download</span>
<Switch checked={isDownloadable} onCheckedChange={setIsDownloadable} />
</div>
</div>
</div>
<div className="pt-4 flex justify-end">
<Button
type="submit"
disabled={isSaving || !file || !title}
className="h-10 px-8 rounded-lg font-bold text-[10px] uppercase tracking-widest transition-all active:scale-95 shadow-none w-full md:w-auto text-white"
style={{ backgroundColor: primaryColor }}
>
{isSaving ? (
<Loader2 className="mr-3 h-4 w-4 animate-spin text-white" />
) : (
<Save size={16} className="mr-2 stroke-[3]" />
)}
{isSaving ? "Enviando Documento..." : "Salvar e Publicar"}
</Button>
</div>
</form>
</div>
</div>
</div>
</main>
</div>
);
}

View File

@@ -0,0 +1,28 @@
import { getSession } from "@/app/actions/auth";
import { redirect } from "next/navigation";
import { getFolders } from "@/app/actions/folders";
import NewDocumentClient from "./NewDocumentClient";
export default async function NewDocumentPage({
searchParams,
}: {
searchParams: Promise<{ folder?: string }>;
}) {
const session = await getSession();
if (!session) {
redirect("/");
}
const params = await searchParams;
const folders = await getFolders();
return (
<NewDocumentClient
user={session}
organization={session.organization!}
folders={folders}
defaultFolderId={params.folder || null}
/>
);
}

View File

@@ -0,0 +1,41 @@
import { getSession } from "@/app/actions/auth";
import { redirect } from "next/navigation";
import { getFolders, getFolderBreadcrumb, getFolder, getAllFolders } from "@/app/actions/folders";
import { getDocuments } from "@/app/actions/documents";
import DocumentsClient from "./DocumentsClient";
export default async function DocumentsPage({
searchParams,
}: {
searchParams: Promise<{ folder?: string }>;
}) {
const session = await getSession();
if (!session) {
redirect("/");
}
const params = await searchParams;
const folderId = params.folder || null;
const [folders, documents, breadcrumb, currentFolder, allFolders] = await Promise.all([
getFolders(folderId),
getDocuments(folderId),
getFolderBreadcrumb(folderId),
folderId ? getFolder(folderId) : null,
getAllFolders(),
]);
return (
<DocumentsClient
user={session}
organization={session.organization!}
folders={folders}
documents={documents}
currentFolderId={folderId}
breadcrumb={breadcrumb}
currentFolder={currentFolder}
allFolders={allFolders}
/>
);
}

View File

@@ -0,0 +1,22 @@
import { getSession } from "@/app/actions/auth";
import { getDashboardStats } from "@/app/actions/dashboard";
import { redirect } from "next/navigation";
import DashboardClient from "./DashboardClient";
export default async function DashboardPage() {
const session = await getSession();
if (!session) {
redirect("/");
}
const stats = await getDashboardStats();
return (
<DashboardClient
user={session}
organization={session.organization!}
stats={stats || { docCount: 0, viewCount: 0, downloadCount: 0, recentDocs: [] }}
/>
);
}

View File

@@ -0,0 +1,253 @@
"use client";
import React, { useState } from "react";
import { motion } from "framer-motion";
import {
Save,
Lock,
User,
Mail,
ArrowRight,
ChevronRight,
} from "lucide-react";
import { updateProfile } from "@/app/actions/profile";
import { useRouter } from "next/navigation";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Sidebar } from "@/components/Sidebar";
type Organization = {
id: string;
name: string;
logoUrl: string | null;
primaryColor: string;
};
type UserType = {
id: string;
name: string | null;
email: string;
role: string;
};
export default function ProfileClient({
user,
organization,
}: {
user: UserType;
organization: Organization;
}) {
const router = useRouter();
const primaryColor = organization.primaryColor || "#2563eb";
const [name, setName] = useState(user.name || "");
const [email, setEmail] = useState(user.email);
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [isSaving, setIsSaving] = useState(false);
const [message, setMessage] = useState({ type: "", text: "" });
const handleSave = async (e: React.FormEvent) => {
e.preventDefault();
setIsSaving(true);
setMessage({ type: "", text: "" });
if (newPassword && newPassword !== confirmPassword) {
setMessage({ type: "error", text: "As novas senhas não coincidem." });
setIsSaving(false);
return;
}
if (newPassword && !currentPassword) {
setMessage({ type: "error", text: "Você deve informar a senha atual para alterá-la." });
setIsSaving(false);
return;
}
const result = await updateProfile({
name,
email,
currentPassword: currentPassword || undefined,
newPassword: newPassword || undefined,
});
if (result.success) {
setMessage({ type: "success", text: "Perfil atualizado com sucesso!" });
setCurrentPassword("");
setNewPassword("");
setConfirmPassword("");
router.refresh();
} else {
setMessage({ type: "error", text: result.error || "Erro ao atualizar." });
}
setIsSaving(false);
};
return (
<div className="min-h-screen bg-white flex">
<Sidebar user={user} organization={organization} />
<main className="flex-1 overflow-y-auto flex flex-col">
{/* Header Section - Integrated */}
<div className="relative border-b border-slate-100 bg-slate-50/40 p-10 lg:p-12">
<div className="flex flex-col md:flex-row md:items-end justify-between gap-6">
<div>
<div className="flex items-center gap-2 mb-3">
<span className="px-2.5 py-0.5 bg-white border border-slate-200 rounded-full text-[10px] font-black uppercase tracking-[0.15em] text-slate-500">Account Settings</span>
</div>
<h2 className="text-3xl font-black text-slate-900 tracking-tight mb-1">Meu Perfil</h2>
<p className="text-base text-slate-500 font-medium">Gerencie suas informações pessoais e credenciais de acesso.</p>
</div>
</div>
</div>
<div className="p-10 lg:p-12 w-full">
{message.text && (
<Alert variant={message.type === "success" ? "default" : "destructive"} className="mb-6 rounded-xl border-2">
<AlertDescription className="font-bold text-xs">{message.text}</AlertDescription>
</Alert>
)}
<form onSubmit={handleSave} className="space-y-10">
{/* Basic Info Section */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-10">
<div>
<h3 className="text-lg font-black text-slate-900 tracking-tight mb-2">Dados Básicos</h3>
<p className="text-xs text-slate-500 font-medium leading-relaxed">
Essas informações são usadas para identificar você no sistema e em registros de atividade.
</p>
</div>
<div className="lg:col-span-2 bg-slate-50 p-8 rounded-[32px] border-2 border-transparent">
<div className="space-y-6">
<div className="space-y-2">
<Label htmlFor="name" className="text-[9px] font-black uppercase tracking-widest text-slate-600 ml-1">Nome Completo</Label>
<div className="relative group">
<User className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-500 group-focus-within:text-blue-500 transition-colors" size={18} />
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
className="h-11 pl-12 bg-white border-2 border-slate-100 rounded-xl text-sm font-bold focus:border-blue-100 focus:ring-4 focus:ring-blue-50/50 transition-all shadow-none"
required
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="email" className="text-[9px] font-black uppercase tracking-widest text-slate-600 ml-1">E-mail de Trabalho</Label>
<div className="relative group">
<Mail className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-500 group-focus-within:text-blue-500 transition-colors" size={18} />
<Input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="h-11 pl-12 bg-white border-2 border-slate-100 rounded-xl text-sm font-bold focus:border-blue-100 focus:ring-4 focus:ring-blue-50/50 transition-all shadow-none"
required
/>
</div>
</div>
</div>
</div>
</div>
{/* Security Section */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-10">
<div>
<h3 className="text-lg font-black text-slate-900 tracking-tight mb-2">Segurança</h3>
<p className="text-xs text-slate-500 font-medium leading-relaxed">
Mantenha sua conta segura alterando sua senha regularmente. A senha atual é necessária para validar a troca.
</p>
</div>
<div className="lg:col-span-2 bg-slate-50 p-8 rounded-[32px] border-2 border-transparent space-y-6">
<div className="space-y-2">
<Label htmlFor="currentPassword" className="text-[9px] font-black uppercase tracking-widest text-slate-600 ml-1">Senha Atual</Label>
<div className="relative group">
<Lock className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-500 group-focus-within:text-blue-500 transition-colors" size={18} />
<Input
id="currentPassword"
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
className="h-11 pl-12 bg-white border-2 border-slate-100 rounded-xl text-sm font-bold focus:border-blue-100 focus:ring-4 focus:ring-blue-50/50 transition-all shadow-none"
placeholder="Confirme para alterar"
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label htmlFor="newPassword" className="text-[9px] font-black uppercase tracking-widest text-slate-400 ml-1">Nova Senha</Label>
<Input
id="newPassword"
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
className="h-11 bg-white border-2 border-slate-100 rounded-xl text-sm font-bold px-4 focus:border-blue-100 focus:ring-4 focus:ring-blue-50/50 transition-all shadow-none"
placeholder="••••••••"
/>
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword" className="text-[9px] font-black uppercase tracking-widest text-slate-400 ml-1">Confirmar Nova</Label>
<Input
id="confirmPassword"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="h-11 bg-white border-2 border-slate-100 rounded-xl text-sm font-bold px-4 focus:border-blue-100 focus:ring-4 focus:ring-blue-50/50 transition-all shadow-none"
placeholder="••••••••"
/>
</div>
</div>
</div>
</div>
{/* Submit Bar */}
<div className="pt-8 border-t-2 border-slate-50 flex justify-end">
<Button
type="submit"
disabled={isSaving}
className="h-11 px-8 rounded-xl font-black text-xs uppercase tracking-widest transition-all active:scale-95 shadow-none text-white"
style={{ backgroundColor: primaryColor }}
>
{isSaving ? (
<Loader2 className="mr-3 h-4 w-4 animate-spin" />
) : (
<Save size={18} className="mr-2.5 stroke-[3]" />
)}
{isSaving ? "Processando..." : "Salvar Alterações"}
</Button>
</div>
</form>
</div>
</main>
</div>
);
}
function Loader2(props: any) {
return (
<svg
{...props}
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
</svg>
)
}

View File

@@ -0,0 +1,18 @@
import { getSession } from "@/app/actions/auth";
import { redirect } from "next/navigation";
import ProfileClient from "./ProfileClient";
export default async function ProfilePage() {
const session = await getSession();
if (!session) {
redirect("/");
}
return (
<ProfileClient
user={session}
organization={session.organization!}
/>
);
}

View File

@@ -0,0 +1,515 @@
"use client";
import React, { useState } from "react";
import { motion } from "framer-motion";
import {
Plus,
Trash2,
Edit,
Shield,
ShieldCheck,
Eye,
ChevronRight,
ArrowRight,
Search,
Loader2,
AlertCircle,
} from "lucide-react";
import { logout } from "@/app/actions/auth";
import { createUser, updateUser, deleteUser } from "@/app/actions/users";
import { useRouter } from "next/navigation";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Sidebar } from "@/components/Sidebar";
import { StandardTable } from "@/components/StandardTable";
type Organization = {
id: string;
name: string;
logoUrl: string | null;
primaryColor: string;
};
type UserType = {
id: string;
name: string | null;
email: string;
role: string;
};
type UserListType = {
id: string;
name: string | null;
email: string;
role: string;
createdAt: Date;
};
export default function UsuariosClient({
user,
organization,
users,
}: {
user: UserType;
organization: Organization;
users: UserListType[];
}) {
const router = useRouter();
const primaryColor = organization.primaryColor || "#2563eb";
const [showNewUser, setShowNewUser] = useState(false);
const [showEditUser, setShowEditUser] = useState(false);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [userToDelete, setUserToDelete] = useState<string | null>(null);
const [editingUser, setEditingUser] = useState<UserListType | null>(null);
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [role, setRole] = useState<"ADMIN" | "EDITOR" | "VIEWER">("EDITOR");
// Search and Pagination
const [searchTerm, setSearchTerm] = useState("");
const [currentPage, setCurrentPage] = useState(1);
const itemsPerPage = 8;
const filteredUsers = users.filter(u =>
u.name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
u.email.toLowerCase().includes(searchTerm.toLowerCase())
);
const totalPages = Math.ceil(filteredUsers.length / itemsPerPage);
const paginatedUsers = filteredUsers.slice(
(currentPage - 1) * itemsPerPage,
currentPage * itemsPerPage
);
const [isLoading, setIsLoading] = useState(false);
const [message, setMessage] = useState({ type: "", text: "" });
const resetForm = () => {
setName("");
setEmail("");
setPassword("");
setRole("EDITOR");
};
const handleCreate = async () => {
if (!name || !email || !password) {
setMessage({ type: "error", text: "Preencha todos os campos." });
return;
}
setIsLoading(true);
const result = await createUser({ name, email, password, role: role as any });
if (result.success) {
setShowNewUser(false);
resetForm();
router.refresh();
} else {
setMessage({ type: "error", text: result.error || "Erro ao criar usuário." });
}
setIsLoading(false);
};
const handleEdit = async () => {
if (!editingUser) return;
setIsLoading(true);
const result = await updateUser(editingUser.id, {
name: name || undefined,
email: email || undefined,
role: role as any,
password: password || undefined,
});
if (result.success) {
setShowEditUser(false);
setEditingUser(null);
resetForm();
router.refresh();
} else {
setMessage({ type: "error", text: result.error || "Erro ao atualizar usuário." });
}
setIsLoading(false);
};
const handleDelete = async () => {
if (!userToDelete) return;
setIsLoading(true);
const result = await deleteUser(userToDelete);
if (result.success) {
setShowDeleteDialog(false);
setUserToDelete(null);
router.refresh();
} else {
setMessage({ type: "error", text: result.error || "Erro ao excluir usuário." });
}
setIsLoading(false);
};
const openEditModal = (u: UserListType) => {
setEditingUser(u);
setName(u.name || "");
setEmail(u.email);
setRole(u.role as "ADMIN" | "EDITOR" | "VIEWER");
setPassword("");
setShowEditUser(true);
};
const getRoleBadge = (role: string) => {
switch (role) {
case "SUPER_ADMIN":
return <Badge className="bg-purple-50 text-purple-700 border-purple-100 font-black text-[10px] uppercase tracking-widest"><ShieldCheck size={12} className="mr-1.5" />Super Admin</Badge>;
case "ADMIN":
return <Badge className="bg-blue-50 text-blue-700 border-blue-100 font-black text-[10px] uppercase tracking-widest"><Shield size={12} className="mr-1.5" />Admin</Badge>;
case "EDITOR":
return <Badge className="bg-green-50 text-green-700 border-green-100 font-black text-[10px] uppercase tracking-widest"><Edit size={12} className="mr-1.5" />Editor</Badge>;
case "VIEWER":
return <Badge className="bg-slate-50 text-slate-500 border-slate-200 font-black text-[10px] uppercase tracking-widest"><Eye size={12} className="mr-1.5" />Viewer</Badge>;
default:
return <Badge variant="outline" className="font-black text-[10px] uppercase tracking-widest">{role}</Badge>;
}
};
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] flex">
<Sidebar user={user} organization={organization} />
<main className="flex-1 overflow-y-auto flex flex-col">
{/* Header Section - Integrated */}
<div className="relative bg-white p-8 lg:p-10">
<div className="flex flex-col md:flex-row md:items-end justify-between gap-6">
<div>
<div className="flex items-center gap-2 mb-2">
<span className="px-2 py-0.5 bg-white border border-slate-200 rounded-full text-[9px] font-bold uppercase tracking-[0.1em] text-slate-500">Access Control</span>
</div>
<h2 className="text-2xl font-black text-slate-900 tracking-tight mb-1">Usuários</h2>
<p className="text-sm text-slate-500 font-medium">Gerencie quem tem permissão para editar no portal.</p>
</div>
<div className="flex items-center gap-3">
<Button
onClick={() => {
resetForm();
setShowNewUser(true);
}}
style={{ backgroundColor: primaryColor }}
className="h-10 px-6 rounded-lg font-bold text-xs shadow-none hover:opacity-90 active:scale-95 transition-all text-white"
>
<Plus size={18} className="mr-2 stroke-[3]" />
Novo Usuário
</Button>
</div>
</div>
</div>
<div className="p-8 lg:p-10 w-full">
{message.text && (
<Alert variant={message.type === "success" ? "default" : "destructive"} className="mb-6 rounded-xl border-2">
<AlertDescription className="font-bold text-xs">{message.text}</AlertDescription>
</Alert>
)}
{/* Users List using StandardTable */}
<StandardTable
searchTerm={searchTerm}
onSearchChange={(val) => { setSearchTerm(val); setCurrentPage(1); }}
searchPlaceholder="Buscar usuários por nome ou e-mail..."
totalItems={filteredUsers.length}
showingCount={paginatedUsers.length}
itemName="usuários"
currentPage={currentPage}
totalPages={totalPages}
onPageChange={setCurrentPage}
columns={
<>
<th className="pl-6 py-3.5 text-[9px] font-bold text-slate-600 uppercase tracking-widest">Nome do Usuário</th>
<th className="px-4 py-3.5 text-[9px] font-bold text-slate-600 uppercase tracking-widest">E-mail</th>
<th className="px-4 py-3.5 text-[9px] font-bold text-slate-600 uppercase tracking-widest text-center">Acesso</th>
<th className="px-4 py-3.5 text-[9px] font-bold text-slate-600 uppercase tracking-widest text-right">Membro desde</th>
<th className="pr-6 py-3.5 w-32 text-right text-[9px] font-bold text-slate-600 uppercase tracking-widest">Ações</th>
</>
}
>
{paginatedUsers.map((u) => (
<tr key={u.id} className="hover:bg-blue-50/40 transition-all group cursor-default">
<td className="pl-6 py-2.5">
<div className="flex items-center gap-3">
<div
className="w-8 h-8 rounded-full flex items-center justify-center text-white text-xs font-bold shrink-0 border border-white"
style={{ backgroundColor: primaryColor }}
>
{u.name?.charAt(0) || u.email.charAt(0).toUpperCase()}
</div>
<div>
<p className="font-bold text-slate-700 text-sm leading-none group-hover:text-red-600 transition-colors uppercase tracking-tight">{u.name || "Sem nome"}</p>
{u.id === user.id && (
<span className="text-[9px] font-bold text-red-500 uppercase tracking-tight mt-0.5 block">Você</span>
)}
</div>
</div>
</td>
<td className="px-4 py-2.5 text-xs font-medium text-slate-500">
{u.email}
</td>
<td className="px-4 py-2.5 text-center">
{getRoleBadge(u.role)}
</td>
<td className="px-4 py-2.5 text-right text-[11px] font-medium text-slate-400">
{formatDate(u.createdAt)}
</td>
<td className="pr-6 py-2.5 text-right">
<div className="flex items-center justify-end gap-1 px-1 opacity-0 group-hover:opacity-100 transition-opacity">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded-lg text-slate-400 hover:text-slate-900 hover:bg-slate-100 transition-all"
onClick={() => openEditModal(u)}
>
<Edit size={14} />
</Button>
{u.id !== user.id && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded-lg text-slate-400 hover:text-red-500 hover:bg-red-50 transition-all"
onClick={() => {
setUserToDelete(u.id);
setShowDeleteDialog(true);
}}
>
<Trash2 size={14} />
</Button>
)}
</div>
</td>
</tr>
))}
</StandardTable>
</div>
{/* Dialogs Integrated */}
<Dialog open={showNewUser} onOpenChange={setShowNewUser}>
<DialogContent className="max-w-md bg-white border-2 border-slate-100 rounded-[24px] p-0 overflow-hidden">
<div className="p-8 border-b border-slate-50 bg-slate-50/50">
<DialogTitle className="text-xl font-black text-slate-900 tracking-tight leading-none italic">Novo Usuário.</DialogTitle>
<p className="text-sm text-slate-500 mt-2 font-medium">Cadastre um novo membro para a equipe.</p>
</div>
<form onSubmit={handleCreate} className="p-8 space-y-5">
<div className="space-y-1.5">
<Label className="text-[9px] font-bold uppercase tracking-widest text-slate-400 ml-0.5">Nome Completo</Label>
<Input
required
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="João Silva"
className="h-10 bg-white border-2 border-slate-100 rounded-xl text-sm font-bold px-4 focus:border-blue-200 focus:ring-4 focus:ring-blue-50/50 transition-all shadow-none"
/>
</div>
<div className="space-y-1.5">
<Label className="text-[9px] font-bold uppercase tracking-widest text-slate-400 ml-0.5">E-mail de Acesso</Label>
<Input
required
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="joao@portal.gov.br"
className="h-10 bg-white border-2 border-slate-100 rounded-xl text-sm font-bold px-4 focus:border-blue-200 focus:ring-4 focus:ring-blue-50/50 transition-all shadow-none"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label className="text-[9px] font-bold uppercase tracking-widest text-slate-400 ml-0.5">Senha Inicial</Label>
<Input
required
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
className="h-10 bg-white border-2 border-slate-100 rounded-xl text-sm font-bold px-4 focus:border-blue-200 focus:ring-4 focus:ring-blue-50/50 transition-all shadow-none"
/>
</div>
<div className="space-y-1.5">
<Label className="text-[9px] font-bold uppercase tracking-widest text-slate-400 ml-0.5">Nível de Acesso</Label>
<Select value={role} onValueChange={(v: any) => setRole(v)}>
<SelectTrigger className="h-10 bg-white border-2 border-slate-100 rounded-xl text-sm font-bold px-4 focus:border-blue-200 focus:ring-4 focus:ring-blue-50/50 transition-all shadow-none text-slate-900">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-white border-2 border-slate-100 rounded-xl">
<SelectItem value="ADMIN" className="font-bold text-[10px] uppercase p-2 text-slate-600 hover:bg-slate-50 cursor-pointer">Admin</SelectItem>
<SelectItem value="EDITOR" className="font-bold text-[10px] uppercase p-2 text-slate-600 hover:bg-slate-50 cursor-pointer">Editor</SelectItem>
<SelectItem value="VIEWER" className="font-bold text-[10px] uppercase p-2 text-slate-600 hover:bg-slate-50 cursor-pointer">Visualizador</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="pt-2 flex gap-3">
<Button
type="button"
variant="ghost"
onClick={() => setShowNewUser(false)}
className="flex-1 h-10 rounded-lg font-bold text-[10px] uppercase tracking-widest"
>
Cancelar
</Button>
<Button
type="submit"
disabled={isLoading}
className="flex-1 h-10 rounded-lg font-bold text-[10px] uppercase tracking-widest shadow-none text-white"
style={{ backgroundColor: primaryColor }}
>
{isLoading ? <Loader2 className="animate-spin" size={16} /> : "Criar Usuário"}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
<Dialog open={showEditUser} onOpenChange={setShowEditUser}>
<DialogContent className="max-w-md bg-white border-2 border-slate-100 rounded-[24px] p-0 overflow-hidden">
<div className="p-8 border-b border-slate-50 bg-slate-50/50">
<DialogTitle className="text-xl font-black text-slate-900 tracking-tight leading-none italic">Editar Usuário.</DialogTitle>
<p className="text-sm text-slate-500 mt-2 font-medium">Altere as informações de acesso deste membro.</p>
</div>
<form onSubmit={(e) => { e.preventDefault(); handleEdit(); }} className="p-8 space-y-5">
<div className="space-y-1.5">
<Label className="text-[9px] font-bold uppercase tracking-widest text-slate-400 ml-0.5">Nome Completo</Label>
<Input
required
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="João Silva"
className="h-10 bg-white border-2 border-slate-100 rounded-xl text-sm font-bold px-4 focus:border-blue-200 focus:ring-4 focus:ring-blue-50/50 transition-all shadow-none"
/>
</div>
<div className="space-y-1.5">
<Label className="text-[9px] font-bold uppercase tracking-widest text-slate-400 ml-0.5">E-mail de Acesso</Label>
<Input
required
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="joao@portal.gov.br"
className="h-10 bg-white border-2 border-slate-100 rounded-xl text-sm font-bold px-4 focus:border-blue-200 focus:ring-4 focus:ring-blue-50/50 transition-all shadow-none"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label className="text-[9px] font-bold uppercase tracking-widest text-slate-400 ml-0.5">Nova Senha (Opcional)</Label>
<Input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Manter atual"
className="h-10 bg-white border-2 border-slate-100 rounded-xl text-sm font-bold px-4 focus:border-blue-200 focus:ring-4 focus:ring-blue-50/50 transition-all shadow-none"
/>
</div>
<div className="space-y-1.5">
<Label className="text-[9px] font-bold uppercase tracking-widest text-slate-400 ml-0.5">Nível de Acesso</Label>
<Select value={role} onValueChange={(v: any) => setRole(v)}>
<SelectTrigger className="h-10 bg-white border-2 border-slate-100 rounded-xl text-sm font-bold px-4 focus:border-blue-200 focus:ring-4 focus:ring-blue-50/50 transition-all shadow-none text-slate-900">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-white border-2 border-slate-100 rounded-xl">
<SelectItem value="ADMIN" className="font-bold text-[10px] uppercase p-2 text-slate-600 hover:bg-slate-50 cursor-pointer">Admin</SelectItem>
<SelectItem value="EDITOR" className="font-bold text-[10px] uppercase p-2 text-slate-600 hover:bg-slate-50 cursor-pointer">Editor</SelectItem>
<SelectItem value="VIEWER" className="font-bold text-[10px] uppercase p-2 text-slate-600 hover:bg-slate-50 cursor-pointer">Visualizador</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="pt-2 flex gap-3">
<Button
type="button"
variant="ghost"
onClick={() => setShowEditUser(false)}
className="flex-1 h-10 rounded-lg font-bold text-[10px] uppercase tracking-widest"
>
Cancelar
</Button>
<Button
type="submit"
disabled={isLoading}
className="flex-1 h-10 rounded-lg font-bold text-[10px] uppercase tracking-widest shadow-none text-white"
style={{ backgroundColor: primaryColor }}
>
{isLoading ? <Loader2 className="animate-spin" size={16} /> : "Salvar Alterações"}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<div className="w-12 h-12 rounded-2xl bg-red-50 flex items-center justify-center text-red-600 mb-2">
<AlertCircle size={24} strokeWidth={2.5} />
</div>
<AlertDialogTitle>Excluir Usuário?</AlertDialogTitle>
<AlertDialogDescription>
Esta ação não pode ser desfeita. O usuário perderá o acesso ao portal imediatamente.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isLoading}>Cancelar</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.preventDefault();
handleDelete();
}}
disabled={isLoading}
className="bg-red-600 hover:bg-red-700 active:bg-red-800"
>
{isLoading ? <Loader2 className="animate-spin" size={16} /> : "Sim, Excluir"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</main>
</div>
);
}

View File

@@ -0,0 +1,23 @@
import React from "react";
import { redirect } from "next/navigation";
import { getSession } from "@/app/actions/auth";
import { getUsers } from "@/app/actions/users";
import UsuariosClient from "@/app/dashboard/usuarios/UsuariosClient";
export default async function UsuariosPage() {
const session = await getSession();
if (!session) {
redirect("/");
}
const users = await getUsers();
return (
<UsuariosClient
user={session}
organization={session.organization!}
users={users}
/>
);
}

View File

@@ -0,0 +1,516 @@
"use client";
import React, { useState } from "react";
import { motion } from "framer-motion";
import {
FileText,
Download,
Lock,
Calendar,
ArrowLeft,
ShieldCheck,
ChevronLeft,
ChevronRight,
ZoomIn,
ZoomOut,
Maximize2,
} from "lucide-react";
import Link from "next/link";
import { Document, Page, pdfjs } from "react-pdf";
import "react-pdf/dist/Page/AnnotationLayer.css";
import "react-pdf/dist/Page/TextLayer.css";
import { incrementViewCount, incrementDownloadCount } from "@/app/actions/documents";
// Configure PDF.js worker
pdfjs.GlobalWorkerOptions.workerSrc = `//unpkg.com/pdfjs-dist@${pdfjs.version}/build/pdf.worker.min.mjs`;
type Organization = {
name: string;
logoUrl: string | null;
primaryColor: string;
};
type DocumentType = {
id: string;
title: string;
description: string | null;
fileName: string;
fileUrl: string;
fileType: string;
fileSize: number;
isDownloadable: boolean;
publishedAt: Date | null;
createdAt: Date;
};
export default function DocumentViewClient({
document: doc,
organization,
}: {
document: DocumentType;
organization: Organization;
}) {
const primaryColor = organization.primaryColor || "#2563eb";
const [numPages, setNumPages] = useState<number | null>(null);
const [pageNumber, setPageNumber] = useState(1);
const [scale, setScale] = useState(1.0);
const [isFullscreen, setIsFullscreen] = useState(false);
const [pdfUrl, setPdfUrl] = useState<string | null>(null);
const [isPdfLoading, setIsPdfLoading] = useState(true);
const isPDF = doc.fileType === "application/pdf";
const isImage = doc.fileType.startsWith("image/");
// Função para descriptografar dados XOR
const xorDecrypt = (data: Uint8Array, key: string): Uint8Array => {
const keyBytes = new TextEncoder().encode(key);
const result = new Uint8Array(data.length);
for (let i = 0; i < data.length; i++) {
result[i] = data[i] ^ keyBytes[i % keyBytes.length];
}
return result;
};
// Set initial scale based on screen width
React.useEffect(() => {
const isMobile = window.innerWidth < 768;
setScale(isMobile ? 0.75 : 1.5);
// Incrementar visualização
incrementViewCount(doc.id);
// Carregar PDF manualmente com descriptografia anti-IDM
if (isPDF) {
setIsPdfLoading(true);
fetch(`/api/view/${doc.id}`)
.then(async (res) => {
const encryptionKey = res.headers.get("X-Encryption-Key");
const buffer = await res.arrayBuffer();
let data = new Uint8Array(buffer);
// Se tem chave de encriptação, descriptografar
if (encryptionKey) {
data = xorDecrypt(data, encryptionKey) as any;
}
// Criar Blob URL para o PDF.js
const blob = new Blob([data], { type: 'application/pdf' });
const url = URL.createObjectURL(blob);
setPdfUrl(url);
setIsPdfLoading(false);
})
.catch(err => {
console.error("Erro ao carregar PDF:", err);
setIsPdfLoading(false);
});
}
// Cleanup: revogar URL ao desmontar
return () => {
if (pdfUrl) {
URL.revokeObjectURL(pdfUrl);
}
};
}, [doc.id, isPDF]);
// Navegação por teclado
React.useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "ArrowLeft") {
setPageNumber((prev) => Math.max(prev - 1, 1));
} else if (e.key === "ArrowRight") {
setPageNumber((prev) => Math.min(prev + 1, numPages || 1));
} else if (e.key === "Escape" && isFullscreen) {
setIsFullscreen(false);
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [numPages, isFullscreen]);
const formatFileSize = (bytes: number) => {
if (bytes < 1024) return bytes + " B";
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB";
return (bytes / (1024 * 1024)).toFixed(1) + " MB";
};
const formatDate = (date: Date) => {
return new Date(date).toLocaleDateString("pt-BR", {
day: "2-digit",
month: "long",
year: "numeric",
});
};
function onDocumentLoadSuccess({ numPages }: { numPages: number }) {
setNumPages(numPages);
}
const goToPrevPage = () => setPageNumber((prev) => Math.max(prev - 1, 1));
const goToNextPage = () => setPageNumber((prev) => Math.min(prev + 1, numPages || 1));
const zoomIn = () => setScale((prev) => Math.min(prev + 0.25, 3.0));
const zoomOut = () => setScale((prev) => Math.max(prev - 0.25, 0.5));
const toggleFullscreen = () => setIsFullscreen((prev) => !prev);
const handleDownload = () => {
incrementDownloadCount(doc.id);
};
return (
<div
className="min-h-screen bg-slate-50"
onContextMenu={!doc.isDownloadable ? (e) => e.preventDefault() : undefined}
>
{/* Header */}
<header
className="py-3 px-4 md:py-4 md:px-6 border-b border-white/10"
style={{ backgroundColor: primaryColor }}
>
<div className="max-w-5xl mx-auto flex items-center justify-between">
<div className="flex items-center gap-2 md:gap-3">
{organization.logoUrl ? (
<img
src={organization.logoUrl}
alt="Logo"
className="w-8 h-8 md:w-10 md:h-10 rounded-lg object-contain bg-white/20 p-1"
/>
) : (
<div className="w-8 h-8 md:w-10 md:h-10 bg-white/20 backdrop-blur-md rounded-xl flex items-center justify-center border border-white/20">
<ShieldCheck size={20} className="text-white md:hidden" />
<ShieldCheck size={24} className="text-white hidden md:block" />
</div>
)}
<div>
<h1 className="text-sm md:text-lg font-bold text-white truncate max-w-[150px] md:max-w-none">{organization.name}</h1>
<p className="text-xs text-white/70 hidden md:block">Portal de Transparência</p>
</div>
</div>
<Link
href="/"
className="text-white/80 hover:text-white flex items-center gap-1 md:gap-2 text-xs md:text-sm"
>
<ArrowLeft size={14} className="md:hidden" />
<ArrowLeft size={16} className="hidden md:block" />
<span className="hidden sm:inline">Voltar ao Portal</span>
<span className="sm:hidden">Voltar</span>
</Link>
</div>
</header>
{/* Content */}
<main className="max-w-5xl mx-auto py-4 px-3 md:py-8 md:px-6">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="bg-white rounded-xl md:rounded-2xl border border-slate-200 shadow-sm overflow-hidden"
>
{/* Document Header */}
<div className="p-4 md:p-6 border-b border-slate-100">
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4">
<div className="flex items-start gap-3 md:gap-4">
<div
className="p-2 md:p-3 rounded-lg md:rounded-xl shrink-0"
style={{ backgroundColor: `${primaryColor}15` }}
>
<FileText size={24} className="md:hidden" style={{ color: primaryColor }} />
<FileText size={28} className="hidden md:block" style={{ color: primaryColor }} />
</div>
<div className="min-w-0">
<h2 className="text-lg md:text-2xl font-bold text-slate-900 break-words">
{doc.title}
</h2>
<div className="flex flex-wrap items-center gap-2 md:gap-4 mt-1 md:mt-2 text-xs md:text-sm text-slate-500">
<span className="flex items-center gap-1">
<Calendar size={12} className="md:hidden" />
<Calendar size={14} className="hidden md:block" />
{formatDate(doc.publishedAt || doc.createdAt)}
</span>
<span>{formatFileSize(doc.fileSize)}</span>
</div>
</div>
</div>
{doc.isDownloadable ? (
<a
href={`/api/view/${doc.id}`}
download={doc.fileName}
onClick={handleDownload}
className="px-4 py-2 md:px-5 md:py-2.5 rounded-lg md:rounded-xl text-white font-medium flex items-center justify-center gap-2 transition-colors hover:opacity-90 text-sm md:text-base shrink-0"
style={{ backgroundColor: primaryColor }}
>
<Download size={16} className="md:hidden" />
<Download size={18} className="hidden md:block" />
Baixar
</a>
) : (
<div className="px-4 py-2 md:px-5 md:py-2.5 rounded-lg md:rounded-xl bg-slate-100 text-slate-500 font-medium flex items-center justify-center gap-2 text-sm md:text-base shrink-0">
<Lock size={16} className="md:hidden" />
<Lock size={18} className="hidden md:block" />
<span className="hidden sm:inline">Apenas Visualização</span>
<span className="sm:hidden">Protegido</span>
</div>
)}
</div>
{doc.description && (
<p className="mt-3 md:mt-4 text-sm md:text-base text-slate-600">{doc.description}</p>
)}
</div>
{/* Document Preview */}
<div className="p-3 md:p-6">
{isPDF ? (
<>
{/* Normal View */}
{!isFullscreen && (
<div className="relative">
{/* PDF Toolbar */}
<div className="flex items-center justify-between mb-3 md:mb-4 p-2 md:p-3 bg-slate-100 rounded-lg md:rounded-xl">
<div className="flex items-center gap-1 md:gap-2">
<button
onClick={goToPrevPage}
disabled={pageNumber <= 1}
className="p-1.5 md:p-2 rounded-lg hover:bg-slate-200 disabled:opacity-50 disabled:cursor-not-allowed"
>
<ChevronLeft size={18} className="md:hidden" />
<ChevronLeft size={20} className="hidden md:block" />
</button>
<span className="text-xs md:text-sm font-medium text-slate-700 whitespace-nowrap">
<span className="hidden sm:inline">Página </span>{pageNumber}/{numPages || "..."}
</span>
<button
onClick={goToNextPage}
disabled={pageNumber >= (numPages || 1)}
className="p-1.5 md:p-2 rounded-lg hover:bg-slate-200 disabled:opacity-50 disabled:cursor-not-allowed"
>
<ChevronRight size={18} className="md:hidden" />
<ChevronRight size={20} className="hidden md:block" />
</button>
</div>
<div className="flex items-center gap-1 md:gap-2">
<button
onClick={zoomOut}
disabled={scale <= 0.5}
className="p-1.5 md:p-2 rounded-lg hover:bg-slate-200 disabled:opacity-50"
>
<ZoomOut size={16} className="md:hidden" />
<ZoomOut size={18} className="hidden md:block" />
</button>
<span className="text-xs md:text-sm font-medium text-slate-600 w-10 md:w-16 text-center">
{Math.round(scale * 100)}%
</span>
<button
onClick={zoomIn}
disabled={scale >= 3.0}
className="p-1.5 md:p-2 rounded-lg hover:bg-slate-200 disabled:opacity-50"
>
<ZoomIn size={16} className="md:hidden" />
<ZoomIn size={18} className="hidden md:block" />
</button>
<div className="w-px h-5 md:h-6 bg-slate-300 mx-0.5 md:mx-1" />
<button
onClick={toggleFullscreen}
className="p-1.5 md:p-2 rounded-lg hover:bg-slate-200"
title="Tela Cheia"
>
<Maximize2 size={16} className="md:hidden" />
<Maximize2 size={18} className="hidden md:block" />
</button>
</div>
</div>
{/* PDF Viewer */}
<div
className="rounded-lg md:rounded-xl overflow-auto border border-slate-200 bg-slate-800 flex justify-center p-2 md:p-4 select-none"
style={{ height: "60vh", minHeight: "400px", maxHeight: "900px" }}
>
{isPdfLoading ? (
<div className="flex items-center justify-center h-full w-full">
<div className="animate-spin rounded-full h-12 w-12 border-4 border-white/20 border-t-white"></div>
</div>
) : (
<Document
file={pdfUrl}
onLoadSuccess={onDocumentLoadSuccess}
loading={
<div className="flex items-center justify-center h-96">
<div className="animate-spin rounded-full h-12 w-12 border-4 border-white/20 border-t-white"></div>
</div>
}
error={
<div className="text-center text-white py-8">
<FileText size={48} className="mx-auto mb-4 opacity-50" />
<p>Erro ao carregar o PDF.</p>
</div>
}
>
<Page
pageNumber={pageNumber}
scale={scale}
renderTextLayer={false}
renderAnnotationLayer={false}
/>
</Document>
)}
</div>
{/* Proteção */}
{!doc.isDownloadable && (
<div className="mt-4 bg-orange-50 border border-orange-200 rounded-lg p-3 flex items-center gap-2 text-sm text-orange-700">
<Lock size={16} />
Este documento está protegido e não pode ser baixado.
</div>
)}
</div>
)}
{/* Fullscreen Modal */}
{isFullscreen && (
<div className="fixed inset-0 z-50 bg-slate-900 flex flex-col">
{/* Fullscreen Toolbar */}
<div className="flex flex-wrap items-center justify-between gap-2 p-2 md:p-4 bg-slate-800 border-b border-slate-700">
{/* Left side - Page navigation */}
<div className="flex items-center gap-1 md:gap-3">
<button
onClick={goToPrevPage}
disabled={pageNumber <= 1}
className="p-1.5 md:p-2 rounded-lg bg-slate-700 hover:bg-slate-600 text-white disabled:opacity-50 disabled:cursor-not-allowed"
>
<ChevronLeft size={18} className="md:hidden" />
<ChevronLeft size={20} className="hidden md:block" />
</button>
<span className="text-xs md:text-sm font-medium text-white whitespace-nowrap">
<span className="hidden sm:inline">Página </span>{pageNumber}<span className="hidden sm:inline"> de </span><span className="sm:hidden">/</span>{numPages || "..."}
</span>
<button
onClick={goToNextPage}
disabled={pageNumber >= (numPages || 1)}
className="p-1.5 md:p-2 rounded-lg bg-slate-700 hover:bg-slate-600 text-white disabled:opacity-50 disabled:cursor-not-allowed"
>
<ChevronRight size={18} className="md:hidden" />
<ChevronRight size={20} className="hidden md:block" />
</button>
</div>
{/* Right side - Zoom and Close */}
<div className="flex items-center gap-1 md:gap-3">
<button
onClick={zoomOut}
disabled={scale <= 0.5}
className="p-1.5 md:p-2 rounded-lg bg-slate-700 hover:bg-slate-600 text-white disabled:opacity-50"
>
<ZoomOut size={16} className="md:hidden" />
<ZoomOut size={18} className="hidden md:block" />
</button>
<span className="text-xs md:text-sm font-medium text-white w-10 md:w-16 text-center">
{Math.round(scale * 100)}%
</span>
<button
onClick={zoomIn}
disabled={scale >= 3.0}
className="p-1.5 md:p-2 rounded-lg bg-slate-700 hover:bg-slate-600 text-white disabled:opacity-50"
>
<ZoomIn size={16} className="md:hidden" />
<ZoomIn size={18} className="hidden md:block" />
</button>
<button
onClick={toggleFullscreen}
className="ml-1 md:ml-2 px-2 md:px-4 py-1.5 md:py-2 rounded-lg bg-red-600 hover:bg-red-500 text-white text-xs md:text-sm font-medium flex items-center gap-1 md:gap-2"
>
<ArrowLeft size={14} className="md:hidden" />
<ArrowLeft size={18} className="hidden md:block" />
<span className="hidden sm:inline">Fechar</span>
</button>
</div>
</div>
{/* Fullscreen PDF Viewer */}
<div className="flex-1 overflow-auto flex justify-center p-2 md:p-6 select-none">
{isPdfLoading ? (
<div className="flex items-center justify-center h-full w-full">
<div className="animate-spin rounded-full h-12 w-12 border-4 border-white/20 border-t-white"></div>
</div>
) : (
<Document
file={pdfUrl}
onLoadSuccess={onDocumentLoadSuccess}
loading={
<div className="flex items-center justify-center h-96">
<div className="animate-spin rounded-full h-12 w-12 border-4 border-white/20 border-t-white"></div>
</div>
}
error={
<div className="text-center text-white py-8">
<FileText size={48} className="mx-auto mb-4 opacity-50" />
<p>Erro ao carregar o PDF.</p>
</div>
}
>
<Page
pageNumber={pageNumber}
scale={scale}
renderTextLayer={false}
renderAnnotationLayer={false}
/>
</Document>
)}
</div>
</div>
)}
</>
) : isImage ? (
<div
className={`rounded-xl overflow-hidden border border-slate-200 flex items-center justify-center bg-slate-50 p-4 ${!doc.isDownloadable ? "select-none" : ""
}`}
>
<img
src={`/api/view/${doc.id}`}
alt={doc.title}
className="max-w-full max-h-[700px] object-contain rounded-lg"
draggable={doc.isDownloadable}
onDragStart={!doc.isDownloadable ? (e) => e.preventDefault() : undefined}
/>
</div>
) : (
<div className="rounded-xl border border-slate-200 p-12 text-center bg-slate-50">
<FileText size={64} className="mx-auto text-slate-300 mb-4" />
<p className="text-slate-600 mb-4">
Visualização prévia não disponível para este tipo de arquivo.
</p>
{doc.isDownloadable ? (
<a
href={`/api/view/${doc.id}`}
download={doc.fileName}
onClick={handleDownload}
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-xl text-white font-medium transition-colors hover:opacity-90"
style={{ backgroundColor: primaryColor }}
>
<Download size={18} />
Baixar Arquivo
</a>
) : (
<div className="inline-flex items-center gap-2 px-5 py-2.5 rounded-xl bg-slate-200 text-slate-600 font-medium">
<Lock size={18} />
Download não permitido
</div>
)}
</div>
)}
</div>
</motion.div>
{/* Info Footer */}
<div className="mt-6 text-center text-sm text-slate-500">
<p>
Documento disponibilizado pelo Portal de Transparência de{" "}
<strong>{organization.name}</strong>
</p>
</div>
</main>
</div>
);
}

View File

@@ -0,0 +1,36 @@
import { prisma } from "@/lib/db";
import { notFound } from "next/navigation";
import DocumentViewClient from "./DocumentViewClient";
export default async function PublicDocumentPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const document = await prisma.document.findUnique({
where: { id },
include: {
organization: {
select: {
name: true,
logoUrl: true,
primaryColor: true,
},
},
},
});
// Se não existe ou não está publicado
if (!document || !document.isPublished) {
notFound();
}
return (
<DocumentViewClient
document={document}
organization={document.organization}
/>
);
}

View File

@@ -1,26 +1,158 @@
@import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #171717;
}
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--font-sans: var(--font-inter);
--color-brand-blue: #2563eb;
--color-brand-black: #0f172a;
--color-brand-gray: #f8fafc;
--spacing-base: 12px;
--spacing-group: 24px;
--spacing-section: 48px;
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--radius-2xl: calc(var(--radius) + 8px);
--radius-3xl: calc(var(--radius) + 12px);
--radius-4xl: calc(var(--radius) + 16px);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
:root {
--card: oklch(1 0 0);
--card-foreground: oklch(0.129 0.042 264.695);
--border: oklch(0.929 0.013 255.508);
--input: oklch(0.929 0.013 255.508);
--ring: oklch(0.704 0.04 256.788);
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.129 0.042 264.695);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.129 0.042 264.695);
--primary: oklch(0.208 0.042 265.755);
--primary-foreground: oklch(0.984 0.003 247.858);
--secondary: oklch(0.968 0.007 247.896);
--secondary-foreground: oklch(0.208 0.042 265.755);
--muted: oklch(0.968 0.007 247.896);
--muted-foreground: oklch(0.554 0.046 257.417);
--accent: oklch(0.968 0.007 247.896);
--accent-foreground: oklch(0.208 0.042 265.755);
--destructive: oklch(0.577 0.245 27.325);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.984 0.003 247.858);
--sidebar-foreground: oklch(0.129 0.042 264.695);
--sidebar-primary: oklch(0.208 0.042 265.755);
--sidebar-primary-foreground: oklch(0.984 0.003 247.858);
--sidebar-accent: oklch(0.968 0.007 247.896);
--sidebar-accent-foreground: oklch(0.208 0.042 265.755);
--sidebar-border: oklch(0.929 0.013 255.508);
--sidebar-ring: oklch(0.704 0.04 256.788);
}
@layer base {
body {
background: var(--background);
color: var(--foreground);
@apply antialiased;
}
a,
button,
[role="button"] {
@apply cursor-pointer;
}
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
.glass-card {
@apply bg-white/80 backdrop-blur-md border border-white/20;
}
.input-field {
@apply w-full px-4 py-3 rounded-xl border border-slate-200 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all;
}
.btn-primary {
@apply bg-blue-600 hover:bg-blue-700 text-white px-6 py-3 rounded-xl font-semibold transition-all active:scale-95;
}
.dark {
--background: oklch(0.129 0.042 264.695);
--foreground: oklch(0.984 0.003 247.858);
--card: oklch(0.208 0.042 265.755);
--card-foreground: oklch(0.984 0.003 247.858);
--popover: oklch(0.208 0.042 265.755);
--popover-foreground: oklch(0.984 0.003 247.858);
--primary: oklch(0.929 0.013 255.508);
--primary-foreground: oklch(0.208 0.042 265.755);
--secondary: oklch(0.279 0.041 260.031);
--secondary-foreground: oklch(0.984 0.003 247.858);
--muted: oklch(0.279 0.041 260.031);
--muted-foreground: oklch(0.704 0.04 256.788);
--accent: oklch(0.279 0.041 260.031);
--accent-foreground: oklch(0.984 0.003 247.858);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.551 0.027 264.364);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.208 0.042 265.755);
--sidebar-foreground: oklch(0.984 0.003 247.858);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.984 0.003 247.858);
--sidebar-accent: oklch(0.279 0.041 260.031);
--sidebar-accent-foreground: oklch(0.984 0.003 247.858);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.551 0.027 264.364);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}

View File

@@ -1,20 +1,15 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { Inter } from "next/font/google";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
const inter = Inter({
subsets: ["latin"],
variable: "--font-inter",
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
title: "Portal de Transparência",
description: "Plataforma de transparência para ONGs e organizações",
};
export default function RootLayout({
@@ -23,10 +18,8 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<html lang="pt-BR">
<body className={`${inter.variable} font-sans antialiased`}>
{children}
</body>
</html>

17
src/app/not-found.tsx Normal file
View File

@@ -0,0 +1,17 @@
import { prisma } from "@/lib/db";
import NotFoundClient from "./NotFoundClient";
export default async function NotFound() {
// Buscar a organização para usar as cores
let organization = null;
try {
organization = await prisma.organization.findFirst();
} catch {
// Se não conseguir buscar, usa cores padrão
}
const primaryColor = organization?.primaryColor || "#2563eb";
const orgName = organization?.name || "Portal de Transparência";
return <NotFoundClient primaryColor={primaryColor} orgName={orgName} />;
}

View File

@@ -1,65 +1,29 @@
import Image from "next/image";
import React from "react";
import { prisma } from "@/lib/db";
import { redirect } from "next/navigation";
import LoginClient from "@/components/LoginClient";
import { getSession } from "@/app/actions/auth";
export default function Home() {
return (
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex min-h-screen w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the page.tsx file.
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
</p>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={16}
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
</div>
</main>
</div>
);
export default async function Page() {
try {
// Verifica se o usuário já está logado
const session = await getSession();
if (session) {
redirect("/dashboard");
}
// Verifica se existe alguma organização cadastrada
const organization = await prisma.organization.findFirst();
// Se não houver organização, redireciona para o setup inicial (Instalação)
if (!organization) {
redirect("/setup");
}
return <LoginClient organization={organization} />;
} catch (error) {
console.error("Erro ao verificar organização:", error);
// Se der erro de banco (provavelmente banco vazio ou não iniciado), vamos para o setup
redirect("/setup");
}
}

406
src/app/setup/page.tsx Normal file
View File

@@ -0,0 +1,406 @@
"use client";
import React, { useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import {
Building2,
Palette,
User,
ArrowRight,
ArrowLeft,
CheckCircle2,
CloudUpload,
Globe,
Layout,
Loader2,
ShieldCheck
} from "lucide-react";
import { uploadFile } from "@/app/actions/upload";
import { createOrganization } from "@/app/actions/setup";
import { useRouter } from "next/navigation";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Alert, AlertDescription } from "@/components/ui/alert";
export default function SetupPage() {
const router = useRouter();
const [step, setStep] = useState(1);
const [isUploading, setIsUploading] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const totalSteps = 3;
// Form State
const [formData, setFormData] = useState({
name: "",
cnpj: "",
logoUrl: "",
primaryColor: "#2563eb",
adminName: "",
adminEmail: "",
adminPassword: "",
confirmPassword: ""
});
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setFormData(prev => ({ ...prev, [e.target.name]: e.target.value }));
};
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setIsUploading(true);
const data = new FormData();
data.append("file", file);
const result = await uploadFile(data);
if (result.success && result.url) {
setFormData(prev => ({ ...prev, logoUrl: result.url || "" }));
} else {
alert("Erro ao fazer upload da logo");
}
setIsUploading(false);
};
const handleSubmit = async () => {
if (formData.adminPassword !== formData.confirmPassword) {
alert("As senhas não coincidem!");
return;
}
setIsSubmitting(true);
const result = await createOrganization(formData);
if (result.success) {
router.push("/dashboard");
router.refresh();
} else {
alert(result.error || "Erro ao finalizar instalação");
setIsSubmitting(false);
}
};
const nextStep = () => {
if (step === totalSteps) {
handleSubmit();
} else {
setStep((s) => Math.min(s + 1, totalSteps));
}
};
const prevStep = () => setStep((s) => Math.max(s - 1, 1));
return (
<div className="min-h-screen bg-[#f8fafc] flex items-center justify-center p-6 bg-[radial-gradient(ellipse_at_top_right,_var(--tw-gradient-stops))] from-blue-50 via-slate-50 to-white font-sans">
<div className="max-w-4xl w-full">
{/* Header Section */}
<div className="text-center mb-10">
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
>
<Badge variant="secondary" className="mb-4 px-3 py-1">
<ShieldCheck size={14} className="mr-1" />
Instalação do Portal
</Badge>
</motion.div>
<motion.h1
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="text-4xl font-bold text-slate-900 tracking-tight mb-2"
>
Configuração Master
</motion.h1>
<motion.p
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
className="text-slate-500"
>
Este portal está cru. Vamos realizar o setup inicial da sua organização.
</motion.p>
</div>
{/* Progress Bar */}
<div className="mb-12 relative">
<div className="flex justify-between items-center max-w-2xl mx-auto relative z-10">
{[1, 2, 3].map((i) => (
<div
key={i}
className={`w-10 h-10 rounded-full flex items-center justify-center transition-all duration-500 ${step >= i ? "bg-blue-600 text-white shadow-lg shadow-blue-200" : "bg-white text-slate-400 border border-slate-200"
}`}
>
{step > i ? <CheckCircle2 size={24} /> : <span>{i}</span>}
</div>
))}
</div>
<div className="absolute top-1/2 left-0 w-full h-0.5 bg-slate-200 -translate-y-1/2 max-w-2xl mx-auto right-0" />
<motion.div
className="absolute top-1/2 left-0 h-0.5 bg-blue-600 -translate-y-1/2 max-w-2xl mx-auto right-0 origin-left"
initial={{ scaleX: 0 }}
animate={{ scaleX: (step - 1) / (totalSteps - 1) }}
transition={{ duration: 0.5 }}
/>
</div>
{/* Form Card */}
<Card className="border-0 shadow-xl bg-white/80 backdrop-blur-md">
<CardContent className="p-8 md:p-12 min-h-[500px] flex flex-col justify-between">
<AnimatePresence mode="wait">
{step === 1 && (
<motion.div
key="step1"
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
className="space-y-8"
>
<div className="flex items-center gap-4 mb-8">
<div className="p-3 bg-blue-50 text-blue-600 rounded-2xl">
<Building2 size={32} />
</div>
<div>
<h2 className="text-2xl font-semibold text-slate-800">Identidade da Organização</h2>
<p className="text-slate-500">Defina o nome oficial e a logo que aparecerá no portal.</p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label htmlFor="name">Nome da Organização</Label>
<Input
id="name"
name="name"
value={formData.name}
onChange={handleChange}
type="text"
placeholder="Ex: Instituto Esperança"
className="h-12"
/>
</div>
<div className="space-y-2">
<Label htmlFor="cnpj">CNPJ (Opcional)</Label>
<Input
id="cnpj"
name="cnpj"
value={formData.cnpj}
onChange={handleChange}
type="text"
placeholder="00.000.000/0000-00"
className="h-12"
/>
</div>
<div className="md:col-span-2 space-y-2">
<Label>Logo da ONG</Label>
<div className="relative border-2 border-dashed border-slate-200 rounded-2xl p-8 flex flex-col items-center justify-center text-slate-400 hover:border-blue-400 hover:bg-blue-50 transition-all cursor-pointer group">
<input
type="file"
className="absolute inset-0 opacity-0 cursor-pointer"
onChange={handleFileUpload}
accept="image/*"
/>
{isUploading ? (
<div className="flex flex-col items-center">
<Loader2 size={48} className="mb-4 text-blue-500 animate-spin" />
<p className="text-sm font-medium">Enviando para o MinIO...</p>
</div>
) : formData.logoUrl ? (
<div className="flex flex-col items-center">
<img src={formData.logoUrl} alt="Logo" className="h-32 object-contain mb-4 rounded-lg" />
<p className="text-sm text-blue-600 font-medium whitespace-nowrap overflow-hidden text-ellipsis max-w-xs"> Logo enviada com sucesso!</p>
</div>
) : (
<>
<CloudUpload size={48} className="mb-4 group-hover:text-blue-500 transition-colors" />
<p className="text-sm font-medium">Arraste sua logo ou clique para buscar</p>
<p className="text-xs mt-1">PNG, JPG até 5MB (Será salva no MinIO)</p>
</>
)}
</div>
</div>
</div>
</motion.div>
)}
{step === 2 && (
<motion.div
key="step2"
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
className="space-y-8"
>
<div className="flex items-center gap-4 mb-8">
<div className="p-3 bg-indigo-50 text-indigo-600 rounded-2xl">
<Palette size={32} />
</div>
<div>
<h2 className="text-2xl font-semibold text-slate-800">Personalização Visual</h2>
<p className="text-slate-500">Adapte o portal às cores da sua marca.</p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label>Cor Principal</Label>
<div className="flex gap-4 items-center">
<input
name="primaryColor"
value={formData.primaryColor}
onChange={handleChange}
type="color"
className="w-16 h-16 rounded-xl border-none p-0 overflow-hidden cursor-pointer"
/>
<div className="space-y-1">
<p className="text-sm font-medium text-slate-600">Selecione o tom da marca</p>
<p className="text-xs text-slate-400 italic">Isso aplicará o tema dinâmico.</p>
</div>
</div>
</div>
<div className="space-y-2">
<Label className="italic opacity-50">Ambiente de Instalação</Label>
<div className="h-12 bg-slate-50 border rounded-md text-slate-400 flex items-center gap-2 px-3 cursor-not-allowed">
<Globe size={16} />
Detectado via domínio atual
</div>
</div>
</div>
<div className="p-6 bg-slate-50 rounded-2xl border border-slate-100 space-y-4">
<h3 className="font-semibold text-slate-800 flex items-center gap-2">
<Layout size={18} className="text-blue-500" />
Preview em tempo real
</h3>
<div className="aspect-video bg-white rounded-xl shadow-sm border border-slate-200 flex flex-col items-center justify-center p-4">
<div className="w-full h-8 bg-slate-100 rounded-lg mb-4 flex items-center px-4 gap-2">
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: formData.primaryColor }} />
<div className="w-24 h-2 bg-slate-200 rounded" />
</div>
<Button style={{ backgroundColor: formData.primaryColor }}>Botão Exemplo</Button>
</div>
</div>
</motion.div>
)}
{step === 3 && (
<motion.div
key="step3"
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
className="space-y-8"
>
<div className="flex items-center gap-4 mb-8">
<div className="p-3 bg-emerald-50 text-emerald-600 rounded-2xl">
<User size={32} />
</div>
<div>
<h2 className="text-2xl font-semibold text-slate-800">Administrador Master</h2>
<p className="text-slate-500">Crie a conta que terá controle total sobre o portal.</p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label htmlFor="adminName">Nome do Responsável</Label>
<Input
id="adminName"
name="adminName"
value={formData.adminName}
onChange={handleChange}
type="text"
placeholder="João Silva"
className="h-12"
/>
</div>
<div className="space-y-2">
<Label htmlFor="adminEmail">E-mail de Acesso</Label>
<Input
id="adminEmail"
name="adminEmail"
value={formData.adminEmail}
onChange={handleChange}
type="email"
placeholder="admin@ong.org"
className="h-12"
/>
</div>
<div className="space-y-2">
<Label htmlFor="adminPassword">Senha</Label>
<Input
id="adminPassword"
name="adminPassword"
value={formData.adminPassword}
onChange={handleChange}
type="password"
placeholder="••••••••"
className="h-12"
/>
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword">Confirmar Senha</Label>
<Input
id="confirmPassword"
name="confirmPassword"
value={formData.confirmPassword}
onChange={handleChange}
type="password"
placeholder="••••••••"
className="h-12"
/>
</div>
</div>
<Alert>
<CheckCircle2 size={18} />
<AlertDescription>
Ao concluir, o banco de dados será populado e este ambiente será bloqueado para novos setups.
</AlertDescription>
</Alert>
</motion.div>
)}
</AnimatePresence>
<div className="flex justify-between items-center mt-12 pt-8 border-t border-slate-100">
<Button
variant="ghost"
onClick={prevStep}
disabled={isSubmitting}
className={step === 1 ? "invisible" : ""}
>
<ArrowLeft size={18} className="mr-2" />
Voltar
</Button>
<Button
onClick={nextStep}
disabled={isSubmitting || isUploading}
className="h-12 px-6"
>
{isSubmitting ? (
<>
<Loader2 size={18} className="animate-spin mr-2" />
Finalizando...
</>
) : (
<>
{step === totalSteps ? "Finalizar Instalação" : "Próximo Passo"}
<ArrowRight size={18} className="ml-2" />
</>
)}
</Button>
</div>
</CardContent>
</Card>
{/* Footer Support */}
<div className="mt-8 text-center text-slate-400 text-sm">
Portal de Transparência v1.0 Setup de Instalação
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,93 @@
"use client";
import Link from "next/link";
import { motion } from "framer-motion";
import { ShieldAlert, Lock, Home } from "lucide-react";
interface UnauthorizedClientProps {
primaryColor: string;
orgName: string;
}
export default function UnauthorizedClient({ primaryColor, orgName }: UnauthorizedClientProps) {
// Gerar cor mais clara para backgrounds
const lightColor = `${primaryColor}15`;
return (
<div className="min-h-screen bg-slate-50 flex items-center justify-center p-4">
<div className="max-w-md w-full text-center">
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.5 }}
className="glass-card p-12 rounded-3xl border border-red-100 shadow-2xl bg-white/40 backdrop-blur-xl relative overflow-hidden"
>
{/* Decorative glow with organization color */}
<div
className="absolute top-0 left-0 w-full h-1"
style={{ background: `linear-gradient(to right, ${primaryColor}, ${primaryColor}99)` }}
/>
<motion.div
initial={{ y: -20, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ delay: 0.2 }}
className="w-24 h-24 bg-red-100 rounded-2xl flex items-center justify-center mx-auto mb-8"
>
<ShieldAlert className="w-12 h-12 text-red-600" />
</motion.div>
<h1 className="text-3xl font-black text-slate-900 mb-2 tracking-tight uppercase">Acesso Negado</h1>
<div className="flex items-center justify-center gap-2 mb-6 text-red-500 font-medium bg-red-50 py-1 px-3 rounded-full w-fit mx-auto">
<Lock className="w-4 h-4" />
<span className="text-sm uppercase tracking-wider">Erro 403 - Forbidden</span>
</div>
<p className="text-slate-600 mb-8 leading-relaxed">
Sinto muito, mas você não tem as permissões necessárias para acessar esta área do portal. Se você acredita que isso é um erro, entre em contato com o administrador.
</p>
<div className="flex flex-col gap-3">
<Link
href="/dashboard"
className="text-white px-6 py-4 rounded-xl font-bold transition-all shadow-lg flex items-center justify-center gap-2 active:scale-95 hover:opacity-90"
style={{
backgroundColor: primaryColor,
boxShadow: `0 10px 25px -5px ${lightColor}`
}}
>
<Home className="w-5 h-5" />
Voltar ao Dashboard
</Link>
<Link
href="/"
className="px-6 py-3 rounded-xl font-semibold text-slate-500 hover:bg-slate-100 transition-all"
>
Fazer Login com outra conta
</Link>
</div>
</motion.div>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 1 }}
className="mt-8 text-slate-400 text-sm"
>
<p className="font-medium mb-1">{orgName}</p>
<p>Identificador de Segurança: SEC-{Math.random().toString(36).substr(2, 9).toUpperCase()}</p>
</motion.div>
</div>
{/* Background Decorative Elements */}
<div className="fixed top-0 left-0 w-full h-full -z-10 overflow-hidden pointer-events-none">
<div className="absolute top-[10%] right-[10%] w-[30%] h-[30%] bg-red-400/5 rounded-full blur-[100px]" />
<div
className="absolute bottom-[10%] left-[10%] w-[30%] h-[30%] rounded-full blur-[100px]"
style={{ backgroundColor: `${primaryColor}08` }}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,17 @@
import { prisma } from "@/lib/db";
import UnauthorizedClient from "./UnauthorizedClient";
export default async function UnauthorizedPage() {
// Buscar a organização para usar as cores
let organization = null;
try {
organization = await prisma.organization.findFirst();
} catch {
// Se não conseguir buscar, usa cores padrão
}
const primaryColor = organization?.primaryColor || "#2563eb";
const orgName = organization?.name || "Portal de Transparência";
return <UnauthorizedClient primaryColor={primaryColor} orgName={orgName} />;
}

View File

@@ -0,0 +1,323 @@
"use client";
import React from "react";
import { motion } from "framer-motion";
import {
FolderOpen,
FileText,
Download,
Calendar,
ShieldCheck,
Search,
ExternalLink,
ChevronRight,
} from "lucide-react";
import Link from "next/link";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
type Organization = {
name: string;
logoUrl: string | null;
primaryColor: string;
};
type DocumentType = {
id: string;
title: string;
fileName: string;
fileUrl: string;
fileType: string;
fileSize: number;
isDownloadable: boolean;
createdAt: Date;
};
type SubfolderType = {
id: string;
name: string;
color: string;
imageUrl?: string | null;
};
export default function FolderViewClient({
folder,
organization,
}: {
folder: {
id: string;
name: string;
description: string | null;
color: string;
imageUrl?: string | null;
documents: DocumentType[];
children?: SubfolderType[];
breadcrumbs?: { id: string; name: string }[];
rootFolder?: {
id: string;
name: string;
description: string | null;
color: string;
imageUrl?: string | null;
} | null;
};
organization: Organization;
}) {
const [searchTerm, setSearchTerm] = React.useState("");
const filteredDocs = folder.documents.filter((doc) =>
doc.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
doc.fileName.toLowerCase().includes(searchTerm.toLowerCase())
);
const filteredChildren = (folder.children || []).filter((child) =>
child.name.toLowerCase().includes(searchTerm.toLowerCase())
);
// Determine what to show in the header
const displayFolder = folder.rootFolder || folder;
const isSubfolder = !!folder.rootFolder;
const formatFileSize = (bytes: number) => {
if (bytes < 1024) return bytes + " B";
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB";
return (bytes / (1024 * 1024)).toFixed(0) + " MB";
};
const formatDate = (date: Date) => {
return new Date(date).toLocaleDateString("pt-BR", {
day: "2-digit",
month: "long",
year: "numeric",
});
};
return (
<div className="min-h-screen bg-[#f9fafb] selection:bg-red-100 selection:text-red-900 flex flex-col">
{/* Header */}
<header className="bg-white border-b border-slate-100 sticky top-0 z-10">
<div className="max-w-7xl mx-auto px-8 h-20 flex items-center justify-between">
<div className="flex items-center gap-4">
{organization.logoUrl ? (
<img src={organization.logoUrl} alt={organization.name} className="h-10 object-contain" />
) : (
<div className="w-10 h-10 rounded-xl bg-slate-100 flex items-center justify-center font-bold text-slate-800">
{organization.name[0]}
</div>
)}
<div>
<h1 className="text-sm font-black text-slate-900 uppercase tracking-tight">{organization.name}</h1>
<p className="text-[10px] font-bold text-slate-400 uppercase tracking-widest">Portal da Transparência</p>
</div>
</div>
<Badge variant="outline" className="border-green-200 bg-green-50 text-green-600 font-bold text-[10px] uppercase py-1 px-3">
Ambiente Seguro
</Badge>
</div>
</header>
<main className="w-full flex-1">
<div className="w-full max-w-7xl mx-auto px-8 py-12">
{/* Folder Info / Project Header */}
<div className="mb-8">
<div className="flex items-center gap-5 mb-6">
{displayFolder.imageUrl ? (
<div className="w-20 h-20 rounded-2xl overflow-hidden shrink-0 border border-slate-100">
<img src={displayFolder.imageUrl} alt={displayFolder.name} className="w-full h-full object-cover" />
</div>
) : (
<div
className="w-16 h-16 rounded-2xl flex items-center justify-center shrink-0"
style={{ backgroundColor: `${displayFolder.color}15`, color: displayFolder.color }}
>
<FolderOpen size={32} fill="currentColor" fillOpacity={0.2} strokeWidth={2} />
</div>
)}
<div>
<h2 className="text-3xl font-black text-slate-900 uppercase tracking-tighter leading-tight mb-1">
{displayFolder.name}
</h2>
<p className="text-sm font-medium text-slate-500">
{displayFolder.description || "Pasta pública contendo documentos oficiais e informativos."}
</p>
</div>
</div>
{/* Breadcrumbs Navigation */}
{isSubfolder && folder.breadcrumbs && (
<div className="flex items-center flex-wrap gap-2 mb-2 text-sm font-medium">
{folder.breadcrumbs.map((crumb) => (
<React.Fragment key={crumb.id}>
<Link href={`/visualizar/pasta/${crumb.id}`} className="px-2 py-1 rounded-lg bg-slate-100 text-slate-600 hover:bg-slate-200 hover:text-slate-900 transition-colors">
{crumb.name}
</Link>
<ChevronRight size={14} className="text-slate-300" />
</React.Fragment>
))}
<span className="px-2 py-1 rounded-lg bg-red-50 text-red-600 font-bold">
{folder.name}
</span>
</div>
)}
<div className="h-[1px] w-full bg-slate-100 mt-6" />
</div>
{/* Search & Stats */}
<div className="flex flex-col md:flex-row justify-between items-center gap-6 mb-8 mt-4">
<div className="relative w-full md:w-96 group">
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
<Search className="text-slate-400 group-focus-within:text-red-500 transition-colors" size={18} />
</div>
<Input
placeholder="Buscar documentos nesta pasta..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="h-12 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-5 py-2.5 rounded-xl border border-slate-100">
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-widest">Itens</span>
<span className="text-sm font-black text-slate-900">{filteredChildren.length + filteredDocs.length}</span>
<div className="w-[1px] h-5 bg-slate-100 mx-1" />
<ShieldCheck className="text-green-500" size={18} />
</div>
</div>
{/* Subfolders and Documents List */}
<div className="grid grid-cols-1 gap-3">
{/* Subfolders */}
{filteredChildren.map((child, idx) => (
<motion.a
key={child.id}
href={`/visualizar/pasta/${child.id}`}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: idx * 0.05 }}
className="bg-white border border-slate-100 p-4 rounded-xl hover:border-slate-200 transition-all group flex items-center justify-between gap-4"
>
<div className="flex items-center gap-4 min-w-0">
{child.imageUrl ? (
<div className="w-12 h-12 rounded-xl overflow-hidden shrink-0 border border-slate-100">
<img src={child.imageUrl} alt={child.name} className="w-full h-full object-cover" />
</div>
) : (
<div
className="w-12 h-12 rounded-xl flex items-center justify-center shrink-0"
style={{ backgroundColor: `${child.color}15`, color: child.color }}
>
<FolderOpen size={24} fill="currentColor" fillOpacity={0.2} strokeWidth={2} />
</div>
)}
<div className="min-w-0">
<h3 className="text-sm font-bold text-slate-800 uppercase tracking-tight truncate group-hover:text-red-600 transition-colors">
{child.name}
</h3>
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-tight">Pasta</span>
</div>
</div>
<ChevronRight size={18} className="text-slate-300 group-hover:text-red-500 transition-colors shrink-0" />
</motion.a>
))}
{/* Documents */}
{filteredDocs.map((doc, idx) => (
<motion.div
key={doc.id}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: idx * 0.05 }}
className="relative bg-white border border-slate-100 p-4 rounded-xl hover:border-slate-200 transition-all group flex flex-col md:flex-row md:items-center justify-between gap-4"
>
<Link href={`/documento/${doc.id}`} target="_blank" className="absolute inset-0 z-0" />
<div className="flex items-center gap-4 min-w-0">
<div className="w-12 h-12 rounded-xl bg-slate-50 flex items-center justify-center text-slate-400 shrink-0 group-hover:bg-red-50 group-hover:text-red-500 transition-colors">
<FileText size={24} />
</div>
<div className="min-w-0">
<h3 className="text-sm font-bold text-slate-800 uppercase tracking-tight truncate group-hover:text-red-600 transition-colors">
{doc.title}
</h3>
<div className="flex items-center gap-3 mt-1">
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-tight flex items-center gap-1">
<Calendar size={12} /> {formatDate(doc.createdAt)}
</span>
<span className="w-1 h-1 rounded-full bg-slate-200" />
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-tight">
{formatFileSize(doc.fileSize)}
</span>
</div>
</div>
</div>
<div className="flex items-center gap-2 relative z-10">
<Button
asChild
variant="outline"
size="sm"
className="h-10 border-slate-200 hover:bg-slate-50 text-slate-600 font-bold text-[10px] uppercase rounded-lg px-4 shadow-none"
>
<Link href={`/documento/${doc.id}`} target="_blank">
<ExternalLink size={14} className="mr-2" />
Visualizar
</Link>
</Button>
{doc.fileUrl && doc.isDownloadable && (
<Button
asChild
variant="default"
size="sm"
className="h-10 bg-slate-900 border-none hover:bg-red-600 text-white font-bold text-[10px] uppercase rounded-lg px-4 transition-all shadow-none"
>
<a href={`/api/view/${doc.id}`} download={doc.fileName}>
<Download size={14} className="mr-2" />
Baixar
</a>
</Button>
)}
</div>
</motion.div>
))}
{filteredDocs.length === 0 && filteredChildren.length === 0 && (
<div className="bg-white border border-dashed border-slate-200 p-20 rounded-2xl flex flex-col items-center justify-center text-center">
<div className="w-20 h-20 bg-slate-50 rounded-full flex items-center justify-center mb-6">
<FolderOpen size={40} className="text-slate-300" />
</div>
<h3 className="text-lg font-bold text-slate-800 uppercase tracking-tight mb-2">Nenhum item encontrado</h3>
<p className="text-sm text-slate-400 max-w-xs mx-auto font-medium">
Não existem itens públicos nesta pasta no momento ou nenhum corresponde à sua busca.
</p>
</div>
)}
</div>
</div>
</main>
{/* Footer - Apenas logo da organização */}
<footer className="bg-white border-t border-slate-100 mt-auto">
<div className="max-w-7xl mx-auto px-8 h-20 flex items-center justify-center">
{organization.logoUrl ? (
<img
src={organization.logoUrl}
alt={organization.name}
className="h-10 object-contain opacity-60 grayscale hover:opacity-100 hover:grayscale-0 transition-all cursor-pointer"
/>
) : (
<div className="flex items-center gap-3 text-slate-400">
<div className="w-10 h-10 rounded-xl bg-slate-100 flex items-center justify-center font-bold text-slate-500">
{organization.name[0]}
</div>
<span className="text-sm font-bold uppercase tracking-widest">{organization.name}</span>
</div>
)}
</div>
</footer>
</div>
);
}

View File

@@ -0,0 +1,14 @@
import { getPublicFolder } from "@/app/actions/folders";
import FolderViewClient from "./FolderViewClient";
import { notFound } from "next/navigation";
export default async function FolderViewPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const folder = await getPublicFolder(id);
if (!folder) {
notFound();
}
return <FolderViewClient folder={folder as any} organization={folder.organization as any} />;
}

View File

@@ -0,0 +1,175 @@
"use client";
import React, { useState } from "react";
import { motion } from "framer-motion";
import { Lock, Mail, ArrowRight, ShieldCheck, CreditCard, FileText } from "lucide-react";
import { login } from "@/app/actions/auth";
import { useRouter } from "next/navigation";
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 { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
type Organization = {
id: string;
name: string;
logoUrl: string | null;
primaryColor: string;
};
export default function LoginClient({ organization }: { organization: Organization }) {
const router = useRouter();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [isLoading, setIsLoading] = useState(false);
const primaryColor = organization.primaryColor || "#2563eb";
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
setIsLoading(true);
const result = await login(email, password);
if (result.success) {
router.push("/dashboard");
router.refresh();
} else {
setError(result.error || "Erro ao fazer login");
setIsLoading(false);
}
};
return (
<div className="min-h-screen grid grid-cols-1 lg:grid-cols-2 bg-white selection:bg-slate-900 selection:text-white">
{/* Left Side: Illustration & Info */}
<div
className="hidden lg:flex flex-col justify-between p-16 text-white relative overflow-hidden"
style={{ background: `linear-gradient(135deg, ${primaryColor} 0%, ${primaryColor}dd 100%)` }}
>
<div className="relative z-10">
<div className="flex items-center gap-4 mb-20">
{organization.logoUrl ? (
<div className="w-12 h-12 bg-white/10 backdrop-blur-md rounded-xl flex items-center justify-center border border-white/10 p-2">
<img src={organization.logoUrl} alt="Logo" className="max-w-full max-h-full object-contain" />
</div>
) : (
<div className="w-12 h-12 bg-white/10 backdrop-blur-md rounded-xl flex items-center justify-center border border-white/10">
<ShieldCheck size={24} />
</div>
)}
<span className="text-2xl font-black tracking-tight uppercase leading-none">{organization.name}</span>
</div>
<div className="space-y-6 max-w-xl">
<motion.h1
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="text-5xl font-black leading-[0.9] tracking-tighter"
>
Gestão & <br />Transparência.
</motion.h1>
<motion.p
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="text-white/70 text-lg font-medium leading-relaxed max-w-md"
>
Controle absoluto e acesso público aos documentos oficiais da {organization.name}.
</motion.p>
</div>
</div>
<div className="grid grid-cols-2 gap-6 relative z-10">
<div className="p-6 bg-white/5 backdrop-blur-3xl rounded-[32px] border border-white/5 group hover:bg-white/10 transition-all duration-500">
<div className="w-10 h-10 bg-white/10 rounded-xl flex items-center justify-center mb-5 border border-white/10 group-hover:scale-110 transition-transform">
<FileText size={20} className="text-white/60" />
</div>
<h3 className="text-base font-black mb-1.5 uppercase tracking-widest text-[9px]">Documentos</h3>
<p className="text-xs text-white/40 font-bold leading-relaxed">Publicação segura de editais, relatórios e atas oficiais.</p>
</div>
<div className="p-6 bg-white/5 backdrop-blur-3xl rounded-[32px] border border-white/5 group hover:bg-white/10 transition-all duration-500">
<div className="w-10 h-10 bg-white/10 rounded-xl flex items-center justify-center mb-5 border border-white/10 group-hover:scale-110 transition-transform">
<CreditCard size={20} className="text-white/60" />
</div>
<h3 className="text-base font-black mb-1.5 uppercase tracking-widest text-[9px]">Prestação</h3>
<p className="text-xs text-white/40 font-bold leading-relaxed">Relatórios financeiros e transparência de custos governamentais.</p>
</div>
</div>
{/* Decorative Background Accents */}
<div className="absolute top-0 right-0 w-[800px] h-[800px] bg-white/10 rounded-full blur-[120px] -mr-96 -mt-96 opacity-20 pointer-events-none" />
<div className="absolute bottom-0 left-0 w-[500px] h-[500px] bg-black/10 rounded-full blur-[100px] -ml-40 -mb-40 opacity-20 pointer-events-none" />
</div>
{/* Right Side: Login Form */}
<div className="flex flex-col items-center justify-center p-10 lg:p-16 relative overflow-hidden bg-white">
<main className="w-full max-w-sm relative z-10">
<div className="mb-10 text-center lg:text-left">
<div className="inline-block px-2.5 py-0.5 bg-slate-50 border border-slate-100 rounded-full text-[9px] font-black uppercase tracking-[0.15em] text-slate-400 mb-4">
Admin Access
</div>
<h2 className="text-4xl font-black text-slate-900 tracking-tighter mb-2 leading-none italic">Acesso Restrito.</h2>
<p className="text-base text-slate-500 font-medium">Insira suas credenciais corporativas.</p>
</div>
<form className="space-y-6" onSubmit={handleSubmit}>
{error && (
<Alert variant="destructive" className="rounded-xl border-2 border-red-100 bg-red-50 text-red-600 font-bold p-5">
<AlertDescription className="text-sm">{error}</AlertDescription>
</Alert>
)}
<div className="space-y-2">
<Label htmlFor="email" className="text-[9px] font-black uppercase tracking-widest text-slate-400 ml-1">E-mail Administrativo</Label>
<div className="relative group">
<Mail className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-400 group-focus-within:text-slate-900 transition-colors" size={18} />
<Input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="admin@exemplo.com"
className="pl-12 h-11 bg-slate-50 border-none rounded-xl text-sm font-bold focus:bg-white focus:ring-4 focus:ring-slate-50 transition-all shadow-none"
required
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="password" className="text-[9px] font-black uppercase tracking-widest text-slate-400 ml-1">Senha de Segurança</Label>
<div className="relative group">
<Lock className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-400 group-focus-within:text-slate-900 transition-colors" size={18} />
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
className="pl-12 h-11 bg-slate-50 border-none rounded-xl text-sm font-bold focus:bg-white focus:ring-4 focus:ring-slate-50 transition-all shadow-none"
required
/>
</div>
</div>
<Button
type="submit"
disabled={isLoading}
className="w-full h-11 rounded-xl text-xs font-black uppercase tracking-widest italic shadow-none transition-all active:scale-95 flex items-center justify-center gap-3 group text-white"
style={{ backgroundColor: primaryColor }}
>
{isLoading ? "Validando..." : "Entrar no Painel"}
{!isLoading && <ArrowRight size={20} className="stroke-[3] group-hover:translate-x-1.5 transition-transform" />}
</Button>
</form>
<p className="mt-10 text-center text-slate-400 text-[10px] font-bold uppercase tracking-widest">
Segurança protegida por criptografia.
</p>
</main>
</div>
</div>
);
}

133
src/components/Sidebar.tsx Normal file
View File

@@ -0,0 +1,133 @@
"use client";
import React from "react";
import {
LayoutDashboard,
FileText,
Users,
Settings,
LogOut,
} 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";
type Organization = {
id: string;
name: string;
logoUrl: string | null;
primaryColor: string;
};
type UserType = {
id: string;
name: string | null;
email: string;
role: string;
};
export function Sidebar({
user,
organization
}: {
user: UserType;
organization: Organization
}) {
const pathname = usePathname();
const primaryColor = organization.primaryColor || "#2563eb";
const menuItems = [
{ href: "/dashboard", label: "Dashboard", icon: LayoutDashboard },
{ href: "/dashboard/documentos", label: "Documentos", icon: FileText },
{ href: "/dashboard/usuarios", label: "Usuários", icon: Users },
{ href: "/dashboard/configuracoes", label: "Configurações", icon: Settings },
];
return (
<aside className="w-72 bg-slate-50/50 border-r border-slate-200/60 flex flex-col h-screen sticky top-0 shrink-0 select-none">
<div className="p-8">
<div className="flex items-center gap-4">
{organization.logoUrl ? (
<div className="w-12 h-12 rounded-2xl bg-white border border-slate-200 p-2 flex items-center justify-center">
<img
src={organization.logoUrl}
alt="Logo"
className="max-w-full max-h-full object-contain"
/>
</div>
) : (
<div
className="w-12 h-12 rounded-2xl flex items-center justify-center text-white font-bold text-xl shrink-0 border border-white/20"
style={{ backgroundColor: primaryColor }}
>
{organization.name.charAt(0)}
</div>
)}
<div className="min-w-0">
<h1 className="text-base font-black text-slate-800 tracking-tight truncate leading-tight">
{organization.name}
</h1>
<p className="text-[10px] font-bold text-slate-400 uppercase tracking-widest mt-0.5">Gestão Fiscal</p>
</div>
</div>
</div>
<nav className="flex-1 px-4 space-y-1.5 overflow-y-auto pt-2">
{menuItems.map((item) => {
const isActive = pathname === item.href || (item.href !== "/dashboard" && pathname.startsWith(item.href));
return (
<Link
key={item.href}
href={item.href}
className={`flex items-center gap-3.5 px-5 py-3.5 rounded-2xl transition-all duration-300 group ${isActive
? "font-bold"
: "text-slate-500 hover:text-slate-900 hover:bg-white"
}`}
style={isActive ? { backgroundColor: `${primaryColor}10`, color: primaryColor } : {}}
>
<item.icon size={20} className={`transition-transform duration-300 ${isActive ? "scale-110" : "group-hover:scale-110 opacity-70 group-hover:opacity-100"}`} />
<span className="text-[13px] tracking-tight">{item.label}</span>
</Link>
);
})}
</nav>
<div className="p-6 mt-auto">
<div className="flex flex-col gap-4 p-5 rounded-[24px] bg-white border border-slate-200/80">
<Link
href="/dashboard/perfil"
className="flex items-center gap-3.5 group"
>
<div
className="w-10 h-10 rounded-full flex items-center justify-center text-white text-sm font-black shrink-0 border-2 border-slate-100"
style={{ backgroundColor: primaryColor }}
>
{user.name?.charAt(0) || user.email.charAt(0).toUpperCase()}
</div>
<div className="flex-1 min-w-0">
<p className="text-[13px] font-black text-slate-900 truncate leading-tight group-hover:underline decoration-2 underline-offset-2">
{user.name || "Usuário"}
</p>
<p className="text-[11px] font-medium text-slate-400 truncate mt-0.5">{user.email}</p>
</div>
</Link>
<div className="h-px bg-slate-100 mx-1" />
<form action={logout}>
<Button
type="submit"
variant="ghost"
className="w-full justify-start gap-3 h-11 px-4 rounded-xl text-slate-500 hover:text-red-600 hover:bg-red-50 text-[13px] font-bold transition-all duration-300"
title="Sair do sistema"
>
<LogOut size={18} />
Encerrar Sessão
</Button>
</form>
</div>
</div>
</aside>
);
}

View File

@@ -0,0 +1,139 @@
"use client";
import React from "react";
import { Search, Loader2, ChevronLeft, ChevronRight } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
interface StandardTableProps {
// Search
searchTerm?: string;
onSearchChange?: (val: string) => void;
searchPlaceholder?: string;
hideSearch?: boolean;
// Stats
totalItems: number;
showingCount: number;
itemName?: string;
// Pagination
currentPage: number;
totalPages: number;
onPageChange: (page: number) => void;
// Table Content
columns: React.ReactNode; // Os <th>
children: React.ReactNode; // Os <tr>
isLoading?: boolean;
}
export function StandardTable({
searchTerm,
onSearchChange,
searchPlaceholder = "Buscar...",
hideSearch = false,
totalItems,
showingCount,
itemName = "itens",
currentPage,
totalPages,
onPageChange,
columns,
children,
isLoading = false,
}: StandardTableProps) {
return (
<div className="bg-white rounded-xl overflow-hidden shadow-none flex flex-col">
{/* Table Header with Search and Stats */}
<div className="bg-white px-2 py-4 flex flex-col md:flex-row justify-between items-center gap-4 border-b border-slate-100/60 transition-all">
{!hideSearch && (
<div className="relative w-full md:w-96 group">
<div className="absolute inset-y-0 left-0 pl-3.5 flex items-center pointer-events-none">
<Search
className="text-slate-400 group-focus-within:text-red-500 transition-colors duration-300"
size={16}
strokeWidth={2.5}
/>
</div>
<Input
placeholder={searchPlaceholder}
value={searchTerm}
onChange={(e) => onSearchChange?.(e.target.value)}
className="h-10 pl-11 pr-4 bg-slate-50/50 border-transparent rounded-lg text-sm font-medium text-slate-800 placeholder:text-slate-400 focus:bg-white focus:border-slate-200 focus:ring-0 transition-all duration-300 w-full"
/>
</div>
)}
<div className="flex items-center gap-2.5 px-3 py-1.5 bg-white border border-slate-100 rounded-lg">
<p className="text-[11px] font-bold text-slate-500 flex items-center gap-2">
<span className="text-slate-900">{showingCount}</span>
<span className="text-slate-300">/</span>
<span className="text-slate-900">{totalItems}</span>
<span className="ml-1 font-medium">{itemName}</span>
</p>
</div>
</div>
{/* The Table Itself */}
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse">
<thead>
<tr className="bg-slate-50/30">
{columns}
</tr>
</thead>
<tbody className="divide-y divide-slate-100/50">
{isLoading ? (
<tr>
<td colSpan={100} className="px-6 py-20 text-center">
<div className="flex flex-col items-center gap-3">
<Loader2 className="animate-spin text-red-500" size={24} />
<p className="text-xs font-medium text-slate-400">Processando...</p>
</div>
</td>
</tr>
) : showingCount === 0 ? (
<tr>
<td colSpan={100} className="px-6 py-20 text-center text-slate-400 font-medium text-xs">
Nenhum {itemName} encontrado.
</td>
</tr>
) : (
children
)}
</tbody>
</table>
</div>
{/* Table Footer with Pagination */}
{totalPages > 1 && (
<div className="p-4 bg-white flex items-center justify-between border-t border-slate-100/60 mt-auto">
<span className="text-xs font-medium text-slate-500">
Página <span className="text-slate-900 font-bold">{currentPage}</span> de <span className="text-slate-900 font-bold">{totalPages}</span>
</span>
<div className="flex items-center gap-1">
<Button
variant="ghost"
disabled={currentPage === 1}
onClick={() => onPageChange(currentPage - 1)}
className="h-9 px-4 rounded-lg font-bold text-xs text-slate-500 hover:text-red-500 hover:bg-red-50/50 transition-all disabled:opacity-30"
>
<ChevronLeft size={16} className="mr-1" />
Anterior
</Button>
<Button
variant="ghost"
disabled={currentPage === totalPages}
onClick={() => onPageChange(currentPage + 1)}
className="h-9 px-4 rounded-lg font-bold text-xs text-slate-500 hover:text-red-500 hover:bg-red-50/50 transition-all disabled:opacity-30"
>
Próximo
<ChevronRight size={16} className="ml-1" />
</Button>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,145 @@
"use client"
import * as React from "react"
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
const AlertDialog = AlertDialogPrimitive.Root
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
const AlertDialogPortal = AlertDialogPrimitive.Portal
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-slate-900/40 backdrop-blur-[2px] data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
ref={ref}
/>
))
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-md translate-x-[-50%] translate-y-[-50%] gap-0 border-2 border-slate-100 bg-white p-0 shadow-2xl duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] rounded-[24px] overflow-hidden",
className
)}
{...props}
/>
</AlertDialogPortal>
))
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
const AlertDialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 p-8 text-left bg-slate-50/50 border-b border-slate-50",
className
)}
{...props}
/>
)
AlertDialogHeader.displayName = "AlertDialogHeader"
const AlertDialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-3 p-8 bg-white",
className
)}
{...props}
/>
)
AlertDialogFooter.displayName = "AlertDialogFooter"
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn("text-xl font-black text-slate-900 tracking-tight italic leading-none", className)}
{...props}
/>
))
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn("text-sm text-slate-500 font-medium", className)}
{...props}
/>
))
AlertDialogDescription.displayName =
AlertDialogPrimitive.Description.displayName
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action
ref={ref}
className={cn(
buttonVariants(),
"h-10 px-6 rounded-lg font-bold text-[10px] uppercase tracking-widest shadow-none text-white",
className
)}
{...props}
/>
))
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(
buttonVariants({ variant: "ghost" }),
"h-10 px-6 rounded-lg font-bold text-[10px] uppercase tracking-widest mt-2 sm:mt-0",
className
)}
{...props}
/>
))
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}

View File

@@ -0,0 +1,66 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
)
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
className
)}
{...props}
/>
)
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
className
)}
{...props}
/>
)
}
export { Alert, AlertTitle, AlertDescription }

View File

@@ -0,0 +1,53 @@
"use client"
import * as React from "react"
import * as AvatarPrimitive from "@radix-ui/react-avatar"
import { cn } from "@/lib/utils"
function Avatar({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
className={cn(
"relative flex size-8 shrink-0 overflow-hidden rounded-full",
className
)}
{...props}
/>
)
}
function AvatarImage({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn("aspect-square size-full", className)}
{...props}
/>
)
}
function AvatarFallback({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"bg-muted flex size-full items-center justify-center rounded-full",
className
)}
{...props}
/>
)
}
export { Avatar, AvatarImage, AvatarFallback }

View File

@@ -0,0 +1,46 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span"
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }

View File

@@ -0,0 +1,62 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

View File

@@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View File

@@ -0,0 +1,32 @@
"use client"
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { CheckIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }

View File

@@ -0,0 +1,143 @@
"use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

View File

@@ -0,0 +1,257 @@
"use client"
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}

View File

@@ -0,0 +1,21 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:ring-0 outline-none focus:border-slate-300",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{...props}
/>
)
}
export { Input }

View File

@@ -0,0 +1,24 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }

View File

@@ -0,0 +1,190 @@
"use client"
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "item-aligned",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span
data-slot="select-item-indicator"
className="absolute right-2 flex size-3.5 items-center justify-center"
>
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}

View File

@@ -0,0 +1,31 @@
"use client"
import * as React from "react"
import * as SwitchPrimitive from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
function Switch({
className,
...props
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
return (
<SwitchPrimitive.Root
data-slot="switch"
className={cn(
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className={cn(
"bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitive.Root>
)
}
export { Switch }

116
src/components/ui/table.tsx Normal file
View File

@@ -0,0 +1,116 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
)
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
)
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
)
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
className
)}
{...props}
/>
)
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("text-muted-foreground mt-4 text-sm", className)}
{...props}
/>
)
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

View File

@@ -0,0 +1,66 @@
"use client"
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
function Tabs({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function TabsList({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.List>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
className={cn(
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
className
)}
{...props}
/>
)
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent }

View File

@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
{...props}
/>
)
}
export { Textarea }

View File

@@ -0,0 +1,61 @@
"use client"
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/lib/utils"
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
)
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return (
<TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
</TooltipProvider>
)
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

9
src/lib/db.ts Normal file
View File

@@ -0,0 +1,9 @@
import { PrismaClient } from "@prisma/client";
const globalForPrisma = global as unknown as { prisma: PrismaClient };
export const prisma =
globalForPrisma.prisma ||
new PrismaClient();
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;

42
src/lib/init-minio.ts Normal file
View File

@@ -0,0 +1,42 @@
import { CreateBucketCommand, HeadBucketCommand, PutBucketPolicyCommand } from "@aws-sdk/client-s3";
import { s3Client, BUCKET_NAME } from "./s3";
const publicPolicy = {
Version: "2012-10-17",
Statement: [
{
Effect: "Allow",
Principal: "*",
Action: ["s3:GetObject"],
Resource: [`arn:aws:s3:::${BUCKET_NAME}/*`],
},
],
};
export async function initMinio() {
try {
await s3Client.send(new HeadBucketCommand({ Bucket: BUCKET_NAME }));
console.log(`Bucket ${BUCKET_NAME} already exists.`);
} catch (error: any) {
if (error.name === "NotFound" || error.$metadata?.httpStatusCode === 404) {
console.log(`Creating bucket ${BUCKET_NAME}...`);
await s3Client.send(new CreateBucketCommand({ Bucket: BUCKET_NAME }));
console.log(`Bucket ${BUCKET_NAME} created successfully.`);
} else {
console.error("Error checking/creating bucket:", error);
}
}
// Configurar política pública para leitura
try {
await s3Client.send(
new PutBucketPolicyCommand({
Bucket: BUCKET_NAME,
Policy: JSON.stringify(publicPolicy),
})
);
console.log(`Bucket ${BUCKET_NAME} configured as public.`);
} catch (error) {
console.error("Error setting bucket policy:", error);
}
}

41
src/lib/s3.ts Normal file
View File

@@ -0,0 +1,41 @@
import { S3Client, DeleteObjectCommand } from "@aws-sdk/client-s3";
export const s3Client = new S3Client({
endpoint: `http://${process.env.MINIO_ENDPOINT}:${process.env.MINIO_PORT}`,
region: "us-east-1",
credentials: {
accessKeyId: process.env.MINIO_ACCESS_KEY || "admin",
secretAccessKey: process.env.MINIO_SECRET_KEY || "password123",
},
forcePathStyle: true, // Required for MinIO
});
export const BUCKET_NAME = process.env.MINIO_BUCKET || "portal-transparencia";
// Função para deletar arquivo do MinIO
export async function deleteFile(fileUrl: string) {
try {
// Extrair o key (nome do arquivo) da URL
// URL format: http://host:port/bucket/filename
const url = new URL(fileUrl);
const pathParts = url.pathname.split('/');
// Remove empty string and bucket name, get the file key
const key = pathParts.slice(2).join('/');
if (!key) {
console.error("Could not extract file key from URL:", fileUrl);
return { success: false };
}
await s3Client.send(new DeleteObjectCommand({
Bucket: BUCKET_NAME,
Key: key,
}));
console.log(`File deleted from MinIO: ${key}`);
return { success: true };
} catch (error) {
console.error("Error deleting file from MinIO:", error);
return { success: false };
}
}

6
src/lib/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}