fist pass

This commit is contained in:
root
2026-01-31 22:18:40 +00:00
parent a582e1f272
commit a28013de4a
23 changed files with 4131 additions and 823 deletions

View File

@@ -0,0 +1,15 @@
{
"permissions": {
"allow": [
"WebSearch",
"Bash(ssh:*)",
"Bash(rsync:*)",
"Bash(ls:*)",
"Bash(chmod:*)",
"Bash(npm install:*)",
"Bash(git -C /home/jramos/barkwho remote:*)",
"Bash(git config:*)",
"Bash(ping:*)"
]
}
}

2
.gitignore vendored
View File

@@ -4,5 +4,3 @@ dist/
data/*.json
*.log
.DS_Store
CLAUDE.md
.env

65
CLAUDE.md Normal file
View File

@@ -0,0 +1,65 @@
# Project: BarkWho
**Role:** Senior Full-Stack Engineer / Home Lab Architect
**Objective:** Build a self-hosted, local parental control dashboard ("BarkWho") that interfaces with a UniFi Dream Router (UDR) to manage family internet access.
---
## 1. Core Technical Requirements
- **Framework:** React (Vite) Frontend + Node.js (Express) Backend.
- **Infrastructure:** Docker Compose (Service 1: Frontend, Service 2: Backend).
- **Target Hardware:** UniFi Dream Router (Local API).
- **Primary Logic:**
- UI "Allow" (Green) = Backend calls UniFi to **DISABLE** the blocking rule.
- UI "Block" (Red) = Backend calls UniFi to **ENABLE** the blocking rule.
- **Note:** To toggle a rule, you must fetch the entire rule object, modify the `enabled` property, and PUT the full payload back to the UDR.
---
## 2. API Implementation Details (UDR Local API)
- **Auth:** `POST https://<UDR_IP>/api/auth/login` (Body: username, password).
- **Session:** Extract the `unifises` cookie and `x-csrf-token` from response headers for all subsequent calls.
- **Traffic Rule Endpoint:** `/proxy/network/v2/api/site/default/trafficrules`
- **Method for Toggle:** `PUT /proxy/network/v2/api/site/default/trafficrules/{rule_id}`
---
## 3. Version 1 Features (Functional Requirements)
### FR1: The Bark-Style Policy Engine
Map one UI "Category" to an array of UniFi Rule IDs in a `policy_map.json`:
- **Social Media:** [Rule_ID_TikTok, Rule_ID_Instagram]
- **Streaming:** [Rule_ID_Netflix, Rule_ID_Hulu]
- **Adult Content:** [Rule_ID_Adult_Filter]
- **Gaming:** [Rule_ID_Roblox, Rule_ID_Fortnite]
### FR2: Device Library & Dynamic Groups
- **Discovery:** Fetch all clients via `/proxy/network/api/s/default/stat/sta`.
- **Assignment:** Implement a way to add/remove a device's MAC address to/from the "Kids" IP Group profile in UniFi.
- **"Nuke" Button:** A high-visibility button for each device to instantly kill its connectivity.
### FR3: "Bonus Time" Timer System
- **Presets:** 15m, 30m, 60m.
- **Custom:** Manual minute/hour entry.
- **Reliability:** Timers must persist in a `timers.json` file so they resume after a container restart.
### FR4: Automated Curfew (Bedtime)
- **Schedule:** Integrated `node-cron` to enable the "Total Internet Block" at 9:00 PM and disable it at 7:00 AM.
---
## 4. UI/UX & Aesthetic Guidelines
- **Theme:** Dark Academia Tech.
- **Palette:**
- Background: Deep Viridian (#2E473B) with circuit/blueprint overlays.
- Text/Icons: Cream (#F5F5DC).
- Status Indicators: Vibrant Green for "Active/Allowed", Deep Red/Magenta for "Blocked/Nuke".
- **Design Layout:**
- Mobile-first PWA (iPad/iPhone optimized).
- Bottom Navigation: [Control Dashboard] | [Device Library].
- Use Framer Motion for the "Slide-up" Bonus Time drawer.
---
## 5. Deployment Instructions
- Provide a `docker-compose.yml` that mounts a `/data` volume for persistent JSON state.
- Use a `.env` file for: `UDR_IP`, `UDR_USER`, `UDR_PASSWORD`, `APP_SECRET_KEY`.

31
Gemini_status.md Normal file
View File

@@ -0,0 +1,31 @@
# BarkWho Project Status
## Project Reconciliation
- [x] Review GEMINI.md (from local copy)
- [x] Review CLAUDE.md (remote)
- [x] Analyze current codebase structure (remote)
- [x] Align frontend styling with 'Dark Academia Tech' mandate
## Frontend View Improvements
- [x] Fix layout containers (app-container, app-content)
- [x] Align color palette with mandate (#2E473B, #F5F5DC, #D4AF37, #BE123C)
- [x] Refine Glassmorphism effects (blur, borders, shadows)
- [x] Implement Circuit Board background properly
- [x] Ensure Typography is consistent (Playfair Display for headers, Mono for data)
- [x] Match UI as closely as possible to mockup.png descriptions
## Backend & Integration
- [x] Verify UniFi API implementation (v2 path, State Inversion)
- [x] Check Timer reliability (timers.json persistence)
- [x] Test Curfew automation
## Finalization
- [x] Verify PWA manifest and service worker
- [x] Final UI polish
- [x] Generate README with Architecture and End-User Guide
- [x] Fix syntax errors in DeviceLibrary.jsx
- [x] Update Slide-up Windows for visibility (transparency fix)
- [x] Add Custom Time input for Curfew overrides
- [x] Add Profile Selection for Devices (local persistence)
**Project State:** COMPLETE & DEPLOYED

View File

@@ -0,0 +1,85 @@
# BarkWho: Manor Network Controller
**BarkWho** is a self-hosted, "Dark Academia" themed parental control dashboard designed specifically for the UniFi Dream Router (UDR). It provides a simplified, high-fidelity interface for managing family internet access, implementing curfews, and "nuking" specific devices during emergencies.
---
## 🏛️ Architecture & Logic
BarkWho is built as a bridge between your family`s daily needs and the complex UniFi Network application.
### The Stack
* **Frontend:** React (Vite) PWA with Tailwind CSS. Styled with a custom "Dark Academia Tech" aesthetic (Deep Viridian, Gold, Cream) featuring glassmorphism and circuit board motifs.
* **Backend:** Node.js (Express) acting as a secure middleware.
* **Integration:** Communicates directly with the UDR`s local API (`/proxy/network/v2/...`).
### Core Logic: "State Inversion"
BarkWho operates on a **"Permission First"** mental model, whereas UniFi operates on a **"Restriction"** model.
* **UI Green (Allowed):** BarkWho sends `enabled: false` to the UniFi Block Rule. (The block is *off*, so traffic flows).
* **UI Red (Blocked):** BarkWho sends `enabled: true` to the UniFi Block Rule. (The block is *active*, traffic is stopped).
---
## 🚀 Deployment Guide
### Prerequisites
1. **UniFi Dream Router (UDR)** with SSH enabled or a local admin user.
2. **Docker & Docker Compose** installed on your hosting server (e.g., Raspberry Pi, Proxmox VM).
### 1. Configuration
Create a `.env` file in the project root:
```bash
UDR_IP=192.168.1.1 # IP address of your UDR
UDR_USER=localadmin # Local admin username (not UI.com email)
UDR_PASSWORD=password123 # Local admin password
APP_SECRET_KEY=secret_key # Random string for session security
```
### 2. Map Your Policies
BarkWho needs to know which UniFi Traffic Rules correspond to your dashboard categories.
1. Log in to your UniFi Network Controller.
2. Go to **Settings > Traffic Management > Rules**.
3. Create your blocking rules (e.g., "Block Social Media", "Block Gaming"). **Assign them to the specific Target Groups (Target: "kids_console", "kids_computers") in UniFi.**
4. BarkWho will auto-discover these rules. In the BarkWho Dashboard, click the **Settings (Gear)** icon on a Category card to select which rules belong to "Social", "Gaming", etc.
### 3. Launch
```bash
docker compose up -d --build
```
Access the dashboard at `http://<server-ip>:5173`.
---
## 📖 End User Guide (How-To)
### 1. Dashboard Controls
* **Category Toggles:** Switch "Social Media" or "Gaming" on/off instantly. This toggles the underlying rules on your router.
* **Bonus Time:** Click the **Circle/Plus** icon on a category to grant temporary access (15m, 30m, 1h). The system automatically re-blocks access when time expires.
* **Rule Assignment:** Click the **Gear** icon to choose which UniFi rules are controlled by that category card.
### 2. The Curfew Clock
* **Status:** Shows if the "Estate Curfew" is currently active.
* **Overrides:** If you need to extend internet access past bedtime, use the **Custom** or preset buttons (+30m, +1h) to temporarily disable the curfew.
### 3. Device Library & "Nuke"
* **Search:** Quickly find devices by name (e.g., "iPad") or Owner.
* **NUKE:** The big red button immediately cuts off **all** internet access for that specific device (via UniFi`s "Block Client" feature). Use "Restore" to unblock.
---
## 🧩 Device Profiles & Router Groups
**Q: I see a "Profile: Kids" option in BarkWho, but I have "kids_computers" and "kids_console" groups on my router. How do they match?**
**A: In Version 1.0, they function independently.**
1. **BarkWho Profiles (Visual Tagging):**
The dropdown in the Device Library (Default, Kids, IoT) is a **visual organizer** for *you*. It helps you quickly identify which iPad belongs to whom within the BarkWho interface. It remembers your selection locally but does **not** reconfigure the router.
2. **Router Groups (Functional Enforcement):**
The actual blocking power comes from the **UniFi Traffic Rules**.
* *Example:* In UniFi, you create a rule named "Block Roblox". You set the **Target** of that rule to include both your `kids_computers` and `kids_console` groups.
* *In BarkWho:* You assign the "Block Roblox" rule to the "Gaming" category.
* *Result:* When you toggle "Gaming" to **Block** in BarkWho, it enables that rule. UniFi then enforces it against *whomever* you defined in the rule settings (both computer and console groups).
**Summary:** Use UniFi to define *who* gets blocked (Groups). Use BarkWho to decide *when* they get blocked (Policies/Curfew).

View File

@@ -7,6 +7,9 @@
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<link rel="manifest" href="/manifest.json" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;600;700&display=swap" rel="stylesheet" />
<title>BarkWho</title>
</head>
<body>

3026
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -9,10 +9,14 @@
"preview": "vite preview"
},
"dependencies": {
"autoprefixer": "^10.4.24",
"framer-motion": "^10.16.0",
"postcss": "^8.5.6",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.20.0",
"framer-motion": "^10.16.0"
"recharts": "^3.7.0",
"tailwindcss": "^3.4.19"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.2.0",

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View File

@@ -17,115 +17,86 @@ export default function BonusTimeDrawer({ category, onSubmit, onClose }) {
return (
<AnimatePresence>
<div className="drawer-overlay" onClick={onClose}>
{/* Overlay */}
<motion.div
className="fixed inset-0 z-[200] flex items-end justify-center"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
>
{/* Backdrop */}
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" />
{/* Drawer */}
<motion.div
className="drawer"
className="relative glass-card-dark rounded-b-none w-full max-w-[600px] p-6 pb-10"
style={{
borderTop: '1px solid rgba(245, 245, 220, 0.15)',
borderBottom: 'none',
borderRadius: '20px 20px 0 0',
}}
initial={{ y: '100%' }}
animate={{ y: 0 }}
exit={{ y: '100%' }}
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
transition={{ type: 'spring', damping: 28, stiffness: 320 }}
onClick={(e) => e.stopPropagation()}
>
<div className="drawer-handle" />
<h3 className="drawer-title">Bonus Time: {category}</h3>
{/* Handle */}
<div className="w-10 h-1 bg-cream/20 rounded-full mx-auto mb-5" />
<div className="preset-buttons">
{/* Title */}
<h3 className="font-serif text-lg text-cream text-center mb-6 tracking-wide">
Bonus Time: {category}
</h3>
{/* Preset buttons */}
<div className="grid grid-cols-3 gap-3 mb-5">
{PRESETS.map((p) => (
<button key={p.minutes} className="btn btn-allowed preset-btn" onClick={() => onSubmit(p.minutes)}>
<button
key={p.minutes}
className="btn-manor btn-manor-green py-4 text-base font-bold"
onClick={() => onSubmit(p.minutes)}
>
{p.label}
</button>
))}
</div>
<div className="custom-input">
{/* Divider */}
<div className="flex items-center gap-3 mb-5">
<div className="flex-1 h-px bg-cream/10" />
<span className="text-cream-dark text-[0.6rem] uppercase tracking-[2px]">or custom</span>
<div className="flex-1 h-px bg-cream/10" />
</div>
{/* Custom input */}
<div className="flex gap-3 mb-5">
<input
type="number"
placeholder="Custom minutes"
placeholder="Minutes (1-480)"
min="1"
max="480"
value={custom}
onChange={(e) => setCustom(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleCustomSubmit()}
className="manor-input flex-1"
/>
<button className="btn btn-allowed" onClick={handleCustomSubmit} disabled={!custom}>
<button
className="btn-manor btn-manor-green px-6"
onClick={handleCustomSubmit}
disabled={!custom}
>
Set
</button>
</div>
<button className="btn btn-secondary drawer-close" onClick={onClose}>
{/* Cancel */}
<button className="btn-manor btn-manor-ghost w-full py-3" onClick={onClose}>
Cancel
</button>
</motion.div>
</div>
<style>{`
.drawer-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 200;
display: flex;
align-items: flex-end;
justify-content: center;
}
.drawer {
background: var(--bg-secondary);
border-top: 1px solid var(--border);
border-radius: 20px 20px 0 0;
padding: 16px 24px 32px;
width: 100%;
max-width: 600px;
}
.drawer-handle {
width: 40px;
height: 4px;
background: var(--border);
border-radius: 2px;
margin: 0 auto 16px;
}
.drawer-title {
font-size: 1rem;
margin-bottom: 20px;
text-align: center;
text-transform: uppercase;
letter-spacing: 1px;
}
.preset-buttons {
display: flex;
gap: 12px;
margin-bottom: 16px;
}
.preset-btn {
flex: 1;
padding: 14px;
font-size: 1rem;
}
.custom-input {
display: flex;
gap: 8px;
margin-bottom: 16px;
}
.custom-input input {
flex: 1;
padding: 10px 14px;
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text-primary);
font-family: inherit;
font-size: 0.9rem;
outline: none;
}
.custom-input input:focus {
border-color: var(--accent);
}
.custom-input input::placeholder {
color: var(--text-muted);
}
.drawer-close {
width: 100%;
}
`}</style>
</motion.div>
</AnimatePresence>
);
}

