first commit
All checks were successful
CI / backend (push) Successful in 13m19s
CI / frontend (push) Successful in 11m3s
CI / docker (push) Successful in 1m58s

This commit is contained in:
Cauê Faleiros
2026-06-03 16:31:42 -03:00
commit 8c7e5fbbe4
92 changed files with 18226 additions and 0 deletions

4
frontend/.dockerignore Normal file
View File

@@ -0,0 +1,4 @@
.git
node_modules
dist
*.log

19
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,19 @@
FROM node:22-alpine AS build
WORKDIR /app
ARG VITE_API_BASE_URL
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL
COPY package.json package-lock.json* ./
RUN npm install
COPY . .
RUN npm run build
FROM nginx:1.27-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80

25
frontend/components.json Normal file
View File

@@ -0,0 +1,25 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "base-nova",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
}

13
frontend/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#f97316" />
<title>Mira</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

6
frontend/metadata.json Normal file
View File

@@ -0,0 +1,6 @@
{
"name": "",
"description": "",
"requestFramePermissions": [],
"majorCapabilities": ["MAJOR_CAPABILITY_SERVER_SIDE_GEMINI_API"]
}

22
frontend/nginx.conf Normal file
View File

@@ -0,0 +1,22 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
location / {
try_files $uri $uri/ /index.html;
}
location ~* \.(js|css|png|jpg|jpeg|gif|svg|webp|ico)$ {
expires 7d;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}
}

7720
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

47
frontend/package.json Normal file
View File

@@ -0,0 +1,47 @@
{
"name": "mira-frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite --port=5173 --host=0.0.0.0",
"build": "vite build",
"preview": "vite preview",
"clean": "rm -rf dist server.js",
"lint": "tsc --noEmit"
},
"dependencies": {
"@base-ui/react": "^1.5.0",
"@fontsource-variable/geist": "^5.2.9",
"@google/genai": "^2.4.0",
"@tailwindcss/vite": "^4.1.14",
"@vitejs/plugin-react": "^5.0.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.4.0",
"dotenv": "^17.2.3",
"express": "^4.21.2",
"lucide-react": "^0.546.0",
"motion": "^12.23.24",
"next-themes": "^0.4.6",
"react": "^19.0.1",
"react-day-picker": "^10.0.1",
"react-dom": "^19.0.1",
"react-router-dom": "^7.16.0",
"shadcn": "^4.10.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.6.0",
"tw-animate-css": "^1.4.0",
"vite": "^6.2.3"
},
"devDependencies": {
"@types/express": "^4.17.21",
"@types/node": "^22.14.0",
"autoprefixer": "^10.4.21",
"esbuild": "^0.25.0",
"tailwindcss": "^4.1.14",
"tsx": "^4.21.0",
"typescript": "~5.8.2",
"vite": "^6.2.3"
}
}

1
frontend/public/.gitkeep Normal file
View File

@@ -0,0 +1 @@

200
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,200 @@
import { useEffect, useState } from "react";
import { ThemeProvider } from "./components/theme-provider";
import { LoginView } from "./views/LoginView";
import { AcceptInvitationView } from "./views/AcceptInvitationView";
import { DashboardView } from "./views/DashboardView";
import { CalendarView } from "./views/CalendarView";
import { UsersView } from "./views/UsersView";
import { ClientsView } from "./views/ClientsView";
import { ClientDashboardView } from "./views/ClientDashboardView";
import { PostDetailView } from "./views/PostDetailView";
import { Topbar } from "./components/layout/Topbar";
import { Sidebar } from "./components/layout/Sidebar";
import { clearStoredToken, getStoredToken, loadCurrentUser, type AuthUser } from "@/lib/auth";
import { listClients, type Client } from "@/lib/clients";
export default function App() {
const [user, setUser] = useState<AuthUser | null>(null);
const [clients, setClients] = useState<Client[]>([]);
const [selectedClientId, setSelectedClientId] = useState<string | null>(null);
const [isCheckingSession, setIsCheckingSession] = useState(true);
const [clientsError, setClientsError] = useState("");
const [currentView, setCurrentView] = useState("dashboard"); // 'dashboard' | 'calendar' | 'users'
const [selectedPostId, setSelectedPostId] = useState<string | null>(null);
const invitationToken =
window.location.pathname === "/accept-invitation"
? new URLSearchParams(window.location.search).get("token")
: null;
async function refreshClients(token = getStoredToken()) {
if (!token) return;
try {
setClientsError("");
const loadedClients = await listClients(token);
setClients(loadedClients);
setSelectedClientId((current) => {
if (!current) return null;
return loadedClients.some((client) => client.id === current) ? current : null;
});
} catch (err) {
setClientsError(err instanceof Error ? err.message : "Nao foi possivel carregar clientes.");
}
}
useEffect(() => {
const token = getStoredToken();
if (!token) {
setIsCheckingSession(false);
return;
}
loadCurrentUser(token)
.then((loadedUser) => {
setUser(loadedUser);
return refreshClients(token);
})
.catch(() => {
clearStoredToken();
setUser(null);
})
.finally(() => setIsCheckingSession(false));
}, []);
function handleLogout() {
clearStoredToken();
setUser(null);
setClients([]);
setSelectedClientId(null);
setCurrentView("dashboard");
setSelectedPostId(null);
}
function handleLogin(loadedUser: AuthUser) {
setUser(loadedUser);
void refreshClients();
}
function handleInvitationAccepted(loadedUser: AuthUser) {
setUser(loadedUser);
void refreshClients();
}
function handleClientCreated(client: Client) {
setClients((current) => [...current, client].sort((a, b) => a.name.localeCompare(b.name)));
setSelectedClientId(client.id);
}
function handleClientUpdated(client: Client) {
setClients((current) => current.map((item) => (item.id === client.id ? client : item)).sort((a, b) => a.name.localeCompare(b.name)));
}
function handleClientDeleted(clientId: string) {
setClients((current) => current.filter((client) => client.id !== clientId));
setSelectedClientId((current) => (current === clientId ? null : current));
setCurrentView((current) => (current === "client-dashboard" && selectedClientId === clientId ? "clients" : current));
}
function handleClientDashboardSelect(clientId: string) {
setSelectedClientId(clientId);
setCurrentView("client-dashboard");
}
function handlePostSelect(postId: string) {
setSelectedPostId(postId);
setCurrentView("post-detail");
}
if (isCheckingSession) {
return (
<ThemeProvider defaultTheme="system" storageKey="mira-theme">
<div className="min-h-screen bg-background text-foreground flex items-center justify-center">
<div className="text-sm text-muted-foreground">Carregando sessao...</div>
</div>
</ThemeProvider>
);
}
if (!user) {
if (invitationToken) {
return (
<ThemeProvider defaultTheme="system" storageKey="mira-theme">
<AcceptInvitationView token={invitationToken} onAccepted={handleInvitationAccepted} />
</ThemeProvider>
);
}
return (
<ThemeProvider defaultTheme="system" storageKey="mira-theme">
<LoginView onLogin={handleLogin} />
</ThemeProvider>
);
}
return (
<ThemeProvider defaultTheme="system" storageKey="mira-theme">
<div className="min-h-screen bg-background text-foreground flex flex-col md:flex-row">
{/* Mobile Topbar for responsive */}
<div className="md:hidden">
<Topbar
onViewChange={setCurrentView}
currentView={currentView}
user={user}
onLogout={handleLogout}
clients={clients}
selectedClientId={selectedClientId}
onClientSelect={handleClientDashboardSelect}
clientsError={clientsError}
/>
</div>
{/* Desktop Sidebar */}
<div className="hidden md:flex sticky top-0 h-screen">
<Sidebar
onViewChange={setCurrentView}
currentView={currentView}
user={user}
onLogout={handleLogout}
clients={clients}
selectedClientId={selectedClientId}
onClientSelect={handleClientDashboardSelect}
clientsError={clientsError}
/>
</div>
{/* Main Content Area */}
<div className="flex-1 overflow-x-hidden">
<main className="p-4 md:p-8 max-w-7xl mx-auto w-full">
{currentView === "dashboard" && (
<DashboardView onViewChange={setCurrentView} onPostSelect={handlePostSelect} />
)}
{currentView === "clients" && (
<ClientsView
clients={clients}
onClientCreated={handleClientCreated}
onClientUpdated={handleClientUpdated}
onClientDeleted={handleClientDeleted}
onClientSelect={setSelectedClientId}
/>
)}
{currentView === "client-dashboard" && selectedClientId && (
<ClientDashboardView
client={clients.find((client) => client.id === selectedClientId) ?? null}
selectedClientId={selectedClientId}
onViewChange={setCurrentView}
onPostSelect={handlePostSelect}
/>
)}
{currentView === "post-detail" && selectedPostId && (
<PostDetailView itemId={selectedPostId} onBack={() => setCurrentView("calendar")} />
)}
{currentView === "calendar" && (
<CalendarView clients={clients} selectedClientId={selectedClientId} user={user} onPostSelect={handlePostSelect} />
)}
{currentView === "users" && <UsersView clients={clients} />}
</main>
</div>
</div>
</ThemeProvider>
);
}

View File

@@ -0,0 +1,153 @@
import { LayoutDashboard, CalendarDays, Users, Settings, LogOut, Search, Building2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { ModeToggle } from "@/components/mode-toggle";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Input } from "@/components/ui/input";
import type { AuthUser } from "@/lib/auth";
import type { Client } from "@/lib/clients";
import { useMemo, useState } from "react";
type NavProps = {
currentView: string;
onViewChange: (target: string) => void;
user: AuthUser;
onLogout: () => void;
clients: Client[];
selectedClientId: string | null;
onClientSelect: (clientId: string) => void;
clientsError: string;
};
export function Sidebar({ currentView, onViewChange, user, onLogout, clients, selectedClientId, onClientSelect, clientsError }: NavProps) {
const [clientSearch, setClientSearch] = useState("");
const navItems = [
{ id: "dashboard", label: "Dashboard Semanal", icon: LayoutDashboard },
{ id: "clients", label: "Clientes", icon: Building2 },
{ id: "calendar", label: "Calendário", icon: CalendarDays },
{ id: "users", label: "Pessoas", icon: Users },
];
const filteredClients = useMemo(() => {
const term = clientSearch.trim().toLowerCase();
if (!term) return clients;
return clients.filter((client) => client.name.toLowerCase().includes(term));
}, [clients, clientSearch]);
return (
<aside className="w-64 border-r border-border/40 bg-card h-screen flex flex-col sticky top-0">
<div className="h-16 flex items-center px-6 border-b border-border/40">
<div className="h-8 w-8 bg-primary rounded-md flex items-center justify-center mr-3">
<span className="text-primary-foreground font-bold leading-none">M</span>
</div>
<span className="font-semibold text-lg tracking-tight">Mira</span>
</div>
<div className="p-4 border-b border-border/40">
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Buscar clientes..."
className="pl-9 h-9 bg-muted/50 border-none shadow-none focus-visible:ring-1"
value={clientSearch}
onChange={(event) => setClientSearch(event.target.value)}
/>
</div>
</div>
<nav className="flex-1 p-4 space-y-1 overflow-y-auto">
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2 ml-2">Agência</div>
{navItems.map((item) => {
const Icon = item.icon;
const isActive = currentView === item.id;
return (
<button
key={item.id}
onClick={() => onViewChange(item.id)}
className={cn(
"w-full flex items-center space-x-3 px-3 py-2 rounded-md text-sm transition-colors",
isActive
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:bg-muted hover:text-foreground"
)}
>
<Icon className="h-4 w-4" />
<span>{item.label}</span>
</button>
);
})}
<div className="pt-5">
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2 ml-2">Dashboards</div>
{clientsError && (
<div className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
{clientsError}
</div>
)}
{!clientsError && filteredClients.length === 0 && (
<div className="rounded-md border border-dashed border-border/60 px-3 py-3 text-xs text-muted-foreground">
{clients.length === 0 ? "Nenhum cliente cadastrado." : "Nenhum cliente encontrado."}
</div>
)}
<div className="space-y-1">
{filteredClients.map((client) => {
const isSelected = currentView === "client-dashboard" && selectedClientId === client.id;
return (
<button
key={client.id}
onClick={() => onClientSelect(client.id)}
className={cn(
"w-full flex items-center space-x-3 px-3 py-2 rounded-md text-sm transition-colors",
isSelected
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:bg-muted hover:text-foreground"
)}
>
<Building2 className="h-4 w-4" />
<span className="truncate">{client.name}</span>
</button>
);
})}
</div>
</div>
</nav>
<div className="p-4 border-t border-border/40 space-y-4">
<div className="flex items-center justify-between">
<ModeToggle />
<Button variant="ghost" size="icon" className="text-muted-foreground h-9 w-9">
<Settings className="h-4 w-4" />
</Button>
</div>
<div className="flex items-center gap-3">
<Avatar className="h-9 w-9 border border-border/50">
<AvatarImage src="" />
<AvatarFallback className="bg-primary/5 text-primary text-xs">{initials(user.name)}</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium leading-none truncate">{user.name}</p>
<p className="text-xs text-muted-foreground truncate mt-1">{roleLabel(user.role)}</p>
</div>
<Button variant="ghost" size="icon" className="h-8 w-8 text-muted-foreground hover:text-destructive" onClick={onLogout}>
<LogOut className="h-4 w-4" />
</Button>
</div>
</div>
</aside>
);
}
function roleLabel(role: AuthUser["role"]) {
if (role === "super_admin") return "Super admin";
if (role === "agency_user") return "Agencia";
return "Cliente";
}
function initials(name: string) {
return name
.split(" ")
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0]?.toUpperCase())
.join("") || "M";
}

