Skip to content
Merged
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ Agora o Docker foi separado em 3 servicos:
### Subir dashboard + API (sem scraping automatico)

```bash
docker compose up --build
docker compose up --build -D

Copilot AI Mar 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

docker compose up uses -d/--detach (lowercase). -D is not a valid flag and will fail for readers following the README.

Suggested change
docker compose up --build -D
docker compose up --build -d

Copilot uses AI. Check for mistakes.
```

### Executar scraping manualmente
Expand Down
17 changes: 16 additions & 1 deletion frontend/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,25 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script>
(function () {
const storageKey = "jobs-theme-preference";
const root = document.documentElement;
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
const storedPreference = localStorage.getItem(storageKey);
const themePreference = storedPreference === "light" || storedPreference === "dark" || storedPreference === "system"
? storedPreference
: "system";
const resolvedTheme = themePreference === "system" ? (prefersDark ? "dark" : "light") : themePreference;
Comment on lines +7 to +16

Copilot AI Mar 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This inline theme bootstrap script reads from localStorage unguarded; localStorage.getItem can throw in environments where storage is blocked, causing a blank page before React loads. Consider wrapping storage access in try/catch (and defaulting to "system") to avoid hard failures.

Copilot uses AI. Check for mistakes.

root.classList.toggle("dark", resolvedTheme === "dark");
root.setAttribute("data-theme", resolvedTheme);
})();
</script>
<title>frontend</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
12 changes: 8 additions & 4 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { useCallback, type SetStateAction } from "react";
import { JobsFiltersCard } from "@/components/JobsFiltersCard";
import { JobsHeaderCard } from "@/components/JobsHeaderCard";
import { JobsTableCard } from "@/components/JobsTableCard";
import { ThemeToggle } from "@/components/ui/theme-toggle";
import { useJobsData } from "@/hooks/useJobsData";
import { useJobsFiltering } from "@/hooks/useJobsFiltering";
import { useJobsPagination } from "@/hooks/useJobsPagination";
import { useTheme } from "@/hooks/useTheme";
import type { JobsMeta } from "@/types/jobs";
import { useCallback, type SetStateAction } from "react";

