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
32 changes: 32 additions & 0 deletions frontend/src/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@
--shadow-md: 0 4px 12px rgb(15 23 42 / 0.1);
--shadow-lg: 0 12px 32px rgb(15 23 42 / 0.18);
--space: 8px;

/* motion */
--ease: cubic-bezier(0.2, 0, 0, 1);
--dur: 180ms;
}

@media (prefers-color-scheme: dark) {
Expand Down Expand Up @@ -174,6 +178,34 @@ a {
flex: none;
}

/* reliability status badge (used by the station detail panel) */
.badge {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 4px 11px;
border-radius: 999px;
font-size: 0.82rem;
font-weight: 600;
border: 1px solid color-mix(in srgb, currentColor 35%, transparent);
background: color-mix(in srgb, currentColor 12%, var(--surface));
}
.badge .dot {
background: currentColor;
}
.badge-green {
color: var(--rel-green);
}
.badge-amber {
color: var(--rel-amber);
}
.badge-red {
color: var(--rel-red);
}
.badge-grey {
color: var(--rel-grey);
}

/* accessible focus ring everywhere */
:focus-visible {
outline: 2px solid var(--accent);
Expand Down
22 changes: 16 additions & 6 deletions frontend/src/lib/Header.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,11 @@

<header class="header">
<a class="brand" href="/" aria-label="PlugPulse home">
<svg class="bolt" viewBox="0 0 24 24" width="22" height="22" aria-hidden="true">
<path d="M13 2 4 14h6l-1 8 9-12h-6l1-8z" fill="currentColor" />
</svg>
<span class="mark">
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
<path d="M13 2 4 14h6l-1 8 9-12h-6l1-8z" fill="currentColor" />
</svg>
</span>
<span class="word">PlugPulse</span>
</a>

Expand Down Expand Up @@ -68,19 +70,27 @@
background: color-mix(in srgb, var(--surface) 82%, transparent);
backdrop-filter: blur(10px);
border-bottom: 1px solid var(--border);
box-shadow: var(--shadow-sm);
}
.brand {
display: inline-flex;
align-items: center;
gap: 8px;
gap: 9px;
text-decoration: none;
color: var(--text);
font-weight: 800;
font-size: 18px;
letter-spacing: -0.01em;
}
.bolt {
color: var(--primary);
.mark {
display: inline-grid;
place-items: center;
width: 30px;
height: 30px;
border-radius: 9px;
background: var(--primary);
color: var(--on-primary);
box-shadow: var(--shadow-sm);
}
.word {
color: var(--text);
Expand Down
52 changes: 52 additions & 0 deletions frontend/src/lib/Legend.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
<script lang="ts">
const items: { color: string; label: string }[] = [
{ color: "var(--rel-green)", label: "Likely working" },
{ color: "var(--rel-amber)", label: "Mixed reports" },
{ color: "var(--rel-red)", label: "Likely down" },
{ color: "var(--rel-grey)", label: "No recent reports" },
];
</script>

<aside class="legend" aria-label="Reliability legend">
<span class="title">Reliability</span>
{#each items as item}
<span class="row"><span class="dot" style="background: {item.color}"></span>{item.label}</span>
{/each}
</aside>

<style>
.legend {
position: fixed;
bottom: 16px;
left: 16px;
z-index: 7;
display: flex;
flex-direction: column;
gap: 5px;
padding: 10px 12px;
background: color-mix(in srgb, var(--surface) 88%, transparent);
backdrop-filter: blur(10px);
border: 1px solid var(--border);
border-radius: var(--r-md);
box-shadow: var(--shadow-sm);
font-size: 0.78rem;
}
.title {
font-weight: 700;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
font-size: 0.68rem;
}
.row {
display: flex;
align-items: center;
gap: 8px;
color: var(--text);
}
@media (max-width: 520px) {
.legend {
display: none;
}
}
</style>
170 changes: 108 additions & 62 deletions frontend/src/lib/StationDetail.svelte
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
<script lang="ts">
import { createEventDispatcher } from "svelte";
import { fly } from "svelte/transition";
import { describeReliability, evidenceLine } from "$lib/reliability";
import { sourceLabel, formatPower, formatCoords, directionsUrl, displayName } from "$lib/format";
import type { Station } from "$lib/stations";

export let station: Station;
Expand All @@ -9,7 +11,6 @@

$: display = describeReliability(station.reliability);
$: evidence = evidenceLine(station.reliability);
$: sourceLabel = station.source === "osm" ? "© OpenStreetMap" : "Open Charge Map";

function onKeydown(e: KeyboardEvent): void {
if (e.key === "Escape") dispatch("close");
Expand All @@ -18,51 +19,59 @@

<svelte:window on:keydown={onKeydown} />

<aside class="panel" role="dialog" aria-label="Station details">
<img
class="banner"
src="/img/ev-charger.webp"
alt=""
width="360"
height="150"
loading="lazy"
decoding="async"
/>
<button class="close btn-icon" on:click={() => dispatch("close")} aria-label="Close details">
</button>
<aside class="panel" role="dialog" aria-label="Station details" transition:fly={{ y: 24, duration: 180 }}>
<div class="banner">
<img src="/img/ev-charger.webp" alt="" width="380" height="150" loading="lazy" decoding="async" />
<div class="banner-grad"></div>
<h2 class="title">{displayName(station)}</h2>
<button class="close" on:click={() => dispatch("close")} aria-label="Close details">✕</button>
</div>

<div class="body">
<h2>{station.name ?? "Unnamed station"}</h2>

<p class="reliability">
<span class="dot" style="background: var(--rel-{display.color})"></span>
<strong>{display.text}</strong>
<span class="muted">· {evidence}</span>
</p>
<span class="badge badge-{display.color}">
<span class="dot"></span>{display.text}
</span>
<p class="evidence">{evidence}</p>

<dl>
{#if station.operator}
<dt>Operator</dt>
<dd>{station.operator}</dd>
{/if}
<dt>Operator</dt>
<dd>{station.operator ?? "Unknown"}</dd>

<dt>Max power</dt>
<dd>{station.max_power_kw ? `${station.max_power_kw} kW` : "Unknown"}</dd>
{#if station.access_type}
<dt>Access</dt>
<dd>{station.access_type}</dd>
{/if}
{#if station.connectors.length}
<dt>Connectors</dt>
<dd class="chips">
{#each station.connectors as connector}
<span class="chip">{connector}</span>
{/each}
</dd>
{/if}
<dd>{formatPower(station.max_power_kw)}</dd>

<dt>Access</dt>
<dd>{station.access_type ?? "Unknown"}</dd>

<dt>Connectors</dt>
<dd>
{#if station.connectors.length}
<span class="chips">
{#each station.connectors as connector}<span class="chip">{connector}</span>{/each}
</span>
{:else}
<span class="muted">Not reported</span>
{/if}
</dd>
</dl>

<p class="source muted">Source: {sourceLabel}</p>
<div class="actions">
<a
class="btn btn-primary"
href={directionsUrl(station.lat, station.lng)}
target="_blank"
rel="noopener noreferrer"
>
➜ Get directions
</a>
<button class="btn btn-ghost" disabled title="Community reports — coming soon">
Report status
</button>
</div>

<p class="meta muted">
{sourceLabel(station.source)} · {formatCoords(station.lat, station.lng)}
</p>
</div>
</aside>

Expand All @@ -72,52 +81,69 @@
top: 72px;
right: 16px;
z-index: 9;
width: min(360px, calc(100vw - 32px));
width: min(380px, calc(100vw - 32px));
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--r-lg);
box-shadow: var(--shadow-lg);
overflow: hidden;
}
.banner {
display: block;
position: relative;
height: 132px;
}
.banner img {
width: 100%;
height: 150px;
height: 100%;
object-fit: cover;
display: block;
background: var(--surface-2);
}
.banner-grad {
position: absolute;
inset: 0;
background: linear-gradient(180deg, rgb(0 0 0 / 0.05) 30%, rgb(0 0 0 / 0.72));
}
.title {
position: absolute;
left: 16px;
right: 48px;
bottom: 10px;
margin: 0;
color: #fff;
font-size: 1.15rem;
line-height: 1.25;
text-shadow: 0 1px 4px rgb(0 0 0 / 0.5);
}
.close {
position: absolute;
top: 10px;
right: 10px;
background: color-mix(in srgb, var(--surface) 70%, transparent);
width: 30px;
height: 30px;
border: none;
border-radius: 50%;
background: rgb(0 0 0 / 0.45);
color: #fff;
cursor: pointer;
backdrop-filter: blur(4px);
}
.body {
padding: 16px;
.close:hover {
background: rgb(0 0 0 / 0.65);
}
h2 {
font-size: 1.15rem;
margin: 0 0 8px;
}
.reliability {
display: flex;
align-items: center;
gap: 8px;
margin: 0 0 12px;
.body {
padding: 14px 16px 16px;
}
.muted {
.evidence {
margin: 8px 0 14px;
font-size: 0.85rem;
color: var(--text-muted);
}
.source {
margin: 12px 0 0;
font-size: 0.78rem;
}
dl {
display: grid;
grid-template-columns: auto 1fr;
gap: 6px 14px;
margin: 0;
grid-template-columns: 92px 1fr;
gap: 8px 14px;
margin: 0 0 16px;
font-size: 0.92rem;
}
dt {
Expand All @@ -138,6 +164,26 @@
padding: 2px 10px;
font-size: 0.82rem;
}
.muted {
color: var(--text-muted);
}
.actions {
display: flex;
gap: 8px;
}
.actions .btn {
flex: 1;
justify-content: center;
text-decoration: none;
}
.btn[disabled] {
opacity: 0.5;
cursor: not-allowed;
}
.meta {
margin: 14px 0 0;
font-size: 0.76rem;
}
@media (max-width: 520px) {
.panel {
top: auto;
Expand Down
8 changes: 6 additions & 2 deletions frontend/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,13 @@ export interface StationQuery {

export const DEFAULT_STATION_LIMIT = 300;

export const DEFAULT_API_BASE = "http://localhost:8000";
// Default to the deployed backend so the app works end-to-end everywhere
// (production, Vercel previews, shared links) even when PUBLIC_API_BASE_URL
// isn't set for that environment. Local dev overrides it via .env
// (PUBLIC_API_BASE_URL=http://localhost:8000).
export const DEFAULT_API_BASE = "https://plugpulse-backend-v5bu.onrender.com";

/** Normalize a configured API base URL: fall back to localhost, strip trailing slash. */
/** Normalize a configured API base URL: fall back to the default, strip trailing slash. */
export function resolveApiBase(raw: string | undefined): string {
const base = (raw ?? "").trim() || DEFAULT_API_BASE;
return base.replace(/\/+$/, "");
Expand Down
Loading
Loading