diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 51a013f..b453dc4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,6 +53,10 @@ jobs: working-directory: frontend run: npm ci + - name: Check formatting + working-directory: frontend + run: npm run format:check + - name: Lint working-directory: frontend run: npm run lint diff --git a/frontend/README.md b/frontend/README.md index 422c62a..dfefecf 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -23,15 +23,15 @@ Talks to the [PyPhos backend API](../backend/README.md) for authentication, proj ## Screens -| Screen | Route | Description | -|---|---|---| -| `Login` | `/login` | Sign in | -| `Signup` | `/signup` | Create an account | -| `AllProjects` | `/projects` | List and create projects | -| `Project` | `/projects/:projectId` | View a project's details, its scenarios and their reports | -| `CreateScenario` | `/projects/:projectId/scenarios/create` | Configure a new scenario | -| `Scenario` | `/projects/:projectId/scenarios/:scenarioId` | Edit scenario details | -| `Settings` | `/settings` | Manage account settings | +| Screen | Route | Description | +| ---------------- | -------------------------------------------- | --------------------------------------------------------- | +| `Login` | `/login` | Sign in | +| `Signup` | `/signup` | Create an account | +| `AllProjects` | `/projects` | List and create projects | +| `Project` | `/projects/:projectId` | View a project's details, its scenarios and their reports | +| `CreateScenario` | `/projects/:projectId/scenarios/create` | Configure a new scenario | +| `Scenario` | `/projects/:projectId/scenarios/:scenarioId` | Edit scenario details | +| `Settings` | `/settings` | Manage account settings | All routes except `/login` and `/signup` are behind `PrivateRoute`, which checks for a valid JWT and redirects to `/login` otherwise. @@ -48,4 +48,4 @@ npm install npm run dev ``` -The app will be available at `http://localhost:5173`. Requests to `/api/*` are proxied to `http://localhost:8000` in development (see `vite.config.js`), and to the deployed backend in production (see `vercel.json`). \ No newline at end of file +The app will be available at `http://localhost:5173`. Requests to `/api/*` are proxied to `http://localhost:8000` in development (see `vite.config.js`), and to the deployed backend in production (see `vercel.json`). diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index d1b7e66..78dd411 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -1,13 +1,13 @@ -import js from '@eslint/js' -import globals from 'globals' -import reactHooks from 'eslint-plugin-react-hooks' -import reactRefresh from 'eslint-plugin-react-refresh' -import { defineConfig, globalIgnores } from 'eslint/config' +import js from "@eslint/js"; +import globals from "globals"; +import reactHooks from "eslint-plugin-react-hooks"; +import reactRefresh from "eslint-plugin-react-refresh"; +import { defineConfig, globalIgnores } from "eslint/config"; export default defineConfig([ - globalIgnores(['dist']), + globalIgnores(["dist"]), { - files: ['**/*.{js,jsx}'], + files: ["**/*.{js,jsx}"], extends: [ js.configs.recommended, reactHooks.configs.flat.recommended, @@ -18,20 +18,23 @@ export default defineConfig([ parserOptions: { ecmaFeatures: { jsx: true } }, }, rules: { - 'no-unused-vars': ['error', { - argsIgnorePattern: '^_', - varsIgnorePattern: '^_', - caughtErrorsIgnorePattern: '^_', - }], + "no-unused-vars": [ + "error", + { + argsIgnorePattern: "^_", + varsIgnorePattern: "^_", + caughtErrorsIgnorePattern: "^_", + }, + ], }, }, { // Vendored shadcn/ui components: many co-export a variants function // (e.g. buttonVariants) alongside their component by design, which // trips this rule. That's expected here, not an app bug. - files: ['src/components/ui/**/*.{js,jsx}'], + files: ["src/components/ui/**/*.{js,jsx}"], rules: { - 'react-refresh/only-export-components': 'off', + "react-refresh/only-export-components": "off", }, }, -]) +]); diff --git a/frontend/index.html b/frontend/index.html index b1a037d..6a0a36b 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2,9 +2,9 @@ - - - + + + PyPhos diff --git a/frontend/jsconfig.json b/frontend/jsconfig.json index 5df6330..848567a 100644 --- a/frontend/jsconfig.json +++ b/frontend/jsconfig.json @@ -15,4 +15,4 @@ // "@/*": ["./src/*"] // } // } -// } \ No newline at end of file +// } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 0d274c9..b716400 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -44,6 +44,7 @@ "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.2", "globals": "^17.6.0", + "prettier": "3.9.6", "vite": "^8.0.12" } }, @@ -5603,6 +5604,22 @@ "node": ">= 0.8.0" } }, + "node_modules/prettier": { + "version": "3.9.6", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", + "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/pretty-ms": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index b064eed..bfdc405 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,7 +7,9 @@ "dev": "vite", "build": "vite build", "lint": "eslint .", - "preview": "vite preview" + "preview": "vite preview", + "format": "prettier . --write", + "format:check": "prettier . --check" }, "dependencies": { "@base-ui/react": "^1.6.0", @@ -46,6 +48,7 @@ "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.2", "globals": "^17.6.0", + "prettier": "3.9.6", "vite": "^8.0.12" } } diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 0a9e73d..e30b3e7 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,49 +1,91 @@ -import { useContext } from "react" -import { BrowserRouter as Router, Route, Routes, Navigate } from "react-router" +import { useContext } from "react"; +import { BrowserRouter as Router, Route, Routes, Navigate } from "react-router"; -import { AuthProvider } from './lib/AuthContext'; -import { AuthContext } from './lib/auth-context'; -import { isTokenValid } from './lib/utils'; -import { Toaster } from '@/components/ui/sonner' -import Landing from './screens/Landing' -import Login from './screens/Login' -import Signup from './screens/Signup' -import AllProjects from './screens/AllProjects' -import Project from './screens/Project' -import CreateScenario from './screens/CreateScenario' -import Report from './screens/Report' -import Scenario from './screens/Scenario' -import Settings from './screens/Settings' +import { AuthProvider } from "./lib/AuthContext"; +import { AuthContext } from "./lib/auth-context"; +import { isTokenValid } from "./lib/utils"; +import { Toaster } from "@/components/ui/sonner"; +import Landing from "./screens/Landing"; +import Login from "./screens/Login"; +import Signup from "./screens/Signup"; +import AllProjects from "./screens/AllProjects"; +import Project from "./screens/Project"; +import CreateScenario from "./screens/CreateScenario"; +import Report from "./screens/Report"; +import Scenario from "./screens/Scenario"; +import Settings from "./screens/Settings"; -import './App.css' +import "./App.css"; export default function App() { - return ( - - - - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - - - - ) + return ( + + + + } /> + } /> + } /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + + ); } const GuestOrPrivateRoute = ({ children }) => { const { isGuest, token } = useContext(AuthContext); - return isGuest || isTokenValid(token) ? children : + return isGuest || isTokenValid(token) ? children : ; }; const PrivateRoute = ({ children }) => { const { token } = useContext(AuthContext); - return isTokenValid(token) ? children : + return isTokenValid(token) ? children : ; }; diff --git a/frontend/src/components/AngleSlider.jsx b/frontend/src/components/AngleSlider.jsx index 18607aa..072133e 100644 --- a/frontend/src/components/AngleSlider.jsx +++ b/frontend/src/components/AngleSlider.jsx @@ -1,9 +1,8 @@ -"use client" - -import { Label } from "@/components/ui/label" -import { Slider } from "@/components/ui/slider" -import { getCompassDirection } from "@/lib/utils" +"use client"; +import { Label } from "@/components/ui/label"; +import { Slider } from "@/components/ui/slider"; +import { getCompassDirection } from "@/lib/utils"; export function AngleSlider({ name, min, max, value, onValueChange, tag }) { return ( @@ -22,5 +21,5 @@ export function AngleSlider({ name, min, max, value, onValueChange, tag }) { max={max} /> - ) + ); } diff --git a/frontend/src/components/BarChart.jsx b/frontend/src/components/BarChart.jsx index 757994c..850e2b2 100644 --- a/frontend/src/components/BarChart.jsx +++ b/frontend/src/components/BarChart.jsx @@ -1,7 +1,7 @@ -"use client" +"use client"; -import * as React from "react" -import { Bar, BarChart, CartesianGrid, XAxis } from "recharts" +import * as React from "react"; +import { Bar, BarChart, CartesianGrid, XAxis } from "recharts"; import { Card, @@ -9,14 +9,14 @@ import { CardDescription, CardHeader, CardTitle, -} from "@/components/ui/card" +} from "@/components/ui/card"; import { ChartContainer, ChartTooltip, ChartTooltipContent, -} from "@/components/ui/chart" +} from "@/components/ui/chart"; -export const description = "An interactive bar chart" +export const description = "An interactive bar chart"; const chartConfig = { yield: { @@ -27,24 +27,24 @@ const chartConfig = { label: "Solar radiation", color: "var(--chart-1)", }, -} +}; export function ChartBarInteractive({ data, height = 250 }) { - const [activeChart, setActiveChart] = React.useState("yield") + const [activeChart, setActiveChart] = React.useState("yield"); const totals = React.useMemo( () => ({ yield: data?.reduce((acc, curr) => acc + curr.yield, 0) ?? 0, radiation: data?.reduce((acc, curr) => acc + curr.radiation, 0) ?? 0, }), - [data] - ) + [data], + ); if (!data || data.length === 0) { - return null + return null; } - const units = { yield: "kWh", radiation: "kWh/m²" } + const units = { yield: "kWh", radiation: "kWh/m²" }; return ( @@ -67,7 +67,9 @@ export function ChartBarInteractive({ data, height = 250 }) { {chartConfig[key].label} - {totals[key].toLocaleString(undefined, { maximumFractionDigits: 0 })} + {totals[key].toLocaleString(undefined, { + maximumFractionDigits: 0, + })} {units[key]} @@ -114,5 +116,5 @@ export function ChartBarInteractive({ data, height = 250 }) { - ) + ); } diff --git a/frontend/src/components/CreateProjectDialog.jsx b/frontend/src/components/CreateProjectDialog.jsx index 11352f5..7bd2bea 100644 --- a/frontend/src/components/CreateProjectDialog.jsx +++ b/frontend/src/components/CreateProjectDialog.jsx @@ -1,119 +1,120 @@ -import { useState } from "react" -import { useNavigate } from "react-router" +import { useState } from "react"; +import { useNavigate } from "react-router"; import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogTrigger, - DialogFooter, -} from "@/components/ui/dialog" -import { Button } from "@/components/ui/button" -import { Field, FieldError, FieldLabel } from "@/components/ui/field" -import { Input } from "@/components/ui/input" -import { toast } from "sonner" + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, + DialogFooter, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Field, FieldError, FieldLabel } from "@/components/ui/field"; +import { Input } from "@/components/ui/input"; +import { toast } from "sonner"; -import { useCreateProject } from "../lib/useData" -import { validateName } from "../lib/validators" -import LocationCombobox from "./LocationCombobox" +import { useCreateProject } from "../lib/useData"; +import { validateName } from "../lib/validators"; +import LocationCombobox from "./LocationCombobox"; export default function CreateProjectDialog() { - const navigate = useNavigate() + const navigate = useNavigate(); - const createProject = useCreateProject() + const createProject = useCreateProject(); - const [open, setOpen] = useState(false) - const [name, setName] = useState("") - const [location, setLocation] = useState(null) - const [submitting, setSubmitting] = useState(false) - const [error, setError] = useState(null) - const [touched, setTouched] = useState({}) + const [open, setOpen] = useState(false); + const [name, setName] = useState(""); + const [location, setLocation] = useState(null); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + const [touched, setTouched] = useState({}); - const nameCheck = validateName(name, "Project name") - const cityCheck = { valid: location !== null, message: "Please select a city from the list" } - const isFormValid = nameCheck.valid && cityCheck.valid + const nameCheck = validateName(name, "Project name"); + const cityCheck = { + valid: location !== null, + message: "Please select a city from the list", + }; + const isFormValid = nameCheck.valid && cityCheck.valid; - function markTouched(field) { - setTouched((t) => ({ ...t, [field]: true })) - } + function markTouched(field) { + setTouched((t) => ({ ...t, [field]: true })); + } - async function handleSubmit(e) { - e.preventDefault() - setError(null) + async function handleSubmit(e) { + e.preventDefault(); + setError(null); - if (!isFormValid) { - setTouched({ name: true, city: true }) - return - } + if (!isFormValid) { + setTouched({ name: true, city: true }); + return; + } - setSubmitting(true) + setSubmitting(true); - try { - const project = await createProject({ - name, - city_input: location.name, - location: location.name, - country_code: location.country_code, - lat: location.lat, - lon: location.lon, - }) - setOpen(false) - toast.success("Project created", { position: "top-center" }) - navigate(`/projects/${project.id}`) - } catch (err) { - setError(err.detail || "Failed to create project") - } finally { - setSubmitting(false) - } + try { + const project = await createProject({ + name, + city_input: location.name, + location: location.name, + country_code: location.country_code, + lat: location.lat, + lon: location.lon, + }); + setOpen(false); + toast.success("Project created", { position: "top-center" }); + navigate(`/projects/${project.id}`); + } catch (err) { + setError(err.detail || "Failed to create project"); + } finally { + setSubmitting(false); } + } - return ( - - }> - New Project - - - - Create a new project - -
- - Project name - markTouched("name")} - onChange={(e) => setName(e.target.value)} - required - /> - {touched.name && !nameCheck.valid && ( - {nameCheck.message} - )} - - - Location - markTouched("city")} - /> - {touched.city && !cityCheck.valid && ( - {cityCheck.message} - )} - - {error &&

{error}

} - - - -
-
-
- ) -} \ No newline at end of file + return ( + + }>New Project + + + Create a new project + +
+ + Project name + markTouched("name")} + onChange={(e) => setName(e.target.value)} + required + /> + {touched.name && !nameCheck.valid && ( + {nameCheck.message} + )} + + + Location + markTouched("city")} + /> + {touched.city && !cityCheck.valid && ( + {cityCheck.message} + )} + + {error &&

{error}

} + + + +
+
+
+ ); +} diff --git a/frontend/src/components/EditProjectDialog.jsx b/frontend/src/components/EditProjectDialog.jsx index ac67622..4e74dfd 100644 --- a/frontend/src/components/EditProjectDialog.jsx +++ b/frontend/src/components/EditProjectDialog.jsx @@ -1,198 +1,206 @@ -import { useState } from "react" -import { useNavigate, useParams } from "react-router" +import { useState } from "react"; +import { useNavigate, useParams } from "react-router"; import { Accordion, AccordionContent, AccordionItem, AccordionTrigger, -} from "@/components/ui/accordion" +} from "@/components/ui/accordion"; import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogTrigger, - DialogFooter, -} from "@/components/ui/dialog" -import { Button } from "@/components/ui/button" -import { Field, FieldError, FieldLabel } from "@/components/ui/field" -import { Input } from "@/components/ui/input" -import { Trash2 } from "lucide-react" -import { toast } from "sonner" - -import { useProject, useUpdateProject, useDeleteProject } from "../lib/useData" -import { validateName, validateRequired } from "../lib/validators" -import LocationCombobox from "./LocationCombobox" + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, + DialogFooter, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Field, FieldError, FieldLabel } from "@/components/ui/field"; +import { Input } from "@/components/ui/input"; +import { Trash2 } from "lucide-react"; +import { toast } from "sonner"; + +import { useProject, useUpdateProject, useDeleteProject } from "../lib/useData"; +import { validateName, validateRequired } from "../lib/validators"; +import LocationCombobox from "./LocationCombobox"; export default function EditProjectDialog({ onSaved }) { - const { projectId } = useParams(); - const navigate = useNavigate() - const { data: project } = useProject(projectId) - const updateProject = useUpdateProject() - const deleteProject = useDeleteProject() - - const [open, setOpen] = useState(false) - const [projectName, setProjectName] = useState("") - const [location, setLocation] = useState(null) - const [locationQuery, setLocationQuery] = useState("") - const [locationChanged, setLocationChanged] = useState(false) - const [submitting, setSubmitting] = useState(false) - const [error, setError] = useState(null) - const [touched, setTouched] = useState({}) - const [deleting, setDeleting] = useState(false) - const [deleteError, setDeleteError] = useState(null) - const [loadedProjectId, setLoadedProjectId] = useState(null) - - const nameCheck = validateName(projectName, "Project name") - const locationCheck = validateRequired(locationQuery, "Location") - const isFormValid = nameCheck.valid && locationCheck.valid - - if (project && project.id !== loadedProjectId) { - setLoadedProjectId(project.id) - setProjectName(project.name ?? "") - setLocation({ - name: project.location, - country_code: project.country_code, - admin1: null, - lat: project.lat, - lon: project.lon, - }) - setLocationQuery(project.location ?? "") - setLocationChanged(false) + const { projectId } = useParams(); + const navigate = useNavigate(); + const { data: project } = useProject(projectId); + const updateProject = useUpdateProject(); + const deleteProject = useDeleteProject(); + + const [open, setOpen] = useState(false); + const [projectName, setProjectName] = useState(""); + const [location, setLocation] = useState(null); + const [locationQuery, setLocationQuery] = useState(""); + const [locationChanged, setLocationChanged] = useState(false); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + const [touched, setTouched] = useState({}); + const [deleting, setDeleting] = useState(false); + const [deleteError, setDeleteError] = useState(null); + const [loadedProjectId, setLoadedProjectId] = useState(null); + + const nameCheck = validateName(projectName, "Project name"); + const locationCheck = validateRequired(locationQuery, "Location"); + const isFormValid = nameCheck.valid && locationCheck.valid; + + if (project && project.id !== loadedProjectId) { + setLoadedProjectId(project.id); + setProjectName(project.name ?? ""); + setLocation({ + name: project.location, + country_code: project.country_code, + admin1: null, + lat: project.lat, + lon: project.lon, + }); + setLocationQuery(project.location ?? ""); + setLocationChanged(false); + } + + function markTouched(field) { + setTouched((t) => ({ ...t, [field]: true })); + } + + function handleLocationSelect(candidate) { + setLocation(candidate); + setLocationChanged(true); + } + + async function handleSubmit(e) { + e.preventDefault(); + setError(null); + + if (!isFormValid) { + setTouched({ name: true, city: true }); + return; } - function markTouched(field) { - setTouched((t) => ({ ...t, [field]: true })) + setSubmitting(true); + + try { + const updates = { name: projectName.trim() }; + if (locationChanged) { + updates.city_input = location.name; + updates.location = location.name; + updates.country_code = location.country_code; + updates.lat = location.lat; + updates.lon = location.lon; + } + + const updated = await updateProject(projectId, updates); + setLocation({ + name: updated.location, + country_code: updated.country_code, + admin1: null, + lat: updated.lat, + lon: updated.lon, + }); + setLocationChanged(false); + onSaved?.(updated); + setOpen(false); + toast.success("Project updated", { position: "top-center" }); + } catch (err) { + setError(err.detail || "Failed to update project"); + } finally { + setSubmitting(false); } - - function handleLocationSelect(candidate) { - setLocation(candidate) - setLocationChanged(true) - } - - async function handleSubmit(e) { - e.preventDefault() - setError(null) - - if (!isFormValid) { - setTouched({ name: true, city: true }) - return - } - - setSubmitting(true) - - try { - const updates = { name: projectName.trim() } - if (locationChanged) { - updates.city_input = location.name - updates.location = location.name - updates.country_code = location.country_code - updates.lat = location.lat - updates.lon = location.lon - } - - const updated = await updateProject(projectId, updates) - setLocation({ - name: updated.location, - country_code: updated.country_code, - admin1: null, - lat: updated.lat, - lon: updated.lon, - }) - setLocationChanged(false) - onSaved?.(updated) - setOpen(false) - toast.success("Project updated", { position: "top-center" }) - } catch (err) { - setError(err.detail || "Failed to update project") - } finally { - setSubmitting(false) - } + } + + async function handleDelete() { + setDeleteError(null); + setDeleting(true); + + try { + await deleteProject(projectId); + toast.info("Project deleted", { position: "top-center" }); + navigate("/projects"); + } catch (err) { + setDeleteError(err.detail || "Failed to delete project"); + setDeleting(false); } - - async function handleDelete() { - setDeleteError(null) - setDeleting(true) - - try { - await deleteProject(projectId) - toast.info("Project deleted", { position: "top-center" }) - navigate("/projects") - } catch (err) { - setDeleteError(err.detail || "Failed to delete project") - setDeleting(false) + } + + return ( + + + Edit project + } - } - - return ( - - Edit project - }> - - - - Edit project - -
- - Project name - markTouched("name")} - onChange={(e) => setProjectName(e.target.value)} - required - /> - {touched.name && !nameCheck.valid && ( - {nameCheck.message} - )} - - - Location - markTouched("city")} - /> - {touched.city && !locationCheck.valid && ( - {locationCheck.message} - )} - - {error &&

{error}

} - {deleteError &&

{deleteError}

} - - - Delete this project - -

- This will permanently delete this project along with all of its scenarios and reports. This action cannot be undone. -

- -
-
-
- - - -
-
-
- ) -} \ No newline at end of file + >
+ + + Edit project + +
+ + Project name + markTouched("name")} + onChange={(e) => setProjectName(e.target.value)} + required + /> + {touched.name && !nameCheck.valid && ( + {nameCheck.message} + )} + + + Location + markTouched("city")} + /> + {touched.city && !locationCheck.valid && ( + {locationCheck.message} + )} + + {error &&

{error}

} + {deleteError && ( +

{deleteError}

+ )} + + + + Delete this project + + +