function formatDate(timestamp: JobsMeta["modifiedAt"]): string {
if (!timestamp) {
Expand All @@ -15,6 +17,8 @@ function formatDate(timestamp: JobsMeta["modifiedAt"]): string {
}

function App() {
const { resolvedTheme, toggleTheme } = useTheme();

const { files, selectedFile, setSelectedFile, jobs, meta, loading, error, loadJobs } = useJobsData();

const { search, setSearch, keywordFilter, setKeywordFilter, keywords, filteredJobs } = useJobsFiltering(jobs);
Expand Down Expand Up @@ -57,11 +61,11 @@ function App() {
);

return (
<main className="relative min-h-screen overflow-hidden bg-background px-4 py-8 md:px-8">
<div className="pointer-events-none absolute inset-0 -z-10 bg-[radial-gradient(circle_at_20%_15%,rgba(236,195,117,0.35),transparent_30%),radial-gradient(circle_at_80%_10%,rgba(92,151,191,0.28),transparent_35%),radial-gradient(circle_at_50%_95%,rgba(201,120,99,0.22),transparent_40%)]" />
<main className="relative min-h-screen overflow-hidden bg-background px-4 py-8 transition-colors duration-300 md:px-8">
<div className="pointer-events-none absolute inset-0 -z-10 bg-[radial-gradient(circle_at_20%_15%,rgba(236,195,117,0.35),transparent_30%),radial-gradient(circle_at_80%_10%,rgba(92,151,191,0.28),transparent_35%),radial-gradient(circle_at_50%_95%,rgba(201,120,99,0.22),transparent_40%)] dark:bg-[radial-gradient(circle_at_20%_15%,rgba(240,180,95,0.18),transparent_30%),radial-gradient(circle_at_80%_10%,rgba(92,151,191,0.18),transparent_35%),radial-gradient(circle_at_50%_95%,rgba(201,120,99,0.15),transparent_40%)]" />

<section className="mx-auto flex w-full max-w-7xl flex-col gap-6">
<JobsHeaderCard meta={meta} />
<JobsHeaderCard meta={meta} actions={<ThemeToggle theme={resolvedTheme} onToggle={toggleTheme} />} />

<JobsFiltersCard
search={search}
Expand Down
6 changes: 3 additions & 3 deletions frontend/src/components/JobsFiltersCard.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { RefreshCcw, Search } from "lucide-react";
import type { Dispatch, SetStateAction } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import type { JobFile } from "@/types/jobs";
import { RefreshCcw, Search } from "lucide-react";
import type { Dispatch, SetStateAction } from "react";

interface JobsFiltersCardProps {
search: string;
Expand Down Expand Up @@ -31,7 +31,7 @@ export function JobsFiltersCard({
onRefresh,
}: JobsFiltersCardProps) {
return (
<Card className="border-white/30 bg-card/85 backdrop-blur">
<Card className="border-border/70 bg-card/85 backdrop-blur dark:bg-card/95">
<CardContent className="grid gap-3 pt-6 md:grid-cols-4">
<div className="relative md:col-span-2">
<Search className="pointer-events-none absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
Expand Down
9 changes: 6 additions & 3 deletions frontend/src/components/JobsHeaderCard.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,24 @@
import { BriefcaseBusiness, FileSpreadsheet } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import type { JobsMeta } from "@/types/jobs";
import { BriefcaseBusiness, FileSpreadsheet } from "lucide-react";
import type { ReactNode } from "react";

interface JobsHeaderCardProps {
meta: JobsMeta;
actions?: ReactNode;
}

export function JobsHeaderCard({ meta }: JobsHeaderCardProps) {
export function JobsHeaderCard({ meta, actions }: JobsHeaderCardProps) {
return (
<Card className="border-white/30 bg-card/85 backdrop-blur">
<Card className="border-border/70 bg-card/85 backdrop-blur dark:bg-card/95">
<CardHeader className="gap-4 pb-4 md:flex-row md:items-center md:justify-between">
<div>
<CardTitle className="text-3xl">Painel de Vagas</CardTitle>
<CardDescription>Leitura automatica dos arquivos XLSX gerados em output.</CardDescription>
</div>
<div className="flex flex-wrap items-center gap-2">
{actions}
<Badge variant="secondary" className="gap-1 text-xs">
<FileSpreadsheet className="h-3.5 w-3.5" />
{meta.file || "Sem arquivo"}
Expand Down
8 changes: 5 additions & 3 deletions frontend/src/components/JobsTableCard.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import type { Job, JobsMeta } from "@/types/jobs";

Expand Down Expand Up @@ -35,7 +35,7 @@ export function JobsTableCard({
onPageSizeChange,
}: JobsTableCardProps) {
return (
<Card className="border-white/30 bg-card/90 backdrop-blur">
<Card className="border-border/70 bg-card/90 backdrop-blur dark:bg-card/95">
<CardHeader>
<CardTitle className="text-lg">Vagas Encontradas</CardTitle>
<CardDescription>
Expand All @@ -44,7 +44,9 @@ export function JobsTableCard({
</CardHeader>
<CardContent>
{error ? (
<div className="rounded-md border border-red-400/40 bg-red-500/10 p-4 text-sm text-red-900">{error}</div>
<div className="rounded-md border border-red-500/40 bg-red-500/10 p-4 text-sm text-red-700 dark:text-red-300">
{error}
</div>
) : null}

<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
Expand Down
27 changes: 27 additions & 0 deletions frontend/src/components/ui/theme-toggle.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { Button } from "@/components/ui/button";
import type { Theme } from "@/hooks/useTheme";
import { Moon, Sun } from "lucide-react";

interface ThemeToggleProps {
theme: Theme;
onToggle: () => void;
}

export function ThemeToggle({ theme, onToggle }: ThemeToggleProps) {
const isDark = theme === "dark";

return (
<Button
type="button"
variant="outline"
size="sm"
onClick={onToggle}
aria-label={isDark ? "Ativar tema claro" : "Ativar tema escuro"}
title={isDark ? "Ativar tema claro" : "Ativar tema escuro"}
className="gap-2"
>
{isDark ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
<span className="hidden sm:inline">{isDark ? "Tema claro" : "Tema escuro"}</span>
</Button>
);
}
69 changes: 69 additions & 0 deletions frontend/src/hooks/useTheme.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { useEffect, useMemo, useState } from "react";

export type Theme = "light" | "dark";
export type ThemePreference = Theme | "system";

const THEME_STORAGE_KEY = "jobs-theme-preference";
const DARK_CLASS = "dark";

function isThemePreference(value: string | null): value is ThemePreference {
return value === "light" || value === "dark" || value === "system";
}

function getSystemTheme(): Theme {
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}

function resolveTheme(themePreference: ThemePreference): Theme {
return themePreference === "system" ? getSystemTheme() : themePreference;
}

function applyThemeToRoot(theme: Theme): void {
const root = document.documentElement;

root.classList.toggle(DARK_CLASS, theme === "dark");
root.setAttribute("data-theme", theme);
}

export function useTheme() {
const [themePreference, setThemePreference] = useState<ThemePreference>(() => {
const storedPreference = localStorage.getItem(THEME_STORAGE_KEY);
return isThemePreference(storedPreference) ? storedPreference : "system";
});

const resolvedTheme = useMemo(() => resolveTheme(themePreference), [themePreference]);

useEffect(() => {
applyThemeToRoot(resolvedTheme);
localStorage.setItem(THEME_STORAGE_KEY, themePreference);
Comment on lines +28 to +38

Copilot AI Mar 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Direct localStorage.getItem access during state initialization can throw (e.g., in some privacy modes or when storage is blocked), which would break the app before it renders. Wrap storage reads/writes in a try/catch (and fall back to "system") to make theme initialization resilient.

Suggested change
export function useTheme() {
const [themePreference, setThemePreference] = useState<ThemePreference>(() => {
const storedPreference = localStorage.getItem(THEME_STORAGE_KEY);
return isThemePreference(storedPreference) ? storedPreference : "system";
});
const resolvedTheme = useMemo(() => resolveTheme(themePreference), [themePreference]);
useEffect(() => {
applyThemeToRoot(resolvedTheme);
localStorage.setItem(THEME_STORAGE_KEY, themePreference);
function getStoredThemePreference(): ThemePreference {
try {
const storedPreference = localStorage.getItem(THEME_STORAGE_KEY);
return isThemePreference(storedPreference) ? storedPreference : "system";
} catch {
return "system";
}
}
export function useTheme() {
const [themePreference, setThemePreference] = useState<ThemePreference>(() => {
return getStoredThemePreference();
});
const resolvedTheme = useMemo(() => resolveTheme(themePreference), [themePreference]);
useEffect(() => {
applyThemeToRoot(resolvedTheme);
try {
localStorage.setItem(THEME_STORAGE_KEY, themePreference);
} catch {
// Ignore storage write errors; theme is still applied to the DOM.
}

Copilot uses AI. Check for mistakes.
}, [resolvedTheme, themePreference]);

useEffect(() => {
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
const handleSystemThemeChange = () => {
if (themePreference === "system") {
applyThemeToRoot(getSystemTheme());
}
};

mediaQuery.addEventListener("change", handleSystemThemeChange);

return () => {
mediaQuery.removeEventListener("change", handleSystemThemeChange);
};
}, [themePreference]);
Comment on lines +34 to +54

Copilot AI Mar 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

resolvedTheme is memoized only on themePreference, so when themePreference === "system" and the OS theme changes, the hook applies the new theme to the DOM but does not trigger a re-render and resolvedTheme stays stale. This can leave UI (toggle icon/labels) out of sync with the actual applied theme. Consider storing the resolved theme in state (or using useSyncExternalStore/a media-query subscription) and updating it in the media query handler so consumers re-render.

Copilot uses AI. Check for mistakes.

const toggleTheme = () => {
setThemePreference((currentTheme) => {
const currentResolved = currentTheme === "system" ? getSystemTheme() : currentTheme;
return currentResolved === "dark" ? "light" : "dark";
});
};

return {
themePreference,
resolvedTheme,
setThemePreference,
toggleTheme,
};
}
22 changes: 21 additions & 1 deletion frontend/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,30 @@
--input: 30 28% 82%;
--ring: 198 63% 36%;
--radius: 0.8rem;
color-scheme: light;
}

.dark {
--background: 215 30% 10%;
--foreground: 210 30% 95%;
--card: 216 26% 14%;
--card-foreground: 210 30% 95%;
--primary: 198 78% 63%;
--primary-foreground: 218 35% 12%;
--secondary: 216 20% 23%;
--secondary-foreground: 210 20% 94%;
--muted: 216 20% 19%;
--muted-foreground: 215 20% 72%;
--accent: 25 75% 58%;
--accent-foreground: 0 0% 100%;
--border: 216 16% 30%;
--input: 216 16% 30%;
--ring: 198 78% 63%;
color-scheme: dark;
}

* {
@apply border-border;
@apply border-border transition-colors duration-300;
}
Comment on lines 47 to 49

Copilot AI Mar 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applying transition-colors to the universal selector (*) makes every element animate color changes, which can cause unnecessary repaints and unintended transitions across the UI. Prefer limiting the transition to a top-level container (e.g., body/main) or only to components that actually need it.

Copilot uses AI. Check for mistakes.

body {
Expand Down
1 change: 1 addition & 0 deletions frontend/tailwind.config.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
/** @type {import('tailwindcss').Config} */
export default {
darkMode: "class",
content: ["./index.html", "./src/**/*.{js,jsx,ts,tsx}"],
theme: {
extend: {
Expand Down
Loading