Compare commits
No commits in common. "f69b9f401ae43f99b8b7315b635762952ff133f8" and "b88f4d67fec92dc58f78bc024084662699ca3a86" have entirely different histories.
f69b9f401a
...
b88f4d67fe
19 changed files with 330 additions and 1678 deletions
12
BUILD.bazel
12
BUILD.bazel
|
|
@ -1,12 +0,0 @@
|
|||
load("@npm//:defs.bzl", "npm_link_all_packages")
|
||||
|
||||
# rules_js PoC: link the full npm dependency graph (from package-lock.json) via
|
||||
# Bazel. `bazel build //walldash:node_modules` resolves all packages.
|
||||
# A complete `next build` under Bazel is a further step (ts_project + next rule).
|
||||
npm_link_all_packages(name = "node_modules")
|
||||
|
||||
filegroup(
|
||||
name = "srcs",
|
||||
srcs = glob(["src/**"], allow_empty = True),
|
||||
visibility = ["//visibility:public"],
|
||||
)
|
||||
101
README.md
101
README.md
|
|
@ -1,108 +1,15 @@
|
|||
# walldash
|
||||
|
||||
A full-screen kiosk dashboard for a Raspberry Pi, showing public transit departures (SL and SBB), weather, Spotify now-playing, and system stats.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- [Bun](https://bun.sh) runtime
|
||||
- Raspberry Pi running Raspberry Pi OS (tested on Bookworm/Lite + LXDE desktop)
|
||||
- Chromium or chromium-browser installed
|
||||
|
||||
## Quick start (development)
|
||||
To install dependencies:
|
||||
|
||||
```bash
|
||||
bun install
|
||||
cp config.example.json config.json # then fill in your credentials
|
||||
bun run start
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000).
|
||||
|
||||
## Configuration
|
||||
|
||||
Copy `config.example.json` to `config.json` and fill in your values:
|
||||
|
||||
| Field | Description |
|
||||
|---|---|
|
||||
| `spotify.clientId` / `spotify.clientSecret` | Spotify app credentials from [developer.spotify.com](https://developer.spotify.com/dashboard) |
|
||||
| `weather.plz` | Swiss postal code (PLZ) for MeteoSwiss forecasts |
|
||||
| `sl.stationId` / `sl.stationName` | Stockholm Lokaltrafik site ID (find via Settings > Search) |
|
||||
| `sbb.stationId` / `sbb.stationName` | Swiss Federal Railways station ID (find via Settings > Search) |
|
||||
| `refreshRate` | Polling interval in milliseconds (default: `10000`) |
|
||||
| `alwaysOn` | `true` reduces animations and refresh rates for burn-in prevention |
|
||||
| `layout` | Maps widget zone names to widget IDs |
|
||||
|
||||
`config.json` and `tokens.json` are gitignored — never commit real credentials.
|
||||
|
||||
## Raspberry Pi deployment
|
||||
|
||||
### 1. Install the server as a systemd service
|
||||
To run:
|
||||
|
||||
```bash
|
||||
bash scripts/setup-systemd.sh
|
||||
bun run index.ts
|
||||
```
|
||||
|
||||
This creates `/etc/systemd/system/walldash.service`, enables it, and starts it. The server auto-restarts on crash and starts on boot.
|
||||
|
||||
```bash
|
||||
systemctl status walldash.service
|
||||
journalctl -u walldash.service -f
|
||||
```
|
||||
|
||||
### 2. Set up the kiosk browser
|
||||
|
||||
```bash
|
||||
bash scripts/setup-kiosk.sh
|
||||
```
|
||||
|
||||
This installs `unclutter` (hides the cursor), disables screen blanking, and adds a `~/.config/autostart/walldash-kiosk.desktop` entry that launches `scripts/start-kiosk.sh` on login.
|
||||
|
||||
`start-kiosk.sh` waits until the server is ready, then opens Chromium in kiosk mode. If Chromium crashes it restarts automatically.
|
||||
|
||||
### 3. Configure auto-login
|
||||
|
||||
Use `raspi-config` → **System Options → Boot / Auto Login → Desktop Autologin** so the kiosk browser starts without manual interaction.
|
||||
|
||||
### 4. Set credentials
|
||||
|
||||
Copy and edit `config.json` on the Pi:
|
||||
|
||||
```bash
|
||||
cp config.example.json config.json
|
||||
nano config.json
|
||||
```
|
||||
|
||||
For Spotify, open `http://<pi-ip>:3000/settings` in a browser and follow the OAuth link.
|
||||
|
||||
## Uninstall
|
||||
|
||||
```bash
|
||||
bash scripts/uninstall.sh
|
||||
```
|
||||
|
||||
Removes the systemd service and autostart entry. The project directory is left untouched.
|
||||
|
||||
## Build
|
||||
|
||||
To regenerate the bundled frontend:
|
||||
|
||||
```bash
|
||||
bun run build
|
||||
```
|
||||
|
||||
Output goes to `public/main.js`.
|
||||
|
||||
## Widget zones
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────┐
|
||||
│ header-left header-right │
|
||||
├────────────────────┬─────────────────────┤
|
||||
│ main-1 │ main-2 │
|
||||
│ │ │
|
||||
├──────────┬─────────┴──────┬──────────────┤
|
||||
│ footer-1 │ footer-2 │ footer-3 │
|
||||
└──────────┴────────────────┴──────────────┘
|
||||
```
|
||||
|
||||
Available widget IDs: `clock`, `weather`, `sl`, `sbb`, `spotify`, `system`, `spacer`.
|
||||
This project was created using `bun init` in bun v1.3.11. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime.
|
||||
|
|
|
|||
|
|
@ -28,8 +28,5 @@
|
|||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-router-dom": "^7.14.1"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": []
|
||||
}
|
||||
}
|
||||
|
|
|
|||
1072
pnpm-lock.yaml
1072
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
220
public/main.js
220
public/main.js
File diff suppressed because one or more lines are too long
|
|
@ -1,43 +0,0 @@
|
|||
#!/bin/bash
|
||||
# Removes all walldash system integrations installed by setup-systemd.sh and setup-kiosk.sh.
|
||||
# Does NOT delete the project directory itself.
|
||||
|
||||
set -e
|
||||
|
||||
SERVICE_NAME="walldash"
|
||||
USER_NAME=$(whoami)
|
||||
|
||||
# Stop and remove the systemd service
|
||||
if systemctl list-unit-files "${SERVICE_NAME}.service" &>/dev/null; then
|
||||
echo "Stopping and disabling ${SERVICE_NAME} service..."
|
||||
sudo systemctl stop "${SERVICE_NAME}.service" 2>/dev/null || true
|
||||
sudo systemctl disable "${SERVICE_NAME}.service" 2>/dev/null || true
|
||||
sudo rm -f "/etc/systemd/system/${SERVICE_NAME}.service"
|
||||
sudo systemctl daemon-reload
|
||||
echo "Systemd service removed."
|
||||
else
|
||||
echo "No systemd service found for ${SERVICE_NAME}, skipping."
|
||||
fi
|
||||
|
||||
# Remove the kiosk autostart entry
|
||||
DESKTOP_FILE="/home/${USER_NAME}/.config/autostart/walldash-kiosk.desktop"
|
||||
if [ -f "$DESKTOP_FILE" ]; then
|
||||
rm -f "$DESKTOP_FILE"
|
||||
echo "Autostart entry removed: $DESKTOP_FILE"
|
||||
else
|
||||
echo "No autostart entry found, skipping."
|
||||
fi
|
||||
|
||||
# Undo screen blanking changes in LXDE autostart
|
||||
LXDE_AUTOSTART="/etc/xdg/lxsession/LXDE-pi/autostart"
|
||||
if [ -f "$LXDE_AUTOSTART" ]; then
|
||||
sudo sed -i '/^@xset s off$/d' "$LXDE_AUTOSTART"
|
||||
sudo sed -i '/^@xset -dpms$/d' "$LXDE_AUTOSTART"
|
||||
sudo sed -i '/^@xset s noblank$/d' "$LXDE_AUTOSTART"
|
||||
sudo sed -i 's/^# @xscreensaver/@xscreensaver/' "$LXDE_AUTOSTART"
|
||||
echo "LXDE autostart screen-blanking settings restored."
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Uninstall complete. The project directory has not been touched."
|
||||
echo "You can safely delete it manually if no longer needed."
|
||||
|
|
@ -1,9 +1,5 @@
|
|||
export const LineBadge = ({ designation }: {
|
||||
designation: string;
|
||||
mode?: string;
|
||||
groupOfLines?: string;
|
||||
}) => (
|
||||
<span className="border border-white text-white px-2 py-0.5 font-bold min-w-[56px] text-center text-xl inline-block shrink-0">
|
||||
export const LineBadge = ({ designation }: { designation: string; mode?: string; groupOfLines?: string }) => (
|
||||
<span className="border border-white text-white px-2 py-0.5 font-bold min-w-[56px] text-center text-xl inline-block">
|
||||
{designation}
|
||||
</span>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
export const SBBLineBadge = ({ name }: { name: string; category?: string }) => (
|
||||
<span className="border border-white text-white px-2 py-0.5 font-bold min-w-[56px] text-center text-xl inline-block shrink-0">
|
||||
<span className="border border-white text-white px-2 py-0.5 font-bold min-w-[56px] text-center text-xl inline-block">
|
||||
{name}
|
||||
</span>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,10 +1,8 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import { useAlwaysOn } from '../../contexts/AlwaysOnContext';
|
||||
import { useSize } from '../../hooks/useSize';
|
||||
|
||||
export const Clock = () => {
|
||||
const alwaysOn = useAlwaysOn();
|
||||
const { h, ref } = useSize();
|
||||
const [time, setTime] = useState(new Date());
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -12,6 +10,8 @@ export const Clock = () => {
|
|||
const id = setInterval(() => setTime(new Date()), 1000);
|
||||
return () => clearInterval(id);
|
||||
}
|
||||
|
||||
// Sync to the next full minute, then tick every 60 s
|
||||
let interval: ReturnType<typeof setInterval>;
|
||||
const now = new Date();
|
||||
const msUntilNext = (60 - now.getSeconds()) * 1000 - now.getMilliseconds();
|
||||
|
|
@ -19,47 +19,22 @@ export const Clock = () => {
|
|||
setTime(new Date());
|
||||
interval = setInterval(() => setTime(new Date()), 60_000);
|
||||
}, msUntilNext);
|
||||
|
||||
return () => { clearTimeout(timeout); clearInterval(interval); };
|
||||
}, [alwaysOn]);
|
||||
|
||||
const effectiveH = h || 80;
|
||||
const timeStr = alwaysOn
|
||||
? time.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', hour12: false })
|
||||
: time.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false });
|
||||
|
||||
// Show seconds when not in alwaysOn and there is enough vertical room
|
||||
const showSeconds = !alwaysOn && effectiveH > 90;
|
||||
// Stack time and date vertically when tall enough; otherwise inline
|
||||
const stacked = effectiveH > 110;
|
||||
// Show full weekday + month name when stacked, short otherwise
|
||||
const longDate = stacked;
|
||||
|
||||
const timeStr = time.toLocaleTimeString('en-GB', {
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
...(showSeconds ? { second: '2-digit' } : {}),
|
||||
hour12: false,
|
||||
});
|
||||
const dateStr = time.toLocaleDateString('en-GB', {
|
||||
weekday: longDate ? 'long' : 'short',
|
||||
day: 'numeric',
|
||||
month: longDate ? 'long' : 'short',
|
||||
...(effectiveH > 160 ? { year: 'numeric' } : {}),
|
||||
weekday: 'long', day: 'numeric', month: 'long',
|
||||
});
|
||||
|
||||
return (
|
||||
<div ref={ref} className="w-full h-full flex flex-col justify-center overflow-hidden">
|
||||
{stacked ? (
|
||||
<>
|
||||
<div className={`text-7xl tabular-nums leading-none ${alwaysOn ? 'font-light' : 'font-bold'}`}>
|
||||
{timeStr}
|
||||
</div>
|
||||
<div className="text-2xl text-gray-400 mt-2">{dateStr}</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex items-baseline gap-6 flex-wrap">
|
||||
<div className={`text-7xl tabular-nums ${alwaysOn ? 'font-light' : 'font-bold'}`}>
|
||||
{timeStr}
|
||||
</div>
|
||||
<div className="flex items-baseline gap-6">
|
||||
<div className={`text-7xl ${alwaysOn ? 'font-light' : 'font-bold'}`}>{timeStr}</div>
|
||||
<div className="text-2xl text-gray-400">{dateStr}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -10,15 +10,11 @@ export const DepartureItem = ({ departure }: { departure: Departure }) => {
|
|||
|
||||
return (
|
||||
<div className={`flex justify-between items-center py-3 border-b border-zinc-700 text-2xl ${alwaysOn ? 'font-light' : ''}`}>
|
||||
<div className="flex items-center gap-4 min-w-0 overflow-hidden">
|
||||
<LineBadge
|
||||
designation={departure.line.designation}
|
||||
mode={departure.line.transport_mode}
|
||||
groupOfLines={departure.line.group_of_lines}
|
||||
/>
|
||||
<span className="truncate">{departure.destination}</span>
|
||||
<div className="flex items-center gap-4">
|
||||
<LineBadge designation={departure.line.designation} mode={departure.line.transport_mode} groupOfLines={departure.line.group_of_lines} />
|
||||
<span>{departure.destination}</span>
|
||||
</div>
|
||||
<span className={`shrink-0 ml-4 ${alwaysOn ? 'font-normal' : 'font-bold'}`}>{timeText}</span>
|
||||
<span className={alwaysOn ? 'font-normal' : 'font-bold'}>{timeText}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,13 +2,7 @@ import { SBBLineBadge } from '../atoms/SBBLineBadge';
|
|||
import { SBBDeparture } from '../../types';
|
||||
import { useAlwaysOn } from '../../contexts/AlwaysOnContext';
|
||||
|
||||
export const SBBDepartureItem = ({
|
||||
departure,
|
||||
showPlatform = false,
|
||||
}: {
|
||||
departure: SBBDeparture;
|
||||
showPlatform?: boolean;
|
||||
}) => {
|
||||
export const SBBDepartureItem = ({ departure }: { departure: SBBDeparture }) => {
|
||||
const alwaysOn = useAlwaysOn();
|
||||
const dep = departure.stop.departure ? new Date(departure.stop.departure) : null;
|
||||
const diffMin = dep ? Math.round((dep.getTime() - Date.now()) / 60000) : null;
|
||||
|
|
@ -17,17 +11,12 @@ export const SBBDepartureItem = ({
|
|||
|
||||
return (
|
||||
<div className={`flex justify-between items-center py-3 border-b border-zinc-700 text-2xl ${alwaysOn ? 'font-light' : ''}`}>
|
||||
<div className="flex items-center gap-4 min-w-0 overflow-hidden">
|
||||
<div className="flex items-center gap-4">
|
||||
<SBBLineBadge name={`${departure.category} ${departure.number}`} category={departure.category} />
|
||||
<span className="truncate">{departure.to}</span>
|
||||
<span>{departure.to}</span>
|
||||
</div>
|
||||
<div className={`flex items-center gap-2 shrink-0 ml-4 ${alwaysOn ? 'font-normal' : 'font-bold'}`}>
|
||||
{showPlatform && departure.stop.platform && (
|
||||
<span className="text-sm text-gray-400 font-normal">Pl.{departure.stop.platform}</span>
|
||||
)}
|
||||
{delay != null && delay > 0 && (
|
||||
<span className="text-lg text-orange-400 font-normal">+{delay}'</span>
|
||||
)}
|
||||
<div className={`flex items-center gap-2 ${alwaysOn ? 'font-normal' : 'font-bold'}`}>
|
||||
{delay != null && delay > 0 && <span className="text-lg font-normal">+{delay}'</span>}
|
||||
<span>{timeText}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,46 +1,14 @@
|
|||
import { useSize } from '../../hooks/useSize';
|
||||
import { DepartureItem } from '../molecules/DepartureItem';
|
||||
import { Departure } from '../../types';
|
||||
import { useAlwaysOn } from '../../contexts/AlwaysOnContext';
|
||||
|
||||
// text-2xl (line-height 2rem = 32px) + py-3 (2 × 12px) = 56 px per row
|
||||
const ROW_H = 56;
|
||||
// text-xl header + pb-1 + a little breathing room
|
||||
const HEADER_H = 38;
|
||||
|
||||
export const DepartureList = ({
|
||||
departures,
|
||||
loading,
|
||||
stationName,
|
||||
}: {
|
||||
departures: Departure[];
|
||||
loading: boolean;
|
||||
stationName?: string;
|
||||
}) => {
|
||||
const alwaysOn = useAlwaysOn();
|
||||
const { h, ref } = useSize();
|
||||
const effectiveH = h || 300;
|
||||
|
||||
const listH = effectiveH - (stationName ? HEADER_H : 0);
|
||||
const maxRows = Math.max(1, Math.floor(listH / ROW_H));
|
||||
const visible = departures.slice(0, maxRows);
|
||||
|
||||
export const DepartureList = ({ departures, loading }: { departures: Departure[]; loading: boolean }) => {
|
||||
if (loading && departures.length === 0) return <p className="text-gray-500 text-xl mt-4">Loading...</p>;
|
||||
if (!loading && departures.length === 0) return <p className="text-gray-500 text-xl mt-4">No departures.</p>;
|
||||
return (
|
||||
<div ref={ref} className="w-full h-full flex flex-col overflow-hidden">
|
||||
{stationName && (
|
||||
<h2 className={`text-xl uppercase tracking-wider shrink-0 pb-1 ${alwaysOn ? 'font-normal' : 'font-bold'}`}>
|
||||
{stationName}
|
||||
</h2>
|
||||
)}
|
||||
{loading && departures.length === 0 ? (
|
||||
<p className="text-gray-500 text-xl mt-4">Loading…</p>
|
||||
) : visible.length === 0 ? (
|
||||
<p className="text-gray-500 text-xl mt-4">No departures</p>
|
||||
) : (
|
||||
visible.map((dep, i) => (
|
||||
<DepartureItem key={`${dep.line.designation}-${dep.scheduled}-${i}`} departure={dep} />
|
||||
))
|
||||
)}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{departures.map((dep, i) => (
|
||||
<DepartureItem key={`${dep.line.designation}-${i}`} departure={dep} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,50 +1,14 @@
|
|||
import { useSize } from '../../hooks/useSize';
|
||||
import { SBBDepartureItem } from '../molecules/SBBDepartureItem';
|
||||
import { SBBDeparture } from '../../types';
|
||||
import { useAlwaysOn } from '../../contexts/AlwaysOnContext';
|
||||
|
||||
const ROW_H = 56;
|
||||
const HEADER_H = 38;
|
||||
|
||||
export const SBBDepartureList = ({
|
||||
departures,
|
||||
loading,
|
||||
stationName,
|
||||
}: {
|
||||
departures: SBBDeparture[];
|
||||
loading: boolean;
|
||||
stationName?: string;
|
||||
}) => {
|
||||
const alwaysOn = useAlwaysOn();
|
||||
const { h, w, ref } = useSize();
|
||||
const effectiveH = h || 300;
|
||||
|
||||
const listH = effectiveH - (stationName ? HEADER_H : 0);
|
||||
const maxRows = Math.max(1, Math.floor(listH / ROW_H));
|
||||
// Platform column only when the slot is wide enough to show it comfortably
|
||||
const showPlatform = (w || 0) > 320;
|
||||
const visible = departures.slice(0, maxRows);
|
||||
|
||||
export const SBBDepartureList = ({ departures, loading }: { departures: SBBDeparture[]; loading: boolean }) => {
|
||||
if (loading && departures.length === 0) return <p className="text-gray-500 text-xl mt-4">Loading...</p>;
|
||||
if (!loading && departures.length === 0) return <p className="text-gray-500 text-xl mt-4">No departures.</p>;
|
||||
return (
|
||||
<div ref={ref} className="w-full h-full flex flex-col overflow-hidden">
|
||||
{stationName && (
|
||||
<h2 className={`text-xl uppercase tracking-wider shrink-0 pb-1 ${alwaysOn ? 'font-normal' : 'font-bold'}`}>
|
||||
{stationName}
|
||||
</h2>
|
||||
)}
|
||||
{loading && departures.length === 0 ? (
|
||||
<p className="text-gray-500 text-xl mt-4">Loading…</p>
|
||||
) : visible.length === 0 ? (
|
||||
<p className="text-gray-500 text-xl mt-4">No departures</p>
|
||||
) : (
|
||||
visible.map((dep, i) => (
|
||||
<SBBDepartureItem
|
||||
key={`${dep.name}-${dep.stop.departure}-${i}`}
|
||||
departure={dep}
|
||||
showPlatform={showPlatform}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{departures.map((dep, i) => (
|
||||
<SBBDepartureItem key={`${dep.name}-${i}`} departure={dep} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useAlwaysOn } from '../../contexts/AlwaysOnContext';
|
||||
import { useSize } from '../../hooks/useSize';
|
||||
import { SpotifyNowPlaying } from '../../types';
|
||||
|
||||
function fmtMs(ms: number) {
|
||||
|
|
@ -10,51 +9,31 @@ function fmtMs(ms: number) {
|
|||
|
||||
export const SpotifyWidget = () => {
|
||||
const alwaysOn = useAlwaysOn();
|
||||
const { w, h, ref } = useSize();
|
||||
|
||||
const { data } = useQuery<SpotifyNowPlaying>({
|
||||
queryKey: ['spotify-now-playing'],
|
||||
queryFn: () => fetch('/api/spotify/now-playing').then(r => r.json()),
|
||||
refetchInterval: alwaysOn ? 30_000 : 1_000,
|
||||
});
|
||||
|
||||
const effectiveH = h || 100;
|
||||
const effectiveW = w || 300;
|
||||
|
||||
// Progressive disclosure — text sizes are fixed, only visibility changes
|
||||
const showAlbum = effectiveH > 110 && effectiveW > 260;
|
||||
const showProgress = !alwaysOn && effectiveH > 90 && effectiveW > 200;
|
||||
const showTimes = showProgress && effectiveW > 280;
|
||||
|
||||
return (
|
||||
<div ref={ref} className="w-full h-full flex flex-col justify-center overflow-hidden">
|
||||
<div className="h-full flex flex-col justify-center">
|
||||
<h2 className="text-sm uppercase tracking-wider text-gray-400 mb-1 font-normal">Now Playing</h2>
|
||||
{data?.playing && data.track ? (
|
||||
<>
|
||||
<p className={`text-2xl leading-tight truncate ${alwaysOn ? 'font-normal' : 'font-bold'}`}>
|
||||
{data.track.name}
|
||||
</p>
|
||||
<p className="text-lg text-gray-300 mt-0.5 truncate">{data.track.artist}</p>
|
||||
{showAlbum && (
|
||||
<p className="text-sm text-gray-500 truncate">{data.track.album}</p>
|
||||
)}
|
||||
{showProgress && (
|
||||
<p className={`text-2xl leading-tight ${alwaysOn ? 'font-normal' : 'font-bold'}`}>{data.track.name}</p>
|
||||
<p className="text-lg text-gray-300 mt-0.5">{data.track.artist}</p>
|
||||
<p className="text-sm text-gray-500">{data.track.album}</p>
|
||||
{!alwaysOn && (
|
||||
<div className="mt-1.5 flex items-center gap-2">
|
||||
{showTimes && (
|
||||
<span className="text-xs text-gray-500 font-mono w-10 shrink-0">
|
||||
{fmtMs(data.track.progressMs)}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs text-gray-500 font-mono w-10 shrink-0">{fmtMs(data.track.progressMs)}</span>
|
||||
<div className="flex-1 h-1.5 bg-zinc-800 relative">
|
||||
<div
|
||||
className="absolute inset-y-0 left-0 bg-white"
|
||||
style={{ width: `${(data.track.progressMs / data.track.durationMs) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
{showTimes && (
|
||||
<span className="text-xs text-gray-500 font-mono w-10 shrink-0 text-right">
|
||||
{fmtMs(data.track.durationMs)}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs text-gray-500 font-mono w-10 shrink-0 text-right">{fmtMs(data.track.durationMs)}</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useAlwaysOn } from '../../contexts/AlwaysOnContext';
|
||||
import { useSize } from '../../hooks/useSize';
|
||||
|
||||
interface SystemData {
|
||||
cpu: { percent: number; load: string[] };
|
||||
|
|
@ -27,40 +26,23 @@ const Row = ({ label, percent, detail }: { label: string; percent: number; detai
|
|||
|
||||
export const SystemWidget = () => {
|
||||
const alwaysOn = useAlwaysOn();
|
||||
const { w, h, ref } = useSize();
|
||||
const { data } = useQuery<SystemData>({
|
||||
queryKey: ['system'],
|
||||
queryFn: () => fetch('/api/system').then(r => r.json()),
|
||||
refetchInterval: alwaysOn ? 60_000 : 2_000,
|
||||
});
|
||||
|
||||
if (!data) return <div ref={ref} className="w-full h-full" />;
|
||||
|
||||
const effectiveH = h || 120;
|
||||
const effectiveW = w || 300;
|
||||
|
||||
// Progressive disclosure — fixed text-sm rows, just show more when taller/wider
|
||||
const showDisk = effectiveH > 95;
|
||||
const showUptime = effectiveH > 110 || effectiveW > 450;
|
||||
const showLoadDetail = effectiveH > 140 && effectiveW > 380;
|
||||
if (!data) return null;
|
||||
|
||||
return (
|
||||
<div ref={ref} className="w-full h-full flex flex-col justify-center gap-3 overflow-hidden">
|
||||
<div className="h-full flex flex-col justify-center gap-3">
|
||||
<div className="flex justify-between items-baseline">
|
||||
<span className="font-bold text-lg">{data.hostname}</span>
|
||||
{showUptime && (
|
||||
<span className="text-gray-500 text-sm font-mono">up {data.uptime}</span>
|
||||
)}
|
||||
</div>
|
||||
<Row
|
||||
label="CPU"
|
||||
percent={data.cpu.percent}
|
||||
detail={showLoadDetail ? `load ${data.cpu.load.join(' ')}` : `load ${data.cpu.load[0]}`}
|
||||
/>
|
||||
<Row label="CPU" percent={data.cpu.percent} detail={`load ${data.cpu.load[0]} ${data.cpu.load[1]} ${data.cpu.load[2]}`} />
|
||||
<Row label="MEM" percent={data.memory.percent} detail={`${data.memory.usedGb} / ${data.memory.totalGb} GB`} />
|
||||
{showDisk && (
|
||||
<Row label="DISK" percent={data.disk.percent} detail={`${data.disk.usedGb} / ${data.disk.totalGb} GB`} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,23 +1,84 @@
|
|||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useSize } from '../../hooks/useSize';
|
||||
import { WeatherData } from '../../types';
|
||||
|
||||
// 5-line ASCII art keyed by condition keyword
|
||||
const ASCII: Record<string, string[]> = {
|
||||
sunny: [' \\ / ', ' .-. ', ' ― ( ) ―', ' `-\' ', ' / \\ '],
|
||||
partlycloudy: [' \\ / ', ' _ /"".--. ', ' \\_( { ) ', ' `-\'--\' ', ' '],
|
||||
cloudy: [' ', ' .--. ', ' .-( ). ', '(___.__)__) ', ' '],
|
||||
overcast: [' ', ' .------. ', '.-( ).', '(__________.) ', ' '],
|
||||
rain: [' .--. ', ' .-( ). ', '(___.__)__) ', " ʻ ʻ ʻ ʻ ", " ʻ ʻ ʻ ʻ "],
|
||||
drizzle: [' .--. ', ' .-( ). ', '(___.__)__) ', " , , , , ", " "],
|
||||
snow: [' .--. ', ' .-( ). ', '(___.__)__) ', ' * * * ', '* * * * '],
|
||||
thunder: [' .--. ', ' .-( ). ', '(___.__)__) ', ' / \\ ', ' / \\ '],
|
||||
fog: [' ', ' _ - _ - _ -', ' _ - _ - _ ', ' _ - _ - _ -', ' '],
|
||||
sleet: [' .--. ', ' .-( ). ', '(___.__)__) ', " * ʻ * ʻ ", " ʻ * ʻ * "],
|
||||
sunny: [
|
||||
' \\ / ',
|
||||
' .-. ',
|
||||
' ― ( ) ―',
|
||||
' `-\' ',
|
||||
' / \\ ',
|
||||
],
|
||||
partlycloudy: [
|
||||
' \\ / ',
|
||||
' _ /"".--. ',
|
||||
' \\_( { ) ',
|
||||
' `-\'--\' ',
|
||||
' ',
|
||||
],
|
||||
cloudy: [
|
||||
' ',
|
||||
' .--. ',
|
||||
' .-( ). ',
|
||||
'(___.__)__) ',
|
||||
' ',
|
||||
],
|
||||
overcast: [
|
||||
' ',
|
||||
' .------. ',
|
||||
'.-( ).',
|
||||
'(__________.) ',
|
||||
' ',
|
||||
],
|
||||
rain: [
|
||||
' .--. ',
|
||||
' .-( ). ',
|
||||
'(___.__)__) ',
|
||||
" ʻ ʻ ʻ ʻ ",
|
||||
" ʻ ʻ ʻ ʻ ",
|
||||
],
|
||||
drizzle: [
|
||||
' .--. ',
|
||||
' .-( ). ',
|
||||
'(___.__)__) ',
|
||||
" , , , , ",
|
||||
" ",
|
||||
],
|
||||
snow: [
|
||||
' .--. ',
|
||||
' .-( ). ',
|
||||
'(___.__)__) ',
|
||||
' * * * ',
|
||||
'* * * * ',
|
||||
],
|
||||
thunder: [
|
||||
' .--. ',
|
||||
' .-( ). ',
|
||||
'(___.__)__) ',
|
||||
' / \\ ',
|
||||
' / \\ ',
|
||||
],
|
||||
fog: [
|
||||
' ',
|
||||
' _ - _ - _ -',
|
||||
' _ - _ - _ ',
|
||||
' _ - _ - _ -',
|
||||
' ',
|
||||
],
|
||||
sleet: [
|
||||
' .--. ',
|
||||
' .-( ). ',
|
||||
'(___.__)__) ',
|
||||
" * ʻ * ʻ ",
|
||||
" ʻ * ʻ * ",
|
||||
],
|
||||
};
|
||||
|
||||
const MOON_CHAR: Record<string, string> = {
|
||||
'New Moon': '🌑', 'Waxing Crescent': '🌒', 'First Quarter': '🌓', 'Waxing Gibbous': '🌔',
|
||||
'Full Moon': '🌕', 'Waning Gibbous': '🌖', 'Last Quarter': '🌗', 'Waning Crescent': '🌘',
|
||||
'New Moon': '🌑', 'Waxing Crescent': '🌒', 'First Quarter': '🌓',
|
||||
'Waxing Gibbous': '🌔', 'Full Moon': '🌕', 'Waning Gibbous': '🌖',
|
||||
'Last Quarter': '🌗', 'Waning Crescent': '🌘',
|
||||
};
|
||||
|
||||
function getAsciiKey(desc: string): string {
|
||||
|
|
@ -27,16 +88,16 @@ function getAsciiKey(desc: string): string {
|
|||
if (d.includes('sleet')) return 'sleet';
|
||||
if (d.includes('drizzle')) return 'drizzle';
|
||||
if (d.includes('rain')) return 'rain';
|
||||
if (d.includes('fog') || d.includes('mist')) return 'fog';
|
||||
if (d.includes('fog') || d.includes('mist') || d.includes('haze')) return 'fog';
|
||||
if (d.includes('overcast')) return 'overcast';
|
||||
if (d.includes('mostly cloudy') || d.includes('very')) return 'cloudy';
|
||||
if (d.includes('partly') || d.includes('mostly sunny')) return 'partlycloudy';
|
||||
if (d.includes('mostly cloudy') || d.includes('very cloudy')) return 'cloudy';
|
||||
if (d.includes('partly cloudy') || d.includes('mostly sunny')) return 'partlycloudy';
|
||||
if (d.includes('cloudy')) return 'cloudy';
|
||||
return 'sunny';
|
||||
if (d.includes('sunny') || d.includes('clear')) return 'sunny';
|
||||
return 'cloudy';
|
||||
}
|
||||
|
||||
export const WeatherWidget = () => {
|
||||
const { w, h, ref } = useSize();
|
||||
const { data, isError } = useQuery<WeatherData>({
|
||||
queryKey: ['weather'],
|
||||
queryFn: () => fetch('/api/weather').then(r => r.json()),
|
||||
|
|
@ -44,50 +105,31 @@ export const WeatherWidget = () => {
|
|||
});
|
||||
|
||||
if (isError || !data || (data as any).error) {
|
||||
return (
|
||||
<div ref={ref} className="w-full h-full flex items-center">
|
||||
<span className="text-gray-500 text-sm">Weather unavailable</span>
|
||||
</div>
|
||||
);
|
||||
return <div className="text-gray-500 text-sm">Weather unavailable</div>;
|
||||
}
|
||||
|
||||
const effectiveH = h || 80;
|
||||
const effectiveW = w || 350;
|
||||
|
||||
// Progressive disclosure — everything is the same size, just more/less shown
|
||||
const showAscii = effectiveH > 180 && effectiveW > 380;
|
||||
const showHourly = effectiveH > 120 && effectiveW > 260;
|
||||
const showSunMoon = effectiveH > 80 || effectiveW > 460;
|
||||
const maxHours = effectiveW > 500 ? 6 : effectiveW > 380 ? 4 : 3;
|
||||
|
||||
const art = ASCII[getAsciiKey(data.description)] ?? ASCII.cloudy;
|
||||
const moon = MOON_CHAR[data.moonPhase] ?? data.moonPhase;
|
||||
|
||||
return (
|
||||
<div ref={ref} className="w-full h-full flex items-center gap-4 overflow-hidden">
|
||||
{showAscii && (
|
||||
<pre className="text-gray-300 text-[11px] leading-tight select-none font-mono shrink-0">
|
||||
{art.join('\n')}
|
||||
</pre>
|
||||
)}
|
||||
<div className="flex flex-col gap-1 min-w-0">
|
||||
<div className="flex items-baseline gap-3 flex-wrap">
|
||||
<div className="flex items-start gap-4">
|
||||
<pre className="text-gray-300 text-[11px] leading-tight select-none font-mono">{art.join('\n')}</pre>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-baseline gap-3">
|
||||
<span className="text-4xl font-bold">{data.temperature}°C</span>
|
||||
<span className="text-lg text-gray-300">{data.description}</span>
|
||||
</div>
|
||||
{showSunMoon && (
|
||||
<div className="flex flex-wrap gap-4 text-sm text-gray-400 font-mono">
|
||||
<div className="flex gap-4 text-sm text-gray-400 font-mono">
|
||||
<span>↑ {data.sunrise}</span>
|
||||
<span>↓ {data.sunset}</span>
|
||||
<span title={data.moonPhase}>{moon} {data.moonPhase}</span>
|
||||
</div>
|
||||
)}
|
||||
{showHourly && data.hourly.length > 0 && (
|
||||
{data.hourly.length > 0 && (
|
||||
<div className="flex gap-3 mt-1">
|
||||
{data.hourly.slice(0, maxHours).map((entry, i) => (
|
||||
<div key={i} className="text-center shrink-0">
|
||||
<div className="text-xs text-gray-500">{entry.time}</div>
|
||||
<div className="text-sm font-bold">{entry.temp}°</div>
|
||||
{data.hourly.map((h, i) => (
|
||||
<div key={i} className="text-center">
|
||||
<div className="text-xs text-gray-500">{h.time}</div>
|
||||
<div className="text-sm font-bold">{h.temp}°</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { createContext, useContext } from "react";
|
||||
import { createContext, useContext } from 'react';
|
||||
|
||||
export const AlwaysOnContext = createContext(false);
|
||||
export const useAlwaysOn = () => useContext(AlwaysOnContext);
|
||||
|
|
|
|||
|
|
@ -1,19 +0,0 @@
|
|||
import { useCallback, useRef, useState } from 'react';
|
||||
|
||||
/** Measures a div's content box via ResizeObserver. Attach `ref` to the element. */
|
||||
export function useSize() {
|
||||
const [size, setSize] = useState({ w: 0, h: 0 });
|
||||
const roRef = useRef<ResizeObserver | null>(null);
|
||||
|
||||
const ref = useCallback((el: HTMLDivElement | null) => {
|
||||
roRef.current?.disconnect();
|
||||
if (!el) return;
|
||||
roRef.current = new ResizeObserver(([entry]) => {
|
||||
const { width, height } = entry.contentRect;
|
||||
setSize({ w: Math.round(width), h: Math.round(height) });
|
||||
});
|
||||
roRef.current.observe(el);
|
||||
}, []);
|
||||
|
||||
return { ...size, ref };
|
||||
}
|
||||
|
|
@ -12,9 +12,7 @@ import { Config, Departure, SBBDeparture, WidgetId, ZoneId } from '../types';
|
|||
const fetchJson = (url: string) => fetch(url).then(r => r.json());
|
||||
|
||||
const FOOTER_ZONES: ZoneId[] = ['footer-1', 'footer-2', 'footer-3'];
|
||||
const DEPARTURE_IDS: WidgetId[] = ['sl', 'sbb'];
|
||||
|
||||
// ── Dashboard ─────────────────────────────────────────────────────────────────
|
||||
const DEPARTURE_WIDGETS: WidgetId[] = ['sl', 'sbb'];
|
||||
|
||||
export const Dashboard = () => {
|
||||
const { data: config } = useQuery<Config>({
|
||||
|
|
@ -24,20 +22,22 @@ export const Dashboard = () => {
|
|||
|
||||
const alwaysOn = !!config?.alwaysOn;
|
||||
const layout = config?.layout ?? {};
|
||||
|
||||
const inLayout = (id: WidgetId) => Object.values(layout).includes(id);
|
||||
|
||||
// In alwaysOn mode, tick once per minute to keep departure times current
|
||||
const [, tick] = useReducer(x => x + 1, 0);
|
||||
useEffect(() => {
|
||||
if (!alwaysOn) return;
|
||||
let interval: ReturnType<typeof setInterval>;
|
||||
let id: ReturnType<typeof setInterval>;
|
||||
const schedule = () => {
|
||||
const now = new Date();
|
||||
const msUntilNext = (60 - now.getSeconds()) * 1000 - now.getMilliseconds();
|
||||
const timeout = setTimeout(() => { tick(); interval = setInterval(tick, 60_000); }, msUntilNext);
|
||||
return () => { clearTimeout(timeout); clearInterval(interval); };
|
||||
return setTimeout(() => { tick(); id = setInterval(tick, 60_000); }, msUntilNext);
|
||||
};
|
||||
const timeout = schedule();
|
||||
return () => { clearTimeout(timeout); clearInterval(id); };
|
||||
}, [alwaysOn]);
|
||||
|
||||
// Data fetching — only enabled when the widget is actually in the layout
|
||||
const { data: slData, isLoading: slLoading } = useQuery<{ departures: Departure[] }>({
|
||||
queryKey: ['sl-departures', config?.sl?.stationId],
|
||||
queryFn: () => fetchJson(`/api/sl/departures/${config!.sl.stationId}`),
|
||||
|
|
@ -52,81 +52,90 @@ export const Dashboard = () => {
|
|||
refetchInterval: alwaysOn ? 60_000 : (config?.refreshRate ?? 30_000),
|
||||
});
|
||||
|
||||
const slName = slData?.departures[0]?.stop_area?.name || config?.sl?.stationName || 'SL';
|
||||
const sbbName = config?.sbb?.stationName || 'SBB';
|
||||
|
||||
// ── Widget factory ───────────────────────────────────────────────────────────
|
||||
|
||||
const renderWidget = (id: WidgetId | undefined) => {
|
||||
if (!id || id === 'spacer') return null;
|
||||
switch (id) {
|
||||
case 'clock': return <Clock />;
|
||||
case 'weather': return <WeatherWidget />;
|
||||
case 'clock': return <div className="h-full flex items-center"><Clock /></div>;
|
||||
case 'weather': return <div className="h-full flex items-center"><WeatherWidget /></div>;
|
||||
case 'spotify': return <SpotifyWidget />;
|
||||
case 'system': return <SystemWidget />;
|
||||
case 'sl': return <DepartureList departures={slData?.departures ?? []} loading={slLoading} stationName={slName} />;
|
||||
case 'sbb': return <SBBDepartureList departures={sbbData?.stationboard ?? []} loading={sbbLoading} stationName={sbbName} />;
|
||||
case 'sl':
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<h2 className={`text-xl uppercase tracking-wider shrink-0 pb-1 ${alwaysOn ? 'font-normal' : 'font-bold'}`}>
|
||||
{slData?.departures[0]?.stop_area?.name || config?.sl?.stationName || 'SL'}
|
||||
</h2>
|
||||
<DepartureList departures={slData?.departures ?? []} loading={slLoading} />
|
||||
</div>
|
||||
);
|
||||
case 'sbb':
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<h2 className={`text-xl uppercase tracking-wider shrink-0 pb-1 ${alwaysOn ? 'font-normal' : 'font-bold'}`}>
|
||||
{config?.sbb?.stationName || 'SBB'}
|
||||
</h2>
|
||||
<SBBDepartureList departures={sbbData?.stationboard ?? []} loading={sbbLoading} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// ── Layout derivations ───────────────────────────────────────────────────────
|
||||
|
||||
const headerLeft = layout['header-left'];
|
||||
const headerRight = layout['header-right'];
|
||||
const main1 = layout['main-1'];
|
||||
const main2 = layout['main-2'];
|
||||
const footerWidgets = FOOTER_ZONES.map(z => layout[z]).filter((w): w is WidgetId => w != null);
|
||||
// Keep spacers — they hold intentional empty slots, only collapse truly unset zones
|
||||
const footerWidgets = FOOTER_ZONES.map(z => layout[z]).filter(w => w != null) as WidgetId[];
|
||||
|
||||
const hasHeader = !!(headerLeft || headerRight);
|
||||
const hasMain = !!(main1 && main1 !== 'spacer') || !!(main2 && main2 !== 'spacer');
|
||||
const hasFooter = footerWidgets.some(w => w !== 'spacer');
|
||||
const footerHasDeps = footerWidgets.some(w => DEPARTURE_IDS.includes(w));
|
||||
const hasHeader = headerLeft || headerRight;
|
||||
// spacer alone doesn't count as real content
|
||||
const hasMain = (main1 && main1 !== 'spacer') || (main2 && main2 !== 'spacer');
|
||||
const hasFooter = footerWidgets.filter(w => w !== 'spacer').length > 0;
|
||||
|
||||
// Zone class: fills its grid/flex cell and prevents content from overflowing
|
||||
const zone = 'h-full min-h-0 min-w-0 overflow-hidden';
|
||||
const footerHasDeps = footerWidgets.some(w => DEPARTURE_WIDGETS.includes(w));
|
||||
|
||||
// Footer height: departure lists need more room; info-only widgets get 20 vh
|
||||
// Footer sizing: takes 40% of viewport when holding departure lists so rows are visible.
|
||||
// When only info widgets: auto-height. When no main: expands to fill.
|
||||
const footerStyle: React.CSSProperties = !hasMain
|
||||
? { flex: '1 1 0', minHeight: 0 }
|
||||
: footerHasDeps
|
||||
? { flex: '0 0 40%', minHeight: 0 }
|
||||
: { flex: '0 0 20%', minHeight: '120px' };
|
||||
: { flex: '0 0 auto', minHeight: '140px' };
|
||||
|
||||
// ── Render ───────────────────────────────────────────────────────────────────
|
||||
// Zone wrapper: min-h-0 prevents a child from inflating beyond the slot
|
||||
const zoneClass = 'min-h-0 min-w-0 overflow-hidden';
|
||||
|
||||
return (
|
||||
<AlwaysOnContext.Provider value={alwaysOn}>
|
||||
<div className="flex flex-col h-screen w-screen p-6 gap-4 overflow-hidden">
|
||||
|
||||
{hasHeader && (
|
||||
<div
|
||||
className="flex items-stretch justify-between border-b border-white pb-4 shrink-0"
|
||||
style={{ minHeight: '72px' }}
|
||||
>
|
||||
<div className={`flex-1 ${zone}`}>{renderWidget(headerLeft)}</div>
|
||||
{headerRight && (
|
||||
<div className={`flex-shrink-0 ml-8 ${zone}`}>{renderWidget(headerRight)}</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between border-b border-white pb-4 shrink-0">
|
||||
<div className={`flex-1 ${zoneClass}`}>{renderWidget(headerLeft)}</div>
|
||||
{headerRight && <div className={`flex-shrink-0 ml-8 ${zoneClass}`}>{renderWidget(headerRight)}</div>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasMain && (
|
||||
<div
|
||||
className={main1 && main2 ? 'grid grid-cols-2 gap-6' : 'flex'}
|
||||
className={`${main1 && main2 ? 'grid grid-cols-2 gap-6' : 'flex'}`}
|
||||
style={{ flex: '1 1 0', minHeight: 0 }}
|
||||
>
|
||||
{main1 && <div className={`${!main2 ? 'flex-1' : ''} ${zone}`}>{renderWidget(main1)}</div>}
|
||||
{main2 && <div className={zone}>{renderWidget(main2)}</div>}
|
||||
{main1 && <div className={`${main1 && main2 ? '' : 'flex-1'} ${zoneClass}`}>{renderWidget(main1)}</div>}
|
||||
{main2 && <div className={zoneClass}>{renderWidget(main2)}</div>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasFooter && (
|
||||
<div
|
||||
className="border-t border-white pt-4 grid gap-6"
|
||||
style={{ ...footerStyle, gridTemplateColumns: `repeat(${footerWidgets.length}, 1fr)` }}
|
||||
style={{
|
||||
...footerStyle,
|
||||
gridTemplateColumns: `repeat(${footerWidgets.length}, 1fr)`,
|
||||
}}
|
||||
>
|
||||
{footerWidgets.map((w, i) => (
|
||||
<div key={`${w}-${i}`} className={zone}>{renderWidget(w)}</div>
|
||||
<div key={`${w}-${i}`} className={`${zoneClass} h-full`}>{renderWidget(w)}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
Loading…
Reference in a new issue