🎉 initial commit
Some checks failed
CI / Lint (push) Failing after 39s
CI / Build (push) Has been skipped
CI / Test (push) Has been skipped
CI / Helm Lint (push) Successful in 13s

This commit is contained in:
Christian van Dijk
2026-02-23 09:47:16 +01:00
commit 94b4f31102
53 changed files with 3220 additions and 0 deletions

8
frontend/.dockerignore Normal file
View File

@@ -0,0 +1,8 @@
node_modules
npm-debug.log
.git
.gitignore
.env
.env.local
.DS_Store
README.md

10
frontend/.idea/.gitignore generated vendored Normal file
View File

@@ -0,0 +1,10 @@
# Default ignored files
/shelf/
/workspace.xml
# Ignored default folder with query files
/queries/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
# Editor-based HTTP Client requests
/httpRequests/

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AgentMigrationStateService">
<option name="migrationStatus" value="COMPLETED" />
</component>
</project>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AskMigrationStateService">
<option name="migrationStatus" value="COMPLETED" />
</component>
</project>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Ask2AgentMigrationStateService">
<option name="migrationStatus" value="COMPLETED" />
</component>
</project>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="EditMigrationStateService">
<option name="migrationStatus" value="COMPLETED" />
</component>
</project>

4
frontend/.idea/encodings.xml generated Normal file
View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Encoding" addBOMForNewFiles="with BOM under Windows, with no BOM otherwise" />
</project>

8
frontend/.idea/frontend.iml generated Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

8
frontend/.idea/modules.xml generated Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/frontend.iml" filepath="$PROJECT_DIR$/.idea/frontend.iml" />
</modules>
</component>
</project>

28
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,28 @@
FROM denoland/deno:alpine
RUN apk add --no-cache wget
# The port the application will listen on
ARG PORT=8090
ENV PORT=${PORT}
WORKDIR /app
# Copy the frontend files
# Since the Dockerfile is in the root and files are in alpinejs/, we copy from there
COPY alpinejs/ .
# Ensure the deno user owns the directory so it can write public/config.js
RUN chown -R deno:deno /app
USER deno
# Cache dependencies
RUN deno cache main.ts
# Expose the port
EXPOSE ${PORT}
# Run the application
# We include --allow-write because main.ts writes public/config.js on startup
CMD ["run", "--allow-net", "--allow-read", "--allow-write", "--allow-env", "--allow-run", "main.ts"]

7
frontend/Makefile Normal file
View File

@@ -0,0 +1,7 @@
.PHONY: run build
build:
docker build -t frontend-alpinejs .
run:
docker run -p 8090:8090 frontend-alpinejs

35
frontend/alpinejs/.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,35 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Run app",
"type": "node",
"request": "launch",
"cwd": "${workspaceFolder}",
"runtimeExecutable": "deno",
"runtimeArgs": [
"run",
"--inspect",
"--allow-net",
"--allow-read",
"--allow-write",
"--allow-env",
"--watch",
"main.ts"
],
"attachSimplePort": 9229,
"console": "integratedTerminal",
"serverReadyAction": {
"action": "startDebugging",
"pattern": "URL: http",
"name": "client"
}
},
{
"name": "client",
"type": "chrome",
"request": "launch",
"url": "http://localhost:8000"
}
]
}

View File

@@ -0,0 +1,5 @@
{
"deno.enable": true,
"deno.lint": true,
"deno.enablePaths": ["."]
}

View File

@@ -0,0 +1,4 @@
{
"api_url": "http://localhost:8080",
"port": 8090
}

View File

@@ -0,0 +1,8 @@
{
"tasks": {
"start": "deno run --allow-net --allow-read --allow-write --allow-env --allow-run --watch main.ts"
},
"imports": {
"@dx/alpine-server": "jsr:@dx/alpine-server"
}
}

120
frontend/alpinejs/deno.lock generated Normal file
View File

