Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ node_modules
.env
.gemini/
.playwright-cli/
.tanstack/

docs

Expand Down
55 changes: 55 additions & 0 deletions app/components/Admin/AdminSidebar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { Link, useRouterState } from "@tanstack/react-router";
import { LayoutDashboard, Users2 } from "lucide-react";
import { cn } from "~/lib/utils";

type NavItem = {
to: string;
label: string;
icon: React.ComponentType<{ className?: string }>;
exact?: boolean;
};

const NAV: NavItem[] = [
{ to: "/admin", label: "Dashboard", icon: LayoutDashboard, exact: true },
{ to: "/admin/contagens", label: "Contagens", icon: Users2 },
];

export function AdminSidebar() {
const pathname = useRouterState({ select: (s) => s.location.pathname });

return (
<aside className="hidden lg:flex w-60 shrink-0 flex-col border-r bg-sidebar text-sidebar-foreground">
<div className="px-6 py-5 border-b">
<Link to="/admin" className="flex items-baseline gap-2">
<span className="text-lg font-semibold tracking-tight">Ameciclo</span>
<span className="text-xs uppercase tracking-wider text-muted-foreground">Admin</span>
</Link>
</div>
<nav className="flex-1 px-3 py-4 space-y-1">
{NAV.map(({ to, label, icon: Icon, exact }) => {
const active = exact ? pathname === to : pathname === to || pathname.startsWith(`${to}/`);
return (
<Link
key={to}
to={to}
className={cn(
"flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors",
active
? "bg-sidebar-accent text-sidebar-accent-foreground"
: "text-sidebar-foreground/80 hover:bg-sidebar-accent/60 hover:text-sidebar-accent-foreground",
)}
>
<Icon className="size-4 shrink-0" />
{label}
</Link>
);
})}
</nav>
<div className="border-t px-6 py-4 text-xs text-muted-foreground">
<Link to="/" className="hover:text-foreground transition-colors">
← Voltar ao site
</Link>
</div>
</aside>
);
}
19 changes: 19 additions & 0 deletions app/components/Admin/AdminTopbar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
type AdminTopbarProps = {
title: string;
description?: string;
actions?: React.ReactNode;
};

export function AdminTopbar({ title, description, actions }: AdminTopbarProps) {
return (
<header className="flex flex-wrap items-start justify-between gap-4 border-b bg-background/80 backdrop-blur-sm px-6 py-4">
<div className="min-w-0">
<h1 className="text-xl font-semibold tracking-tight">{title}</h1>
{description && (
<p className="mt-1 text-sm text-muted-foreground">{description}</p>
)}
</div>
{actions && <div className="flex items-center gap-2">{actions}</div>}
</header>
);
}
152 changes: 152 additions & 0 deletions app/components/Admin/ContagensTable.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import { useMemo, useState } from "react";
import { Link } from "@tanstack/react-router";
import { Search, Pencil } from "lucide-react";
import { Input } from "~/components/ui/input";
import { Button } from "~/components/ui/button";
import { Badge } from "~/components/ui/badge";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "~/components/ui/table";

export type ContagemRow = {
id: number | string;
name: string;
slug: string;
date: string;
total_cyclists: number;
};

type SortKey = "name" | "date" | "total_cyclists";
type SortDir = "asc" | "desc";

const dateFormatter = new Intl.DateTimeFormat("pt-BR", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});

function formatDate(value: string): string {
if (!value) return "—";
const d = new Date(value);
if (Number.isNaN(d.getTime())) return value;
return dateFormatter.format(d);
}

export function ContagensTable({ rows }: { rows: ContagemRow[] }) {
const [query, setQuery] = useState("");
const [sortKey, setSortKey] = useState<SortKey>("date");
const [sortDir, setSortDir] = useState<SortDir>("desc");

const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
const base = q ? rows.filter((r) => r.name.toLowerCase().includes(q)) : rows;
const sorted = [...base].sort((a, b) => {
const av = a[sortKey];
const bv = b[sortKey];
if (typeof av === "number" && typeof bv === "number") return av - bv;
return String(av).localeCompare(String(bv));
});
return sortDir === "desc" ? sorted.reverse() : sorted;
}, [rows, query, sortKey, sortDir]);