View File

@@ -0,0 +1,74 @@
import { LogOut, Menu } from "lucide-react";
import { buttonVariants } from "@/components/ui/button";
import { Sheet, SheetContent, SheetTrigger, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { Sidebar } from "./Sidebar";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import type { AuthUser } from "@/lib/auth";
import type { Client } from "@/lib/clients";
type NavProps = {
currentView: string;
onViewChange: (target: string) => void;
user: AuthUser;
onLogout: () => void;
clients: Client[];
selectedClientId: string | null;
onClientSelect: (clientId: string) => void;
clientsError: string;
};
export function Topbar({ currentView, onViewChange, user, onLogout, clients, selectedClientId, onClientSelect, clientsError }: NavProps) {
return (
<header className="h-14 border-b border-border/40 bg-card flex items-center justify-between px-4 sticky top-0 z-30">
<div className="flex items-center gap-3">
<Sheet>
<SheetTrigger className={buttonVariants({ variant: "ghost", size: "icon", className: "-ml-2 h-9 w-9" })}>
<Menu className="h-5 w-5" />
<span className="sr-only">Toggle Menu</span>
</SheetTrigger>
<SheetContent side="left" className="p-0 w-72">
<SheetHeader className="sr-only">
<SheetTitle>Navigation Menu</SheetTitle>
</SheetHeader>
<Sidebar
currentView={currentView}
onViewChange={onViewChange}
user={user}
onLogout={onLogout}
clients={clients}
selectedClientId={selectedClientId}
onClientSelect={onClientSelect}
clientsError={clientsError}
/>
</SheetContent>
</Sheet>
<div className="flex items-center gap-2">
<div className="h-6 w-6 rounded bg-primary flex items-center justify-center">
<span className="text-primary-foreground font-bold text-[10px] leading-none">M</span>
</div>
<span className="font-semibold text-base tracking-tight">Mira</span>
</div>
</div>
<div className="flex items-center gap-2">
<Avatar className="h-8 w-8 border border-border/50">
<AvatarFallback className="bg-primary/5 text-primary text-xs">{initials(user.name)}</AvatarFallback>
</Avatar>
<Button variant="ghost" size="icon" className="h-8 w-8 text-muted-foreground" onClick={onLogout}>
<LogOut className="h-4 w-4" />
</Button>
</div>
</header>
);
}
function initials(name: string) {
return name
.split(" ")
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0]?.toUpperCase())
.join("") || "M";
}

View File

@@ -0,0 +1,35 @@
import { Moon, Sun } from "lucide-react"
import { buttonVariants } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { useTheme } from "@/components/theme-provider"
export function ModeToggle() {
const { setTheme } = useTheme()
return (
<DropdownMenu>
<DropdownMenuTrigger className={buttonVariants({ variant: "ghost", size: "icon" })}>
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only">Toggle theme</span>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme("light")}>
Claro
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("dark")}>
Escuro
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("system")}>
Sistema
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@@ -0,0 +1,73 @@
import { createContext, useContext, useEffect, useState, type ReactNode } from "react"
type Theme = "dark" | "light" | "system"
type ThemeProviderProps = {
children: ReactNode
defaultTheme?: Theme
storageKey?: string
}
type ThemeProviderState = {
theme: Theme
setTheme: (theme: Theme) => void
}
const initialState: ThemeProviderState = {
theme: "system",
setTheme: () => null,
}
const ThemeProviderContext = createContext<ThemeProviderState>(initialState)
export function ThemeProvider({
children,
defaultTheme = "system",
storageKey = "vite-ui-theme",
...props
}: ThemeProviderProps) {
const [theme, setTheme] = useState<Theme>(
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme
)
useEffect(() => {
const root = window.document.documentElement
root.classList.remove("light", "dark")
if (theme === "system") {
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)")
.matches
? "dark"
: "light"
root.classList.add(systemTheme)
return
}
root.classList.add(theme)
}, [theme])
const value = {
theme,
setTheme: (theme: Theme) => {
localStorage.setItem(storageKey, theme)
setTheme(theme)
},
}
return (
<ThemeProviderContext.Provider {...props} value={value}>
{children}
</ThemeProviderContext.Provider>
)
}
export const useTheme = () => {
const context = useContext(ThemeProviderContext)
if (context === undefined)
throw new Error("useTheme must be used within a ThemeProvider")
return context
}

View File

@@ -0,0 +1,109 @@
"use client"
import * as React from "react"
import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar"
import { cn } from "@/lib/utils"
function Avatar({
className,
size = "default",
...props
}: AvatarPrimitive.Root.Props & {
size?: "default" | "sm" | "lg"
}) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
data-size={size}
className={cn(
"group/avatar relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten",
className
)}
{...props}
/>
)
}
function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn(
"aspect-square size-full rounded-full object-cover",
className
)}
{...props}
/>
)
}
function AvatarFallback({
className,
...props
}: AvatarPrimitive.Fallback.Props) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs",
className
)}
{...props}
/>
)
}
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="avatar-badge"
className={cn(
"absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground bg-blend-color ring-2 ring-background select-none",
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
className
)}
{...props}
/>
)
}
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group"
className={cn(
"group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background",
className
)}
{...props}
/>
)
}
function AvatarGroupCount({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group-count"
className={cn(
"relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
className
)}
{...props}
/>
)
}
export {
Avatar,
AvatarImage,
AvatarFallback,
AvatarGroup,
AvatarGroupCount,
AvatarBadge,
}

View File

@@ -0,0 +1,52 @@
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
secondary:
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
destructive:
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
outline:
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
ghost:
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
link: "text-primary underline-offset-4 hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
render,
...props
}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
return useRender({
defaultTagName: "span",
props: mergeProps<"span">(
{
className: cn(badgeVariants({ variant }), className),
},
props
),
render,
state: {
slot: "badge",
variant,
},
})
}
export { Badge, badgeVariants }

View File

@@ -0,0 +1,58 @@
import { Button as ButtonPrimitive } from "@base-ui/react/button"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/80",
outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default:
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
icon: "size-8",
"icon-xs":
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm":
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
...props
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
return (
<ButtonPrimitive
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

View File

@@ -0,0 +1,219 @@
import * as React from "react"
import {
DayPicker,
getDefaultClassNames,
type DayButton,
type Locale,
} from "react-day-picker"
import { cn } from "@/lib/utils"
import { Button, buttonVariants } from "@/components/ui/button"
import { ChevronLeftIcon, ChevronRightIcon, ChevronDownIcon } from "lucide-react"
function Calendar({
className,
classNames,
showOutsideDays = true,
captionLayout = "label",
buttonVariant = "ghost",
locale,
formatters,
components,
...props
}: React.ComponentProps<typeof DayPicker> & {
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
}) {
const defaultClassNames = getDefaultClassNames()
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn(
"group/calendar bg-background p-2 [--cell-radius:var(--radius-md)] [--cell-size:--spacing(7)] in-data-[slot=card-content]:bg-transparent in-data-[slot=popover-content]:bg-transparent",
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
className
)}
captionLayout={captionLayout}
locale={locale}
formatters={{
formatMonthDropdown: (date) =>
date.toLocaleString(locale?.code, { month: "short" }),
...formatters,
}}
classNames={{
root: cn("w-fit", defaultClassNames.root),
months: cn(
"relative flex flex-col gap-4 md:flex-row",
defaultClassNames.months
),
month: cn("flex w-full flex-col gap-4", defaultClassNames.month),
nav: cn(
"absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1",
defaultClassNames.nav
),
button_previous: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) p-0 select-none aria-disabled:opacity-50",
defaultClassNames.button_previous
),
button_next: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) p-0 select-none aria-disabled:opacity-50",
defaultClassNames.button_next
),
month_caption: cn(
"flex h-(--cell-size) w-full items-center justify-center px-(--cell-size)",
defaultClassNames.month_caption
),
dropdowns: cn(
"flex h-(--cell-size) w-full items-center justify-center gap-1.5 text-sm font-medium",
defaultClassNames.dropdowns
),
dropdown_root: cn(
"relative rounded-(--cell-radius)",
defaultClassNames.dropdown_root
),
dropdown: cn(
"absolute inset-0 bg-popover opacity-0",
defaultClassNames.dropdown
),
caption_label: cn(
"font-medium select-none",
captionLayout === "label"
? "text-sm"
: "flex items-center gap-1 rounded-(--cell-radius) text-sm [&>svg]:size-3.5 [&>svg]:text-muted-foreground",
defaultClassNames.caption_label
),
table: "w-full border-collapse",
weekdays: cn("flex", defaultClassNames.weekdays),
weekday: cn(
"flex-1 rounded-(--cell-radius) text-[0.8rem] font-normal text-muted-foreground select-none",
defaultClassNames.weekday
),
week: cn("mt-2 flex w-full", defaultClassNames.week),
week_number_header: cn(
"w-(--cell-size) select-none",
defaultClassNames.week_number_header
),
week_number: cn(
"text-[0.8rem] text-muted-foreground select-none",
defaultClassNames.week_number
),
day: cn(
"group/day relative aspect-square h-full w-full rounded-(--cell-radius) p-0 text-center select-none [&:last-child[data-selected=true]_button]:rounded-r-(--cell-radius)",
props.showWeekNumber
? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-(--cell-radius)"
: "[&:first-child[data-selected=true]_button]:rounded-l-(--cell-radius)",
defaultClassNames.day
),
range_start: cn(
"relative isolate z-0 rounded-l-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:right-0 after:w-4 after:bg-muted",
defaultClassNames.range_start
),
range_middle: cn("rounded-none", defaultClassNames.range_middle),
range_end: cn(
"relative isolate z-0 rounded-r-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:left-0 after:w-4 after:bg-muted",
defaultClassNames.range_end
),
today: cn(
"rounded-(--cell-radius) bg-muted text-foreground data-[selected=true]:rounded-none",
defaultClassNames.today
),
outside: cn(
"text-muted-foreground aria-selected:text-muted-foreground",
defaultClassNames.outside
),
disabled: cn(
"text-muted-foreground opacity-50",
defaultClassNames.disabled
),
hidden: cn("invisible", defaultClassNames.hidden),
...classNames,
}}
components={{
Root: ({ className, rootRef, ...props }) => {
return (
<div
data-slot="calendar"
ref={rootRef}
className={cn(className)}
{...props}
/>
)
},
Chevron: ({ className, orientation, ...props }) => {
if (orientation === "left") {
return (
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
)
}
if (orientation === "right") {
return (
<ChevronRightIcon className={cn("size-4", className)} {...props} />
)
}
return (
<ChevronDownIcon className={cn("size-4", className)} {...props} />
)
},
DayButton: ({ ...props }) => (
<CalendarDayButton locale={locale} {...props} />
),
WeekNumber: ({ children, ...props }) => {
return (
<td {...props}>
<div className="flex size-(--cell-size) items-center justify-center text-center">
{children}
</div>
</td>
)
},
...components,
}}
{...props}
/>
)
}
function CalendarDayButton({
className,
day,
modifiers,
locale,
...props
}: React.ComponentProps<typeof DayButton> & { locale?: Partial<Locale> }) {
const defaultClassNames = getDefaultClassNames()
const ref = React.useRef<HTMLButtonElement>(null)
React.useEffect(() => {
if (modifiers.focused) ref.current?.focus()
}, [modifiers.focused])
return (
<Button
variant="ghost"
size="icon"
data-day={day.date.toLocaleDateString(locale?.code)}
data-selected-single={
modifiers.selected &&
!modifiers.range_start &&
!modifiers.range_end &&
!modifiers.range_middle
}
data-range-start={modifiers.range_start}
data-range-end={modifiers.range_end}
data-range-middle={modifiers.range_middle}
className={cn(
"relative isolate z-10 flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 border-0 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-[3px] group-data-[focused=true]/day:ring-ring/50 data-[range-end=true]:rounded-(--cell-radius) data-[range-end=true]:rounded-r-(--cell-radius) data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground data-[range-middle=true]:rounded-none data-[range-middle=true]:bg-muted data-[range-middle=true]:text-foreground data-[range-start=true]:rounded-(--cell-radius) data-[range-start=true]:rounded-l-(--cell-radius) data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground dark:hover:text-foreground [&>span]:text-xs [&>span]:opacity-70",
defaultClassNames.day,
className
)}
{...props}
/>
)
}
export { Calendar, CalendarDayButton }

View File