+ This will permanently delete this project along with all of + its scenarios and reports. This action cannot be undone. +

+ +
+
+
+ + + +
+
+
+ ); +} diff --git a/frontend/src/components/Footer.jsx b/frontend/src/components/Footer.jsx index c3412e5..37fcc11 100644 --- a/frontend/src/components/Footer.jsx +++ b/frontend/src/components/Footer.jsx @@ -1,17 +1,21 @@ -export default function Footer () { - return ( - - ) -} \ No newline at end of file +export default function Footer() { + return ( + + ); +} diff --git a/frontend/src/components/GuestAlert.jsx b/frontend/src/components/GuestAlert.jsx index 1b9ddb4..0d7b604 100644 --- a/frontend/src/components/GuestAlert.jsx +++ b/frontend/src/components/GuestAlert.jsx @@ -1,22 +1,25 @@ export default function GuestAlert() { - return ( -
-
-

You are using PyPhos as a guest. To create multiple projects,{" "} - - log in - or {" "} - - sign up. - -

-
-
- ) -} \ No newline at end of file + return ( +
+
+

+ You are using PyPhos as a + guest. To create multiple projects,{" "} + + log in + {" "} + or{" "} + + sign up. + +

+
+
+ ); +} diff --git a/frontend/src/components/Header.jsx b/frontend/src/components/Header.jsx index 73101ff..228dac2 100644 --- a/frontend/src/components/Header.jsx +++ b/frontend/src/components/Header.jsx @@ -1,16 +1,10 @@ -import { AuthContext } from '@/lib/auth-context' -import { useContext } from "react" -import { useNavigate } from 'react-router' +import { AuthContext } from "@/lib/auth-context"; +import { useContext } from "react"; +import { useNavigate } from "react-router"; -import { - SettingsIcon, - LogOutIcon, -} from "lucide-react" -import { - Avatar, - AvatarFallback, -} from "@/components/ui/avatar" -import { Button } from "@/components/ui/button" +import { SettingsIcon, LogOutIcon } from "lucide-react"; +import { Avatar, AvatarFallback } from "@/components/ui/avatar"; +import { Button } from "@/components/ui/button"; import { DropdownMenu, DropdownMenuContent, @@ -18,33 +12,39 @@ import { DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu" -import GuestAlert from "./GuestAlert" -import Logo from "../assets/pyphos-logo.svg" +} from "@/components/ui/dropdown-menu"; +import GuestAlert from "./GuestAlert"; +import Logo from "../assets/pyphos-logo.svg"; export default function Header() { - const { isGuest, user, logout } = useContext(AuthContext) - const navigate = useNavigate() - const avatarLetter = user?.email?.[0]?.toUpperCase() ?? "G" + const { isGuest, user, logout } = useContext(AuthContext); + const navigate = useNavigate(); + const avatarLetter = user?.email?.[0]?.toUpperCase() ?? "G"; function logOut() { - logout() - navigate('/') + logout(); + navigate("/"); } return (
{isGuest && }
- PyPhos Logo + PyPhos Logo {!isGuest ? ( - - {avatarLetter} - } /> + + + {avatarLetter} + + + } + /> - navigate('/settings')}> + navigate("/settings")}> Settings @@ -58,15 +58,18 @@ export default function Header() { ) : (
- - Log in - - + + Log in + +
)}
- ) + ); } diff --git a/frontend/src/components/LocationCombobox.jsx b/frontend/src/components/LocationCombobox.jsx index 80902e0..4582289 100644 --- a/frontend/src/components/LocationCombobox.jsx +++ b/frontend/src/components/LocationCombobox.jsx @@ -1,108 +1,126 @@ -import { useState, useEffect, useContext } from "react" +import { useState, useEffect, useContext } from "react"; import { - Combobox, - ComboboxContent, - ComboboxEmpty, - ComboboxInput, - ComboboxItem, - ComboboxList, -} from "@/components/ui/combobox" + Combobox, + ComboboxContent, + ComboboxEmpty, + ComboboxInput, + ComboboxItem, + ComboboxList, +} from "@/components/ui/combobox"; -import { AuthContext } from "../lib/auth-context" -import { searchLocations } from "../lib/api" +import { AuthContext } from "../lib/auth-context"; +import { searchLocations } from "../lib/api"; -const DEBOUNCE_MS = 300 +const DEBOUNCE_MS = 300; function formatLocation(candidate) { - if (!candidate) return "" - return [candidate.name, candidate.admin1, candidate.country_code].filter(Boolean).join(", ") + if (!candidate) return ""; + return [candidate.name, candidate.admin1, candidate.country_code] + .filter(Boolean) + .join(", "); } function isSameLocation(a, b) { - return a?.name === b?.name && a?.country_code === b?.country_code && a?.lat === b?.lat && a?.lon === b?.lon + return ( + a?.name === b?.name && + a?.country_code === b?.country_code && + a?.lat === b?.lat && + a?.lon === b?.lon + ); } // value: { name, country_code, admin1, lat, lon } | null -export default function LocationCombobox({ id, value, onSelect, ariaInvalid, onBlur, onInputChange }) { - const { token } = useContext(AuthContext) +export default function LocationCombobox({ + id, + value, + onSelect, + ariaInvalid, + onBlur, + onInputChange, +}) { + const { token } = useContext(AuthContext); - const [query, setQueryState] = useState(() => formatLocation(value)) - const [results, setResults] = useState([]) - const [loading, setLoading] = useState(false) + const [query, setQueryState] = useState(() => formatLocation(value)); + const [results, setResults] = useState([]); + const [loading, setLoading] = useState(false); - function setQuery(next) { - setQueryState(next) - onInputChange?.(next) - } - - useEffect(() => { - function syncQuery() { - setQuery(formatLocation(value)) - } + function setQuery(next) { + setQueryState(next); + onInputChange?.(next); + } - syncQuery() - }, [value]) + useEffect(() => { + function syncQuery() { + setQuery(formatLocation(value)); + } - useEffect(() => { - function search() { - if (!query.trim() || query === formatLocation(value)) { - setLoading(false) - return - } + syncQuery(); + }, [value]); - setLoading(true) - const timeout = setTimeout(async () => { - try { - const candidates = await searchLocations(token, query) - setResults(candidates ?? []) - } catch { - setResults([]) - } finally { - setLoading(false) - } - }, DEBOUNCE_MS) + useEffect(() => { + function search() { + if (!query.trim() || query === formatLocation(value)) { + setLoading(false); + return; + } - return () => clearTimeout(timeout) + setLoading(true); + const timeout = setTimeout(async () => { + try { + const candidates = await searchLocations(token, query); + setResults(candidates ?? []); + } catch { + setResults([]); + } finally { + setLoading(false); } + }, DEBOUNCE_MS); - return search() - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [query, token]) + return () => clearTimeout(timeout); + } + + return search(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [query, token]); - return ( - - - - - {loading ? "Searching..." : query.trim() ? "No cities found." : "Type to search for a city"} - - - {(candidate) => ( - - {formatLocation(candidate)} - - )} - - - - ) -} \ No newline at end of file + return ( + + + + + {loading + ? "Searching..." + : query.trim() + ? "No cities found." + : "Type to search for a city"} + + + {(candidate) => ( + + {formatLocation(candidate)} + + )} + + + + ); +} diff --git a/frontend/src/components/Map.jsx b/frontend/src/components/Map.jsx index d3560cc..2506d52 100644 --- a/frontend/src/components/Map.jsx +++ b/frontend/src/components/Map.jsx @@ -1,49 +1,55 @@ -import { useEffect, useRef } from "react" -import L from "leaflet" -import "leaflet/dist/leaflet.css" -import markerIcon2x from "leaflet/dist/images/marker-icon-2x.png" -import markerIcon from "leaflet/dist/images/marker-icon.png" -import markerShadow from "leaflet/dist/images/marker-shadow.png" - -delete L.Icon.Default.prototype._getIconUrl +import { useEffect, useRef } from "react"; +import L from "leaflet"; +import "leaflet/dist/leaflet.css"; +import markerIcon2x from "leaflet/dist/images/marker-icon-2x.png"; +import markerIcon from "leaflet/dist/images/marker-icon.png"; +import markerShadow from "leaflet/dist/images/marker-shadow.png"; + +delete L.Icon.Default.prototype._getIconUrl; L.Icon.Default.mergeOptions({ - iconRetinaUrl: markerIcon2x, - iconUrl: markerIcon, - shadowUrl: markerShadow, -}) + iconRetinaUrl: markerIcon2x, + iconUrl: markerIcon, + shadowUrl: markerShadow, +}); export default function Map({ lat, lon, zoom = 13, className }) { - const containerRef = useRef(null) - const mapRef = useRef(null) - const markerRef = useRef(null) - - useEffect(() => { - if (!containerRef.current || lat == null || lon == null) return - - if (!mapRef.current) { - const map = L.map(containerRef.current, { scrollWheelZoom: false }).setView([lat, lon], zoom) - - L.tileLayer("https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png", { - maxZoom: 20, - attribution: '© OpenStreetMap contributors © CARTO', - }).addTo(map) - - markerRef.current = L.marker([lat, lon]).addTo(map) - mapRef.current = map - } else { - mapRef.current.setView([lat, lon], zoom) - markerRef.current?.setLatLng([lat, lon]) - } - }, [lat, lon, zoom]) - - useEffect(() => { - return () => { - mapRef.current?.remove() - mapRef.current = null - } - }, []) - - if (lat == null || lon == null) return null - - return
+ const containerRef = useRef(null); + const mapRef = useRef(null); + const markerRef = useRef(null); + + useEffect(() => { + if (!containerRef.current || lat == null || lon == null) return; + + if (!mapRef.current) { + const map = L.map(containerRef.current, { + scrollWheelZoom: false, + }).setView([lat, lon], zoom); + + L.tileLayer( + "https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png", + { + maxZoom: 20, + attribution: + '© OpenStreetMap contributors © CARTO', + }, + ).addTo(map); + + markerRef.current = L.marker([lat, lon]).addTo(map); + mapRef.current = map; + } else { + mapRef.current.setView([lat, lon], zoom); + markerRef.current?.setLatLng([lat, lon]); + } + }, [lat, lon, zoom]); + + useEffect(() => { + return () => { + mapRef.current?.remove(); + mapRef.current = null; + }; + }, []); + + if (lat == null || lon == null) return null; + + return
; } diff --git a/frontend/src/components/ProjectCard.jsx b/frontend/src/components/ProjectCard.jsx index 78dd4b0..2a7f2b5 100644 --- a/frontend/src/components/ProjectCard.jsx +++ b/frontend/src/components/ProjectCard.jsx @@ -1,33 +1,35 @@ -import { Badge } from "@/components/ui/badge" +import { Badge } from "@/components/ui/badge"; import { Card, CardContent, CardDescription, CardHeader, CardTitle, -} from "@/components/ui/card" +} from "@/components/ui/card"; function scenarioCountLabel(count) { - if (!count) return "No scenarios yet" - if (count === 1) return "1 scenario" - return `${count} scenarios` + if (!count) return "No scenarios yet"; + if (count === 1) return "1 scenario"; + return `${count} scenarios`; } export default function ProjectCard(props) { - return ( - - -
- {props.name} - - {props.countryCode ? `${props.location}, ${props.countryCode}` : props.location} - -
- { !props.scenarioCount ? Draft : ""} -
- -

{scenarioCountLabel(props.scenarioCount)}

-
-
- ) -} \ No newline at end of file + return ( + + +
+ {props.name} + + {props.countryCode + ? `${props.location}, ${props.countryCode}` + : props.location} + +
+ {!props.scenarioCount ? Draft : ""} +
+ +

{scenarioCountLabel(props.scenarioCount)}

+
+
+ ); +} diff --git a/frontend/src/components/ScenarioCard.jsx b/frontend/src/components/ScenarioCard.jsx index c1fbee3..3a68997 100644 --- a/frontend/src/components/ScenarioCard.jsx +++ b/frontend/src/components/ScenarioCard.jsx @@ -1,22 +1,22 @@ -import { Button } from "@/components/ui/button" +import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle, -} from "@/components/ui/card" +} from "@/components/ui/card"; export default function ScenarioCard(props) { - return ( - - - Scenario {props.id} - {props.name} - - - - - - ) -} \ No newline at end of file + return ( + + + Scenario {props.id} + {props.name} + + + + + + ); +} diff --git a/frontend/src/components/ui/accordion.jsx b/frontend/src/components/ui/accordion.jsx index 1fe1483..850fae4 100644 --- a/frontend/src/components/ui/accordion.jsx +++ b/frontend/src/components/ui/accordion.jsx @@ -1,77 +1,70 @@ -import { Accordion as AccordionPrimitive } from "@base-ui/react/accordion" +import { Accordion as AccordionPrimitive } from "@base-ui/react/accordion"; -import { cn } from "@/lib/utils" -import { ChevronDownIcon, ChevronUpIcon } from "lucide-react" +import { cn } from "@/lib/utils"; +import { ChevronDownIcon, ChevronUpIcon } from "lucide-react"; -function Accordion({ - className, - ...props -}) { +function Accordion({ className, ...props }) { return ( + {...props} + /> ); } -function AccordionItem({ - className, - ...props -}) { +function AccordionItem({ className, ...props }) { return ( + {...props} + /> ); } -function AccordionTrigger({ - className, - children, - ...props -}) { +function AccordionTrigger({ className, children, ...props }) { return ( + {...props} + > {children} + className="pointer-events-none shrink-0 group-aria-expanded/accordion-trigger:hidden" + /> + className="pointer-events-none hidden shrink-0 group-aria-expanded/accordion-trigger:inline" + /> ); } -function AccordionContent({ - className, - children, - ...props -}) { +function AccordionContent({ className, children, ...props }) { return ( + {...props} + >
+ className, + )} + > {children}
); } -export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }; diff --git a/frontend/src/components/ui/alert-dialog.jsx b/frontend/src/components/ui/alert-dialog.jsx index f387b9a..608a601 100644 --- a/frontend/src/components/ui/alert-dialog.jsx +++ b/frontend/src/components/ui/alert-dialog.jsx @@ -1,48 +1,40 @@ -"use client" +"use client"; -import { AlertDialog as AlertDialogPrimitive } from "@base-ui/react/alert-dialog" +import { AlertDialog as AlertDialogPrimitive } from "@base-ui/react/alert-dialog"; -import { cn } from "@/lib/utils" -import { Button } from "@/components/ui/button" +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; -function AlertDialog({ - ...props -}) { +function AlertDialog({ ...props }) { return ; } -function AlertDialogTrigger({ - ...props -}) { - return (); +function AlertDialogTrigger({ ...props }) { + return ( + + ); } -function AlertDialogPortal({ - ...props -}) { - return (); +function AlertDialogPortal({ ...props }) { + return ( + + ); } -function AlertDialogOverlay({ - className, - ...props -}) { +function AlertDialogOverlay({ className, ...props }) { return ( + {...props} + /> ); } -function AlertDialogContent({ - className, - size = "default", - ...props -}) { +function AlertDialogContent({ className, size = "default", ...props }) { return ( @@ -51,93 +43,87 @@ function AlertDialogContent({ data-size={size} className={cn( "group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]: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 + className, )} - {...props} /> + {...props} + /> ); } -function AlertDialogHeader({ - className, - ...props -}) { +function AlertDialogHeader({ className, ...props }) { return (
+ {...props} + /> ); } -function AlertDialogFooter({ - className, - ...props -}) { +function AlertDialogFooter({ className, ...props }) { return (
+ {...props} + /> ); } -function AlertDialogMedia({ - className, - ...props -}) { +function AlertDialogMedia({ className, ...props }) { return (
+ {...props} + /> ); } -function AlertDialogTitle({ - className, - ...props -}) { +function AlertDialogTitle({ className, ...props }) { return ( + {...props} + /> ); } -function AlertDialogDescription({ - className, - ...props -}) { +function AlertDialogDescription({ className, ...props }) { return ( + {...props} + /> ); } -function AlertDialogAction({ - className, - ...props -}) { - return (