function toggleSort(key: SortKey) {
if (sortKey === key) {
setSortDir((d) => (d === "asc" ? "desc" : "asc"));
} else {
setSortKey(key);
setSortDir(key === "name" ? "asc" : "desc");
}
}

return (
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="relative max-w-sm flex-1">
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Buscar por nome do ponto..."
className="pl-9"
/>
</div>
<Badge variant="secondary" className="text-xs">
{filtered.length.toLocaleString("pt-BR")} de{" "}
{rows.length.toLocaleString("pt-BR")}
</Badge>
</div>

<div className="rounded-md border bg-background">
<Table>
<TableHeader>
<TableRow>
<SortableHead label="Ponto" active={sortKey === "name"} dir={sortDir} onClick={() => toggleSort("name")} />
<SortableHead label="Data" active={sortKey === "date"} dir={sortDir} onClick={() => toggleSort("date")} />
<SortableHead label="Ciclistas" align="right" active={sortKey === "total_cyclists"} dir={sortDir} onClick={() => toggleSort("total_cyclists")} />
<TableHead className="w-32 text-right">Ações</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filtered.length === 0 ? (
<TableRow>
<TableCell colSpan={4} className="text-center text-sm text-muted-foreground py-12">
Nenhum ponto corresponde à busca.
</TableCell>
</TableRow>
) : (
filtered.map((row, idx) => (
<TableRow key={`${row.id}-${row.date}-${idx}`}>
<TableCell className="font-medium">{row.name}</TableCell>
<TableCell>{formatDate(row.date)}</TableCell>
<TableCell className="text-right tabular-nums">
{row.total_cyclists.toLocaleString("pt-BR")}
</TableCell>
<TableCell className="text-right">
<Button asChild size="sm" variant="ghost">
<Link to="/dados/contagens/$slug" params={{ slug: row.slug }}>
<Pencil className="size-4" />
Editar
</Link>
</Button>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div>
);
}

function SortableHead({
label,
active,
dir,
onClick,
align = "left",
}: {
label: string;
active: boolean;
dir: SortDir;
onClick: () => void;
align?: "left" | "right";
}) {
return (
<TableHead className={align === "right" ? "text-right" : undefined}>
<button
type="button"
onClick={onClick}
className="inline-flex items-center gap-1 text-xs font-medium uppercase tracking-wide text-muted-foreground hover:text-foreground transition-colors"
>
{label}
{active && <span aria-hidden>{dir === "asc" ? "▲" : "▼"}</span>}
</button>
</TableHead>
);
}
8 changes: 5 additions & 3 deletions app/components/Commom/MainContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,12 @@ interface MainContentProps {

export function MainContent({ children }: MainContentProps) {
const location = useRouterState({ select: (s) => s.location });
const isCicloDadosPage = location.pathname === '/dados/ciclodados';

const path = location.pathname;
const isFullBleed =
path === '/dados/ciclodados' || path === '/admin' || path.startsWith('/admin/');

return (
<main className={isCicloDadosPage ? '' : 'pt-14'}>
<main className={isFullBleed ? '' : 'pt-14'}>
{children}
</main>
);
Expand Down
48 changes: 48 additions & 0 deletions app/components/ui/badge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"

import { cn } from "~/lib/utils"

const badgeVariants = cva(
"inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 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/90",
secondary:
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"bg-destructive text-white focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 [a&]:hover:bg-destructive/90",
outline:
"border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
link: "text-primary underline-offset-4 [a&]:hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)

function Badge({
className,
variant = "default",
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "span"

return (
<Comp
data-slot="badge"
data-variant={variant}
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}

export { Badge, badgeVariants }
92 changes: 92 additions & 0 deletions app/components/ui/card.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import * as React from "react"

import { cn } from "~/lib/utils"

function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm",
className
)}
{...props}
/>
)
}

function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}

function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}

function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-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-6", className)}
{...props}
/>
)
}

function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}

export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}
Loading
Loading