@@ -0,0 +1,103 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({
className,
size = "default",
...props
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
return (
<div
data-slot="card"
data-size={size}
className={cn(
"group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn(
"font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
className
)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", 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-4 group-data-[size=sm]/card:px-3", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn(
"flex items-center rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/card:p-3",
className
)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View File

@@ -0,0 +1,27 @@
import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox"
import { cn } from "@/lib/utils"
import { CheckIcon } from "lucide-react"
function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer relative flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input transition-colors outline-none group-has-disabled/field:opacity-50 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none [&>svg]:size-3.5"
>
<CheckIcon
/>
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }

View File

@@ -0,0 +1,158 @@
import * as React from "react"
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: DialogPrimitive.Backdrop.Props) {
return (
<DialogPrimitive.Backdrop
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: DialogPrimitive.Popup.Props & {
showCloseButton?: boolean
}) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Popup
data-slot="dialog-content"
className={cn(
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
render={
<Button
variant="ghost"
className="absolute top-2 right-2"
size="icon-sm"
/>
}
>
<XIcon
/>
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Popup>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close render={<Button variant="outline" />}>
Close
</DialogPrimitive.Close>
)}
</div>
)
}
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn(
"font-heading text-base leading-none font-medium",
className
)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: DialogPrimitive.Description.Props) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn(
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

View File

@@ -0,0 +1,266 @@
import * as React from "react"
import { Menu as MenuPrimitive } from "@base-ui/react/menu"
import { cn } from "@/lib/utils"
import { ChevronRightIcon, CheckIcon } from "lucide-react"
function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
}
function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
return <MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />
}
function DropdownMenuContent({
align = "start",
alignOffset = 0,
side = "bottom",
sideOffset = 4,
className,
...props
}: MenuPrimitive.Popup.Props &
Pick<
MenuPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<MenuPrimitive.Portal>
<MenuPrimitive.Positioner
className="isolate z-50 outline-none"
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
>
<MenuPrimitive.Popup
data-slot="dropdown-menu-content"
className={cn("z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-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 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
/>
</MenuPrimitive.Positioner>
</MenuPrimitive.Portal>
)
}
function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) {
return <MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
}
function DropdownMenuLabel({
className,
inset,
...props
}: MenuPrimitive.GroupLabel.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.GroupLabel
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
className
)}
{...props}
/>
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: MenuPrimitive.Item.Props & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<MenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
return <MenuPrimitive.SubmenuRoot data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: MenuPrimitive.SubmenuTrigger.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.SubmenuTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto" />
</MenuPrimitive.SubmenuTrigger>
)
}
function DropdownMenuSubContent({
align = "start",
alignOffset = -3,
side = "right",
sideOffset = 0,
className,
...props
}: React.ComponentProps<typeof DropdownMenuContent>) {
return (
<DropdownMenuContent
data-slot="dropdown-menu-sub-content"
className={cn("w-auto min-w-[96px] rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 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 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
inset,
...props
}: MenuPrimitive.CheckboxItem.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 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 right-2 flex items-center justify-center"
data-slot="dropdown-menu-checkbox-item-indicator"
>
<MenuPrimitive.CheckboxItemIndicator>
<CheckIcon
/>
</MenuPrimitive.CheckboxItemIndicator>
</span>
{children}
</MenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
return (
<MenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
inset,
...props
}: MenuPrimitive.RadioItem.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 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 right-2 flex items-center justify-center"
data-slot="dropdown-menu-radio-item-indicator"
>
<MenuPrimitive.RadioItemIndicator>
<CheckIcon
/>
</MenuPrimitive.RadioItemIndicator>
</span>
{children}
</MenuPrimitive.RadioItem>
)
}
function DropdownMenuSeparator({
className,
...props
}: MenuPrimitive.Separator.Props) {
return (
<MenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}

View File

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

View File

@@ -0,0 +1,20 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
function Label({ className, ...props }: React.ComponentProps<"label">) {
return (
<label
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,88 @@
import * as React from "react"
import { Popover as PopoverPrimitive } from "@base-ui/react/popover"
import { cn } from "@/lib/utils"
function Popover({ ...props }: PopoverPrimitive.Root.Props) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
}
function PopoverTrigger({ ...props }: PopoverPrimitive.Trigger.Props) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}
function PopoverContent({
className,
align = "center",
alignOffset = 0,
side = "bottom",
sideOffset = 4,
...props
}: PopoverPrimitive.Popup.Props &
Pick<
PopoverPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Positioner
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
className="isolate z-50"
>
<PopoverPrimitive.Popup
data-slot="popover-content"
className={cn(
"z-50 flex w-72 origin-(--transform-origin) flex-col gap-2.5 rounded-lg bg-popover p-2.5 text-sm text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-hidden duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-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 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
/>
</PopoverPrimitive.Positioner>
</PopoverPrimitive.Portal>
)
}
function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="popover-header"
className={cn("flex flex-col gap-0.5 text-sm", className)}
{...props}
/>
)
}
function PopoverTitle({ className, ...props }: PopoverPrimitive.Title.Props) {
return (
<PopoverPrimitive.Title
data-slot="popover-title"
className={cn("font-medium", className)}
{...props}
/>
)
}
function PopoverDescription({
className,
...props
}: PopoverPrimitive.Description.Props) {
return (
<PopoverPrimitive.Description
data-slot="popover-description"
className={cn("text-muted-foreground", className)}
{...props}
/>
)
}
export {
Popover,
PopoverContent,
PopoverDescription,
PopoverHeader,
PopoverTitle,
PopoverTrigger,
}

View File

@@ -0,0 +1,55 @@
"use client"
import * as React from "react"
import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area"
import { cn } from "@/lib/utils"
function ScrollArea({
className,
children,
...props
}: ScrollAreaPrimitive.Root.Props) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: ScrollAreaPrimitive.Scrollbar.Props) {
return (
<ScrollAreaPrimitive.Scrollbar
data-slot="scroll-area-scrollbar"
data-orientation={orientation}
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none data-horizontal:h-2.5 data-horizontal:flex-col data-horizontal:border-t data-horizontal:border-t-transparent data-vertical:h-full data-vertical:w-2.5 data-vertical:border-l data-vertical:border-l-transparent",
className
)}
{...props}
>
<ScrollAreaPrimitive.Thumb
data-slot="scroll-area-thumb"
className="relative flex-1 rounded-full bg-border"
/>
</ScrollAreaPrimitive.Scrollbar>
)
}
export { ScrollArea, ScrollBar }

View File

@@ -0,0 +1,201 @@
"use client"
import * as React from "react"
import { Select as SelectPrimitive } from "@base-ui/react/select"
import { cn } from "@/lib/utils"
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
const Select = SelectPrimitive.Root
function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
return (
<SelectPrimitive.Group
data-slot="select-group"
className={cn("scroll-my-1 p-1", className)}
{...props}
/>
)
}
function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
return (
<SelectPrimitive.Value
data-slot="select-value"
className={cn("flex flex-1 text-left", className)}
{...props}
/>
)
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: SelectPrimitive.Trigger.Props & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon
render={
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
}
/>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
side = "bottom",
sideOffset = 4,
align = "center",
alignOffset = 0,
alignItemWithTrigger = true,
...props
}: SelectPrimitive.Popup.Props &
Pick<
SelectPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Positioner
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
alignItemWithTrigger={alignItemWithTrigger}
className="isolate z-50"
>
<SelectPrimitive.Popup
data-slot="select-content"
data-align-trigger={alignItemWithTrigger}
className={cn("relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-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 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.List>{children}</SelectPrimitive.List>
<SelectScrollDownButton />
</SelectPrimitive.Popup>
</SelectPrimitive.Positioner>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: SelectPrimitive.GroupLabel.Props) {
return (
<SelectPrimitive.GroupLabel
data-slot="select-label"
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: SelectPrimitive.Item.Props) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground 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}
>
<SelectPrimitive.ItemText className="flex flex-1 shrink-0 gap-2 whitespace-nowrap">
{children}
</SelectPrimitive.ItemText>
<SelectPrimitive.ItemIndicator
render={
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
}
>
<CheckIcon className="pointer-events-none" />
</SelectPrimitive.ItemIndicator>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: SelectPrimitive.Separator.Props) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpArrow>) {
return (
<SelectPrimitive.ScrollUpArrow
data-slot="select-scroll-up-button"
className={cn(
"top-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronUpIcon
/>
</SelectPrimitive.ScrollUpArrow>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownArrow>) {
return (
<SelectPrimitive.ScrollDownArrow
data-slot="select-scroll-down-button"
className={cn(
"bottom-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronDownIcon
/>
</SelectPrimitive.ScrollDownArrow>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}

View File

@@ -0,0 +1,138 @@
"use client"
import * as React from "react"
import { Dialog as SheetPrimitive } from "@base-ui/react/dialog"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
function Sheet({ ...props }: SheetPrimitive.Root.Props) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({ ...props }: SheetPrimitive.Trigger.Props) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({ ...props }: SheetPrimitive.Close.Props) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({ ...props }: SheetPrimitive.Portal.Props) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) {
return (
<SheetPrimitive.Backdrop
data-slot="sheet-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/10 transition-opacity duration-150 data-ending-style:opacity-0 data-starting-style:opacity-0 supports-backdrop-filter:backdrop-blur-xs",
className
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = "right",
showCloseButton = true,
...props
}: SheetPrimitive.Popup.Props & {
side?: "top" | "right" | "bottom" | "left"
showCloseButton?: boolean
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Popup
data-slot="sheet-content"
data-side={side}
className={cn(
"fixed z-50 flex flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition duration-200 ease-in-out data-ending-style:opacity-0 data-starting-style:opacity-0 data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=bottom]:data-ending-style:translate-y-[2.5rem] data-[side=bottom]:data-starting-style:translate-y-[2.5rem] data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=left]:data-ending-style:translate-x-[-2.5rem] data-[side=left]:data-starting-style:translate-x-[-2.5rem] data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=right]:data-ending-style:translate-x-[2.5rem] data-[side=right]:data-starting-style:translate-x-[2.5rem] data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=top]:data-ending-style:translate-y-[-2.5rem] data-[side=top]:data-starting-style:translate-y-[-2.5rem] data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm",
className
)}
{...props}
>
{children}
{showCloseButton && (
<SheetPrimitive.Close
data-slot="sheet-close"
render={
<Button
variant="ghost"
className="absolute top-3 right-3"
size="icon-sm"
/>
}
>
<XIcon
/>
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Popup>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-0.5 p-4", className)}
{...props}
/>
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn(
"font-heading text-base font-medium text-foreground",
className
)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: SheetPrimitive.Description.Props) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}

View File

@@ -0,0 +1,50 @@
"use client"
import { useTheme } from "next-themes"
import { Toaster as Sonner, type ToasterProps } from "sonner"
import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react"
import type { CSSProperties } from "react"
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
icons={{
success: (
<CircleCheckIcon className="size-4" />
),
info: (
<InfoIcon className="size-4" />
),
warning: (
<TriangleAlertIcon className="size-4" />
),
error: (
<OctagonXIcon className="size-4" />
),
loading: (
<Loader2Icon className="size-4 animate-spin" />
),
}}
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
"--border-radius": "var(--radius)",
} as CSSProperties
}
toastOptions={{
classNames: {
toast: "cn-toast",
},
}}
{...props}
/>
)
}
export { Toaster }

View File

@@ -0,0 +1,114 @@
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(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
)
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0",
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",
className
)}
{...props}
/>
)
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

View File

@@ -0,0 +1,80 @@
import { Tabs as TabsPrimitive } from "@base-ui/react/tabs"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
function Tabs({
className,
orientation = "horizontal",
...props
}: TabsPrimitive.Root.Props) {
return (
<TabsPrimitive.Root
data-slot="tabs"
data-orientation={orientation}
className={cn(
"group/tabs flex gap-2 data-horizontal:flex-col",
className
)}
{...props}
/>
)
}
const tabsListVariants = cva(
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
{
variants: {
variant: {
default: "bg-muted",
line: "gap-1 bg-transparent",
},
},
defaultVariants: {
variant: "default",
},
}
)
function TabsList({
className,
variant = "default",
...props
}: TabsPrimitive.List.Props & VariantProps<typeof tabsListVariants>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
data-variant={variant}
className={cn(tabsListVariants({ variant }), className)}
{...props}
/>
)
}
function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
return (
<TabsPrimitive.Tab
data-slot="tabs-trigger"
className={cn(
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 aria-disabled:pointer-events-none aria-disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
className
)}
{...props}
/>
)
}
function TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) {
return (
<TabsPrimitive.Panel
data-slot="tabs-content"
className={cn("flex-1 text-sm outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }

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(
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Textarea }

138
frontend/src/index.css Normal file
View File

@@ -0,0 +1,138 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@import "@fontsource-variable/geist";
@custom-variant dark (&:is(.dark *));
@theme inline {
--font-heading: var(--font-sans);
--font-sans: 'Geist Variable', sans-serif;
--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);
--color-foreground: var(--foreground);
--color-background: var(--background);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
}
:root {
--background: oklch(0.98 0.005 60); /* Off-white */
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.65 0.22 45); /* Orange */
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.15 0.005 45); /* Near black/charcoal */
--foreground: oklch(0.985 0 0);
--card: oklch(0.20 0.006 45);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.20 0.006 45);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.65 0.22 45); /* Orange */
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.65 0.22 45);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
html {
@apply font-sans;
}
button:not(:disabled),
[role="button"]:not([aria-disabled="true"]),
a[href],
select,
input[type="checkbox"],
input[type="file"] {
cursor: pointer;
}
}