@@ -0,0 +1,120 @@
{
"version": "5",
"specifiers": {
"jsr:@b-fuze/deno-dom@~0.1.56": "0.1.56",
"jsr:@dx/alpine-server@*": "0.1.13",
"jsr:@oak/commons@1": "1.0.1",
"jsr:@oak/oak@^17.2.0": "17.2.0",
"jsr:@std/assert@1": "1.0.16",
"jsr:@std/bytes@1": "1.0.6",
"jsr:@std/crypto@1": "1.0.5",
"jsr:@std/encoding@1": "1.0.10",
"jsr:@std/encoding@^1.0.10": "1.0.10",
"jsr:@std/fmt@^1.0.5": "1.0.9",
"jsr:@std/fmt@^1.0.9": "1.0.9",
"jsr:@std/fs@^1.0.11": "1.0.22",
"jsr:@std/http@1": "1.0.24",
"jsr:@std/internal@^1.0.12": "1.0.12",
"jsr:@std/io@~0.225.2": "0.225.2",
"jsr:@std/log@~0.224.14": "0.224.14",
"jsr:@std/media-types@1": "1.1.0",
"jsr:@std/path@1": "1.1.4",
"jsr:@std/path@^1.1.4": "1.1.4",
"npm:path-to-regexp@^6.3.0": "6.3.0"
},
"jsr": {
"@b-fuze/deno-dom@0.1.56": {
"integrity": "8030e2dc1d8750f1682b53462ab893d9c3470f2287feecbe22f44a88c54ab148"
},
"@dx/alpine-server@0.1.13": {
"integrity": "64e294e2064b76f8ebd7f8e08f4351068c27915bb4fc8cd8136a63d8bb517774",
"dependencies": [
"jsr:@b-fuze/deno-dom",
"jsr:@oak/oak",
"jsr:@std/fmt@^1.0.9",
"jsr:@std/log",
"jsr:@std/path@^1.1.4"
]
},
"@oak/commons@1.0.1": {
"integrity": "889ff210f0b4292591721be07244ecb1b5c118742f5273c70cf30d7cd4184d0c",
"dependencies": [
"jsr:@std/assert",
"jsr:@std/bytes",
"jsr:@std/crypto",
"jsr:@std/encoding@1",
"jsr:@std/http",
"jsr:@std/media-types"
]
},
"@oak/oak@17.2.0": {
"integrity": "938537a92fc7922a46a9984696c65fb189c9baad164416ac3e336768a9ff0cd1",
"dependencies": [
"jsr:@oak/commons",
"jsr:@std/assert",
"jsr:@std/bytes",
"jsr:@std/http",
"jsr:@std/media-types",
"jsr:@std/path@1",
"npm:path-to-regexp"
]
},
"@std/assert@1.0.16": {
"integrity": "6a7272ed1eaa77defe76e5ff63ca705d9c495077e2d5fd0126d2b53fc5bd6532"
},
"@std/bytes@1.0.6": {
"integrity": "f6ac6adbd8ccd99314045f5703e23af0a68d7f7e58364b47d2c7f408aeb5820a"
},
"@std/crypto@1.0.5": {
"integrity": "0dcfbb319fe0bba1bd3af904ceb4f948cde1b92979ec1614528380ed308a3b40"
},
"@std/encoding@1.0.10": {
"integrity": "8783c6384a2d13abd5e9e87a7ae0520a30e9f56aeeaa3bdf910a3eaaf5c811a1"
},
"@std/fmt@1.0.9": {
"integrity": "2487343e8899fb2be5d0e3d35013e54477ada198854e52dd05ed0422eddcabe0"
},
"@std/fs@1.0.22": {
"integrity": "de0f277a58a867147a8a01bc1b181d0dfa80bfddba8c9cf2bacd6747bcec9308"
},
"@std/http@1.0.24": {
"integrity": "4dd59afd7cfd6e2e96e175b67a5a829b449ae55f08575721ec691e5d85d886d4",
"dependencies": [
"jsr:@std/encoding@^1.0.10"
]
},
"@std/internal@1.0.12": {
"integrity": "972a634fd5bc34b242024402972cd5143eac68d8dffaca5eaa4dba30ce17b027"
},
"@std/io@0.225.2": {
"integrity": "3c740cd4ee4c082e6cfc86458f47e2ab7cb353dc6234d5e9b1f91a2de5f4d6c7"
},
"@std/log@0.224.14": {
"integrity": "257f7adceee3b53bb2bc86c7242e7d1bc59729e57d4981c4a7e5b876c808f05e",
"dependencies": [
"jsr:@std/fmt@^1.0.5",
"jsr:@std/fs",
"jsr:@std/io"
]
},
"@std/media-types@1.1.0": {
"integrity": "c9d093f0c05c3512932b330e3cc1fe1d627b301db33a4c2c2185c02471d6eaa4"
},
"@std/path@1.1.4": {
"integrity": "1d2d43f39efb1b42f0b1882a25486647cb851481862dc7313390b2bb044314b5",
"dependencies": [
"jsr:@std/internal"
]
}
},
"npm": {
"path-to-regexp@6.3.0": {
"integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="
}
},
"workspace": {
"dependencies": [
"jsr:@dx/alpine-server@*"
]
}
}

