fist pass
This commit is contained in:
15
.claude/settings.local.json
Normal file
15
.claude/settings.local.json
Normal 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
2
.gitignore
vendored
@@ -4,5 +4,3 @@ dist/
|
||||
data/*.json
|
||||
*.log
|
||||
.DS_Store
|
||||
CLAUDE.md
|
||||
.env
|
||||
|
||||
65
CLAUDE.md
Normal file
65
CLAUDE.md
Normal 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
31
Gemini_status.md
Normal 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
|
||||
85
README.md
85
README.md
@@ -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).
|
||||
|
||||
@@ -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
3026
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
68
frontend/tailwind.config.js
Normal file
68
frontend/tailwind.config.js
Normal 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
BIN
mockup.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.6 MiB |
BIN
mockup.png:Zone.Identifier
Normal file
BIN
mockup.png:Zone.Identifier
Normal file
Binary file not shown.
20
start.sh
Executable file
20
start.sh
Executable 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"
|
||||
Reference in New Issue
Block a user