65
frontend/src/lib/api.ts Normal file
View File

@@ -0,0 +1,65 @@
export const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8080/api/v1";
type RequestOptions = RequestInit & {
token?: string | null;
};
export async function apiRequest<T>(path: string, options: RequestOptions = {}): Promise<T> {
const headers = new Headers(options.headers);
headers.set("Content-Type", "application/json");
if (options.token) {
headers.set("Authorization", `Bearer ${options.token}`);
}
const response = await fetch(`${API_BASE_URL}${path}`, {
...options,
headers,
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
const message =
typeof data === "object" && data !== null && "message" in data
? String(data.message)
: "Nao foi possivel concluir a solicitacao.";
throw new ApiError(message, response.status);
}
return data as T;
}
export async function apiFormRequest<T>(path: string, formData: FormData, token?: string | null): Promise<T> {
const headers = new Headers();
if (token) {
headers.set("Authorization", `Bearer ${token}`);
}
const response = await fetch(`${API_BASE_URL}${path}`, {
method: "POST",
headers,
body: formData,
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
const message =
typeof data === "object" && data !== null && "message" in data
? String(data.message)
: "Nao foi possivel concluir a solicitacao.";
throw new ApiError(message, response.status);
}
return data as T;
}
export class ApiError extends Error {
status: number;
constructor(message: string, status: number) {
super(message);
this.name = "ApiError";
this.status = status;
}
}

62
frontend/src/lib/auth.ts Normal file
View File

@@ -0,0 +1,62 @@
import { apiRequest } from "@/lib/api";
const TOKEN_STORAGE_KEY = "mira_access_token";
export type AuthUser = {
id: string;
email: string;
name: string;
role: "super_admin" | "agency_user" | "client_viewer";
status: string;
};
type LoginResponse = {
access_token: string;
token_type: "Bearer";
user: AuthUser;
};
type MeResponse = {
user: AuthUser;
};
export function getStoredToken() {
return localStorage.getItem(TOKEN_STORAGE_KEY);
}
export function storeToken(token: string) {
localStorage.setItem(TOKEN_STORAGE_KEY, token);
}
export function clearStoredToken() {
localStorage.removeItem(TOKEN_STORAGE_KEY);
}
export async function login(email: string, password: string) {
const response = await apiRequest<LoginResponse>("/auth/login", {
method: "POST",
body: JSON.stringify({ email, password }),
});
storeToken(response.access_token);
return response;
}
export async function acceptInvitation(token: string, name: string, password: string) {
const response = await apiRequest<LoginResponse>("/auth/invitations/accept", {
method: "POST",
body: JSON.stringify({ token, name, password }),
});
storeToken(response.access_token);
return response;
}
export async function loadCurrentUser(token: string) {
const response = await apiRequest<MeResponse>("/me", {
method: "GET",
token,
});
return response.user;
}

View File

@@ -0,0 +1,256 @@
import { API_BASE_URL, apiFormRequest, apiRequest } from "@/lib/api";
export type Holiday = {
id: string;
name: string;
date: string;
type: string;
source: string;
};
export type CustomDate = {
id: string;
client_id: string | null;
name: string;
description: string;
date: string;
recurs_annually: boolean;
visibility: "agency" | "client";
category: string;
created_at: string;
updated_at: string;
};
export type CalendarItem = {
id: string;
client_id: string;
client_name: string;
title: string;
description: string;
content_type: string;
status: "rascunho" | "planejado" | "em_producao" | "em_revisao" | "aprovado" | "publicado" | "cancelado";
owner_id: string | null;
scheduled_date: string;
scheduled_at: string | null;
copy_text: string;
internal_notes: string;
client_notes: string;
created_at: string;
updated_at: string;
};
export type Attachment = {
id: string;
calendar_item_id: string;
original_filename: string;
stored_filename: string;
mime_type: string;
size_bytes: number;
storage_driver: string;
created_by: string | null;
created_at: string;
};
export type CreateCustomDateInput = {
client_id?: string;
name: string;
description?: string;
date: string;
recurs_annually: boolean;
visibility: "agency" | "client";
category: string;
};
export type UpdateCustomDateInput = {
client_id?: string;
name: string;
description?: string;
date: string;
recurs_annually: boolean;
visibility: "agency" | "client";
};
export type CreateCalendarItemInput = {
client_id: string;
title: string;
description?: string;
content_type: string;
status: CalendarItem["status"];
scheduled_date: string;
copy_text?: string;
internal_notes?: string;
client_notes?: string;
};
export type UpdateCalendarItemInput = Partial<{
title: string;
description: string;
status: CalendarItem["status"];
scheduled_date: string;
copy_text: string;
internal_notes: string;
client_notes: string;
}>;
type HolidaysResponse = {
holidays: Holiday[];
};
type CustomDatesResponse = {
custom_dates: CustomDate[];
};
type CustomDateResponse = {
custom_date: CustomDate;
};
type CalendarItemsResponse = {
items: CalendarItem[];
};
type CalendarItemResponse = {
item: CalendarItem;
};
type AttachmentsResponse = {
attachments: Attachment[];
};
type AttachmentResponse = {
attachment: Attachment;
};
export async function listHolidays(token: string, year: number) {
const response = await apiRequest<HolidaysResponse>(`/calendar/holidays?year=${year}`, {
method: "GET",
token,
});
return response.holidays;
}
export async function listCustomDates(token: string, year: number, clientId?: string | null) {
const params = new URLSearchParams({ year: String(year) });
if (clientId) {
params.set("client_id", clientId);
}
const response = await apiRequest<CustomDatesResponse>(`/calendar/custom-dates?${params.toString()}`, {
method: "GET",
token,
});
return response.custom_dates;
}
export async function createCustomDate(token: string, input: CreateCustomDateInput) {
const response = await apiRequest<CustomDateResponse>("/calendar/custom-dates", {
method: "POST",
token,
body: JSON.stringify(input),
});
return response.custom_date;
}
export async function updateCustomDate(token: string, customDateId: string, input: UpdateCustomDateInput) {
const response = await apiRequest<CustomDateResponse>(`/calendar/custom-dates/${customDateId}`, {
method: "PATCH",
token,
body: JSON.stringify(input),
});
return response.custom_date;
}
export async function deleteCustomDate(token: string, customDateId: string) {
await apiRequest<{ message: string }>(`/calendar/custom-dates/${customDateId}`, {
method: "DELETE",
token,
});
}
export async function listCalendarItems(token: string, year: number, clientId?: string | null) {
const params = new URLSearchParams({ year: String(year) });
if (clientId) {
params.set("client_id", clientId);
}
const response = await apiRequest<CalendarItemsResponse>(`/calendar/items?${params.toString()}`, {
method: "GET",
token,
});
return response.items;
}
export async function listCalendarItemsByRange(token: string, from: string, to: string, clientId?: string | null) {
const params = new URLSearchParams({ from, to });
if (clientId) {
params.set("client_id", clientId);
}
const response = await apiRequest<CalendarItemsResponse>(`/calendar/items?${params.toString()}`, {
method: "GET",
token,
});
return response.items;
}
export async function createCalendarItem(token: string, input: CreateCalendarItemInput) {
const response = await apiRequest<CalendarItemResponse>("/calendar/items", {
method: "POST",
token,
body: JSON.stringify(input),
});
return response.item;
}
export async function getCalendarItem(token: string, itemId: string) {
const response = await apiRequest<CalendarItemResponse>(`/calendar/items/${itemId}`, {
method: "GET",
token,
});
return response.item;
}
export async function updateCalendarItem(token: string, itemId: string, input: UpdateCalendarItemInput) {
const response = await apiRequest<CalendarItemResponse>(`/calendar/items/${itemId}`, {
method: "PATCH",
token,
body: JSON.stringify(input),
});
return response.item;
}
export async function cancelCalendarItem(token: string, itemId: string) {
await apiRequest<{ message: string }>(`/calendar/items/${itemId}`, {
method: "DELETE",
token,
});
}
export async function listAttachments(token: string, itemId: string) {
const response = await apiRequest<AttachmentsResponse>(`/calendar/items/${itemId}/attachments`, {
method: "GET",
token,
});
return response.attachments;
}
export async function uploadAttachment(token: string, itemId: string, file: File) {
const formData = new FormData();
formData.set("file", file);
const response = await apiFormRequest<AttachmentResponse>(`/calendar/items/${itemId}/attachments`, formData, token);
return response.attachment;
}
export function attachmentDownloadURL(attachmentId: string) {
return `${API_BASE_URL}/calendar/attachments/${attachmentId}/download`;
}

View File

@@ -0,0 +1,79 @@
import { apiRequest } from "@/lib/api";
export type Client = {
id: string;
name: string;
slug: string;
status: "active" | "archived";
website_url: string;
instagram_url: string;
facebook_url: string;
linkedin_url: string;
color: string;
notes: string;
created_at: string;
updated_at: string;
};
type ClientsResponse = {
clients: Client[];
};
type ClientResponse = {
client: Client;
};
export async function listClients(token: string) {
const response = await apiRequest<ClientsResponse>("/clients", {
method: "GET",
token,
});
return response.clients;
}
export async function createClient(token: string, name: string) {
const response = await apiRequest<ClientResponse>("/clients", {
method: "POST",
token,
body: JSON.stringify({ name }),
});
return response.client;
}
export async function updateClient(token: string, client: Client) {
const response = await apiRequest<ClientResponse>(`/clients/${client.id}`, {
method: "PATCH",
token,
body: JSON.stringify({
name: client.name,
status: client.status,
website_url: client.website_url,
instagram_url: client.instagram_url,
facebook_url: client.facebook_url,
linkedin_url: client.linkedin_url,
color: client.color,
notes: client.notes,
}),
});
return response.client;
}
export async function archiveClient(token: string, client: Client) {
const response = await apiRequest<ClientResponse>(`/clients/${client.id}`, {
method: "PATCH",
token,
body: JSON.stringify({ ...client, status: "archived" }),
});
return response.client;
}
export async function deleteClient(token: string, clientId: string) {
await apiRequest<{ message: string }>(`/clients/${clientId}`, {
method: "DELETE",
token,
});
}

54
frontend/src/lib/users.ts Normal file
View File

@@ -0,0 +1,54 @@
import { apiRequest } from "@/lib/api";
import type { AuthUser } from "@/lib/auth";
export type Invitation = {
id: string;
email: string;
role: "agency_user" | "client_viewer";
client_id: string | null;
client_name: string | null;
expires_at: string;
accepted_at: string | null;
created_at: string;
invitation_url?: string;
};
type UsersResponse = {
users: AuthUser[];
};
type InvitationsResponse = {
invitations: Invitation[];
};
type InvitationResponse = {
invitation: Invitation;
};
export async function listUsers(token: string) {
const response = await apiRequest<UsersResponse>("/users", {
method: "GET",
token,
});
return response.users;
}
export async function listInvitations(token: string) {
const response = await apiRequest<InvitationsResponse>("/users/invitations", {
method: "GET",
token,
});
return response.invitations;
}
export async function createInvitation(token: string, input: { email: string; role: Invitation["role"]; client_id?: string }) {
const response = await apiRequest<InvitationResponse>("/users/invitations", {
method: "POST",
token,
body: JSON.stringify(input),
});
return response.invitation;
}

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))
}

10
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import {StrictMode} from 'react';
import {createRoot} from 'react-dom/client';
import App from './App.tsx';
import './index.css';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
);

View File