68
frontend/alpinejs/main.ts Normal file
View File

@@ -0,0 +1,68 @@
import { AlpineApp } from '@dx/alpine-server';
import { Router } from 'jsr:@oak/oak';
let config = {
api_url: Deno.env.get('API_URL') || 'http://localhost:8080',
port: parseInt(Deno.env.get('PORT') || '8090')
};
try {
const configFile = await Deno.readTextFile('./config.json');
const fileConfig = JSON.parse(configFile);
config = { ...config, ...fileConfig };
} catch (e) {
console.warn('Could not read config.json, using defaults', e.message);
}
const app = new AlpineApp({
app: {
dev: true,
staticFilesPath: './public',
},
oak: {
listenOptions: { port: config.port },
},
});
const healthRouter = new Router();
healthRouter.get('/health', (ctx) => {
ctx.response.body = { status: 'ok' };
ctx.response.status = 200;
});
app.append(healthRouter);
app.use(async (ctx, next) => {
await next();
const contentType = ctx.response.headers.get('content-type') || '';
if (contentType.includes('text/html')) {
let csp = ctx.response.headers.get('Content-Security-Policy');
if (!csp) {
// securityHeaders might not have set it yet if it runs after us in the call stack
// but in run(), securityHeaders is app.use()ed BEFORE user middlewares.
// So when we are here (after await next()), securityHeaders has already finished its await next()
// and set the headers.
csp = [
"default-src 'self'",
"base-uri 'self'",
"object-src 'none'",
"frame-ancestors 'none'",
"script-src 'self' 'unsafe-eval'",
"style-src 'self'",
"img-src 'self' data:",
"font-src 'self'",
"connect-src 'self'",
"media-src 'self'",
].join('; ');
}
const updatedCsp = csp.replace("connect-src 'self'", `connect-src 'self' ${config.api_url} ws:`);
ctx.response.headers.set('Content-Security-Policy', updatedCsp);
}
});
console.log(`URL: http://localhost:${config.port}`);
console.log(`API: ${config.api_url}`);
// Create config.js file in public directory
await Deno.writeTextFile('./public/config.js', `window.APP_CONFIG = ${JSON.stringify(config)};`);
await app.run();

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
window.APP_CONFIG = {"api_url":"http://localhost:8080","port":8090};

Binary file not shown.

After

Width:  |  Height:  |  Size: 699 B

View File

@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Handheld Emulation Devices Manager</title>
<link rel="stylesheet" href="style.css" />
<script src="config.js"></script>
<script src="main.js" defer></script>
<script src="alpine.min.js" defer></script>
</head>
<body>
</body>
</html>

View File

