🎉 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

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;
}
}));
});