@@ -0,0 +1,87 @@
import { useState, type FormEvent } from "react";
import { ArrowRight, CalendarDays } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { acceptInvitation, type AuthUser } from "@/lib/auth";
type AcceptInvitationViewProps = {
token: string;
onAccepted: (user: AuthUser) => void;
};
export function AcceptInvitationView({ token, onAccepted }: AcceptInvitationViewProps) {
const [name, setName] = useState("");
const [password, setPassword] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState("");
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setIsSubmitting(true);
setError("");
try {
const response = await acceptInvitation(token, name, password);
onAccepted(response.user);
window.history.replaceState({}, "", "/");
} catch (err) {
setError(err instanceof Error ? err.message : "Não foi possível aceitar o convite.");
} finally {
setIsSubmitting(false);
}
}
return (
<div className="min-h-screen bg-background text-foreground flex items-center justify-center p-6">
<div className="w-full max-w-md space-y-8">
<div className="space-y-4 text-center">
<div className="h-12 w-12 rounded-xl bg-primary text-primary-foreground flex items-center justify-center mx-auto">
<CalendarDays className="h-6 w-6" />
</div>
<div>
<h1 className="text-2xl font-semibold tracking-tight">Aceitar convite</h1>
<p className="text-sm text-muted-foreground mt-2">Crie sua senha para acessar o Mira.</p>
</div>
</div>
<form className="space-y-4" onSubmit={handleSubmit}>
<div className="grid gap-2">
<Label htmlFor="invite-name">Nome</Label>
<Input
id="invite-name"
value={name}
onChange={(event) => setName(event.target.value)}
placeholder="Seu nome"
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="invite-password">Senha</Label>
<Input
id="invite-password"
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
placeholder="Mínimo de 12 caracteres"
minLength={12}
required
/>
</div>
{error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</div>
)}
<Button className="w-full" type="submit" disabled={isSubmitting}>
{isSubmitting ? "Ativando..." : "Entrar no Mira"}
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</form>
</div>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,249 @@
import { useEffect, useMemo, useState } from "react";
import { AlertCircle, CalendarDays, CheckCircle2, Clock, ExternalLink, FileText, Plus } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { getStoredToken } from "@/lib/auth";
import {
listCalendarItemsByRange,
listCustomDates,
listHolidays,
type CalendarItem,
type CustomDate,
type Holiday,
} from "@/lib/calendar";
import type { Client } from "@/lib/clients";
type ClientDashboardViewProps = {
client: Client | null;
selectedClientId: string;
onViewChange: (view: string) => void;
onPostSelect: (postId: string) => void;
};
const statusLabels: Record<CalendarItem["status"], string> = {
rascunho: "Rascunho",
planejado: "Planejado",
em_producao: "Em produção",
em_revisao: "Em revisão",
aprovado: "Aprovado",
publicado: "Publicado",
cancelado: "Cancelado",
};
const shortDateFormatter = new Intl.DateTimeFormat("pt-BR", { day: "2-digit", month: "short" });
const longDateFormatter = new Intl.DateTimeFormat("pt-BR", { day: "2-digit", month: "long", year: "numeric" });
export function ClientDashboardView({ client, selectedClientId, onViewChange, onPostSelect }: ClientDashboardViewProps) {
const today = useMemo(() => new Date(), []);
const monthStart = useMemo(() => new Date(today.getFullYear(), today.getMonth(), 1), [today]);
const monthEnd = useMemo(() => new Date(today.getFullYear(), today.getMonth() + 1, 0), [today]);
const [items, setItems] = useState<CalendarItem[]>([]);
const [holidays, setHolidays] = useState<Holiday[]>([]);
const [customDates, setCustomDates] = useState<CustomDate[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState("");
const monthStartValue = formatDateValue(monthStart);
const monthEndValue = formatDateValue(monthEnd);
const todayValue = formatDateValue(today);
const year = today.getFullYear();
useEffect(() => {
const token = getStoredToken();
if (!token) return;
setIsLoading(true);
setError("");
Promise.all([
listCalendarItemsByRange(token, monthStartValue, monthEndValue, selectedClientId),
listHolidays(token, year),
listCustomDates(token, year, selectedClientId),
])
.then(([loadedItems, loadedHolidays, loadedCustomDates]) => {
setItems(loadedItems);
setHolidays(loadedHolidays);
setCustomDates(loadedCustomDates);
})
.catch((err) => {
setError(err instanceof Error ? err.message : "Não foi possível carregar o dashboard do cliente.");
})
.finally(() => setIsLoading(false));
}, [monthEndValue, monthStartValue, selectedClientId, year]);
const visibleItems = items
.filter((item) => item.status !== "cancelado")
.sort((a, b) => a.scheduled_date.localeCompare(b.scheduled_date));
const upcomingItems = visibleItems.filter((item) => item.scheduled_date >= todayValue);
const reviewItems = visibleItems.filter((item) => item.status === "em_revisao");
const approvedItems = visibleItems.filter((item) => item.status === "aprovado" || item.status === "publicado");
const dates = [...holidays, ...customDates]
.filter((date) => date.date >= monthStartValue && date.date <= monthEndValue)
.sort((a, b) => a.date.localeCompare(b.date));
if (!client) {
return (
<div className="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
Cliente não encontrado.
</div>
);
}
return (
<div className="space-y-6">
<div className="flex flex-col sm:flex-row sm:items-end justify-between gap-4">
<div>
<div className="flex flex-wrap items-center gap-2">
<h1 className="text-2xl font-semibold tracking-tight">Dashboard de {client.name}</h1>
<Badge variant="outline">{client.status === "archived" ? "Arquivado" : "Ativo"}</Badge>
</div>
<p className="text-sm text-muted-foreground mt-1">
Visão do cliente entre {shortDateFormatter.format(monthStart)} e {shortDateFormatter.format(monthEnd)}.
</p>
</div>
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" className="h-9" onClick={() => onViewChange("calendar")}>
<CalendarDays className="mr-2 h-4 w-4" />
Calendário
</Button>
<Button size="sm" className="h-9" onClick={() => onViewChange("calendar")}>
<Plus className="mr-2 h-4 w-4" />
Novo Post
</Button>
</div>
</div>
{error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<MetricCard title="Posts do mês" value={visibleItems.length} detail={`${upcomingItems.length} próximos`} />
<MetricCard title="Em aprovação" value={reviewItems.length} detail="Aguardando retorno" />
<MetricCard title="Aprovados" value={approvedItems.length} detail="Prontos ou publicados" emphasize />
<MetricCard title="Datas do mês" value={dates.length} detail="Feriados e datas úteis" />
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<Card className="lg:col-span-2 shadow-sm">
<CardHeader className="p-4 pb-2">
<CardTitle className="text-base flex items-center gap-2">
<FileText className="h-4 w-4 text-muted-foreground" />
Linha do tempo de posts
</CardTitle>
</CardHeader>
<CardContent className="p-4 pt-2 space-y-3">
{isLoading && <p className="text-sm text-muted-foreground py-6">Carregando posts...</p>}
{!isLoading && visibleItems.length === 0 && (
<p className="text-sm text-muted-foreground py-6">Nenhum post previsto para este mês.</p>
)}
{visibleItems.map((item) => (
<button key={item.id} type="button" className="w-full rounded-lg border border-border/60 bg-card p-4 text-left hover:bg-muted/30" onClick={() => onPostSelect(item.id)}>
<div className="flex flex-col sm:flex-row sm:items-start justify-between gap-3">
<div className="min-w-0 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="secondary" className="text-[10px] bg-orange-500/10 text-orange-700 dark:text-orange-300">
{statusLabels[item.status]}
</Badge>
<span className="text-xs text-muted-foreground flex items-center gap-1">
<Clock className="h-3.5 w-3.5" />
{longDateFormatter.format(parseDateValue(item.scheduled_date))}
</span>
</div>
<h2 className="font-medium text-sm text-foreground">{item.title}</h2>
{item.description && <p className="text-sm text-muted-foreground">{item.description}</p>}
{item.client_notes && <p className="text-sm text-muted-foreground">{item.client_notes}</p>}
</div>
{item.status === "aprovado" || item.status === "publicado" ? (
<CheckCircle2 className="h-5 w-5 shrink-0 text-primary" />
) : (
<AlertCircle className="h-5 w-5 shrink-0 text-orange-500" />
)}
</div>
</button>
))}
</CardContent>
</Card>
<div className="space-y-6">
<Card className="shadow-sm">
<CardHeader className="p-4 pb-2">
<CardTitle className="text-base flex items-center gap-2">
<CalendarDays className="h-4 w-4 text-muted-foreground" />
Datas relevantes
</CardTitle>
</CardHeader>
<CardContent className="p-4 pt-2 space-y-3">
{dates.map((date) => (
<div key={`${date.date}-${date.name}`} className="flex items-center justify-between gap-3 text-sm">
<div className="min-w-0">
<p className="font-medium truncate">{date.name}</p>
<p className="text-xs text-muted-foreground">
{"source" in date ? "Feriado nacional" : date.category === "feriados-brasil" ? "Data comemorativa" : "Data da agência"}
</p>
</div>
<Badge variant="outline" className="font-mono bg-muted/40">
{shortDateFormatter.format(parseDateValue(date.date))}
</Badge>
</div>
))}
{dates.length === 0 && (
<p className="text-sm text-muted-foreground py-4">Nenhuma data relevante neste mês.</p>
)}
</CardContent>
</Card>
{(client.website_url || client.instagram_url || client.facebook_url || client.linkedin_url) && (
<Card className="shadow-sm">
<CardHeader className="p-4 pb-2">
<CardTitle className="text-base">Links do cliente</CardTitle>
</CardHeader>
<CardContent className="p-4 pt-2 space-y-2">
<ClientLink label="Website" url={client.website_url} />
<ClientLink label="Instagram" url={client.instagram_url} />
<ClientLink label="Facebook" url={client.facebook_url} />
<ClientLink label="LinkedIn" url={client.linkedin_url} />
</CardContent>
</Card>
)}
</div>
</div>
</div>
);
}
function MetricCard({ title, value, detail, emphasize = false }: { title: string; value: number; detail: string; emphasize?: boolean }) {
return (
<Card className="shadow-sm">
<CardHeader className="p-4 pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">{title}</CardTitle>
</CardHeader>
<CardContent className="p-4 pt-0">
<div className={`text-2xl font-semibold ${emphasize ? "text-primary" : ""}`}>{value}</div>
<p className="text-xs text-muted-foreground mt-1">{detail}</p>
</CardContent>
</Card>
);
}
function ClientLink({ label, url }: { label: string; url: string }) {
if (!url) return null;
return (
<a className="flex items-center justify-between gap-3 rounded-lg border border-border/60 px-3 py-2 text-sm hover:bg-muted/30" href={url} target="_blank" rel="noreferrer">
<span>{label}</span>
<ExternalLink className="h-4 w-4 text-muted-foreground" />
</a>
);
}
function formatDateValue(date: Date) {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
}
function parseDateValue(value: string) {
const [year, month, day] = value.split("-").map(Number);
return new Date(year, month - 1, day);
}

View File

@@ -0,0 +1,345 @@
import { Archive, Building2, ChevronDown, ChevronUp, Plus, Save, Trash2 } from "lucide-react";
import { useState, type FormEvent } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { Textarea } from "@/components/ui/textarea";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { archiveClient, createClient, deleteClient, updateClient, type Client } from "@/lib/clients";
import { getStoredToken } from "@/lib/auth";
type ClientsViewProps = {
clients: Client[];
onClientCreated: (client: Client) => void;
onClientUpdated: (client: Client) => void;
onClientDeleted: (clientId: string) => void;
onClientSelect: (clientId: string) => void;
};
export function ClientsView({ clients, onClientCreated, onClientUpdated, onClientDeleted, onClientSelect }: ClientsViewProps) {
const [name, setName] = useState("");
const [editing, setEditing] = useState<Record<string, Client>>({});
const [expandedClients, setExpandedClients] = useState<Record<string, boolean>>({});
const [clientToDelete, setClientToDelete] = useState<Client | null>(null);
const [deleteConfirmation, setDeleteConfirmation] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const [busyClientId, setBusyClientId] = useState("");
const [error, setError] = useState("");
const [deleteError, setDeleteError] = useState("");
const colorOptions = ["#f97316", "#06b6d4", "#22c55e", "#a855f7", "#ef4444", "#eab308", "#14b8a6", "#64748b"];
async function handleCreate(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const token = getStoredToken();
if (!token) return;
setError("");
setIsSubmitting(true);
try {
const client = await createClient(token, name);
onClientCreated(client);
setName("");
} catch (err) {
setError(err instanceof Error ? err.message : "Nao foi possivel criar o cliente.");
} finally {
setIsSubmitting(false);
}
}
function draftFor(client: Client) {
return editing[client.id] ?? client;
}
function updateDraft(client: Client, field: keyof Client, value: string) {
setEditing((current) => ({
...current,
[client.id]: { ...draftFor(client), [field]: value },
}));
}
async function handleSave(client: Client) {
const token = getStoredToken();
if (!token) return;
setBusyClientId(client.id);
setError("");
try {
const updated = await updateClient(token, draftFor(client));
onClientUpdated(updated);
setEditing((current) => {
const copy = { ...current };
delete copy[client.id];
return copy;
});
} catch (err) {
setError(err instanceof Error ? err.message : "Nao foi possivel atualizar o cliente.");
} finally {
setBusyClientId("");
}
}
async function handleArchive(client: Client) {
const token = getStoredToken();
if (!token) return;
setBusyClientId(client.id);
setError("");
try {
const archived = await archiveClient(token, draftFor(client));
onClientUpdated(archived);
} catch (err) {
setError(err instanceof Error ? err.message : "Nao foi possivel arquivar o cliente.");
} finally {
setBusyClientId("");
}
}
async function handleDeleteClient() {
const token = getStoredToken();
if (!token || !clientToDelete || deleteConfirmation !== clientToDelete.name) return;
setBusyClientId(clientToDelete.id);
setDeleteError("");
try {
await deleteClient(token, clientToDelete.id);
onClientDeleted(clientToDelete.id);
setClientToDelete(null);
setDeleteConfirmation("");
} catch (err) {
setDeleteError(err instanceof Error ? err.message : "Nao foi possivel excluir o cliente.");
} finally {
setBusyClientId("");
}
}
function viewAsClient(client: Client) {
onClientSelect(client.id);
setExpandedClients((current) => ({ ...current, [client.id]: true }));
}
function toggleClient(client: Client) {
onClientSelect(client.id);
setExpandedClients((current) => ({ ...current, [client.id]: !current[client.id] }));
}
return (
<>
<div className="space-y-6">
<div className="flex flex-col sm:flex-row sm:items-end justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Clientes</h1>
<p className="text-sm text-muted-foreground mt-1">Cadastre e organize os clientes atendidos pela agência.</p>
</div>
</div>
<Card>
<CardHeader>
<CardTitle className="text-base">Novo cliente</CardTitle>
</CardHeader>
<CardContent>
<form className="flex flex-col sm:flex-row gap-3" onSubmit={handleCreate}>
<div className="grid gap-2 flex-1">
<Label htmlFor="client-name">Nome do cliente</Label>
<Input id="client-name" placeholder="Ex.: TechCorp" value={name} onChange={(event) => setName(event.target.value)} required />
</div>
<Button className="sm:self-end" type="submit" disabled={isSubmitting}>
<Plus className="mr-2 h-4 w-4" />
{isSubmitting ? "Criando..." : "Criar cliente"}
</Button>
</form>
{error && (
<div className="mt-3 rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</div>
)}
</CardContent>
</Card>
<div className="grid gap-3">
{clients.length === 0 && (
<div className="flex items-center justify-center h-[260px] border border-dashed border-border rounded-xl">
<div className="text-center space-y-3">
<div className="bg-muted w-12 h-12 rounded-full flex items-center justify-center mx-auto">
<Building2 className="h-6 w-6 text-muted-foreground" />
</div>
<h3 className="text-lg font-medium">Nenhum cliente cadastrado</h3>
<p className="text-sm text-muted-foreground w-[300px]">Crie o primeiro cliente para começar a montar calendários e posts.</p>
</div>
</div>
)}
{clients.map((client) => {
const draft = draftFor(client);
const isArchived = client.status === "archived";
const isExpanded = expandedClients[client.id] ?? false;
return (
<Card key={client.id} className={isArchived ? "opacity-70" : ""}>
<CardContent className="p-4 space-y-4">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="h-3 w-3 shrink-0 rounded-full" style={{ backgroundColor: client.color || "#f97316" }} />
<h3 className="font-medium truncate">{client.name}</h3>
</div>
<p className="text-sm text-muted-foreground truncate">/{client.slug}</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<Badge variant={isArchived ? "outline" : "secondary"}>{isArchived ? "Arquivado" : "Ativo"}</Badge>
<Button type="button" size="sm" variant="outline" onClick={() => toggleClient(client)}>
{isExpanded ? <ChevronUp className="mr-2 h-4 w-4" /> : <ChevronDown className="mr-2 h-4 w-4" />}
{isExpanded ? "Minimizar" : "Detalhes"}
</Button>
</div>
</div>
{isExpanded && (
<>
<div className="grid gap-3 md:grid-cols-2">
<Field label="Nome" value={draft.name} onChange={(value) => updateDraft(client, "name", value)} />
<Field label="Website" value={draft.website_url} onChange={(value) => updateDraft(client, "website_url", value)} />
<Field label="Instagram" value={draft.instagram_url} onChange={(value) => updateDraft(client, "instagram_url", value)} />
<Field label="Facebook" value={draft.facebook_url} onChange={(value) => updateDraft(client, "facebook_url", value)} />
<Field label="LinkedIn" value={draft.linkedin_url} onChange={(value) => updateDraft(client, "linkedin_url", value)} />
</div>
<div className="grid gap-2">
<Label>Cor do cliente</Label>
<div className="flex flex-wrap items-center gap-2">
{colorOptions.map((color) => (
<button
key={color}
type="button"
aria-label={`Selecionar cor ${color}`}
className={`h-8 w-8 rounded-full border-2 transition ${draft.color === color ? "border-foreground" : "border-transparent"}`}
style={{ backgroundColor: color }}
onClick={() => updateDraft(client, "color", color)}
/>
))}
<Input
className="h-9 w-[120px] font-mono"
value={draft.color}
onChange={(event) => updateDraft(client, "color", event.target.value)}
pattern="^#[0-9a-fA-F]{6}$"
/>
</div>
</div>
<div className="grid gap-2">
<Label>Notas</Label>
<Textarea value={draft.notes} onChange={(event) => updateDraft(client, "notes", event.target.value)} />
</div>
<div className="flex flex-wrap gap-2">
<Button type="button" size="sm" onClick={() => handleSave(client)} disabled={busyClientId === client.id}>
<Save className="mr-2 h-4 w-4" />
Salvar
</Button>
<Button type="button" size="sm" variant="outline" onClick={() => viewAsClient(client)}>
Atualizar dashboard
</Button>
{!isArchived && (
<Button type="button" size="sm" variant="outline" onClick={() => handleArchive(client)} disabled={busyClientId === client.id}>
<Archive className="mr-2 h-4 w-4" />
Arquivar
</Button>
)}
<Button
type="button"
size="sm"
variant="destructive"
onClick={() => {
setClientToDelete(client);
setDeleteConfirmation("");
setDeleteError("");
}}
disabled={busyClientId === client.id}
>
<Trash2 className="mr-2 h-4 w-4" />
Excluir
</Button>
</div>
</>
)}
</CardContent>
</Card>
);
})}
</div>
</div>
<Dialog open={clientToDelete !== null} onOpenChange={(open) => {
if (open) return;
setClientToDelete(null);
setDeleteConfirmation("");
setDeleteError("");
}}>
<DialogContent className="sm:max-w-[460px]">
<DialogHeader>
<DialogTitle>Excluir cliente</DialogTitle>
<DialogDescription>
Esta ação exclui permanentemente o cliente, seus vínculos, posts e datas personalizadas. Para confirmar, digite o nome do cliente.
</DialogDescription>
</DialogHeader>
{clientToDelete && (
<div className="space-y-4 pt-2">
<div className="rounded-lg border border-border/60 bg-muted/30 px-3 py-2 text-sm">
<span className="text-muted-foreground">Nome do cliente: </span>
<span className="font-medium">{clientToDelete.name}</span>
</div>
<div className="grid gap-2">
<Label htmlFor="delete-client-confirmation">Digite o nome do cliente</Label>
<Input
id="delete-client-confirmation"
value={deleteConfirmation}
onChange={(event) => setDeleteConfirmation(event.target.value)}
autoComplete="off"
/>
</div>
{deleteError && (
<div className="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{deleteError}
</div>
)}
<div className="flex flex-col-reverse sm:flex-row sm:justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={() => {
setClientToDelete(null);
setDeleteConfirmation("");
setDeleteError("");
}}
>
Cancelar
</Button>
<Button
type="button"
variant="destructive"
disabled={deleteConfirmation !== clientToDelete.name || busyClientId === clientToDelete.id}
onClick={handleDeleteClient}
>
{busyClientId === clientToDelete.id ? "Excluindo..." : "Excluir definitivamente"}
</Button>
</div>
</div>
)}
</DialogContent>
</Dialog>
</>
);
}
function Field({ label, value, onChange }: { label: string; value: string; onChange: (value: string) => void }) {
return (
<div className="grid gap-2">
<Label>{label}</Label>
<Input value={value} onChange={(event) => onChange(event.target.value)} />
</div>
);
}