@@ -0,0 +1,449 @@
// Inject the HTML before Alpine initializes its data components
if (!document.querySelector('.container')) {
document.body.innerHTML = `
<div class="container" x-data="deviceManager" x-init="loadDevices(); connectWebSocket();" x-cloak>
<header>
<h1>🎮 Handheld Emulation Devices</h1>
<p class="subtitle">Manage your collection of retro handheld devices</p>
</header>
<div class="controls">
<button class="btn btn-primary" @click="addNewDevice()">
Add New Device
</button>
<input
type="text"
x-model="searchQuery"
placeholder="Search devices..."
class="search-input"
/>
</div>
<!-- Create/Edit Form Modal -->
<div
id="createFormPopover"
popover
class="modal"
@toggle="showCreateForm = $event.newState === 'open'; if ($event.newState === 'closed') editingDevice = null;"
>
<div class="modal-content" @click.stop>
<div class="modal-header">
<h2 x-text="editingDevice ? 'Edit Device' : 'Add New Device'"></h2>
<button class="close-btn" @click="showCreateForm = false">
&times;
</button>
</div>
<form @submit.prevent="saveDevice()" class="form">
<div class="form-group">
<label>Device Name *</label>
<input type="text" x-model="form.name" required />
</div>
<div class="form-row">
<div class="form-group">
<label>Manufacturer *</label>
<input type="text" x-model="form.manufacturer" required />
</div>
<div class="form-group">
<label>Release Year</label>
<input type="number" x-model="form.release_year" />
</div>
</div>
<div class="form-group">
<label>CPU</label>
<input type="text" x-model="form.cpu" />
</div>
<div class="form-row">
<div class="form-group">
<label>RAM (MB)</label>
<input type="number" x-model="form.ram_mb" />
</div>
<div class="form-group">
<label>Storage (MB)</label>
<input type="number" x-model="form.storage_mb" />
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Display Size</label>
<input
type="text"
x-model="form.display_size"
placeholder="e.g., 3.5 inch"
/>
</div>
<div class="form-group">
<label>Battery (Hours)</label>
<input type="number" x-model="form.battery_hours" step="0.5" />
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-success">Save Device</button>
<button
type="button"
class="btn btn-secondary"
@click="showCreateForm = false"
>
Cancel
</button>
</div>
</form>
</div>
</div>
<!-- Devices Table -->
<div class="table-container">
<table class="devices-table">
<thead>
<tr>
<th>Device Name</th>
<th>Manufacturer</th>
<th>Year</th>
<th>CPU</th>
<th>RAM</th>
<th>Display</th>
<th>Battery</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<template x-for="device in filteredDevices" :key="device.id">
<tr>
<td class="device-name" @click="viewDevice(device)">
<strong x-text="device.name"></strong>
</td>
<td x-text="device.manufacturer"></td>
<td x-text="device.release_year || '-'"></td>
<td class="small" x-text="device.cpu || '-'"></td>
<td x-text="device.ram_mb ? device.ram_mb + ' MB' : '-'"></td>
<td x-text="device.display_size || '-'"></td>
<td
x-text="device.battery_hours ? device.battery_hours + 'h' : '-'"
></td>
<td class="actions">
<button
class="btn-icon"
@click="editDevice(device)"
title="Edit"
>
✏️
</button>
<button
class="btn-icon btn-danger"
@click="deleteDevice(device.id)"
title="Delete"
>
🗑️
</button>
</td>
</tr>
</template>
</tbody>
</table>
<div x-show="filteredDevices.length === 0" class="empty-state">
<p>No devices found. Try adding one!</p>
</div>
</div>
<!-- Device Details Modal -->
<div
id="detailsModalPopover"
popover
class="modal"
@toggle="showDetailsModal = $event.newState === 'open'"
>
<div class="modal-content" @click.stop>
<div class="modal-header">
<h2 x-text="selectedDevice?.name || 'Device Details'"></h2>
<button class="close-btn" @click="showDetailsModal = false">
&times;
</button>
</div>
<div class="device-details" x-show="selectedDevice">
<div class="details-grid">
<div class="detail-item">
<span class="label">Manufacturer:</span>
<span x-text="selectedDevice?.manufacturer"></span>
</div>
<div class="detail-item">
<span class="label">Release Year:</span>
<span x-text="selectedDevice?.release_year || 'N/A'"></span>
</div>
<div class="detail-item">
<span class="label">CPU:</span>
<span x-text="selectedDevice?.cpu || 'N/A'"></span>
</div>
<div class="detail-item">
<span class="label">RAM:</span>
<span
x-text="selectedDevice?.ram_mb ? selectedDevice.ram_mb + ' MB' : 'N/A'"
></span>
</div>
<div class="detail-item">
<span class="label">Storage:</span>
<span
x-text="selectedDevice?.storage_mb ? selectedDevice.storage_mb + ' MB' : 'N/A'"
></span>
</div>
<div class="detail-item">
<span class="label">Display:</span>
<span x-text="selectedDevice?.display_size || 'N/A'"></span>
</div>
<div class="detail-item">
<span class="label">Battery Life:</span>
<span
x-text="selectedDevice?.battery_hours ? selectedDevice.battery_hours + ' hours' : 'N/A'"
></span>
</div>
<div class="detail-item">
<span class="label">Average Rating:</span>
<span
x-text="selectedDevice?.average_rating ? selectedDevice.average_rating.toFixed(1) + ' ⭐' : 'No ratings'"
></span>
</div>
</div>
<div class="actions-modal">
<button
class="btn btn-warning"
@click="editDevice(selectedDevice)"
>
Edit
</button>
<button
class="btn btn-secondary"
@click="showDetailsModal = false"
>
Close
</button>
</div>
</div>
</div>
</div>
<!-- Toast Notification -->
<div
id="toastPopover"
popover="manual"
class="toast"
x-text="toastMessage"
></div>
</div>
`;
}
document.addEventListener('alpine:init', () => {
Alpine.data('deviceManager', () => ({
devices: [],
searchQuery: '',
showCreateForm: false,
showDetailsModal: false,
showToast: false,
toastMessage: '',
editingDevice: null,
selectedDevice: null,
// Use config from server or default
apiBase: window.APP_CONFIG?.api_url || '',
init() {
this.$watch('showCreateForm', value => {
const popover = document.getElementById('createFormPopover');
if (value) {
try { popover.showPopover(); } catch(e) {}
} else {
try { popover.hidePopover(); } catch(e) {}
}
});
this.$watch('showDetailsModal', value => {
const popover = document.getElementById('detailsModalPopover');
if (value) {
try { popover.showPopover(); } catch(e) {}
} else {
try { popover.hidePopover(); } catch(e) {}
}
});
this.$watch('showToast', value => {
const popover = document.getElementById('toastPopover');
if (value) {
try { popover.showPopover(); } catch(e) {}
} else {
try { popover.hidePopover(); } catch(e) {}
}
});
},
form: {
name: '',
manufacturer: '',
release_year: null,
cpu: '',
ram_mb: null,
storage_mb: null,
display_size: '',
battery_hours: null
},
get filteredDevices() {
return this.devices.filter(device =>
device.name.toLowerCase().includes(this.searchQuery.toLowerCase()) ||
device.manufacturer.toLowerCase().includes(this.searchQuery.toLowerCase())
);
},
async loadDevices() {
try {
const response = await fetch(this.apiBase + '/devices');
const data = await response.json();
console.log('Devices data:', data);
this.devices = Array.isArray(data.data) ? data.data : (Array.isArray(data) ? data : []);
} catch (error) {
this.showNotification('Failed to load devices');
console.error(error);
}
},
connectWebSocket() {
const wsUrl = this.apiBase.replace('http', 'ws');
console.log('Connecting to WebSocket:', wsUrl);
const socket = new WebSocket(wsUrl);
socket.onmessage = (event) => {
const data = JSON.parse(event.data);
console.log('WS Message received:', data);
if (data.event_type === 'DevicePublished') {
this.showNotification(`New device: ${data.device_name}`);
this.loadDevices(); // Refresh list
} else if (data.event_type === 'DeviceUpdated') {
this.showNotification(`Device updated: ${data.device_name}`);
this.loadDevices(); // Refresh list
} else if (data.event_type === 'DeviceDeleted') {
this.showNotification(`Device removed: ${data.device_name}`);
// Optimistically remove from local list or just refresh
this.devices = this.devices.filter(d => d.id != data.device_id);
if (this.selectedDevice && this.selectedDevice.id == data.device_id) {
this.showDetailsModal = false;
}
}
};
socket.onclose = () => {
console.log('WS disconnected, retrying in 5s...');
setTimeout(() => this.connectWebSocket(), 5000);
};
socket.onerror = (error) => {
console.error('WS Error:', error);
};
},
async saveDevice() {
try {
const url = this.editingDevice
? `${this.apiBase}/devices/${this.editingDevice.id}`
: `${this.apiBase}/devices`;
const method = this.editingDevice ? 'PUT' : 'POST';
// Filter out null/undefined values and only send necessary data
const payload = {
name: this.form.name,
manufacturer: this.form.manufacturer,
release_year: this.form.release_year ? parseInt(this.form.release_year) : null,
cpu: this.form.cpu || null,
ram_mb: this.form.ram_mb ? parseInt(this.form.ram_mb) : null,
storage_mb: this.form.storage_mb ? parseInt(this.form.storage_mb) : null,
display_size: this.form.display_size || null,
battery_hours: this.form.battery_hours ? parseFloat(this.form.battery_hours) : null
};
const response = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (response.ok) {
await this.loadDevices();
this.resetForm();
this.showCreateForm = false;
this.showNotification(
this.editingDevice ? 'Device updated successfully!' : 'Device created successfully!'
);
} else {
const errorData = await response.json().catch(() => ({}));
this.showNotification(`Failed to save device: ${errorData.error || response.statusText}`);
}
} catch (error) {
this.showNotification('Failed to save device: Network error');
console.error(error);
}
},
editDevice(device) {
this.editingDevice = device;
this.form = { ...device };
this.showCreateForm = true;
this.showDetailsModal = false;
},
async deleteDevice(id) {
if (confirm('Are you sure you want to delete this device?')) {
try {
const response = await fetch(`${this.apiBase}/devices/${id}`, {
method: 'DELETE'
});
if (response.ok) {
await this.loadDevices();
this.showNotification('Device deleted successfully!');
} else {
this.showNotification('Failed to delete device');
}
} catch (error) {
this.showNotification('Failed to delete device');
console.error(error);
}
}
},
viewDevice(device) {
this.selectedDevice = device;
this.showDetailsModal = true;
},
resetForm() {
this.form = {
name: '',
manufacturer: '',
release_year: null,
cpu: '',
ram_mb: null,
storage_mb: null,
display_size: '',
battery_hours: null
};
this.editingDevice = null;
},
showNotification(message) {
this.toastMessage = message;
this.showToast = true;
setTimeout(() => {
this.showToast = false;
}, 3000);
},
addNewDevice() {
this.resetForm();
this.showCreateForm = true;
}
}));
});