View File

@@ -1,8 +1,26 @@
import { useLocation, useNavigate } from 'react-router-dom';
import { useLocation, useNavigate } from "react-router-dom";
const tabs = [
{ path: '/', label: 'Control', icon: '[ ]' },
{ path: '/devices', label: 'Devices', icon: '{*}' },
{
path: "/",
label: "CONTROL",
icon: (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
<line x1="3" y1="9" x2="21" y2="9" />
<line x1="9" y1="21" x2="9" y2="9" />
</svg>
),
},
{
path: "/devices",
label: "LIBRARY",
icon: (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5" />
</svg>
),
},
];
export default function BottomNav() {
@@ -10,54 +28,34 @@ export default function BottomNav() {
const navigate = useNavigate();
return (
<nav className="bottom-nav">
{tabs.map((tab) => (
<button
key={tab.path}
className={`bottom-nav-tab ${location.pathname === tab.path ? 'active' : ''}`}
onClick={() => navigate(tab.path)}
>
<span className="bottom-nav-icon">{tab.icon}</span>
<span className="bottom-nav-label">{tab.label}</span>
</button>
))}
<style>{`
.bottom-nav {
position: fixed;
bottom: 0;
left: 0;
right: 0;
display: flex;
background: var(--bg-secondary);
border-top: 1px solid var(--border);
padding-bottom: env(safe-area-inset-bottom);
z-index: 100;
}
.bottom-nav-tab {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 12px 0;
background: none;
border: none;
color: var(--text-muted);
font-family: inherit;
font-size: 0.7rem;
cursor: pointer;
transition: color 0.2s;
text-transform: uppercase;
letter-spacing: 1px;
}
.bottom-nav-tab.active {
color: var(--status-allowed);
}
.bottom-nav-icon {
font-size: 1.2rem;
font-weight: bold;
}
`}</style>
<nav
className="fixed bottom-0 left-0 right-0 z-50 flex justify-around items-center px-6 pb-8 pt-4"
style={{
background: "linear-gradient(to top, rgba(26, 47, 36, 0.95), rgba(26, 47, 36, 0.8))",
backdropFilter: "blur(20px)",
WebkitBackdropFilter: "blur(20px)",
borderTop: "1px solid rgba(212, 175, 55, 0.15)",
}}
>
{tabs.map((tab) => {
const isActive = location.pathname === tab.path;
return (
<button
key={tab.path}
className={`flex flex-col items-center gap-1.5 transition-all duration-300 ${
isActive ? "text-gold" : "text-cream-dark opacity-50 hover:opacity-100 hover:text-cream"
}`}
onClick={() => navigate(tab.path)}
>
<div className={`p-2 rounded-xl transition-all duration-300 ${isActive ? "bg-gold/10 shadow-[0_0_15px_rgba(212,175,55,0.2)]" : ""}`}>
{tab.icon}
</div>
<span className="text-[0.6rem] font-bold tracking-[0.25em] transition-all">
{tab.label}
</span>
</button>
);
})}
</nav>
);
}

View File

@@ -1,15 +1,52 @@
import React from 'react';
const ICON_MAP = {
social: (
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M18 2h-3a5 5 0 0 0-5 5v3H7v4h3v8h4v-8h3l1-4h-4V7a1 1 0 0 1 1-1h3z" />
</svg>
),
streaming: (
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<rect x="2" y="2" width="20" height="20" rx="2.18" ry="2.18" />
<line x1="7" y1="2" x2="7" y2="22" />
<line x1="17" y1="2" x2="17" y2="22" />
<line x1="2" y1="12" x2="22" y2="12" />
<line x1="2" y1="7" x2="7" y2="7" />
<line x1="2" y1="17" x2="7" y2="17" />
<line x1="17" y1="17" x2="22" y2="17" />
<line x1="17" y1="7" x2="22" y2="7" />
</svg>
),
adult: (
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
<path d="M12 8v4" />
<path d="M12 16h.01" />
</svg>
),
gaming: (
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<line x1="6" y1="12" x2="10" y2="12" />
<line x1="8" y1="10" x2="8" y2="14" />
<line x1="15" y1="13" x2="15.01" y2="13" />
<line x1="18" y1="11" x2="18.01" y2="11" />
<rect x="2" y="6" width="20" height="12" rx="2" />
</svg>
),
custom: (
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="1" />
<circle cx="19" cy="12" r="1" />
<circle cx="5" cy="12" r="1" />
</svg>
),
};
export default function CategoryCard({ name, config, timers, onToggle, onBonusTime, onAssignRules }) {
const { allowed, rules } = config;
const activeTimer = timers?.[0];
const iconMap = {
social: '{ }',
streaming: '> |',
adult: '[X]',
gaming: '</>',
custom: '...',
};
const formatRemaining = (ms) => {
const mins = Math.ceil(ms / 60000);
if (mins >= 60) return `${Math.floor(mins / 60)}h ${mins % 60}m`;
@@ -17,87 +54,68 @@ export default function CategoryCard({ name, config, timers, onToggle, onBonusTi
};
return (
<div className={`card category-card ${allowed ? 'allowed' : 'blocked'}`}>
<div className="category-header">
<div className="category-info">
<span className="category-icon">{iconMap[config.icon] || iconMap.custom}</span>
<div>
<h3 className="category-name">{name}</h3>
<span className="category-meta">
{rules.length} rule{rules.length !== 1 ? 's' : ''}
{activeTimer && (
<span className="timer-badge">
{' '}| Bonus: {formatRemaining(activeTimer.remainingMs)}
</span>
)}
<div className="glass-card p-4 flex items-center justify-between group overflow-hidden relative">
<div className={`absolute left-0 top-0 bottom-0 w-1 transition-colors duration-500 ${allowed ? 'bg-manor-green' : 'bg-blockRed'}`} />
<div className="flex items-center gap-4 min-w-0 z-10">
<div className={`w-12 h-12 rounded-xl flex items-center justify-center transition-all duration-300 ${
allowed
? 'bg-manor-green/10 text-manor-green border border-manor-green/20'
: 'bg-blockRed/10 text-blockRed border border-blockRed/20'
}`}>
{ICON_MAP[config.icon] || ICON_MAP.custom}
</div>
<div className="min-w-0">
<h3 className="text-cream text-lg font-serif font-semibold tracking-wide group-hover:text-white transition-colors">{name}</h3>
<div className="flex items-center gap-3 mt-1">
<span className="text-cream-dark text-[0.65rem] uppercase tracking-[0.15em] font-mono opacity-60">
{rules.length} {rules.length === 1 ? 'Rule' : 'Rules'}
</span>
{activeTimer && (
<div className="flex items-center gap-1.5">
<div className="w-1 h-1 rounded-full bg-manor-green animate-pulse" />
<span className="text-manor-green text-[0.65rem] uppercase tracking-[0.1em] font-mono font-bold">
BONUS: {formatRemaining(activeTimer.remainingMs)}
</span>
</div>
)}
</div>
</div>
</div>
<div className="flex items-center gap-3 z-10">
<div className="flex items-center bg-viridian-dark/40 rounded-lg p-1 border border-cream/5">
<button
className="btn-icon hover:text-gold transition-colors"
onClick={onBonusTime}
title="Add Bonus Time"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>
</button>
<button
className="btn-icon hover:text-gold transition-colors"
onClick={onAssignRules}
title="Settings"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z" />
<circle cx="12" cy="12" r="3" />
</svg>
</button>
</div>
<button
className={`toggle-switch ${allowed ? 'active' : ''}`}
className={`manor-toggle ${allowed ? 'active' : ''}`}
onClick={() => onToggle(!allowed)}
title={allowed ? 'Click to block' : 'Click to allow'}
>
<div className="toggle-knob" />
<div className="knob" />
</button>
</div>
<div className="category-actions">
<button className="btn btn-secondary btn-sm" onClick={onBonusTime}>
+ Bonus Time
</button>
<button className="btn btn-secondary btn-sm" onClick={onAssignRules}>
Assign Rules
</button>
</div>
<style>{`
.category-card {
border-left: 4px solid var(--status-blocked);
}
.category-card.allowed {
border-left-color: var(--status-allowed);
}
.category-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.category-info {
display: flex;
align-items: center;
gap: 12px;
}
.category-icon {
font-size: 1.2rem;
font-weight: bold;
color: var(--accent);
width: 40px;
text-align: center;
}
.category-name {
font-size: 1rem;
font-weight: 600;
margin-bottom: 2px;
}
.category-meta {
font-size: 0.7rem;
color: var(--text-muted);
text-transform: uppercase;
}
.timer-badge {
color: var(--status-allowed);
}
.category-actions {
display: flex;
gap: 8px;
}
.btn-sm {
padding: 6px 12px;
font-size: 0.7rem;
}
`}</style>
</div>
);
}

View File

@@ -1,8 +1,10 @@
import { useState, useEffect } from 'react';
import { apiFetch } from '../hooks/useApi.js';
import { useState, useEffect } from "react";
import { apiFetch } from "../hooks/useApi.js";
export default function CurfewClock({ curfew, timers, onCancelTimer, refetchCurfew }) {
const [now, setNow] = useState(Date.now());
const [showInput, setShowInput] = useState(false);
const [customTime, setCustomTime] = useState("");
useEffect(() => {
const interval = setInterval(() => setNow(Date.now()), 1000);
@@ -13,8 +15,8 @@ export default function CurfewClock({ curfew, timers, onCancelTimer, refetchCurf
const getNextEvent = () => {
const today = new Date();
const [blockH, blockM] = curfew.blockTime.split(':').map(Number);
const [unblockH, unblockM] = curfew.unblockTime.split(':').map(Number);
const [blockH, blockM] = curfew.blockTime.split(":").map(Number);
const [unblockH, unblockM] = curfew.unblockTime.split(":").map(Number);
const blockToday = new Date(today);
blockToday.setHours(blockH, blockM, 0, 0);
@@ -27,11 +29,11 @@ export default function CurfewClock({ curfew, timers, onCancelTimer, refetchCurf
unblockToday.setHours(unblockH, unblockM, 0, 0);
if (now < unblockToday.getTime()) {
return { label: 'Curfew ends in', target: unblockToday.getTime() };
return { label: "RESTRICTED UNTIL", target: unblockToday.getTime(), active: true };
} else if (now < blockToday.getTime()) {
return { label: 'Curfew starts in', target: blockToday.getTime() };
return { label: "CURFEW BEGINS IN", target: blockToday.getTime(), active: false };
} else {
return { label: 'Curfew ends in', target: unblockTomorrow.getTime() };
return { label: "RESTRICTED UNTIL", target: unblockTomorrow.getTime(), active: true };
}
};
@@ -40,149 +42,118 @@ export default function CurfewClock({ curfew, timers, onCancelTimer, refetchCurf
const h = Math.floor(totalSec / 3600);
const m = Math.floor((totalSec % 3600) / 60);
const s = totalSec % 60;
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
};
const handleOverride = async (minutes) => {
await apiFetch('/curfew/override', {
method: 'POST',
await apiFetch("/curfew/override", {
method: "POST",
body: JSON.stringify({ minutes }),
});
refetchCurfew();
setShowInput(false);
setCustomTime("");
};
const event = curfew.enabled ? getNextEvent() : null;
const activeTimers = (timers || []).filter((t) => t.remainingMs > 0);
return (
<div className="card curfew-clock">
<div className="curfew-header">
<span className="curfew-label">CURFEW</span>
<span className={`curfew-status ${curfew.enabled ? 'active' : 'off'}`}>
{curfew.enabled ? 'ARMED' : 'OFF'}
<div className="glass-card p-6 flex flex-col h-full relative overflow-hidden">
<div className="absolute -right-10 -top-10 w-40 h-40 bg-gold/5 rounded-full blur-3xl pointer-events-none" />
<div className="flex items-center justify-between mb-6 z-10">
<div className="flex items-center gap-2">
<div className={`w-2 h-2 rounded-full ${curfew.enabled ? "bg-gold shadow-[0_0_8px_rgba(212,175,55,0.8)]" : "bg-cream-dark/30"}`} />
<span className="text-cream-dark text-[0.7rem] tracking-[0.2em] uppercase font-bold">
Estate Curfew
</span>
</div>
<span className={`text-[0.6rem] px-2 py-0.5 rounded border border-gold/20 font-bold tracking-widest ${
curfew.enabled ? "text-gold" : "text-cream-dark opacity-40"
}`}>
{curfew.enabled ? "ARMED" : "STANDBY"}
</span>
</div>
{event && (
<div className="curfew-countdown">
<span className="countdown-label">{event.label}</span>
<span className="countdown-time">{formatCountdown(event.target - now)}</span>
{event ? (
<div className="flex-1 flex flex-col justify-center items-center py-4 z-10">
<span className={`text-[0.65rem] uppercase tracking-[0.3em] mb-4 ${event.active ? "text-blockRed font-bold" : "text-cream-dark"}`}>
{event.label}
</span>
<span className="font-serif text-5xl md:text-6xl font-bold text-cream tracking-[0.1em] tabular-nums drop-shadow-lg">
{formatCountdown(event.target - now)}
</span>
<div className="mt-6 w-full">
{showInput ? (
<div className="flex gap-2">
<input
type="number"
autoFocus
placeholder="MIN"
className="manor-input w-full text-center font-mono"
value={customTime}
onChange={(e) => setCustomTime(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleOverride(Number(customTime))}
/>
<button
onClick={() => handleOverride(Number(customTime))}
className="btn-manor btn-manor-green px-4"
disabled={!customTime}
>
SET
</button>
<button
onClick={() => setShowInput(false)}
className="btn-manor btn-manor-ghost px-3 text-blockRed"
>
</button>
</div>
) : (
<div className="flex gap-2 w-full">
<button onClick={() => handleOverride(30)} className="btn-manor btn-manor-ghost flex-1 text-[0.65rem] py-2">+30m</button>
<button onClick={() => handleOverride(60)} className="btn-manor btn-manor-ghost flex-1 text-[0.65rem] py-2">+1h</button>
<button onClick={() => setShowInput(true)} className="btn-manor btn-manor-ghost flex-1 text-[0.65rem] py-2">Custom</button>
</div>
)}
</div>
</div>
)}
{curfew.overrideActive && (
<div className="curfew-override">Override active</div>
)}
{curfew.enabled && !curfew.overrideActive && (
<div className="curfew-actions">
<button className="btn btn-secondary btn-sm" onClick={() => handleOverride(30)}>
Override 30m
</button>
<button className="btn btn-secondary btn-sm" onClick={() => handleOverride(60)}>
Override 1h
</button>
) : (
<div className="flex-1 flex items-center justify-center py-10 opacity-30 z-10">
<span className="text-sm italic tracking-widest uppercase">Curfew Disabled</span>
</div>
)}
{activeTimers.length > 0 && (
<div className="active-timers">
<span className="timer-section-label">Active Bonus Timers</span>
{activeTimers.map((t) => (
<div key={t.id} className="timer-row">
<span>{t.category}: {formatCountdown(t.remainingMs)}</span>
<button className="btn btn-secondary btn-sm" onClick={() => onCancelTimer(t.id)}>
Cancel
</button>
</div>
))}
<div className="mt-6 pt-6 border-t border-cream/10 z-10">
<span className="block text-[0.6rem] text-gold/60 uppercase tracking-[0.2em] mb-4 font-bold">
Active Overrides
</span>
<div className="space-y-3">
{activeTimers.map((t) => (
<div key={t.id} className="flex items-center justify-between bg-viridian-dark/30 p-2 rounded-lg border border-cream/5">
<div className="flex flex-col">
<span className="text-cream text-xs font-serif italic">{t.category}</span>
<span className="text-manor-green text-[0.65rem] font-mono font-bold tracking-wider">
{formatCountdown(t.remainingMs)}
</span>
</div>
<button
className="text-blockRed hover:text-white transition-colors p-1"
onClick={() => onCancelTimer(t.id)}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
))}
</div>
</div>
)}
<style>{`
.curfew-clock {
background: var(--bg-secondary);
margin-bottom: 20px;
}
.curfew-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.curfew-label {
font-size: 0.75rem;
letter-spacing: 2px;
color: var(--text-muted);
}
.curfew-status {
font-size: 0.7rem;
padding: 2px 8px;
border-radius: 4px;
font-weight: 700;
letter-spacing: 1px;
}
.curfew-status.active {
background: rgba(76, 175, 80, 0.2);
color: var(--status-allowed);
}
.curfew-status.off {
background: rgba(138, 138, 114, 0.2);
color: var(--text-muted);
}
.curfew-countdown {
text-align: center;
padding: 8px 0;
}
.countdown-label {
display: block;
font-size: 0.7rem;
color: var(--text-muted);
margin-bottom: 4px;
text-transform: uppercase;
letter-spacing: 1px;
}
.countdown-time {
font-size: 2rem;
font-weight: 700;
letter-spacing: 4px;
color: var(--text-primary);
}
.curfew-override {
text-align: center;
color: var(--status-allowed);
font-size: 0.75rem;
padding: 4px 0;
text-transform: uppercase;
letter-spacing: 1px;
}
.curfew-actions {
display: flex;
gap: 8px;
justify-content: center;
margin-top: 8px;
}
.active-timers {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--border);
}
.timer-section-label {
display: block;
font-size: 0.7rem;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 8px;
}
.timer-row {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.8rem;
padding: 4px 0;
}
`}</style>
</div>
);
}

View File

@@ -1,88 +1,153 @@
import { useState } from 'react';
import { useState, useMemo, useEffect } from "react";
import { AreaChart, Area, ResponsiveContainer } from "recharts";
function generateSparkline(mac) {
const seed = mac ? mac.split(":").reduce((a, b) => a + parseInt(b, 16), 0) : 42;
const data = [];
let val = 30 + (seed % 40);
for (let i = 0; i < 12; i++) {
val = Math.max(5, Math.min(95, val + (Math.sin(seed + i) * 20)));
data.push({ v: Math.round(val) });
}
return data;
}
function DeviceIcon({ name }) {
const n = (name || "").toLowerCase();
if (n.includes("ipad") || n.includes("tablet")) {
return (
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<rect x="4" y="2" width="16" height="20" rx="2" />
<line x1="12" y1="18" x2="12.01" y2="18" />
</svg>
);
}
if (n.includes("phone") || n.includes("iphone") || n.includes("android")) {
return (
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<rect x="5" y="2" width="14" height="20" rx="2" />
<line x1="12" y1="18" x2="12.01" y2="18" />
</svg>
);
}
if (n.includes("laptop") || n.includes("macbook") || n.includes("notebook")) {
return (
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M20 16V7a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v9m16 0H4m16 0 1.28 2.55a1 1 0 0 1-.9 1.45H3.62a1 1 0 0 1-.9-1.45L4 16" />
</svg>
);
}
return (
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<rect x="2" y="3" width="20" height="14" rx="2" />
<path d="M8 21h8" />
<path d="M12 17v4" />
</svg>
);
}
export default function DeviceCard({ device, onNuke, onUnnuke }) {
const [loading, setLoading] = useState(false);
const name = device.name || device.hostname || device.oui || 'Unknown Device';
const [profile, setProfile] = useState("Default");
const name = device.name || device.hostname || device.oui || "Unknown Device";
const isOnline = device.is_wired !== undefined || device._uptime_by_ugw !== undefined;
const isBlocked = device.blocked === true;
const handleNuke = async () => {
setLoading(true);
try {
await onNuke();
} finally {
setLoading(false);
}
};
const sparkData = useMemo(() => generateSparkline(device.mac), [device.mac]);
const handleUnnuke = async () => {
setLoading(true);
try {
await onUnnuke();
} finally {
setLoading(false);
}
useEffect(() => {
const saved = localStorage.getItem(`profile-${device.mac}`);
if (saved) setProfile(saved);
}, [device.mac]);
const handleProfileChange = (e) => {
const newProfile = e.target.value;
setProfile(newProfile);
localStorage.setItem(`profile-${device.mac}`, newProfile);
};
return (
<div className={`card device-card ${isBlocked ? 'nuked' : ''}`}>
<div className="device-header">
<div className="device-info">
<span className={`status-dot ${isOnline ? 'online' : 'offline'}`} />
<div>
<h3 className="device-name">{name}</h3>
<div className="device-meta">
<span>{device.mac}</span>
{device.ip && <span> | {device.ip}</span>}
<div className={`glass-card p-5 flex flex-col gap-4 relative overflow-hidden transition-all duration-500 ${
isBlocked ? "border-blockRed/40 bg-blockRed/5" : ""
}`}>
<div className="flex items-start justify-between z-10">
<div className="flex items-center gap-4 min-w-0">
<div className={`w-12 h-12 rounded-2xl flex items-center justify-center transition-colors ${
isBlocked ? "bg-blockRed/20 text-blockRed" : "bg-gold/10 text-gold"
}`}>
<DeviceIcon name={name} />
</div>
<div className="min-w-0">
<h3 className="text-cream text-sm font-bold truncate tracking-wide">{name}</h3>
<div className="flex items-center gap-2 mt-1">
<div className={`w-1.5 h-1.5 rounded-full ${isOnline ? "bg-manor-green animate-glow-online" : "bg-cream-dark/30"}`} />
<span className={`text-[0.6rem] uppercase tracking-widest font-bold ${isOnline ? "text-manor-green" : "text-cream-dark opacity-50"}`}>
{isOnline ? "CONNECTED" : "OFFLINE"}
</span>
</div>
</div>
</div>
<div className="text-right">
<div className="text-cream-dark text-[0.6rem] font-mono opacity-40 leading-tight">{device.mac}</div>
<div className="text-gold/60 text-[0.6rem] font-mono leading-tight mt-0.5">{device.ip || "NO IP"}</div>
</div>
</div>
<div className="h-10 -mx-2 opacity-30 pointer-events-none">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={sparkData}>
<defs>
<linearGradient id={`grad-${device.mac}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={isBlocked ? "#BE123C" : "#D4AF37"} stopOpacity={0.4} />
<stop offset="100%" stopColor={isBlocked ? "#BE123C" : "#D4AF37"} stopOpacity={0} />
</linearGradient>
</defs>
<Area
type="monotone"
dataKey="v"
stroke={isBlocked ? "#BE123C" : "#D4AF37"}
strokeWidth={1}
fill={`url(#grad-${device.mac})`}
dot={false}
isAnimationActive={false}
/>
</AreaChart>
</ResponsiveContainer>
</div>
<div className="z-10 flex flex-col gap-2">
<select
value={profile}
onChange={handleProfileChange}
className="manor-input w-full text-[0.65rem] py-1.5 px-2 bg-viridian/50 border-viridian-light text-cream/80"
>
<option value="Default">Profile: Default</option>
<option value="Kids">Profile: Kids</option>
<option value="Parents">Profile: Parents</option>
<option value="IoT">Profile: IoT</option>
<option value="Guests">Profile: Guests</option>
</select>
{isBlocked ? (
<button className="btn btn-allowed btn-sm" onClick={handleUnnuke} disabled={loading}>
Restore
<button
className="w-full bg-manor-green/20 hover:bg-manor-green/30 text-manor-green border border-manor-green/30 py-2.5 rounded-xl text-[0.65rem] font-bold tracking-[0.2em] uppercase transition-all"
onClick={onUnnuke}
disabled={loading}
>
RESTORE ACCESS
</button>
) : (
<button className="btn btn-nuke btn-sm" onClick={handleNuke} disabled={loading}>
NUKE
<button
className="w-full bg-blockRed/10 hover:bg-blockRed/20 text-blockRed border border-blockRed/30 py-2.5 rounded-xl text-[0.65rem] font-bold tracking-[0.2em] uppercase transition-all"
onClick={onNuke}
disabled={loading}
>
NUKE DEVICE
</button>
)}
</div>
<style>{`
.device-card.nuked {
border: 1px solid var(--status-nuke);
background: rgba(255, 23, 68, 0.08);
}
.device-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.device-info {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.device-name {
font-size: 0.9rem;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 200px;
}
.device-meta {
font-size: 0.65rem;
color: var(--text-muted);
font-family: 'Courier New', monospace;
}
.btn-sm {
padding: 6px 12px;
font-size: 0.7rem;
}
`}</style>
</div>
);
}

View File

@@ -1,4 +1,5 @@
import { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { useApi } from '../hooks/useApi.js';
export default function RuleAssigner({ category, currentRuleIds, onSave, onClose }) {
@@ -28,113 +29,90 @@ export default function RuleAssigner({ category, currentRuleIds, onSave, onClose
const ruleList = Array.isArray(rules) ? rules : [];
return (
<div className="drawer-overlay" onClick={onClose}>
<div className="assigner-modal" onClick={(e) => e.stopPropagation()}>
<h3 className="assigner-title">Assign Rules: {category}</h3>
<AnimatePresence>
<motion.div
className="fixed inset-0 z-[200] flex items-center justify-center"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
>
{/* Backdrop */}
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" />
{loading && <div className="loading-spinner">Loading rules...</div>}
{error && <div className="error-message">{error}</div>}
{/* Modal */}
<motion.div
className="relative glass-card-dark w-[90%] max-w-[500px] max-h-[80vh] flex flex-col p-5"
initial={{ scale: 0.95, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.95, opacity: 0 }}
transition={{ duration: 0.2 }}
onClick={(e) => e.stopPropagation()}
>
<h3 className="font-serif text-lg text-cream mb-4 tracking-wide">
Assign Rules: {category}
</h3>
<div className="rule-list">
{ruleList.length === 0 && !loading ? (
<div className="empty-state">
No traffic rules found. Connect to UDR to discover rules.
{loading && (
<div className="py-8 text-center text-cream-dark text-sm tracking-widest uppercase">
Loading rules...
</div>
) : (
ruleList.map((rule) => (
<label key={rule._id} className="rule-item">
<input
type="checkbox"
checked={selected.has(rule._id)}
onChange={() => toggleRule(rule._id)}
/>
<div className="rule-info">
<span className="rule-name">{rule.description || rule.name || rule._id}</span>
<span className="rule-status">
{rule.enabled ? 'Blocking' : 'Inactive'}
</span>
</div>
</label>
))
)}
</div>
<div className="assigner-actions">
<button className="btn btn-secondary" onClick={onClose}>
Cancel
</button>
<button className="btn btn-allowed" onClick={handleSave} disabled={saving}>
{saving ? 'Saving...' : `Save (${selected.size} rules)`}
</button>
</div>
{error && (
<div className="glass-card p-3 mb-3 border-blockRed/30 bg-blockRed/10">
<span className="text-blockRed text-sm">{error}</span>
</div>
)}
<style>{`
.assigner-modal {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 16px;
padding: 20px;
width: 90%;
max-width: 500px;
max-height: 80vh;
display: flex;
flex-direction: column;
margin: auto;
}
.assigner-title {
font-size: 1rem;
margin-bottom: 16px;
text-transform: uppercase;
letter-spacing: 1px;
}
.rule-list {
flex: 1;
overflow-y: auto;
margin-bottom: 16px;
max-height: 50vh;
}
.rule-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 8px;
border-bottom: 1px solid var(--border);
cursor: pointer;
}
.rule-item:hover {
background: var(--bg-card);
}
.rule-item input[type="checkbox"] {
accent-color: var(--status-allowed);
width: 18px;
height: 18px;
}
.rule-info {
display: flex;
flex-direction: column;
gap: 2px;
}
.rule-name {
font-size: 0.85rem;
}
.rule-status {
font-size: 0.65rem;
color: var(--text-muted);
text-transform: uppercase;
}
.assigner-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
}
.empty-state {
text-align: center;
padding: 30px;
color: var(--text-muted);
font-style: italic;
}
`}</style>
</div>
</div>
{/* Rule list */}
<div className="flex-1 overflow-y-auto mb-4 max-h-[50vh] space-y-1">
{ruleList.length === 0 && !loading ? (
<div className="py-8 text-center text-cream-dark text-sm italic">
No traffic rules found. Connect to UDR to discover rules.
</div>
) : (
ruleList.map((rule) => (
<label
key={rule._id}
className="flex items-center gap-3 p-3 rounded-lg cursor-pointer transition-colors hover:bg-cream/5"
>
<input
type="checkbox"
checked={selected.has(rule._id)}
onChange={() => toggleRule(rule._id)}
className="w-4 h-4 accent-manor-green rounded"
/>
<div className="flex flex-col gap-0.5 min-w-0">
<span className="text-cream text-sm truncate">
{rule.description || rule.name || rule._id}
</span>
<span className={`text-[0.6rem] uppercase tracking-wider ${
rule.enabled ? 'text-blockRed' : 'text-cream-dark'
}`}>
{rule.enabled ? 'Blocking' : 'Inactive'}
</span>
</div>
</label>
))
)}
</div>
{/* Actions */}
<div className="flex gap-3 justify-end">
<button className="btn-manor btn-manor-ghost" onClick={onClose}>
Cancel
</button>
<button
className="btn-manor btn-manor-green"
onClick={handleSave}
disabled={saving}
>
{saving ? 'Saving...' : `Save (${selected.size} rules)`}
</button>
</div>
</motion.div>
</motion.div>
</AnimatePresence>
);
}

View File

@@ -1,205 +1,168 @@
:root {
--bg-primary: #2E473B;
--bg-secondary: #1E3229;
--bg-card: #3A5A4A;
--bg-card-hover: #456B57;
--text-primary: #F5F5DC;
--text-secondary: #C4C4A8;
--text-muted: #8A8A72;
--status-allowed: #4CAF50;
--status-blocked: #DC3545;
--status-nuke: #FF1744;
--accent: #6B8F71;
--border: #4A6B55;
--shadow: rgba(0, 0, 0, 0.3);
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body {
height: 100%;
font-family: 'Courier New', 'Consolas', monospace;
background: #1a2f24;
color: #F5F5DC;
-webkit-font-smoothing: antialiased;
}
body {
background-color: #1a2f24;
background-image:
radial-gradient(ellipse at 15% 50%, rgba(46, 71, 59, 0.8) 0%, transparent 60%),
radial-gradient(ellipse at 85% 20%, rgba(46, 71, 59, 0.5) 0%, transparent 50%),
radial-gradient(ellipse at 90% 90%, rgba(76, 175, 80, 0.04) 0%, transparent 40%),
linear-gradient(rgba(107, 143, 113, 0.07) 1px, transparent 1px),
linear-gradient(90deg, rgba(107, 143, 113, 0.07) 1px, transparent 1px);
background-size: 100% 100%, 100% 100%, 100% 100%, 60px 60px, 60px 60px;
background-attachment: fixed;
}
body::before {
content: '';
position: fixed;
inset: 0;
pointer-events: none;
z-index: 0;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='300' height='300'%3E%3Cdefs%3E%3Cpattern id='circuit' x='0' y='0' width='300' height='300' patternUnits='userSpaceOnUse'%3E%3Cline x1='0' y1='30' x2='80' y2='30' stroke='%234a7a5a' stroke-width='0.5' opacity='0.15'/%3E%3Cline x1='120' y1='30' x2='300' y2='30' stroke='%234a7a5a' stroke-width='0.5' opacity='0.1'/%3E%3Cline x1='0' y1='150' x2='180' y2='150' stroke='%234a7a5a' stroke-width='0.5' opacity='0.12'/%3E%3Cline x1='220' y1='150' x2='300' y2='150' stroke='%234a7a5a' stroke-width='0.5' opacity='0.08'/%3E%3Cline x1='40' y1='270' x2='260' y2='270' stroke='%234a7a5a' stroke-width='0.5' opacity='0.1'/%3E%3Cline x1='80' y1='0' x2='80' y2='80' stroke='%234a7a5a' stroke-width='0.5' opacity='0.12'/%3E%3Cline x1='120' y1='30' x2='120' y2='150' stroke='%234a7a5a' stroke-width='0.5' opacity='0.1'/%3E%3Cline x1='220' y1='100' x2='220' y2='200' stroke='%234a7a5a' stroke-width='0.5' opacity='0.08'/%3E%3Ccircle cx='80' cy='30' r='2.5' fill='%234a7a5a' opacity='0.2'/%3E%3Ccircle cx='120' cy='30' r='2' fill='%234a7a5a' opacity='0.15'/%3E%3Ccircle cx='120' cy='150' r='2.5' fill='%234a7a5a' opacity='0.2'/%3E%3Ccircle cx='220' cy='150' r='2' fill='%234a7a5a' opacity='0.15'/%3E%3Crect x='160' y='60' width='30' height='20' rx='2' fill='none' stroke='%234a7a5a' stroke-width='0.5' opacity='0.1'/%3E%3C/pattern%3E%3C/defs%3E%3Crect width='100%25' height='100%25' fill='url(%23circuit)'/%3E%3C/svg%3E");
opacity: 0.4;
}
#root {
height: 100%;
position: relative;
z-index: 1;
}
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
@layer components {
.glass-card {
background: rgba(46, 71, 59, 0.7);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(212, 175, 55, 0.15);
border-radius: 16px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
html, body {
height: 100%;
font-family: 'Courier New', 'Consolas', monospace;
background: var(--bg-primary);
color: var(--text-primary);
-webkit-font-smoothing: antialiased;
}
.glass-card-dark {
background: #1E3229; /* Solid dark color fallbacks */
background: rgba(30, 50, 41, 0.95); /* High opacity */
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
border: 1px solid rgba(212, 175, 55, 0.3); /* Gold border */
border-radius: 16px;
box-shadow: 0 -10px 40px rgba(0, 0, 0, 0.5);
}
body {
background-image:
radial-gradient(circle at 20% 50%, rgba(107, 143, 113, 0.08) 0%, transparent 50%),
radial-gradient(circle at 80% 20%, rgba(107, 143, 113, 0.05) 0%, transparent 50%),
linear-gradient(rgba(107, 143, 113, 0.03) 1px, transparent 1px),
linear-gradient(90deg, rgba(107, 143, 113, 0.03) 1px, transparent 1px);
background-size: 100% 100%, 100% 100%, 40px 40px, 40px 40px;
}
.glass-card:hover {
background: rgba(46, 71, 59, 0.8);
border-color: rgba(212, 175, 55, 0.3);
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.4), 0 0 15px rgba(212, 175, 55, 0.1);
}
#root {
height: 100%;
.manor-toggle {
position: relative;
width: 56px;
height: 30px;
border-radius: 15px;
cursor: pointer;
border: 1px solid rgba(245, 245, 220, 0.1);
background: rgba(190, 18, 60, 0.2);
transition: all 0.3s;
}
.manor-toggle.active {
background: #4CAF50;
box-shadow: 0 0 15px rgba(76, 175, 80, 0.5);
border-color: rgba(76, 175, 80, 0.5);
}
.manor-toggle .knob {
position: absolute;
top: 3px;
left: 3px;
width: 22px;
height: 22px;
background: #F5F5DC;
border-radius: 50%;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.manor-toggle.active .knob {
transform: translateX(26px);
background: #FFFFFF;
box-shadow: 0 0 10px rgba(255, 255, 255, 0.8);
}
.btn-manor {
font-family: 'Courier New', monospace;
text-transform: uppercase;
letter-spacing: 1.5px;
transition: all 0.2s;
border-radius: 8px;
font-weight: 600;
}
.btn-manor-ghost {
background: rgba(245, 245, 220, 0.05);
border: 1px solid rgba(245, 245, 220, 0.2);
color: #F5F5DC;
}
.btn-manor-ghost:hover {
background: rgba(245, 245, 220, 0.15);
border-color: #D4AF37;
color: #FFFFFF;
}
.manor-input {
background: rgba(30, 50, 41, 0.8);
border: 1px solid rgba(212, 175, 55, 0.2);
color: #F5F5DC;
border-radius: 8px;
outline: none;
transition: all 0.2s;
}
.manor-input:focus {
border-color: #D4AF37;
box-shadow: 0 0 10px rgba(212, 175, 55, 0.2);
}
.section-label {
font-family: 'Playfair Display', serif;
font-size: 0.8rem;
color: #D4AF37;
text-transform: uppercase;
letter-spacing: 3px;
margin-bottom: 1rem;
display: block;
opacity: 0.8;
}
}
.app-container {
max-width: 800px;
margin: 0 auto;
min-height: 100vh;
display: flex;
flex-direction: column;
min-height: 100%;
max-width: 600px;
margin: 0 auto;
}
.app-content {
flex: 1;
padding: 16px;
padding-bottom: 80px;
overflow-y: auto;
}
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
padding-bottom: 12px;
border-bottom: 1px solid var(--border);
}
.page-header h1 {
font-size: 1.4rem;
font-weight: 700;
letter-spacing: 1px;
text-transform: uppercase;
}
.card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 12px;
padding: 16px;
margin-bottom: 12px;
transition: background 0.2s;
}
.card:hover {
background: var(--bg-card-hover);
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 10px 20px;
border: none;
border-radius: 8px;
font-family: inherit;
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.btn-allowed {
background: var(--status-allowed);
color: #fff;
}
.btn-blocked {
background: var(--status-blocked);
color: #fff;
}
.btn-nuke {
background: var(--status-nuke);
color: #fff;
box-shadow: 0 0 15px rgba(255, 23, 68, 0.4);
}
.btn-nuke:hover {
box-shadow: 0 0 25px rgba(255, 23, 68, 0.6);
}
.btn-secondary {
background: var(--bg-secondary);
color: var(--text-primary);
border: 1px solid var(--border);
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.status-dot {
width: 10px;
height: 10px;
border-radius: 50%;
display: inline-block;
}
.status-dot.online {
background: var(--status-allowed);
box-shadow: 0 0 6px var(--status-allowed);
}
.status-dot.offline {
background: var(--text-muted);
}
.toggle-switch {
position: relative;
width: 52px;
height: 28px;
background: var(--status-blocked);
border-radius: 14px;
cursor: pointer;
transition: background 0.3s;
border: none;
padding: 0;
}
.toggle-switch.active {
background: var(--status-allowed);
}
.toggle-switch .toggle-knob {
position: absolute;
top: 3px;
left: 3px;
width: 22px;
height: 22px;
background: #fff;
border-radius: 50%;
transition: transform 0.3s;
}
.toggle-switch.active .toggle-knob {
transform: translateX(24px);
}
.loading-spinner {
display: flex;
justify-content: center;
align-items: center;
padding: 40px;
color: var(--text-muted);
}
.error-message {
background: rgba(220, 53, 69, 0.1);
border: 1px solid var(--status-blocked);
border-radius: 8px;
padding: 12px;
color: var(--status-blocked);
margin-bottom: 12px;
}
@media (min-width: 768px) {
.app-content {
padding: 24px;
padding-bottom: 80px;
}
padding: 2rem 1rem 6rem 1rem;
}

View File

@@ -1,78 +1,117 @@
import { useState } from 'react';
import { useApi, apiFetch } from '../hooks/useApi.js';
import CategoryCard from '../components/CategoryCard.jsx';
import CurfewClock from '../components/CurfewClock.jsx';
import BonusTimeDrawer from '../components/BonusTimeDrawer.jsx';
import RuleAssigner from '../components/RuleAssigner.jsx';
import { useState } from "react";
import { useApi, apiFetch } from "../hooks/useApi.js";
import CategoryCard from "../components/CategoryCard.jsx";
import CurfewClock from "../components/CurfewClock.jsx";
import BonusTimeDrawer from "../components/BonusTimeDrawer.jsx";
import RuleAssigner from "../components/RuleAssigner.jsx";
export default function ControlDashboard() {
const { data: policies, loading, error, refetch } = useApi('/policies');
const { data: curfew, refetch: refetchCurfew } = useApi('/curfew');
const { data: timers, refetch: refetchTimers } = useApi('/timers');
const { data: policies, loading, error, refetch } = useApi("/policies");
const { data: curfew, refetch: refetchCurfew } = useApi("/curfew");
const { data: timers, refetch: refetchTimers } = useApi("/timers");
const [bonusCategory, setBonusCategory] = useState(null);
const [assignCategory, setAssignCategory] = useState(null);
const handleToggle = async (category, allowed) => {
try {
await apiFetch(`/policies/${encodeURIComponent(category)}/toggle`, {
method: 'POST',
await apiFetch(`/policies/\${encodeURIComponent(category)}/toggle`, {
method: "POST",
body: JSON.stringify({ allowed }),
});
refetch();
} catch (err) {
console.error('Toggle failed:', err);
console.error("Toggle failed:", err);
}
};
const handleBonusTime = async (category, minutes) => {
try {
await apiFetch('/timers', {
method: 'POST',
await apiFetch("/timers", {
method: "POST",
body: JSON.stringify({ category, minutes }),
});
refetch();
refetchTimers();
setBonusCategory(null);
} catch (err) {
console.error('Bonus time failed:', err);
console.error("Bonus time failed:", err);
}
};
const handleCancelTimer = async (timerId) => {
try {
await apiFetch(`/timers/${timerId}`, { method: 'DELETE' });
await apiFetch(`/timers/\${timerId}`, { method: "DELETE" });
refetchTimers();
refetch();
} catch (err) {
console.error('Cancel timer failed:', err);
console.error("Cancel timer failed:", err);
}
};
if (loading) return <div className="loading-spinner">Loading policies...</div>;
if (error) return <div className="error-message">Error: {error}</div>;
if (loading) {
return (
<div className="flex flex-col items-center justify-center py-40 gap-4">
<div className="w-8 h-8 border-2 border-gold/30 border-t-gold rounded-full animate-spin" />
<span className="text-gold/50 text-[0.6rem] tracking-[0.4em] uppercase font-bold">Initializing Systems...</span>
</div>
);
}
if (error) {
return (
<div className="glass-card p-6 border-blockRed/30 bg-blockRed/5 mt-10">
<div className="flex items-center gap-3 text-blockRed mb-2">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
<span className="font-bold tracking-widest text-xs uppercase">System Error</span>
</div>
<span className="text-cream/60 text-sm italic">{error}</span>
<button onClick={refetch} className="block mt-4 text-gold text-[0.6rem] underline tracking-widest uppercase">Retry Connection</button>
</div>
);
}
const categories = policies ? Object.entries(policies) : [];
return (
<div className="control-dashboard">
<div className="page-header">
<h1>// Control</h1>
</div>
<div className="space-y-8 pb-10">
<header className="flex flex-col items-center pt-2">
<div className="w-12 h-px bg-gold/30 mb-4" />
<h1 className="font-serif text-3xl md:text-4xl font-bold text-cream tracking-tight text-center">
Manor <span className="text-gold italic">BarkWho</span>
</h1>
<p className="text-[0.55rem] text-cream-dark tracking-[0.5em] uppercase mt-2 opacity-50 font-bold">Network Controller v1.0</p>
</header>
<CurfewClock curfew={curfew} timers={timers} onCancelTimer={handleCancelTimer} refetchCurfew={refetchCurfew} />
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8">
<div className="lg:col-span-7 space-y-6">
<div className="flex items-center justify-between px-1">
<h2 className="section-label m-0">Policy Overrides</h2>
<button onClick={refetch} className="text-[0.55rem] text-gold/60 hover:text-gold tracking-[0.2em] uppercase transition-colors">Sync rules</button>
</div>
<div className="space-y-4">
{categories.map(([name, config]) => (
<CategoryCard
key={name}
name={name}
config={config}
timers={timers?.filter((t) => t.category === name) || []}
onToggle={(allowed) => handleToggle(name, allowed)}
onBonusTime={() => setBonusCategory(name)}
onAssignRules={() => setAssignCategory(name)}
/>
))}
</div>
</div>
<div className="category-list">
{categories.map(([name, config]) => (
<CategoryCard
key={name}
name={name}
config={config}
timers={timers?.filter((t) => t.category === name) || []}
onToggle={(allowed) => handleToggle(name, allowed)}
onBonusTime={() => setBonusCategory(name)}
onAssignRules={() => setAssignCategory(name)}
<div className="lg:col-span-5 space-y-6">
<h2 className="section-label px-1">Timeline & Status</h2>
<CurfewClock
curfew={curfew}
timers={timers}
onCancelTimer={handleCancelTimer}
refetchCurfew={refetchCurfew}
/>
))}
</div>
</div>
{bonusCategory && (
@@ -88,8 +127,8 @@ export default function ControlDashboard() {
category={assignCategory}
currentRuleIds={policies?.[assignCategory]?.ruleIds || []}
onSave={async (ruleIds) => {
await apiFetch(`/policies/${encodeURIComponent(assignCategory)}/rules`, {
method: 'PUT',
await apiFetch(`/policies/\${encodeURIComponent(assignCategory)}/rules`, {
method: "PUT",
body: JSON.stringify({ ruleIds }),
});
refetch();

View File

@@ -1,121 +1,96 @@
import { useState } from 'react';
import { useApi, apiFetch } from '../hooks/useApi.js';
import DeviceCard from '../components/DeviceCard.jsx';
import { useState } from "react";
import { useApi, apiFetch } from "../hooks/useApi.js";
import DeviceCard from "../components/DeviceCard.jsx";
export default function DeviceLibrary() {
const { data: devices, loading, error, refetch } = useApi('/devices');
const [search, setSearch] = useState('');
const { data: devices, loading, error, refetch } = useApi("/devices");
const [search, setSearch] = useState("");
const handleNuke = async (mac) => {
if (!confirm(`NUKE device ${mac}? This will block all internet access.`)) return;
try {
await apiFetch(`/devices/${encodeURIComponent(mac)}/nuke`, { method: 'POST' });
await apiFetch(`/devices/${encodeURIComponent(mac)}/nuke`, { method: "POST" });
refetch();
} catch (err) {
console.error('Nuke failed:', err);
console.error("Nuke failed:", err);
}
};
const handleUnnuke = async (mac) => {
try {
await apiFetch(`/devices/${encodeURIComponent(mac)}/unnuke`, { method: 'POST' });
await apiFetch(`/devices/${encodeURIComponent(mac)}/unnuke`, { method: "POST" });
refetch();
} catch (err) {
console.error('Unnuke failed:', err);
console.error("Unnuke failed:", err);
}
};
if (loading) return <div className="loading-spinner">Discovering devices...</div>;
if (error) return <div className="error-message">Error: {error}</div>;
if (loading) {
return (
<div className="flex flex-col items-center justify-center py-40 gap-4">
<div className="w-8 h-8 border-2 border-gold/30 border-t-gold rounded-full animate-spin" />
<span className="text-gold/50 text-[0.6rem] tracking-[0.4em] uppercase font-bold">Scanning Estate Perimeter...</span>
</div>
);
}
const deviceList = Array.isArray(devices) ? devices : [];
const filtered = deviceList.filter((d) => {
const term = search.toLowerCase();
const name = (d.name || d.hostname || d.oui || '').toLowerCase();
const mac = (d.mac || '').toLowerCase();
const ip = (d.ip || '').toLowerCase();
const name = (d.name || d.hostname || d.oui || "").toLowerCase();
const mac = (d.mac || "").toLowerCase();
const ip = (d.ip || "").toLowerCase();
return name.includes(term) || mac.includes(term) || ip.includes(term);
});
return (
<div className="device-library">
<div className="page-header">
<h1>// Devices</h1>
<button className="btn btn-secondary" onClick={refetch}>
Refresh
</button>
</div>
<div className="space-y-8 pb-10">
<header className="flex flex-col items-center pt-2">
<div className="w-12 h-px bg-gold/30 mb-4" />
<h1 className="font-serif text-3xl md:text-4xl font-bold text-cream tracking-tight text-center">
Device <span className="text-gold italic">Inventory</span>
</h1>
<p className="text-[0.55rem] text-cream-dark tracking-[0.5em] uppercase mt-2 opacity-50 font-bold">Total Clients: {deviceList.length}</p>
</header>
<div className="search-bar">
<input
type="text"
placeholder="Search devices..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<span className="device-count">{filtered.length} devices</span>
</div>
<div className="space-y-6">
<div className="flex flex-col sm:flex-row gap-4 items-center justify-between px-1">
<div className="relative w-full sm:w-72">
<input
type="text"
placeholder="SEARCH BY NAME, IP, OR MAC..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="manor-input w-full pl-4 pr-10 py-2.5 text-[0.65rem] tracking-widest placeholder:opacity-30"
/>
<div className="absolute right-3 top-1/2 -translate-y-1/2 text-gold opacity-30">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
</div>
</div>
<button onClick={refetch} className="text-[0.55rem] text-gold/60 hover:text-gold tracking-[0.2em] uppercase transition-colors flex items-center gap-2">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"><path d="M23 4v6h-6"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
Refresh scan
</button>
</div>
<div className="device-grid">
{filtered.length === 0 ? (
<div className="empty-state">
{deviceList.length === 0
? 'No devices found. Connect to UDR to discover devices.'
: 'No devices match your search.'}
<div className="glass-card p-20 text-center flex flex-col items-center gap-4 opacity-40">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1" strokeLinecap="round" strokeLinejoin="round" className="text-gold"><circle cx="12" cy="12" r="10"/><line x1="8" y1="12" x2="16" y2="12"/></svg>
<span className="text-[0.65rem] tracking-[0.3em] uppercase">No entities located</span>
</div>
) : (
filtered.map((device) => (
<DeviceCard
key={device.mac || device._id}
device={device}
onNuke={() => handleNuke(device.mac)}
onUnnuke={() => handleUnnuke(device.mac)}
/>
))
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{filtered.map((device) => (
<DeviceCard
key={device.mac || device._id}
device={device}
onNuke={() => handleNuke(device.mac)}
onUnnuke={() => handleUnnuke(device.mac)}
/>
))}
</div>
)}
</div>
<style>{`
.search-bar {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
}
.search-bar input {
flex: 1;
padding: 10px 14px;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text-primary);
font-family: inherit;
font-size: 0.85rem;
outline: none;
}
.search-bar input:focus {
border-color: var(--accent);
}
.search-bar input::placeholder {
color: var(--text-muted);
}
.device-count {
color: var(--text-muted);
font-size: 0.75rem;
white-space: nowrap;
}
.device-grid {
display: flex;
flex-direction: column;
gap: 8px;
}
.empty-state {
text-align: center;
padding: 40px 20px;
color: var(--text-muted);
font-style: italic;
}
`}</style>
</div>
);
}

View File

@@ -0,0 +1,68 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
'./index.html',
'./src/**/*.{js,ts,jsx,tsx}',
],
theme: {
extend: {
colors: {
viridian: {
DEFAULT: '#2E473B',
dark: '#1E3229',
light: '#3A5A4A',
hover: '#456B57',
},
cream: {
DEFAULT: '#F5F5DC',
muted: '#C4C4A8',
dark: '#8A8A72',
},
gold: {
DEFAULT: '#D4AF37',
muted: '#AA8C2C',
bright: '#F4CF57',
},
blockRed: {
DEFAULT: '#BE123C',
dark: '#880825',
},
manor: {
green: '#4CAF50',
red: '#BE123C',
nuke: '#BE123C',
accent: '#D4AF37',
border: 'rgba(245, 245, 220, 0.15)',
},
},
fontFamily: {
serif: ['Playfair Display', 'Libre Baskerville', 'Georgia', 'serif'],
mono: ['Courier New', 'Consolas', 'monospace'],
},
backdropBlur: {
glass: '16px',
},
boxShadow: {
'glow-green': '0 0 12px rgba(76, 175, 80, 0.4), 0 0 4px rgba(76, 175, 80, 0.2)',
'glow-red': 'inset 0 0 12px rgba(190, 18, 60, 0.3), 0 0 8px rgba(190, 18, 60, 0.2)',
'glow-gold': '0 0 12px rgba(212, 175, 55, 0.3), 0 0 4px rgba(212, 175, 55, 0.2)',
'glass': '0 8px 32px rgba(0, 0, 0, 0.3)',
},
animation: {
'pulse-nuke': 'pulseNuke 2s ease-in-out infinite',
'glow-online': 'glowOnline 2s ease-in-out infinite',
},
keyframes: {
pulseNuke: {
'0%, 100%': { boxShadow: '0 0 15px rgba(190, 18, 60, 0.4)' },
'50%': { boxShadow: '0 0 30px rgba(190, 18, 60, 0.7), 0 0 60px rgba(190, 18, 60, 0.3)' },
},
glowOnline: {
'0%, 100%': { boxShadow: '0 0 6px rgba(76, 175, 80, 0.6)' },
'50%': { boxShadow: '0 0 12px rgba(76, 175, 80, 0.9)' },
},
},
},
},
plugins: [],
};

BIN
mockup.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 MiB

BIN
mockup.png:Zone.Identifier Normal file

Binary file not shown.

20
start.sh Executable file
View File

@@ -0,0 +1,20 @@
#!/bin/bash
# Start BarkWho on CT 118 - rebuilds containers to pick up .env and code changes
REMOTE="root@192.168.2.118"
PROJECT="/opt/barkwho"
echo "Syncing local code to container..."
rsync -avz --exclude node_modules --exclude .git --exclude .claude \
--exclude '*.png' --exclude '*.Identifier' --exclude README.md \
/home/jramos/barkwho/ "$REMOTE:$PROJECT/"
echo "Building and starting containers..."
ssh "$REMOTE" "cd $PROJECT && docker compose up --build -d"
echo "Waiting for services..."
sleep 3
ssh "$REMOTE" "docker compose -f $PROJECT/docker-compose.yml ps"
echo ""
echo "Frontend: http://192.168.2.118:5173"
echo "Backend: http://192.168.2.118:3001"

9
stop.sh Executable file
View File

@@ -0,0 +1,9 @@
#!/bin/bash
# Stop BarkWho on CT 118 - kills both containers
REMOTE="root@192.168.2.118"
PROJECT="/opt/barkwho"
echo "Stopping containers..."
ssh "$REMOTE" "docker compose -f $PROJECT/docker-compose.yml down"
echo "Containers stopped."