View File

@@ -0,0 +1,344 @@
import { useEffect, useMemo, useState } from "react";
import { AlertCircle, Calendar as CalendarIcon, CheckCircle2, Clock, FileImage, Filter, Plus } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { getStoredToken } from "@/lib/auth";
import {
listCalendarItemsByRange,
listCustomDates,
listHolidays,
type CalendarItem,
type CustomDate,
type Holiday,
} from "@/lib/calendar";
type DashboardViewProps = {
selectedClientId?: string | null;
title?: string;
description?: string;
compact?: boolean;
onViewChange: (view: string) => void;
onPostSelect: (postId: string) => void;
};
const statusLabels: Record<CalendarItem["status"], string> = {
rascunho: "Rascunho",
planejado: "Planejado",
em_producao: "Em produção",
em_revisao: "Em revisão",
aprovado: "Aprovado",
publicado: "Publicado",
cancelado: "Cancelado",
};
const shortDateFormatter = new Intl.DateTimeFormat("pt-BR", { day: "2-digit", month: "short" });
export function DashboardView({
selectedClientId = null,
title = "Dashboard Semanal",
description,
compact = false,
onViewChange,
onPostSelect,
}: DashboardViewProps) {
const today = useMemo(() => new Date(), []);
const weekStart = useMemo(() => startOfWeek(today), [today]);
const weekEnd = useMemo(() => addDays(weekStart, 6), [weekStart]);
const [items, setItems] = useState<CalendarItem[]>([]);
const [holidays, setHolidays] = useState<Holiday[]>([]);
const [customDates, setCustomDates] = useState<CustomDate[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState("");
const [showFilters, setShowFilters] = useState(false);
const [statusFilter, setStatusFilter] = useState<"all" | CalendarItem["status"]>("all");
const [searchFilter, setSearchFilter] = useState("");
const todayValue = formatDateValue(today);
const weekStartValue = formatDateValue(weekStart);
const weekEndValue = formatDateValue(weekEnd);
const years = useMemo(() => yearsInRange(weekStart, weekEnd), [weekEnd, weekStart]);
const yearsKey = years.join(",");
useEffect(() => {
const token = getStoredToken();
if (!token) return;
setIsLoading(true);
setError("");
Promise.all([
listCalendarItemsByRange(token, weekStartValue, weekEndValue, selectedClientId),
Promise.all(years.map((year) => listHolidays(token, year))),
Promise.all(years.map((year) => listCustomDates(token, year, selectedClientId))),
])
.then(([loadedItems, loadedHolidaysByYear, loadedCustomDatesByYear]) => {
setItems(loadedItems);
setHolidays(loadedHolidaysByYear.flat());
setCustomDates(loadedCustomDatesByYear.flat());
})
.catch((err) => {
setError(err instanceof Error ? err.message : "Não foi possível carregar o dashboard.");
})
.finally(() => setIsLoading(false));
}, [selectedClientId, weekEndValue, weekStartValue, yearsKey]);
const filteredItems = items.filter((item) => {
const matchesStatus = statusFilter === "all" || item.status === statusFilter;
const term = searchFilter.trim().toLowerCase();
const matchesSearch =
term === "" ||
item.title.toLowerCase().includes(term) ||
item.client_name.toLowerCase().includes(term);
return matchesStatus && matchesSearch;
});
const todayItems = filteredItems.filter((item) => item.scheduled_date === todayValue);
const reviewItems = filteredItems.filter((item) => item.status === "em_revisao");
const approvedItems = filteredItems.filter((item) => item.status === "aprovado" || item.status === "publicado");
const pendingItems = filteredItems.filter((item) => !["aprovado", "publicado", "cancelado"].includes(item.status));
const weekDates = [...holidays, ...customDates]
.filter((date) => date.date >= weekStartValue && date.date <= weekEndValue)
.sort((a, b) => a.date.localeCompare(b.date));
const warningDate = weekDates[0];
const periodDescription =
description ??
`Visão geral entre ${shortDateFormatter.format(weekStart)} e ${shortDateFormatter.format(weekEnd)}.`;
return (
<div className={compact ? "space-y-4" : "space-y-6"}>
<div className="flex flex-col sm:flex-row sm:items-end justify-between gap-4">
<div>
<h1 className={compact ? "text-lg font-semibold tracking-tight" : "text-2xl font-semibold tracking-tight"}>{title}</h1>
<p className="text-sm text-muted-foreground mt-1">
{periodDescription}
</p>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" className="h-9" onClick={() => setShowFilters((current) => !current)}>
<Filter className="mr-2 h-4 w-4" />
Filtros
</Button>
<Button size="sm" className="h-9" onClick={() => onViewChange("calendar")}>
<Plus className="mr-2 h-4 w-4" />
Novo Post
</Button>
</div>
</div>
{error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</div>
)}
{showFilters && (
<div className="grid gap-3 rounded-lg border border-border/60 bg-card p-4 sm:grid-cols-[180px_1fr]">
<div className="grid gap-2">
<label className="text-sm font-medium">Status</label>
<select
className="h-9 rounded-lg border border-input bg-background px-3 text-sm"
value={statusFilter}
onChange={(event) => setStatusFilter(event.target.value as "all" | CalendarItem["status"])}
>
<option value="all">Todos</option>
{Object.entries(statusLabels).map(([value, label]) => (
<option key={value} value={value}>{label}</option>
))}
</select>
</div>
<div className="grid gap-2">
<label className="text-sm font-medium">Buscar</label>
<input
className="h-9 rounded-lg border border-input bg-background px-3 text-sm"
placeholder="Título ou cliente"
value={searchFilter}
onChange={(event) => setSearchFilter(event.target.value)}
/>
</div>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<SummaryCard title="Para Hoje" value={todayItems.length} detail={`${pendingItems.length} pendentes na semana`} />
<SummaryCard title="Em Aprovação" value={reviewItems.length} detail="Aguardando revisão ou cliente" />
<SummaryCard title="Aprovados" value={approvedItems.length} detail="Prontos ou publicados" emphasize />
<Card className="shadow-sm bg-muted/40 border-dashed border-2">
<CardHeader className="p-4 pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Avisos</CardTitle>
</CardHeader>
<CardContent className="p-4 pt-0">
<div className="text-sm font-medium text-foreground flex items-center gap-1.5">
<AlertCircle className="h-4 w-4 text-orange-500" />
{warningDate ? warningDate.name : "Sem datas críticas"}
</div>
<p className="text-xs text-muted-foreground mt-1">
{warningDate ? shortDateFormatter.format(parseDateValue(warningDate.date)) : "Nenhum feriado ou data na semana"}
</p>
</CardContent>
</Card>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2 space-y-4">
<Tabs defaultValue="pending" className="w-full">
<div className="flex items-center justify-between">
<TabsList className="bg-transparent border-b border-border/40 w-full justify-start h-auto p-0 rounded-none">
<TabsTrigger value="pending" className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-4 py-2 font-medium">
Ações Pendentes
<Badge variant="secondary" className="ml-2 h-5 px-1.5 rounded-sm">{pendingItems.length}</Badge>
</TabsTrigger>
<TabsTrigger value="approved" className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-4 py-2 font-medium">
Aprovados
</TabsTrigger>
</TabsList>
</div>
<TabsContent value="pending" className="pt-4 space-y-3 m-0">
<PostList items={pendingItems} isLoading={isLoading} emptyText="Nenhuma ação pendente nesta semana." onPostSelect={onPostSelect} />
</TabsContent>
<TabsContent value="approved" className="pt-4 space-y-3 m-0">
<PostList items={approvedItems} isLoading={isLoading} emptyText="Nenhum post aprovado nesta semana." onPostSelect={onPostSelect} />
</TabsContent>
</Tabs>
</div>
<div className="space-y-6">
<Card className="shadow-sm">
<CardHeader className="p-4 pb-0">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<CalendarIcon className="h-4 w-4 text-muted-foreground" />
Datas da Semana
</CardTitle>
</CardHeader>
<CardContent className="p-4 pt-3 space-y-3">
{weekDates.map((date) => (
<div key={`${date.date}-${date.name}`} className="flex items-center justify-between text-sm">
<div className="flex flex-col">
<span className="font-medium text-foreground">{date.name}</span>
<span className="text-xs text-muted-foreground">{"source" in date ? "Nacional" : "Personalizada"}</span>
</div>
<Badge variant="outline" className="font-mono bg-muted/40">
{shortDateFormatter.format(parseDateValue(date.date))}
</Badge>
</div>
))}
{weekDates.length === 0 && (
<p className="text-sm text-muted-foreground py-3">Nenhuma data comemorativa nesta semana.</p>
)}
</CardContent>
</Card>
<Button variant="ghost" className="w-full text-xs h-8 text-primary" onClick={() => onViewChange("calendar")}>
Ver calendário completo
</Button>
</div>
</div>
</div>
);
}
function SummaryCard({ title, value, detail, emphasize = false }: { title: string; value: number; detail: string; emphasize?: boolean }) {
return (
<Card className="shadow-sm">
<CardHeader className="p-4 pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">{title}</CardTitle>
</CardHeader>
<CardContent className="p-4 pt-0">
<div className={`text-2xl font-semibold ${emphasize ? "text-primary" : ""}`}>{value}</div>
<p className="text-xs text-muted-foreground mt-1">{detail}</p>
</CardContent>
</Card>
);
}
function PostList({
items,
isLoading,
emptyText,
onPostSelect,
}: {
items: CalendarItem[];
isLoading: boolean;
emptyText: string;
onPostSelect: (postId: string) => void;
}) {
if (isLoading) {
return <p className="text-sm text-muted-foreground py-6">Carregando postagens...</p>;
}
if (items.length === 0) {
return <p className="text-sm text-muted-foreground py-6">{emptyText}</p>;
}
return (
<>
{items.map((post) => (
<button key={post.id} type="button" className="flex w-full flex-col sm:flex-row gap-4 p-4 rounded-lg border border-border/60 bg-card hover:bg-muted/30 transition-colors shadow-sm text-left" onClick={() => onPostSelect(post.id)}>
<div className="flex-1 space-y-1 z-10">
<div className="flex items-center gap-2 mb-1">
<Badge variant="outline" className="text-[10px] uppercase font-semibold text-muted-foreground">{post.client_name}</Badge>
<Badge variant="secondary" className="text-[10px] bg-orange-500/10 text-orange-700 dark:text-orange-300 hover:bg-orange-500/20">
{post.content_type}
</Badge>
</div>
<h3 className="font-medium text-sm text-foreground line-clamp-1">{post.title}</h3>
<div className="flex items-center gap-4 text-xs text-muted-foreground pt-1">
<span className="flex items-center gap-1.5"><Clock className="h-3.5 w-3.5" /> {shortDateFormatter.format(parseDateValue(post.scheduled_date))}</span>
<span className="flex items-center gap-1.5"><FileImage className="h-3.5 w-3.5" /> Anexos pendentes</span>
</div>
</div>
<div className="flex sm:flex-col items-center sm:items-end justify-between sm:justify-center border-t sm:border-t-0 sm:border-l border-border/40 pt-3 sm:pt-0 sm:pl-4 mt-2 sm:mt-0 gap-2">
<span className="text-xs font-medium text-orange-600 dark:text-orange-300 flex items-center gap-1.5">
{post.status === "aprovado" || post.status === "publicado" ? <CheckCircle2 className="h-3.5 w-3.5" /> : <AlertCircle className="h-3.5 w-3.5" />}
{statusLabels[post.status]}
</span>
<Avatar className="h-6 w-6 border-border border">
<AvatarFallback className="text-[10px] bg-primary/5 text-primary font-medium">{initials(post.client_name)}</AvatarFallback>
</Avatar>
</div>
</button>
))}
</>
);
}
function startOfWeek(date: Date) {
const copy = new Date(date.getFullYear(), date.getMonth(), date.getDate());
const day = copy.getDay();
const mondayOffset = day === 0 ? -6 : 1 - day;
return addDays(copy, mondayOffset);
}
function addDays(date: Date, days: number) {
const copy = new Date(date);
copy.setDate(copy.getDate() + days);
return copy;
}
function formatDateValue(date: Date) {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
}
function parseDateValue(value: string) {
const [year, month, day] = value.split("-").map(Number);
return new Date(year, month - 1, day);
}
function yearsInRange(from: Date, to: Date) {
const years: number[] = [];
for (let year = from.getFullYear(); year <= to.getFullYear(); year += 1) {
years.push(year);
}
return years;
}
function initials(value: string) {
return value
.split(" ")
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0])
.join("")
.toUpperCase();
}