View File

@@ -0,0 +1,432 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--primary: #3b82f6;
--secondary: #6b7280;
--success: #10b981;
--danger: #ef4444;
--warning: #f59e0b;
--dark: #1f2937;
--light: #f3f4f6;
--border: #e5e7eb;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px;
color: var(--dark);
}
.container {
max-width: 1200px;
margin: 0 auto;
background: white;
border-radius: 12px;
box-shadow: 0 20px 25px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
header {
background: linear-gradient(135deg, var(--primary) 0%, #667eea 100%);
color: white;
padding: 40px 20px;
text-align: center;
}
header h1 {
font-size: 2.5em;
margin-bottom: 10px;
}
.subtitle {
font-size: 1.1em;
opacity: 0.9;
}
.controls {
display: flex;
gap: 15px;
padding: 20px;
background: var(--light);
border-bottom: 1px solid var(--border);
flex-wrap: wrap;
}
.btn {
padding: 10px 20px;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 1em;
font-weight: 500;
transition: all 0.3s ease;
}
.btn-primary {
background: var(--primary);
color: white;
}
.btn-primary:hover {
background: #2563eb;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4);
}
.btn-success {
background: var(--success);
color: white;
}
.btn-success:hover {
background: #059669;
}
.btn-danger {
background: var(--danger);
color: white;
}
.btn-danger:hover {
background: #dc2626;
}
.btn-warning {
background: var(--warning);
color: white;
}
.btn-warning:hover {
background: #d97706;
}
.btn-secondary {
background: var(--secondary);
color: white;
}
.btn-secondary:hover {
background: #4b5563;
}
.btn-icon {
background: none;
border: none;
font-size: 1.2em;
cursor: pointer;
padding: 5px 10px;
transition: transform 0.2s;
}
.btn-icon:hover {
transform: scale(1.2);
}
.search-input {
padding: 10px 15px;
border: 1px solid var(--border);
border-radius: 6px;
font-size: 1em;
flex: 1;
min-width: 250px;
transition: border-color 0.3s;
}
.search-input:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.table-container {
overflow-x: auto;
padding: 20px;
}
.devices-table {
width: 100%;
border-collapse: collapse;
}
.devices-table thead {
background: var(--light);
border-bottom: 2px solid var(--border);
}
.devices-table th {
padding: 15px;
text-align: left;
font-weight: 600;
color: var(--dark);
}
.devices-table tbody tr {
border-bottom: 1px solid var(--border);
transition: background 0.2s;
}
.devices-table tbody tr:hover {
background: #f9fafb;
}
.devices-table td {
padding: 15px;
}
.device-name {
cursor: pointer;
color: var(--primary);
text-decoration: none;
font-weight: 500;
}
.device-name:hover {
text-decoration: underline;
}
.small {
font-size: 0.9em;
color: var(--secondary);
}
.actions {
display: flex;
gap: 10px;
}
.empty-state {
text-align: center;
padding: 60px 20px;
color: var(--secondary);
font-size: 1.1em;
}
/* Modal Styles */
.modal {
padding: 0;
border: none;
background: transparent;
max-width: 100vw;
max-height: 100vh;
width: 100%;
height: 100%;
overflow: visible;
}
.modal::backdrop {
background: rgba(0, 0, 0, 0.5);
}
.modal:popover-open {
display: flex;
align-items: center;
justify-content: center;
}
.modal-content {
background: white;
border-radius: 12px;
width: 90%;
max-width: 600px;
max-height: 90vh;
overflow-y: auto;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
}
@keyframes slideUp {
from {
transform: translateY(30px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px;
border-bottom: 1px solid var(--border);
background: var(--light);
}
.modal-header h2 {
font-size: 1.5em;
}
.close-btn {
background: none;
border: none;
font-size: 2em;
cursor: pointer;
color: var(--secondary);
transition: color 0.2s;
}
.close-btn:hover {
color: var(--dark);
}
.form {
padding: 20px;
}
.form-group {
margin-bottom: 20px;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
}
.form-group label {
display: block;
margin-bottom: 8px;
font-weight: 500;
color: var(--dark);
}
.form-group input {
width: 100%;
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: 6px;
font-size: 1em;
transition: border-color 0.3s;
}
.form-group input:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.form-actions {
display: flex;
gap: 10px;
justify-content: flex-end;
padding-top: 20px;
border-top: 1px solid var(--border);
}
.device-details {
padding: 20px;
}
.details-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.detail-item {
padding: 15px;
background: var(--light);
border-radius: 8px;
}
.detail-item .label {
display: block;
font-weight: 600;
color: var(--secondary);
margin-bottom: 5px;
font-size: 0.9em;
}
.detail-item span:last-child {
font-size: 1.1em;
color: var(--dark);
}
.actions-modal {
display: flex;
gap: 10px;
justify-content: flex-end;
padding-top: 20px;
border-top: 1px solid var(--border);
}
/* Toast Notification */
.toast {
margin: 0;
padding: 15px 20px;
border: none;
position: fixed;
bottom: 20px;
right: 20px;
background: var(--success);
color: white;
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 2000;
max-width: 300px;
}
.toast:popover-open {
display: block;
animation: toastIn 0.3s ease;
}
@keyframes toastIn {
from {
transform: translateY(100px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
/* Responsive Design */
@media (max-width: 768px) {
header h1 {
font-size: 1.8em;
}
.controls {
flex-direction: column;
}
.search-input {
min-width: auto;
}
.form-row {
grid-template-columns: 1fr;
}
.devices-table {
font-size: 0.9em;
}
.devices-table th,
.devices-table td {
padding: 10px;
}
.actions {
flex-direction: column;
}
.details-grid {
grid-template-columns: 1fr;
}
.modal-content {
width: 95%;
}
}