View File

@@ -0,0 +1,118 @@
import { useState, type FormEvent } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardFooter } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { ModeToggle } from "@/components/mode-toggle";
import { Eye, EyeOff } from "lucide-react";
import { login, type AuthUser } from "@/lib/auth";
export function LoginView({ onLogin }: { onLogin: (user: AuthUser) => void }) {
const [showPassword, setShowPassword] = useState(false);
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState("");
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setError("");
setIsSubmitting(true);
try {
const response = await login(email, password);
onLogin(response.user);
} catch (err) {
setError(err instanceof Error ? err.message : "Nao foi possivel fazer login.");
} finally {
setIsSubmitting(false);
}
}
return (
<div className="min-h-screen bg-muted/40 flex items-center justify-center p-4 relative">
<div className="absolute top-4 right-4">
<ModeToggle />
</div>
<div className="w-full max-w-md space-y-6">
<div className="flex justify-center flex-col items-center gap-2 mb-8">
<div className="h-12 w-12 bg-primary rounded-xl flex items-center justify-center">
<span className="text-primary-foreground font-bold text-2xl tracking-tighter">M</span>
</div>
<h1 className="text-2xl font-semibold tracking-tight text-foreground">Mira</h1>
<p className="text-sm text-muted-foreground">Planejamento Editorial & Operações</p>
</div>
<Card className="shadow-sm border-border/50">
<CardHeader className="space-y-1 pb-4">
<CardTitle className="text-xl">Acessar a Plataforma</CardTitle>
<CardDescription>
Insira suas credenciais corporativas ou do cliente.
</CardDescription>
</CardHeader>
<form onSubmit={handleSubmit}>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">E-mail corporativo</Label>
<Input
id="email"
type="email"
placeholder="nome@agencia.com.br"
autoComplete="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
required
/>
</div>
<div className="space-y-2 relative">
<div className="flex items-center justify-between">
<Label htmlFor="password">Senha</Label>
</div>
<div className="relative">
<Input
id="password"
type={showPassword ? "text" : "password"}
placeholder="Sua senha de acesso"
autoComplete="current-password"
value={password}
onChange={(event) => setPassword(event.target.value)}
required
/>
<Button
variant="ghost"
size="icon"
type="button"
className="absolute right-0 top-0 h-9 w-9 text-muted-foreground hover:text-foreground"
onClick={() => setShowPassword(!showPassword)}
>
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</Button>
</div>
</div>
<div className="flex items-center space-x-2 pt-2">
<Checkbox id="remember" />
<Label htmlFor="remember" className="text-sm font-normal text-muted-foreground">Lembrar neste dispositivo</Label>
</div>
{error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</div>
)}
</CardContent>
<CardFooter>
<Button className="w-full" type="submit" disabled={isSubmitting}>
{isSubmitting ? "Entrando..." : "Entrar"}
</Button>
</CardFooter>
</form>
</Card>
<div className="text-center text-xs text-muted-foreground">
Problemas com o acesso? Fale com o gestor de contas.
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,287 @@
import { useEffect, useMemo, useState, type FormEvent } from "react";
import { ArrowLeft, Download, FileText, Image as ImageIcon, Upload } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { getStoredToken } from "@/lib/auth";
import {
attachmentDownloadURL,
getCalendarItem,
listAttachments,
uploadAttachment,
type Attachment,
type CalendarItem,
} from "@/lib/calendar";
type PostDetailViewProps = {
itemId: string;
onBack: () => void;
};
const statusLabels: Record<CalendarItem["status"], string> = {
rascunho: "Rascunho",
planejado: "Planejado",
em_producao: "Em produção",
em_revisao: "Em revisão",
aprovado: "Aprovado",
publicado: "Publicado",
cancelado: "Cancelado",
};
const dateFormatter = new Intl.DateTimeFormat("pt-BR", { day: "2-digit", month: "long", year: "numeric" });
export function PostDetailView({ itemId, onBack }: PostDetailViewProps) {
const [item, setItem] = useState<CalendarItem | null>(null);
const [attachments, setAttachments] = useState<Attachment[]>([]);
const [previewAttachment, setPreviewAttachment] = useState<Attachment | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const [error, setError] = useState("");
const [attachmentError, setAttachmentError] = useState("");
const imageAttachments = useMemo(() => attachments.filter((attachment) => isImageAttachment(attachment)), [attachments]);
const fileAttachments = useMemo(() => attachments.filter((attachment) => !isImageAttachment(attachment)), [attachments]);
useEffect(() => {
const token = getStoredToken();
if (!token) return;
setIsLoading(true);
setError("");
Promise.all([getCalendarItem(token, itemId), listAttachments(token, itemId)])
.then(([loadedItem, loadedAttachments]) => {
setItem(loadedItem);
setAttachments(loadedAttachments);
})
.catch((err) => {
setError(err instanceof Error ? err.message : "Não foi possível carregar a postagem.");
})
.finally(() => setIsLoading(false));
}, [itemId]);
async function handleUploadAttachment(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const token = getStoredToken();
const fileInput = event.currentTarget.elements.namedItem("attachment") as HTMLInputElement | null;
const file = fileInput?.files?.[0];
if (!token || !file) return;
setIsUploading(true);
setAttachmentError("");
try {
const attachment = await uploadAttachment(token, itemId, file);
setAttachments((current) => [attachment, ...current]);
if (fileInput) fileInput.value = "";
} catch (err) {
setAttachmentError(err instanceof Error ? err.message : "Não foi possível enviar o anexo.");
} finally {
setIsUploading(false);
}
}
if (isLoading && !item) {
return <p className="text-sm text-muted-foreground">Carregando postagem...</p>;
}
if (error) {
return (
<div className="space-y-4">
<Button variant="outline" size="sm" onClick={onBack}>
<ArrowLeft className="mr-2 h-4 w-4" />
Voltar
</Button>
<div className="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</div>
</div>
);
}
if (!item) return null;
return (
<div className="space-y-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div className="space-y-3">
<Button variant="outline" size="sm" onClick={onBack}>
<ArrowLeft className="mr-2 h-4 w-4" />
Voltar
</Button>
<div>
<div className="flex flex-wrap items-center gap-2">
<h1 className="text-2xl font-semibold tracking-tight">{item.title}</h1>
<Badge variant="secondary" className="bg-orange-500/10 text-orange-700 dark:text-orange-300">
{statusLabels[item.status]}
</Badge>
</div>
<p className="text-sm text-muted-foreground mt-1">
{item.client_name} · {dateFormatter.format(parseDateValue(item.scheduled_date))}
</p>
</div>
</div>
</div>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
<div className="space-y-6 lg:col-span-2">
<Card>
<CardHeader>
<CardTitle className="text-base">Detalhes</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<ReadOnlyField label="Resumo" value={item.description} fallback="Sem resumo." />
<ReadOnlyField label="Texto da postagem" value={item.copy_text} fallback="Sem texto cadastrado." multiline />
<ReadOnlyField label="Notas para o cliente" value={item.client_notes} fallback="Sem notas para o cliente." multiline />
<ReadOnlyField label="Notas internas" value={item.internal_notes} fallback="Sem notas internas." multiline />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Anexos</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<form className="grid gap-3 sm:grid-cols-[1fr_auto]" onSubmit={handleUploadAttachment}>
<div className="grid gap-2">
<Label htmlFor="post-detail-attachment">Enviar anexo</Label>
<Input id="post-detail-attachment" name="attachment" type="file" accept="image/jpeg,image/png,image/webp,application/pdf" />
</div>
<Button className="sm:self-end" type="submit" disabled={isUploading}>
<Upload className="mr-2 h-4 w-4" />
{isUploading ? "Enviando..." : "Enviar"}
</Button>
</form>
{attachmentError && (
<div className="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{attachmentError}
</div>
)}
{attachments.length === 0 && (
<p className="text-sm text-muted-foreground py-4">Nenhum anexo enviado.</p>
)}
{imageAttachments.length > 0 && (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
{imageAttachments.map((attachment) => (
<button
key={attachment.id}
type="button"
className="group overflow-hidden rounded-lg border border-border/60 bg-muted/20 text-left"
onClick={() => setPreviewAttachment(attachment)}
>
<img
src={attachmentDownloadURL(attachment.id)}
alt={attachment.original_filename}
className="aspect-square w-full object-cover transition group-hover:scale-[1.02]"
/>
<div className="flex items-center gap-2 px-2 py-2 text-xs">
<ImageIcon className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="truncate">{attachment.original_filename}</span>
</div>
</button>
))}
</div>
)}
{fileAttachments.length > 0 && (
<div className="space-y-2">
{fileAttachments.map((attachment) => (
<a
key={attachment.id}
href={attachmentDownloadURL(attachment.id)}
target="_blank"
rel="noreferrer"
className="flex items-center justify-between gap-3 rounded-lg border border-border/60 px-3 py-2 text-sm hover:bg-muted/30"
>
<span className="flex min-w-0 items-center gap-2">
<FileText className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="truncate">{attachment.original_filename}</span>
</span>
<span className="flex shrink-0 items-center gap-2 text-xs text-muted-foreground">
{formatBytes(attachment.size_bytes)}
<Download className="h-4 w-4" />
</span>
</a>
))}
</div>
)}
</CardContent>
</Card>
</div>
<Card className="h-fit">
<CardHeader>
<CardTitle className="text-base">Informações</CardTitle>
</CardHeader>
<CardContent className="space-y-3 text-sm">
<InfoRow label="Cliente" value={item.client_name} />
<InfoRow label="Status" value={statusLabels[item.status]} />
<InfoRow label="Tipo" value={item.content_type} />
<InfoRow label="Data" value={dateFormatter.format(parseDateValue(item.scheduled_date))} />
<InfoRow label="Anexos" value={String(attachments.length)} />
</CardContent>
</Card>
</div>
<Dialog open={previewAttachment !== null} onOpenChange={(open) => !open && setPreviewAttachment(null)}>
<DialogContent className="sm:max-w-[860px]">
<DialogHeader>
<DialogTitle>{previewAttachment?.original_filename}</DialogTitle>
<DialogDescription>Visualização do anexo.</DialogDescription>
</DialogHeader>
{previewAttachment && (
<div className="overflow-hidden rounded-lg border border-border/60 bg-black/5">
<img
src={attachmentDownloadURL(previewAttachment.id)}
alt={previewAttachment.original_filename}
className="max-h-[70vh] w-full object-contain"
/>
</div>
)}
</DialogContent>
</Dialog>
</div>
);
}
function ReadOnlyField({ label, value, fallback, multiline = false }: { label: string; value: string; fallback: string; multiline?: boolean }) {
return (
<div className="grid gap-2">
<Label>{label}</Label>
{multiline ? (
<Textarea value={value || fallback} readOnly className="min-h-[110px]" />
) : (
<Input value={value || fallback} readOnly />
)}
</div>
);
}
function InfoRow({ label, value }: { label: string; value: string }) {
return (
<div className="flex items-center justify-between gap-3 border-b border-border/40 pb-2 last:border-b-0 last:pb-0">
<span className="text-muted-foreground">{label}</span>
<span className="font-medium text-right">{value}</span>
</div>
);
}
function isImageAttachment(attachment: Attachment) {
return attachment.mime_type.startsWith("image/");
}
function parseDateValue(value: string) {
const [year, month, day] = value.split("-").map(Number);
return new Date(year, month - 1, day);
}
function formatBytes(value: number) {
if (value < 1024) return `${value} B`;
if (value < 1024 * 1024) return `${Math.round(value / 1024)} KB`;
return `${(value / 1024 / 1024).toFixed(1)} MB`;
}

View File

@@ -0,0 +1,226 @@
import { useEffect, useState, type FormEvent, type ReactNode } from "react";
import { Copy, Plus, Users } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { getStoredToken, type AuthUser } from "@/lib/auth";
import { createInvitation, listInvitations, listUsers, type Invitation } from "@/lib/users";
import type { Client } from "@/lib/clients";
type UsersViewProps = {
clients: Client[];
};
const roleLabels: Record<AuthUser["role"] | Invitation["role"], string> = {
super_admin: "Super-admin",
agency_user: "Agência",
client_viewer: "Cliente",
};
export function UsersView({ clients }: UsersViewProps) {
const [users, setUsers] = useState<AuthUser[]>([]);
const [invitations, setInvitations] = useState<Invitation[]>([]);
const [email, setEmail] = useState("");
const [role, setRole] = useState<Invitation["role"]>("agency_user");
const [clientId, setClientId] = useState("");
const [createdURL, setCreatedURL] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
void refresh();
}, []);
async function refresh() {
const token = getStoredToken();
if (!token) return;
setIsLoading(true);
setError("");
try {
const [loadedUsers, loadedInvitations] = await Promise.all([listUsers(token), listInvitations(token)]);
setUsers(loadedUsers);
setInvitations(loadedInvitations);
} catch (err) {
setError(err instanceof Error ? err.message : "Não foi possível carregar acessos.");
} finally {
setIsLoading(false);
}
}
async function handleInvite(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const token = getStoredToken();
if (!token) return;
setIsSubmitting(true);
setError("");
setCreatedURL("");
try {
const invitation = await createInvitation(token, {
email,
role,
client_id: role === "client_viewer" ? clientId : undefined,
});
setInvitations((current) => [invitation, ...current]);
setEmail("");
setClientId("");
setCreatedURL(invitation.invitation_url ?? "");
} catch (err) {
setError(err instanceof Error ? err.message : "Não foi possível criar o convite.");
} finally {
setIsSubmitting(false);
}
}
async function copyInvitationURL() {
if (!createdURL) return;
await navigator.clipboard.writeText(createdURL);
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Gestão de Acessos</h1>
<p className="text-sm text-muted-foreground mt-1">Gerencie usuários da agência e clientes.</p>
</div>
</div>
<Card>
<CardHeader>
<CardTitle className="text-base">Convidar usuário</CardTitle>
</CardHeader>
<CardContent>
<form className="grid gap-3 md:grid-cols-[1fr_180px_220px_auto]" onSubmit={handleInvite}>
<div className="grid gap-2">
<Label htmlFor="invite-email">E-mail</Label>
<Input
id="invite-email"
type="email"
placeholder="nome@empresa.com.br"
value={email}
onChange={(event) => setEmail(event.target.value)}
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="invite-role">Perfil</Label>
<select
id="invite-role"
className="h-9 rounded-lg border border-input bg-background px-3 text-sm"
value={role}
onChange={(event) => setRole(event.target.value as Invitation["role"])}
>
<option value="agency_user">Agência</option>
<option value="client_viewer">Cliente</option>
</select>
</div>
<div className="grid gap-2">
<Label htmlFor="invite-client">Cliente</Label>
<select
id="invite-client"
className="h-9 rounded-lg border border-input bg-background px-3 text-sm disabled:opacity-60"
value={clientId}
onChange={(event) => setClientId(event.target.value)}
disabled={role !== "client_viewer"}
required={role === "client_viewer"}
>
<option value="">Selecione</option>
{clients.map((client) => (
<option key={client.id} value={client.id}>{client.name}</option>
))}
</select>
</div>
<Button className="md:self-end" type="submit" disabled={isSubmitting || (role === "client_viewer" && !clientId)}>
<Plus className="mr-2 h-4 w-4" />
{isSubmitting ? "Criando..." : "Convidar"}
</Button>
</form>
{createdURL && (
<div className="mt-4 rounded-lg border border-border bg-muted/30 p-3">
<div className="text-xs font-medium text-muted-foreground mb-2">Link do convite</div>
<div className="flex flex-col sm:flex-row gap-2">
<Input value={createdURL} readOnly />
<Button type="button" variant="outline" onClick={copyInvitationURL}>
<Copy className="mr-2 h-4 w-4" />
Copiar
</Button>
</div>
</div>
)}
{error && (
<div className="mt-3 rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</div>
)}
</CardContent>
</Card>
<div className="grid gap-6 lg:grid-cols-2">
<AccessList title="Usuários ativos" emptyText="Nenhum usuário carregado." isLoading={isLoading}>
{users.map((user) => (
<div key={user.id} className="flex items-center justify-between gap-4 rounded-lg border border-border/60 bg-card p-4">
<div className="min-w-0">
<h3 className="font-medium truncate">{user.name}</h3>
<p className="text-sm text-muted-foreground truncate">{user.email}</p>
</div>
<Badge variant="secondary">{roleLabels[user.role]}</Badge>
</div>
))}
</AccessList>
<AccessList title="Convites" emptyText="Nenhum convite criado." isLoading={isLoading}>
{invitations.map((invitation) => (
<div key={invitation.id} className="rounded-lg border border-border/60 bg-card p-4 space-y-2">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<h3 className="font-medium truncate">{invitation.email}</h3>
<p className="text-sm text-muted-foreground truncate">
{invitation.client_name ? `Cliente: ${invitation.client_name}` : "Acesso da agência"}
</p>
</div>
<Badge variant={invitation.accepted_at ? "secondary" : "outline"}>
{invitation.accepted_at ? "Aceito" : roleLabels[invitation.role]}
</Badge>
</div>
<p className="text-xs text-muted-foreground">
Expira em {new Intl.DateTimeFormat("pt-BR", { dateStyle: "medium" }).format(new Date(invitation.expires_at))}
</p>
</div>
))}
</AccessList>
</div>
</div>
);
}
function AccessList({ title, emptyText, isLoading, children }: { title: string; emptyText: string; isLoading: boolean; children: ReactNode }) {
const hasChildren = Array.isArray(children) ? children.length > 0 : Boolean(children);
return (
<div className="space-y-3">
<h2 className="text-base font-semibold">{title}</h2>
{isLoading && <p className="text-sm text-muted-foreground py-4">Carregando...</p>}
{!isLoading && !hasChildren && (
<div className="flex items-center justify-center h-[220px] border border-dashed border-border rounded-xl">
<div className="text-center space-y-3">
<div className="bg-muted w-12 h-12 rounded-full flex items-center justify-center mx-auto">
<Users className="h-6 w-6 text-muted-foreground" />
</div>
<p className="text-sm text-muted-foreground w-[260px]">{emptyText}</p>
</div>
</div>
)}
{!isLoading && hasChildren && <div className="grid gap-3">{children}</div>}
</div>
);
}

1
frontend/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

26
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "ES2022",
"experimentalDecorators": true,
"useDefineForClassFields": false,
"module": "ESNext",
"lib": [
"ES2022",
"DOM",
"DOM.Iterable"
],
"skipLibCheck": true,
"moduleResolution": "bundler",
"isolatedModules": true,
"moduleDetection": "force",
"allowJs": true,
"jsx": "react-jsx",
"paths": {
"@/*": [
"./src/*"
]
},
"allowImportingTsExtensions": true,
"noEmit": true
}
}

22
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,22 @@
import tailwindcss from '@tailwindcss/vite';
import react from '@vitejs/plugin-react';
import path from 'path';
import {defineConfig} from 'vite';
export default defineConfig(() => {
return {
plugins: [react(), tailwindcss()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
// HMR is disabled in AI Studio via DISABLE_HMR env var.
// Do not modify—file watching is disabled to prevent flickering during agent edits.
hmr: process.env.DISABLE_HMR !== 'true',
// Disable file watching when DISABLE_HMR is true to save CPU during agent edits.
watch: process.env.DISABLE_HMR === 'true' ? null : {},
},
};
});