good progress

This commit is contained in:
TylerCG 2026-04-16 18:19:15 -04:00
parent c5cf527b50
commit af86f78ec3
21 changed files with 1776 additions and 119 deletions

213
CMS_CONFIG.md Normal file
View File

@ -0,0 +1,213 @@
# Kiosk Configuration System
## Overview
The kiosk uses a **CMS-managed configuration system** via Directus. Settings are stored in the `settings` collection and can be managed through the Directus admin interface without needing to change environment variables.
## Configuration Fields
### Core Settings
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `key` | String | `general` | Unique identifier for this settings group (should always be "general") |
| `title` | String | `Guest` | Welcome title displayed in idle/home screens |
| `use_ip_location` | Boolean | `false` | If true, uses IP geolocation; if false, uses manual location |
| `manual_location` | String | `` | Manual location string (e.g., "Disneyland, Anaheim, CA") |
| `idle_timeout_seconds` | Integer | `300` | Seconds before returning to idle screen |
| `plex_enabled` | Boolean | `true` | Enable/disable Plex integration |
| `restaurants_enabled` | Boolean | `true` | Show/hide restaurants section |
| `attractions_enabled` | Boolean | `true` | Show/hide attractions section |
| `brand_color` | String | `#1f2937` | Primary brand color (hex) |
| `metadata` | JSON | `{}` | Extensible JSON for custom settings |
## Setup Instructions
### 1. Create the Settings Collection in Directus
Access Directus admin at `http://localhost:8055` and:
1. Create a new collection called `settings`
2. Add the fields listed above with their types
3. Create the initial record with `key: "general"`
Alternatively, if using Directus auto-migrations, the schema should be applied automatically.
### 2. Seed Initial Configuration
Insert the default settings into the database:
```sql
INSERT INTO directus_collections.settings (id, key, title, use_ip_location, manual_location, idle_timeout_seconds, plex_enabled, restaurants_enabled, attractions_enabled, brand_color)
VALUES (
gen_random_uuid(),
'general',
'Guest',
false,
'Your Hotel Location',
300,
true,
true,
true,
'#1f2937'
);
```
### 3. Frontend Integration
#### Option A: Using Config Store (Recommended)
In your Svelte components:
```svelte
<script>
import { config, welcomeTitle, location, configLoading, initConfig } from '$lib/configStore.js';
import { onMount } from 'svelte';
onMount(() => {
initConfig();
});
</script>
{#if $configLoading}
<p>Loading configuration...</p>
{:else}
<h1>Welcome, {$welcomeTitle}!</h1>
<p>Location: {$location}</p>
{/if}
```
#### Option B: Using Config Module Directly
```javascript
import { fetchConfig, getLocation } from '$lib/config.js';
const config = await fetchConfig();
console.log('Welcome title:', config.title);
const location = await getLocation();
console.log('Location:', location);
```
## Location Options
### Using IP Geolocation
```javascript
// In Directus, set:
// use_ip_location: true
// manual_location: "" (can be empty)
// The system will use ipapi.co to determine location
// Returns format: "City, Region, Country"
```
### Using Manual Location
```javascript
// In Directus, set:
// use_ip_location: false
// manual_location: "Disneyland, Anaheim, CA"
// Returns exactly what you set in manual_location
```
## Updating App.svelte
Replace environment variable usage with config store:
**Before:**
```javascript
const welcomeName = import.meta.env.VITE_WELCOME_NAME || 'Guest';
const IDLE_TIMEOUT = (import.meta.env.VITE_IDLE_TIMEOUT || 5) * 60 * 1000;
```
**After:**
```javascript
import { welcomeTitle, idleTimeoutMs, initConfig } from './lib/configStore.js';
import { onMount } from 'svelte';
onMount(() => {
initConfig();
});
let welcomeName = 'Guest';
let IDLE_TIMEOUT = 300000;
welcomeTitle.subscribe(val => welcomeName = val);
idleTimeoutMs.subscribe(val => IDLE_TIMEOUT = val);
```
## Caching
The config is cached for **5 minutes** to reduce database queries. To force a refresh:
```javascript
import { refreshConfig } from '$lib/configStore.js';
await refreshConfig();
```
## Extending Configuration
To add custom settings without modifying the schema, use the `metadata` field:
**In Directus:**
```json
{
"customFeature": true,
"apiKey": "abc123",
"theme": "dark"
}
```
**In JavaScript:**
```javascript
const config = await fetchConfig();
const customFeature = config.metadata.customFeature;
```
## API Endpoints
The config module doesn't require backend changes—it queries Directus directly:
```
GET /items/settings?filter={"key":"general"}
```
Response format:
```json
{
"data": [
{
"id": "uuid",
"key": "general",
"title": "Guest",
"use_ip_location": false,
"manual_location": "Disneyland, Anaheim, CA",
...
}
]
}
```
## Environment Variables
No environment variables needed! Everything is managed via Directus. However, you still need:
- `VITE_API_URL`: Points to Directus instance (default: `http://localhost:8055`)
## Troubleshooting
### Config not loading
- Check Directus is running and accessible
- Verify `settings` collection exists with `key: "general"` record
- Check browser console for errors
### Location not updating
- If using IP geolocation, check ipapi.co is accessible
- If using manual location, verify `use_ip_location: false` and `manual_location` is set
### Stale config after updates
- Config is cached for 5 minutes
- Force refresh with `refreshConfig()` or wait for cache to expire

View File

@ -6,14 +6,20 @@ WORKDIR /app
# Install system dependencies for CEC
RUN apk add --no-cache libcec-dev
# Copy package files
COPY package.json package-lock.json ./
# Copy package files (package*.json matches package.json and package-lock.json if it exists)
COPY control-service/package*.json ./
# Install dependencies
RUN npm ci
RUN npm install
# Copy source
COPY src ./src
COPY control-service/src ./src
# Copy public assets
COPY control-service/public ./public
# Copy media files
COPY media ./media
EXPOSE 3001

View File

@ -10,8 +10,7 @@
"lint": "eslint src"
},
"dependencies": {
"ws": "^8.14.2",
"cec-client": "^1.0.0"
"ws": "^8.14.2"
},
"devDependencies": {
"eslint": "^8.54.0"

View File

@ -0,0 +1,520 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Hotel Pi - Settings</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.container {
background: white;
border-radius: 12px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
max-width: 600px;
width: 100%;
padding: 40px;
}
h1 {
color: #333;
margin-bottom: 10px;
font-size: 28px;
}
.subtitle {
color: #666;
margin-bottom: 30px;
font-size: 14px;
}
.form-group {
margin-bottom: 25px;
}
label {
display: block;
color: #333;
font-weight: 500;
margin-bottom: 8px;
font-size: 14px;
}
input[type="text"],
input[type="number"],
input[type="color"],
select {
width: 100%;
padding: 10px 12px;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 14px;
font-family: inherit;
transition: border-color 0.2s;
}
input[type="text"]:focus,
input[type="number"]:focus,
input[type="color"]:focus,
select:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
.checkbox-group {
display: flex;
align-items: center;
gap: 10px;
}
input[type="checkbox"] {
width: 20px;
height: 20px;
cursor: pointer;
}
.checkbox-group label {
margin: 0;
cursor: pointer;
user-select: none;
}
.section {
margin-bottom: 30px;
padding-bottom: 30px;
border-bottom: 1px solid #eee;
}
.section:last-child {
border-bottom: none;
margin-bottom: 0;
padding-bottom: 0;
}
.section h2 {
font-size: 16px;
color: #667eea;
margin-bottom: 20px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.buttons {
display: flex;
gap: 12px;
margin-top: 30px;
}
button {
flex: 1;
padding: 12px 24px;
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.btn-save {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.btn-save:hover {
transform: translateY(-2px);
box-shadow: 0 10px 20px rgba(102, 126, 234, 0.3);
}
.btn-save:active {
transform: translateY(0);
}
.btn-reset {
background: #f0f0f0;
color: #333;
}
.btn-reset:hover {
background: #e0e0e0;
}
.message {
padding: 12px 16px;
border-radius: 6px;
margin-bottom: 20px;
display: none;
font-size: 14px;
}
.message.success {
background: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
display: block;
}
.message.error {
background: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
display: block;
}
.info-text {
font-size: 12px;
color: #888;
margin-top: 4px;
}
.location-mode {
display: flex;
gap: 20px;
align-items: center;
margin-top: 15px;
}
.location-mode label {
margin: 0;
}
#manualLocation {
margin-top: 10px;
}
@media (max-width: 600px) {
.container {
padding: 20px;
}
h1 {
font-size: 24px;
}
.buttons {
flex-direction: column;
}
}
</style>
</head>
<body>
<div class="container">
<h1>🎛️ Hotel Pi Settings</h1>
<p class="subtitle">Configure your kiosk display</p>
<div class="message" id="message"></div>
<form id="settingsForm">
<!-- Display Settings -->
<div class="section">
<h2>Display Settings</h2>
<div class="form-group">
<label for="title">Hero Name (Top Right)</label>
<input type="text" id="title" name="title" placeholder="e.g., Guest" required>
<p class="info-text">Shown as the guest/hero name (e.g., "Guest's Family")</p>
</div>
<div class="form-group">
<label for="welcomePrefix">Welcome Prefix</label>
<input type="text" id="welcomePrefix" name="welcomePrefix" placeholder="e.g., WELCOME" required>
<p class="info-text">First line above the hero name</p>
</div>
<div class="form-group">
<label for="welcomeSuffix">Welcome Suffix</label>
<input type="text" id="welcomeSuffix" name="welcomeSuffix" placeholder="e.g., 's Family">
<p class="info-text">Shown after the guest name on idle screen</p>
</div>
<div class="form-group">
<label for="resortName">Resort Name</label>
<input type="text" id="resortName" name="resortName" placeholder="e.g., Mojo Dojo Casa House" required>
<p class="info-text">Displayed on both welcome and hero pages</p>
</div>
<div class="form-group">
<label for="roomNumber">Room Number</label>
<input type="text" id="roomNumber" name="roomNumber" placeholder="e.g., ROOM 201" required>
<p class="info-text">Displayed on hero page top bar</p>
</div>
<div class="form-group">
<label for="brandColor">Brand Color</label>
<input type="color" id="brandColor" name="brand_color">
<p class="info-text">Primary accent color for UI</p>
</div>
<div class="form-group">
<label for="idleTimeout">Idle Timeout (seconds)</label>
<input type="number" id="idleTimeout" name="idle_timeout_seconds" min="30" max="600" required>
<p class="info-text">Time before returning to idle screen</p>
</div>
</div>
<!-- Hero Page Settings -->
<div class="section">
<h2>Hero Page</h2>
<div class="form-group">
<label for="backgroundVideoPath">Background Video Path</label>
<input type="text" id="backgroundVideoPath" name="backgroundVideoPath" placeholder="e.g., /media/background.mp4" required>
<p class="info-text">Path to video file (e.g., /media/background.mp4)</p>
</div>
<div class="form-group">
<label for="heroWelcomeText">Welcome Text</label>
<input type="text" id="heroWelcomeText" name="heroWelcomeText" placeholder="e.g., WELCOME" required>
<p class="info-text">Large text displayed on hero page (e.g., "WELCOME")</p>
</div>
<div class="form-group">
<label for="heroGuestText">Guest Name Text</label>
<input type="text" id="heroGuestText" name="heroGuestText" placeholder="e.g., Guest" required>
<p class="info-text">Guest name displayed below welcome text</p>
</div>
</div>
<!-- Themes -->
<div class="section">
<h2>Color Themes</h2>
<div class="form-group">
<label>Hero Page Colors</label>
</div>
<div class="form-group">
<label for="heroStatusBarBg">Status Bar Background</label>
<input type="color" id="heroStatusBarBg" name="heroStatusBarBg">
<p class="info-text">Top bar background color</p>
</div>
<div class="form-group">
<label for="heroHeaderBg">Header Background</label>
<input type="color" id="heroHeaderBg" name="heroHeaderBg">
<p class="info-text">Main header gradient base color</p>
</div>
<div class="form-group">
<label for="heroAccentLineColor">Accent Line Color</label>
<input type="color" id="heroAccentLineColor" name="heroAccentLineColor">
<p class="info-text">Divider line between status and header</p>
</div>
<div class="form-group">
<label for="heroWelcomeTextColor">Welcome Text Color</label>
<input type="color" id="heroWelcomeTextColor" name="heroWelcomeTextColor">
<p class="info-text">Color of "WELCOME" text</p>
</div>
<div class="form-group">
<label for="heroGuestTextColor">Guest Name Text Color</label>
<input type="color" id="heroGuestTextColor" name="heroGuestTextColor">
<p class="info-text">Color of guest name text</p>
</div>
<div class="form-group">
<label>Idle Page Colors</label>
</div>
<div class="form-group">
<label for="idleBgColor">Background Color</label>
<input type="color" id="idleBgColor" name="idleBgColor">
<p class="info-text">Idle screen background</p>
</div>
<div class="form-group">
<label for="idleTitleColor">Title Color</label>
<input type="color" id="idleTitleColor" name="idleTitleColor">
<p class="info-text">Guest name color on idle screen</p>
</div>
<div class="form-group">
<label for="idleSuffixColor">Suffix Color</label>
<input type="color" id="idleSuffixColor" name="idleSuffixColor">
<p class="info-text">Family suffix color on idle screen</p>
</div>
</div>
<!-- Location Settings -->
<div class="section">
<h2>Location Settings</h2>
<div class="location-mode">
<label>
<input type="radio" name="location_mode" value="ip" id="locationIP">
Use IP Geolocation
</label>
<label>
<input type="radio" name="location_mode" value="manual" id="locationManual">
Manual Location
</label>
</div>
<div class="form-group">
<label for="manualLocation">Location String</label>
<input type="text" id="manualLocation" name="manual_location" placeholder="e.g., Disneyland, Anaheim, CA">
<p class="info-text">Used when manual location is selected</p>
</div>
</div>
<!-- Features -->
<div class="section">
<h2>Features</h2>
<div class="form-group">
<div class="checkbox-group">
<input type="checkbox" id="plexEnabled" name="plex_enabled">
<label for="plexEnabled">Enable Plex Integration</label>
</div>
</div>
<div class="form-group">
<div class="checkbox-group">
<input type="checkbox" id="restaurantsEnabled" name="restaurants_enabled">
<label for="restaurantsEnabled">Show Restaurants Section</label>
</div>
</div>
<div class="form-group">
<div class="checkbox-group">
<input type="checkbox" id="attractionsEnabled" name="attractions_enabled">
<label for="attractionsEnabled">Show Attractions Section</label>
</div>
</div>
</div>
<!-- Buttons -->
<div class="buttons">
<button type="submit" class="btn-save">💾 Save Settings</button>
<button type="reset" class="btn-reset">↻ Reset</button>
</div>
</form>
</div>
<script>
const API_URL = '/api/settings';
const form = document.getElementById('settingsForm');
const message = document.getElementById('message');
// Load settings on page load
loadSettings();
async function loadSettings() {
try {
const response = await fetch(API_URL);
const settings = await response.json();
// Display Settings
document.getElementById('title').value = settings.title;
document.getElementById('welcomePrefix').value = settings.welcomePrefix || 'WELCOME';
document.getElementById('welcomeSuffix').value = settings.welcomeSuffix || "'s Family";
document.getElementById('resortName').value = settings.resortName || 'Mojo Dojo Casa House';
document.getElementById('roomNumber').value = settings.roomNumber || 'ROOM 201';
document.getElementById('brandColor').value = settings.brand_color;
document.getElementById('idleTimeout').value = settings.idle_timeout_seconds;
// Hero page
document.getElementById('backgroundVideoPath').value = settings.backgroundVideoPath || '/media/background.mp4';
document.getElementById('heroWelcomeText').value = settings.heroWelcomeText || 'WELCOME';
document.getElementById('heroGuestText').value = settings.heroGuestText || 'Guest';
// Color themes
document.getElementById('heroStatusBarBg').value = settings.heroStatusBarBg || '#1B4965';
document.getElementById('heroHeaderBg').value = settings.heroHeaderBg || '#1E8E9F';
document.getElementById('heroAccentLineColor').value = settings.heroAccentLineColor || '#FF6F61';
document.getElementById('heroWelcomeTextColor').value = settings.heroWelcomeTextColor || '#FF6F61';
document.getElementById('heroGuestTextColor').value = settings.heroGuestTextColor || '#ffffff';
document.getElementById('idleBgColor').value = settings.idleBgColor || '#1a1a1a';
document.getElementById('idleTitleColor').value = settings.idleTitleColor || '#ffffff';
document.getElementById('idleSuffixColor').value = settings.idleSuffixColor || '#d4af37';
document.getElementById('plexEnabled').checked = settings.plex_enabled;
document.getElementById('restaurantsEnabled').checked = settings.restaurants_enabled;
document.getElementById('attractionsEnabled').checked = settings.attractions_enabled;
// Set location mode
if (settings.use_ip_location) {
document.getElementById('locationIP').checked = true;
} else {
document.getElementById('locationManual').checked = true;
}
} catch (error) {
showMessage('Failed to load settings', 'error');
}
}
// Handle form submission
form.addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(form);
const settings = {
title: formData.get('title'),
welcomePrefix: formData.get('welcomePrefix'),
welcomeSuffix: formData.get('welcomeSuffix'),
resortName: formData.get('resortName'),
roomNumber: formData.get('roomNumber'),
backgroundVideoPath: formData.get('backgroundVideoPath'),
heroWelcomeText: formData.get('heroWelcomeText'),
heroGuestText: formData.get('heroGuestText'),
use_ip_location: document.getElementById('locationIP').checked,
manual_location: formData.get('manual_location'),
idle_timeout_seconds: parseInt(formData.get('idle_timeout_seconds')),
plex_enabled: formData.get('plex_enabled') === 'on',
restaurants_enabled: formData.get('restaurants_enabled') === 'on',
attractions_enabled: formData.get('attractions_enabled') === 'on',
brand_color: formData.get('brand_color'),
heroStatusBarBg: formData.get('heroStatusBarBg'),
heroHeaderBg: formData.get('heroHeaderBg'),
heroAccentLineColor: formData.get('heroAccentLineColor'),
heroWelcomeTextColor: formData.get('heroWelcomeTextColor'),
heroGuestTextColor: formData.get('heroGuestTextColor'),
idleBgColor: formData.get('idleBgColor'),
idleTitleColor: formData.get('idleTitleColor'),
idleSuffixColor: formData.get('idleSuffixColor'),
};
try {
const response = await fetch(API_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(settings),
});
if (response.ok) {
showMessage('✓ Settings saved successfully!', 'success');
} else {
showMessage('Failed to save settings', 'error');
}
} catch (error) {
showMessage('Error saving settings: ' + error.message, 'error');
}
});
function showMessage(text, type) {
message.textContent = text;
message.className = `message ${type}`;
setTimeout(() => {
message.className = 'message';
}, 4000);
}
</script>
</body>
</html>

View File

@ -1,12 +1,20 @@
// Main control service
import http from 'http';
import { WebSocketServer } from 'ws';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import CECHandler from './cec-handler.js';
import CommandExecutor from './commands.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const PORT = parseInt(process.env.PORT || '3001', 10);
const CEC_DEVICE = process.env.CEC_DEVICE || '/dev/ttyAMA0';
// Settings file path
const SETTINGS_FILE = path.join(__dirname, '../../settings.json');
// Initialize handlers
const cec = new CECHandler(CEC_DEVICE);
const executor = new CommandExecutor({
@ -14,8 +22,134 @@ const executor = new CommandExecutor({
kioskLaunchCommand: process.env.KIOSK_LAUNCH_COMMAND,
});
// Helper functions for settings
function loadSettings() {
try {
if (fs.existsSync(SETTINGS_FILE)) {
const data = fs.readFileSync(SETTINGS_FILE, 'utf8');
return JSON.parse(data);
}
} catch (error) {
console.error('Error loading settings:', error.message);
}
return getDefaultSettings();
}
function saveSettings(settings) {
try {
fs.writeFileSync(SETTINGS_FILE, JSON.stringify(settings, null, 2));
return true;
} catch (error) {
console.error('Error saving settings:', error.message);
return false;
}
}
function getDefaultSettings() {
return {
title: 'Guest',
use_ip_location: false,
manual_location: 'Your Hotel Location',
idle_timeout_seconds: 300,
plex_enabled: true,
restaurants_enabled: true,
attractions_enabled: true,
brand_color: '#1f2937',
};
}
// Create HTTP server
const server = http.createServer((req, res) => {
// Enable CORS
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
if (req.method === 'OPTIONS') {
res.writeHead(200);
res.end();
return;
}
// CMS Dashboard
if (req.url === '/cms' || req.url === '/cms/' || req.url === '/cms/index.html') {
try {
const cmsPath = path.join(__dirname, '../public/cms.html');
const content = fs.readFileSync(cmsPath, 'utf8');
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(content);
} catch (error) {
res.writeHead(404, { 'Content-Type': 'text/plain' });
res.end('CMS not found');
}
return;
}
// Media files (video, etc) from media directory
if (req.url.startsWith('/media/')) {
try {
const filename = req.url.replace('/media/', '');
const mediaPath = path.join(__dirname, '../media', filename);
// Prevent directory traversal attacks
if (!mediaPath.startsWith(path.join(__dirname, '../media'))) {
res.writeHead(403);
res.end();
return;
}
if (fs.existsSync(mediaPath)) {
const stat = fs.statSync(mediaPath);
const contentType = filename.endsWith('.mp4') ? 'video/mp4' : 'application/octet-stream';
res.writeHead(200, { 'Content-Type': contentType, 'Content-Length': stat.size });
fs.createReadStream(mediaPath).pipe(res);
} else {
res.writeHead(404);
res.end();
}
} catch (error) {
res.writeHead(500);
res.end();
}
return;
}
// Settings API - GET
if (req.url === '/api/settings' && req.method === 'GET') {
const settings = loadSettings();
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(settings));
return;
}
// Settings API - POST
if (req.url === '/api/settings' && req.method === 'POST') {
let body = '';
req.on('data', (chunk) => {
body += chunk.toString();
});
req.on('end', () => {
try {
const newSettings = JSON.parse(body);
const currentSettings = loadSettings();
const updated = { ...currentSettings, ...newSettings };
if (saveSettings(updated)) {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: true, settings: updated }));
console.log('✓ Settings updated');
} else {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: false, error: 'Failed to save settings' }));
}
} catch (error) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: false, error: error.message }));
}
});
return;
}
if (req.url === '/health') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(executor.getHealth()));
@ -24,7 +158,7 @@ const server = http.createServer((req, res) => {
if (req.url === '/' && req.method === 'GET') {
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('Hotel Pi Control Service\nWebSocket available at ws://localhost:' + PORT);
res.end('Hotel Pi Control Service\nWebSocket available at ws://localhost:' + PORT + '\nCMS available at http://localhost:' + PORT + '/cms');
return;
}

View File

@ -93,6 +93,87 @@ const COLLECTIONS = {
},
],
},
settings: {
name: 'settings',
description: 'Global kiosk configuration and settings',
fields: [
{
field: 'id',
type: 'uuid',
primary: true,
},
{
field: 'key',
type: 'string',
required: true,
unique: true,
example: 'general',
comment: 'Unique key identifying this setting group',
},
{
field: 'title',
type: 'string',
required: true,
default: 'Amanda',
example: 'Amanda',
comment: 'Displayed welcome title (e.g., "Welcome, Amanda")',
},
{
field: 'use_ip_location',
type: 'boolean',
default: false,
comment: 'If true, uses IP geolocation. If false, uses manual_location.',
},
{
field: 'manual_location',
type: 'string',
example: 'Disneyland, Anaheim, CA',
comment: 'Manual location string used when use_ip_location is false',
},
{
field: 'idle_timeout_seconds',
type: 'integer',
default: 300,
comment: 'Time in seconds before idle screen is shown',
},
{
field: 'plex_enabled',
type: 'boolean',
default: true,
comment: 'Whether Plex integration is enabled',
},
{
field: 'restaurants_enabled',
type: 'boolean',
default: true,
comment: 'Whether restaurants section is visible',
},
{
field: 'attractions_enabled',
type: 'boolean',
default: true,
comment: 'Whether attractions section is visible',
},
{
field: 'brand_color',
type: 'string',
default: '#1f2937',
example: '#1f2937',
comment: 'Primary brand color (hex)',
},
{
field: 'metadata',
type: 'json',
comment: 'Additional configuration JSON for extensibility',
},
{
field: 'updated_at',
type: 'timestamp',
readonly: true,
},
],
},
};
export default COLLECTIONS;

76
directus/settings-seed.js Normal file
View File

@ -0,0 +1,76 @@
// Example configuration for Directus seed data
// Use this to initialize the settings collection with default values
export const defaultSettings = {
key: 'general',
title: 'Guest',
use_ip_location: false,
manual_location: 'Hotel Location',
idle_timeout_seconds: 300,
plex_enabled: true,
restaurants_enabled: true,
attractions_enabled: true,
brand_color: '#1f2937',
metadata: {
version: '1.0.0',
},
};
/**
* SQL to insert default settings into Directus
* Run this in your database initialization script
*/
export const seedSettingsSql = `
INSERT INTO directus_collections (collection, icon, sort, note)
VALUES ('settings', 'settings', 1000, 'Global kiosk configuration settings')
ON CONFLICT (collection) DO NOTHING;
INSERT INTO directus_fields (collection, field, type, interface, options, required, readonly, hidden, sort, note)
VALUES
('settings', 'id', 'uuid', 'input', '{"hidden":true}', false, true, true, 0, 'Primary Key'),
('settings', 'key', 'string', 'input', '{"options":{"iconRight":"vpn_key"}}', true, false, false, 1, 'Unique setting key'),
('settings', 'title', 'string', 'input', NULL, true, false, false, 2, 'Welcome title'),
('settings', 'use_ip_location', 'boolean', 'boolean', NULL, false, false, false, 3, 'Use IP geolocation'),
('settings', 'manual_location', 'string', 'input', NULL, false, false, false, 4, 'Manual location if IP geolocation disabled'),
('settings', 'idle_timeout_seconds', 'integer', 'input', '{"options":{"iconRight":"schedule"}}', false, false, false, 5, 'Idle timeout in seconds'),
('settings', 'plex_enabled', 'boolean', 'boolean', NULL, false, false, false, 6, 'Enable Plex integration'),
('settings', 'restaurants_enabled', 'boolean', 'boolean', NULL, false, false, false, 7, 'Show restaurants section'),
('settings', 'attractions_enabled', 'boolean', 'boolean', NULL, false, false, false, 8, 'Show attractions section'),
('settings', 'brand_color', 'string', 'input', '{"iconRight":"palette"}', false, false, false, 9, 'Brand color in hex'),
('settings', 'metadata', 'json', 'json', NULL, false, false, false, 10, 'Custom metadata and settings')
ON CONFLICT DO NOTHING;
INSERT INTO directus_settings (key, title, use_ip_location, manual_location, idle_timeout_seconds, plex_enabled, restaurants_enabled, attractions_enabled, brand_color, metadata)
VALUES (
'general',
'Guest',
false,
'Hotel Location',
300,
true,
true,
true,
'#1f2937',
'{}'
)
ON CONFLICT (key) DO UPDATE SET
title = EXCLUDED.title,
use_ip_location = EXCLUDED.use_ip_location,
manual_location = EXCLUDED.manual_location,
idle_timeout_seconds = EXCLUDED.idle_timeout_seconds,
plex_enabled = EXCLUDED.plex_enabled,
restaurants_enabled = EXCLUDED.restaurants_enabled,
attractions_enabled = EXCLUDED.attractions_enabled,
brand_color = EXCLUDED.brand_color;
`;
/**
* Directus REST API payload to create settings
* Use this to programmatically initialize from Node.js
*/
export function createSettingsPayload(overrides = {}) {
return {
...defaultSettings,
...overrides,
};
}

221
directus/setup-settings.js Normal file
View File

@ -0,0 +1,221 @@
#!/usr/bin/env node
/**
* Setup script to initialize the settings collection in Directus
* Usage: node directus/setup-settings.js
*
* This script:
* 1. Creates the 'settings' collection
* 2. Adds all required fields
* 3. Seeds initial data with default values
*
* Note: Local dev only - no authentication
*/
const API_URL = process.env.VITE_API_URL || 'http://localhost:8055';
async function createCollection() {
console.log('📦 Creating settings collection...');
try {
const response = await fetch(`${API_URL}/collections`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
collection: 'settings',
icon: 'settings',
note: 'Global kiosk configuration and settings',
fields: [
{
field: 'id',
type: 'uuid',
data_type: 'uuid',
primary_key: true,
readonly: true,
},
{
field: 'key',
type: 'string',
data_type: 'string',
required: true,
note: 'Unique key identifying this setting group',
},
{
field: 'title',
type: 'string',
data_type: 'string',
required: true,
default_value: 'Guest',
note: 'Displayed welcome title',
},
{
field: 'use_ip_location',
type: 'boolean',
data_type: 'boolean',
default_value: false,
note: 'Use IP geolocation or manual location',
},
{
field: 'manual_location',
type: 'string',
data_type: 'string',
note: 'Manual location string',
},
{
field: 'idle_timeout_seconds',
type: 'integer',
data_type: 'integer',
default_value: 300,
note: 'Seconds before idle screen',
},
{
field: 'plex_enabled',
type: 'boolean',
data_type: 'boolean',
default_value: true,
note: 'Enable Plex integration',
},
{
field: 'restaurants_enabled',
type: 'boolean',
data_type: 'boolean',
default_value: true,
note: 'Show restaurants section',
},
{
field: 'attractions_enabled',
type: 'boolean',
data_type: 'boolean',
default_value: true,
note: 'Show attractions section',
},
{
field: 'brand_color',
type: 'string',
data_type: 'string',
default_value: '#1f2937',
note: 'Primary brand color',
},
{
field: 'metadata',
type: 'json',
data_type: 'json',
note: 'Custom metadata and settings',
},
],
}),
});
if (!response.ok) {
const error = await response.json();
// Collection might already exist, which is fine
if (error.errors?.[0]?.message?.includes('already exists')) {
console.log(' Collection already exists');
return true;
}
throw new Error(`Failed to create collection: ${error.errors?.[0]?.message || response.statusText}`);
}
console.log('✅ Collection created');
return true;
} catch (error) {
console.error('❌ Collection creation failed:', error.message);
return false;
}
}
async function seedData() {
console.log('🌱 Seeding settings data...');
try {
// First check if data already exists
const checkResponse = await fetch(`${API_URL}/items/settings?filter={"key":"general"}`, {
headers: {
'Content-Type': 'application/json',
},
});
if (checkResponse.ok) {
const checkData = await checkResponse.json();
if (checkData.data?.length > 0) {
console.log(' Settings already exist, updating...');
// Update existing
const settingId = checkData.data[0].id;
const updateResponse = await fetch(`${API_URL}/items/settings/${settingId}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
title: 'Guest',
use_ip_location: false,
manual_location: 'Your Hotel',
idle_timeout_seconds: 300,
plex_enabled: true,
restaurants_enabled: true,
attractions_enabled: true,
brand_color: '#1f2937',
metadata: {},
}),
});
if (!updateResponse.ok) {
throw new Error(`Failed to update settings: ${updateResponse.statusText}`);
}
console.log('✅ Settings updated');
return true;
}
}
// Create new settings record
const response = await fetch(`${API_URL}/items/settings`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
key: 'general',
title: 'Guest',
use_ip_location: false,
manual_location: 'Your Hotel',
idle_timeout_seconds: 300,
plex_enabled: true,
restaurants_enabled: true,
attractions_enabled: true,
brand_color: '#1f2937',
metadata: {},
}),
});
if (!response.ok) {
throw new Error(`Failed to seed data: ${response.statusText}`);
}
console.log('✅ Settings record created');
return true;
} catch (error) {
console.error('❌ Seeding failed:', error.message);
return false;
}
}
async function main() {
console.log(`\n🚀 Setting up Hotel Pi configuration...\n`);
console.log(`API URL: ${API_URL}\n`);
try {
const collectionOk = await createCollection();
if (collectionOk) {
await seedData();
console.log('\n✅ Setup complete! Your settings are ready in Directus.\n');
console.log('📝 You can now edit settings in Directus admin panel:');
console.log(` ${API_URL}/admin/collections/settings\n`);
}
} catch (error) {
console.error('\n❌ Setup failed:', error.message);
process.exit(1);
}
}
main();

View File

@ -1,59 +1,4 @@
version: '3.8'
services:
# PostgreSQL Database
postgres:
image: postgres:16-alpine
container_name: hotel_pi_db
environment:
POSTGRES_USER: ${POSTGRES_USER:-directus}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-directus123}
POSTGRES_DB: ${POSTGRES_DB:-directus}
volumes:
- postgres_data:/var/lib/postgresql/data
ports:
- '5432:5432'
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER:-directus}']
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
networks:
- hotel_pi_network
# Directus CMS
directus:
image: directus/directus:latest
container_name: hotel_pi_cms
depends_on:
postgres:
condition: service_healthy
environment:
DB_CLIENT: postgres
DB_HOST: postgres
DB_PORT: 5432
DB_DATABASE: ${POSTGRES_DB:-directus}
DB_USER: ${POSTGRES_USER:-directus}
DB_PASSWORD: ${POSTGRES_PASSWORD:-directus123}
SECRET: ${SECRET:-your-secret-key-change-in-production}
AUTH_SECRET: ${AUTH_SECRET:-your-auth-secret-key-change-in-production}
CORS_ORIGIN: ${CORS_ORIGIN:-http://localhost:5173,http://localhost:5000}
PUBLIC_URL: http://localhost:8055
volumes:
- directus_uploads:/directus/uploads
- ./directus/extensions:/directus/extensions
ports:
- '8055:8055'
healthcheck:
test: ['CMD', 'wget', '--quiet', '--tries=1', '--spider', 'http://localhost:8055/server/health']
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
networks:
- hotel_pi_network
# Frontend (SvelteKit)
frontend:
build:
@ -61,14 +6,12 @@ services:
dockerfile: Dockerfile
container_name: hotel_pi_frontend
environment:
VITE_API_URL: ${VITE_API_URL:-http://localhost:8055}
VITE_API_URL: ${VITE_API_URL:-http://localhost:3001}
VITE_WS_URL: ${VITE_WS_URL:-ws://localhost:3001}
VITE_WELCOME_NAME: ${WELCOME_NAME:-Guest}
VITE_IDLE_TIMEOUT: ${IDLE_TIMEOUT_MINUTES:-5}
ports:
- '5173:5173'
depends_on:
- directus
- control-service
restart: unless-stopped
networks:
- hotel_pi_network
@ -76,8 +19,8 @@ services:
# Control Service (Node.js)
control-service:
build:
context: ./control-service
dockerfile: Dockerfile
context: .
dockerfile: control-service/Dockerfile
container_name: hotel_pi_control
environment:
PORT: 3001
@ -89,15 +32,9 @@ services:
restart: unless-stopped
networks:
- hotel_pi_network
# Enable device access for Raspberry Pi (CEC)
devices:
- /dev/ttyAMA0:/dev/ttyAMA0
volumes:
postgres_data:
driver: local
directus_uploads:
driver: local
# Note: For Raspberry Pi deployment, uncomment this to enable CEC device access:
# devices:
# - /dev/ttyAMA0:/dev/ttyAMA0
networks:
hotel_pi_network:

View File

@ -15,6 +15,7 @@
"prettier": "^3.1.0",
"prettier-plugin-svelte": "^3.0.3",
"svelte": "^4.2.2",
"terser": "^5.27.0",
"vite": "^5.0.0"
}
},
@ -444,6 +445,17 @@
"node": ">=6.0.0"
}
},
"node_modules/@jridgewell/source-map": {
"version": "0.3.11",
"resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz",
"integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.5",
"@jridgewell/trace-mapping": "^0.3.25"
}
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
@ -917,6 +929,13 @@
"node": ">= 0.4"
}
},
"node_modules/buffer-from": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
"dev": true,
"license": "MIT"
},
"node_modules/camelcase": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
@ -969,6 +988,13 @@
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"license": "MIT"
},
"node_modules/commander": {
"version": "2.20.3",
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
"dev": true,
"license": "MIT"
},
"node_modules/css-tree": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.3.1.tgz",
@ -1421,6 +1447,16 @@
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
"license": "ISC"
},
"node_modules/source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@ -1431,6 +1467,17 @@
"node": ">=0.10.0"
}
},
"node_modules/source-map-support": {
"version": "0.5.21",
"resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz",
"integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==",
"dev": true,
"license": "MIT",
"dependencies": {
"buffer-from": "^1.0.0",
"source-map": "^0.6.0"
}
},
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
@ -1496,6 +1543,25 @@
"svelte": "^3.19.0 || ^4.0.0"
}
},
"node_modules/terser": {
"version": "5.46.1",
"resolved": "https://registry.npmjs.org/terser/-/terser-5.46.1.tgz",
"integrity": "sha512-vzCjQO/rgUuK9sf8VJZvjqiqiHFaZLnOiimmUuOKODxWL8mm/xua7viT7aqX7dgPY60otQjUotzFMmCB4VdmqQ==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"@jridgewell/source-map": "^0.3.3",
"acorn": "^8.15.0",
"commander": "^2.20.0",
"source-map-support": "~0.5.20"
},
"bin": {
"terser": "bin/terser"
},
"engines": {
"node": ">=10"
}
},
"node_modules/vite": {
"version": "5.4.21",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",

View File

@ -15,6 +15,7 @@
"@sveltejs/vite-plugin-svelte": "^3.0.0",
"svelte": "^4.2.2",
"vite": "^5.0.0",
"terser": "^5.27.0",
"prettier": "^3.1.0",
"prettier-plugin-svelte": "^3.0.3"
},

View File

@ -16,12 +16,21 @@
} from './lib/store.js';
import { fetchRestaurants, fetchAttractions } from './lib/api.js';
import WebSocketManager from './lib/websocket.js';
import { initConfig, welcomeTitle, idleTimeoutMs, backgroundVideoPath } from './lib/configStore.js';
const IDLE_TIMEOUT = (import.meta.env.VITE_IDLE_TIMEOUT || 5) * 60 * 1000;
let idleTimer;
let ws;
let IDLE_TIMEOUT = 300000; // Default 5 minutes
let welcomeName = 'Guest';
const welcomeName = import.meta.env.VITE_WELCOME_NAME || 'Guest';
// Subscribe to config stores
const unsubscribeTitle = welcomeTitle.subscribe(val => {
welcomeName = val;
});
const unsubscribeTimeout = idleTimeoutMs.subscribe(val => {
IDLE_TIMEOUT = val;
});
function resetIdleTimer() {
clearTimeout(idleTimer);
@ -87,6 +96,9 @@
}
onMount(async () => {
// Initialize configuration from CMS
await initConfig();
// Initialize WebSocket
const wsUrl = import.meta.env.VITE_WS_URL || 'ws://localhost:3001';
ws = new WebSocketManager(wsUrl);
@ -134,13 +146,27 @@
return () => {
clearTimeout(idleTimer);
window.removeEventListener('keydown', handleKeyboardInput);
ws?.close();
unsubscribeTitle();
unsubscribeTimeout();
};
});
</script>
<main class="app">
<!-- Global background video, always loaded and playing -->
<video
class="global-video"
src={$backgroundVideoPath}
autoplay
muted
loop
playsinline
preload="auto"
/>
{#if $currentScreen === 'idle'}
<IdleScreen {welcomeName} />
<IdleScreen />
{:else if $currentScreen === 'home'}
<HomeScreen />
{:else if $currentScreen === 'restaurants'}
@ -157,6 +183,7 @@
</main>
<style global>
/* Global reset styles - intentionally global for fullscreen kiosk experience */
* {
box-sizing: border-box;
margin: 0;
@ -210,6 +237,17 @@
overflow: hidden;
}
.global-video {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
object-fit: cover;
z-index: 5;
pointer-events: none;
}
.connection-warning {
position: fixed;
bottom: 1rem;

View File

@ -0,0 +1,154 @@
<!-- Example Settings Display Component -->
<!-- This shows the current configuration and can be used for debugging -->
<script>
import { config, welcomeTitle, location, configLoading, idleTimeoutMs, refreshConfig } from '../lib/configStore.js';
</script>
<div class="config-display">
{#if $configLoading}
<div class="loading">
<p>Loading configuration...</p>
</div>
{:else if $config}
<div class="settings-info">
<h3>Kiosk Settings</h3>
<div class="setting-item">
<span class="label">Welcome Title:</span>
<span class="value">{$welcomeTitle}</span>
</div>
<div class="setting-item">
<span class="label">Location:</span>
<span class="value">{$location || 'Loading...'}</span>
</div>
<div class="setting-item">
<span class="label">Location Mode:</span>
<span class="value">
{#if $config.use_ip_location}
IP Geolocation
{:else}
Manual: {$config.manual_location}
{/if}
</span>
</div>
<div class="setting-item">
<span class="label">Idle Timeout:</span>
<span class="value">{$config.idle_timeout_seconds} seconds</span>
</div>
<div class="features">
<h4>Features</h4>
<ul>
<li>
<span class="status" class:enabled={$config.plex_enabled}>●</span>
Plex {$config.plex_enabled ? 'Enabled' : 'Disabled'}
</li>
<li>
<span class="status" class:enabled={$config.restaurants_enabled}>●</span>
Restaurants {$config.restaurants_enabled ? 'Enabled' : 'Disabled'}
</li>
<li>
<span class="status" class:enabled={$config.attractions_enabled}>●</span>
Attractions {$config.attractions_enabled ? 'Enabled' : 'Disabled'}
</li>
</ul>
</div>
<button on:click={refreshConfig} class="refresh-btn">
Refresh Configuration
</button>
</div>
{/if}
</div>
<style>
.config-display {
background: rgba(0, 0, 0, 0.8);
color: #fff;
padding: 20px;
border-radius: 8px;
font-family: monospace;
font-size: 14px;
}
.loading {
text-align: center;
padding: 20px;
}
.settings-info h3 {
margin: 0 0 15px 0;
font-size: 18px;
color: #4ade80;
}
.setting-item {
display: flex;
justify-content: space-between;
margin: 10px 0;
padding: 8px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.label {
font-weight: bold;
color: #94a3b8;
min-width: 130px;
}
.value {
color: #e2e8f0;
}
.features h4 {
margin: 15px 0 10px 0;
color: #4ade80;
font-size: 14px;
}
.features ul {
list-style: none;
padding: 0;
margin: 0;
}
.features li {
padding: 5px 0;
display: flex;
align-items: center;
}
.status {
display: inline-block;
width: 12px;
height: 12px;
margin-right: 8px;
font-size: 16px;
color: #ef4444;
}
.status.enabled {
color: #4ade80;
}
.refresh-btn {
margin-top: 15px;
padding: 8px 16px;
background: #4ade80;
color: #000;
border: none;
border-radius: 4px;
font-weight: bold;
cursor: pointer;
font-family: monospace;
font-size: 12px;
}
.refresh-btn:hover {
background: #22c55e;
}
</style>

View File

@ -1,5 +1,16 @@
<script>
import { selectedIndex, pushScreen } from '../lib/store.js';
import {
heroWelcomeText,
heroGuestText,
resortName,
roomNumber,
heroStatusBarBg,
heroHeaderBg,
heroAccentLineColor,
heroWelcomeTextColor,
heroGuestTextColor
} from '../lib/configStore.js';
import { onMount } from 'svelte';
let currentTime = new Date();
@ -172,29 +183,22 @@
</script>
<div class="home-container">
<!-- YouTube Background Video -->
<iframe
class="youtube-bg"
src="https://www.youtube.com/embed/eU3wLRp3bXA?autoplay=1&mute=1&loop=1&playlist=eU3wLRp3bXA&controls=0&modestbranding=1&start=13"
title="Background Video"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullscreen
/>
<!-- Background Video is handled globally in App.svelte -->
<!-- Top Status Bar -->
<div class="status-bar">
<div class="resort-name">Mojo Dojo Casa House</div>
<div class="room-number">ROOM 201</div>
<div class="status-bar" style="background-color: {$heroStatusBarBg};">
<div class="resort-name">{$resortName}</div>
<div class="room-number">{$roomNumber}</div>
</div>
<!-- Accent Line -->
<div class="accent-line"></div>
<div class="accent-line" style="background-color: {$heroAccentLineColor};"></div>
<!-- Header Bar -->
<div class="header-bar">
<div class="header-bar" style="background: linear-gradient(135deg, {$heroHeaderBg} 0%, {$heroHeaderBg}dd 50%, {$heroHeaderBg} 100%);">
<div class="header-left">
<div class="welcome">WELCOME</div>
<div class="guest-name">Guest</div>
<div class="welcome" style="color: {$heroWelcomeTextColor};">{$heroWelcomeText}</div>
<div class="guest-name" style="color: {$heroGuestTextColor};">{$heroGuestText}</div>
</div>
<div class="header-right">
<div class="time-date">
@ -407,15 +411,15 @@
overflow: hidden;
}
.youtube-bg {
.background-video {
position: absolute;
left: 0;
top: 0;
width: 100vw;
aspect-ratio: 16 / 9;
border: none;
height: 100vh;
object-fit: cover;
z-index: 10;
pointer-events: none;
object-fit: cover;
}
/* Top Status Bar */

View File

@ -1,14 +1,14 @@
<script>
export let welcomeName = 'Guest';
import { config, welcomeTitle, welcomePrefix, welcomeSuffix, location, idleBgColor, idleTitleColor, idleSuffixColor, resortName } from '../lib/configStore.js';
</script>
<div class="container">
<div class="container" style="background: linear-gradient(135deg, {$idleBgColor} 0%, {$idleBgColor} 100%);">
<div class="status-bar">
<div class="resort-name">Resort</div>
<div class="resort-name">{$resortName}</div>
</div>
<div class="content">
<h1 class="welcome">WELCOME</h1>
<h2 class="guest-family">Guest Family</h2>
<h1 class="welcome" style="color: {$idleTitleColor};">{$welcomePrefix}</h1>
<h2 class="guest-family" style="color: {$idleSuffixColor};">{$welcomeTitle}{$welcomeSuffix}</h2>
</div>
<div class="bottom-bar">
<div class="bottom-text">Press a button on your remote to get started</div>
@ -26,6 +26,7 @@
background: linear-gradient(135deg, #ffffff 0%, #f5f5f5 100%);
display: flex;
flex-direction: column;
z-index: 1000;
}
.status-bar {
@ -36,7 +37,8 @@
align-items: center;
border-bottom: 2px solid rgba(255, 255, 255, 0.3);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
animation: fade-in 0.6s ease-out;
z-index: 1001;
position: relative;
}
.resort-name {
@ -53,7 +55,7 @@
.content {
position: relative;
z-index: 2;
z-index: 1001;
display: flex;
flex-direction: column;
align-items: center;
@ -84,7 +86,6 @@
font-weight: 300;
letter-spacing: 0.1em;
color: #333;
animation: fade-in 1s ease-out;
}
.guest-family {
@ -94,16 +95,6 @@
font-weight: 700;
letter-spacing: -0.02em;
color: #333;
animation: fade-in 1.2s ease-out;
}
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.bottom-bar {
@ -114,7 +105,8 @@
align-items: center;
border-top: 2px solid rgba(255, 255, 255, 0.3);
box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.3);
animation: fade-in 0.6s ease-out;
z-index: 1001;
position: relative;
}
.bottom-text {

109
frontend/src/lib/config.js Normal file
View File

@ -0,0 +1,109 @@
// Configuration management - fetches from control-service API
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001';
// Default configuration fallback
const DEFAULT_CONFIG = {
title: 'Guest',
welcomePrefix: 'WELCOME',
welcomeSuffix: 'Family',
useCustomSuffix: false,
use_ip_location: false,
manual_location: '',
idle_timeout_seconds: 300,
plex_enabled: true,
restaurants_enabled: true,
attractions_enabled: true,
brand_color: '#1f2937',
metadata: {},
};
let cachedConfig = null;
let cacheTimestamp = null;
const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes
/**
* Fetch configuration from control-service settings API
* Returns cached config if available and recent
*/
export async function fetchConfig() {
try {
// Return cached config if still fresh
if (cachedConfig && cacheTimestamp && Date.now() - cacheTimestamp < CACHE_DURATION) {
return cachedConfig;
}
const response = await fetch(`${API_URL}/api/settings`, {
headers: {
'Content-Type': 'application/json',
},
});
if (!response.ok) {
console.warn(`Failed to fetch config (HTTP ${response.status}), using defaults`);
return DEFAULT_CONFIG;
}
const settings = await response.json();
if (!settings) {
console.warn('No settings found, using defaults');
return DEFAULT_CONFIG;
}
cachedConfig = { ...DEFAULT_CONFIG, ...settings };
cacheTimestamp = Date.now();
return cachedConfig;
} catch (error) {
console.error('Failed to fetch config:', error);
return DEFAULT_CONFIG;
}
}
/**
* Get the current location string (either from IP or manual)
*/
export async function getLocation() {
const config = await fetchConfig();
if (config.use_ip_location) {
return getLocationFromIP();
}
return config.manual_location || 'Unknown Location';
}
/**
* Get location from IP geolocation API (free service)
* Falls back to manual location if IP geolocation fails
*/
export async function getLocationFromIP() {
try {
const response = await fetch('https://ipapi.co/json/', {
headers: {
'Content-Type': 'application/json',
},
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
const location = [data.city, data.region, data.country_code]
.filter(Boolean)
.join(', ');
return location || 'Unknown Location';
} catch (error) {
console.warn('IP geolocation failed:', error);
const config = await fetchConfig();
return config.manual_location || 'Unknown Location';
}
}
/**
* Invalidate cache to force fresh fetch on next call
*/
export function invalidateCache() {
cachedConfig = null;
cacheTimestamp = null;
}

View File

@ -0,0 +1,76 @@
// Configuration Svelte store
import { writable, derived } from 'svelte/store';
import { fetchConfig, getLocation, invalidateCache } from './config.js';
// Main config store
export const config = writable(null);
export const configLoading = writable(true);
export const configError = writable(null);
export const location = writable('');
// Derived store for title
export const welcomeTitle = derived(config, ($config) => $config?.title || 'Guest');
// Derived store for welcome display
export const welcomePrefix = derived(config, ($config) => $config?.welcomePrefix || 'WELCOME');
export const welcomeSuffix = derived(config, ($config) => $config?.welcomeSuffix || "'s Family");
// Hero page text fields
export const heroWelcomeText = derived(config, ($config) => $config?.heroWelcomeText || 'WELCOME');
export const heroGuestText = derived(config, ($config) => $config?.heroGuestText || 'Guest');
export const resortName = derived(config, ($config) => $config?.resortName || 'Mojo Dojo Casa House');
export const roomNumber = derived(config, ($config) => $config?.roomNumber || 'ROOM 201');
export const backgroundVideoPath = derived(config, ($config) => {
const path = $config?.backgroundVideoPath || '/media/background.mp4';
// If path is relative, prepend the API URL
if (path.startsWith('/')) {
const apiUrl = import.meta.env.VITE_API_URL || 'http://localhost:3001';
return apiUrl + path;
}
return path;
});
// Hero page theme colors
export const heroStatusBarBg = derived(config, ($config) => $config?.heroStatusBarBg || '#1B4965');
export const heroHeaderBg = derived(config, ($config) => $config?.heroHeaderBg || '#1E8E9F');
export const heroAccentLineColor = derived(config, ($config) => $config?.heroAccentLineColor || '#FF6F61');
export const heroWelcomeTextColor = derived(config, ($config) => $config?.heroWelcomeTextColor || '#FF6F61');
export const heroGuestTextColor = derived(config, ($config) => $config?.heroGuestTextColor || '#ffffff');
// Idle page theme colors
export const idleBgColor = derived(config, ($config) => $config?.idleBgColor || '#1a1a1a');
export const idleTitleColor = derived(config, ($config) => $config?.idleTitleColor || '#ffffff');
export const idleSuffixColor = derived(config, ($config) => $config?.idleSuffixColor || '#d4af37');
// Derived store for idle timeout in milliseconds
export const idleTimeoutMs = derived(
config,
($config) => ($config?.idle_timeout_seconds || 300) * 1000
);
// Fetch config on store initialization
export async function initConfig() {
configLoading.set(true);
configError.set(null);
try {
const data = await fetchConfig();
config.set(data);
// Also fetch location
const loc = await getLocation();
location.set(loc);
configLoading.set(false);
} catch (error) {
configError.set(error.message || 'Failed to load configuration');
configLoading.set(false);
console.error('Config error:', error);
}
}
// Manual refresh function
export async function refreshConfig() {
invalidateCache();
return initConfig();
}

View File

@ -7,6 +7,10 @@ export default defineConfig({
port: 5173,
host: '0.0.0.0'
},
preview: {
port: 5173,
host: '0.0.0.0'
},
build: {
target: 'esnext',
minify: 'terser'

BIN
media/background.mp4 Normal file

Binary file not shown.

View File

@ -10,7 +10,8 @@
"docker:up": "docker-compose up -d",
"docker:down": "docker-compose down",
"docker:logs": "docker-compose logs -f",
"install:all": "npm install && npm --prefix frontend install && npm --prefix control-service install"
"install:all": "npm install && npm --prefix frontend install && npm --prefix control-service install",
"setup:cms": "node directus/setup-settings.js"
},
"dependencies": {
"concurrently": "^8.2.0"

25
settings.json Normal file
View File

@ -0,0 +1,25 @@
{
"title": "Guest",
"welcomePrefix": "WELCOME",
"welcomeSuffix": "'s Family",
"heroWelcomeText": "WELCOME",
"heroGuestText": "Guest",
"use_ip_location": false,
"manual_location": "Your Hotel Location",
"idle_timeout_seconds": 300,
"plex_enabled": true,
"restaurants_enabled": true,
"attractions_enabled": true,
"brand_color": "#1f2937",
"resortName": "Mojo Dojo Casa House",
"roomNumber": "ROOM 201",
"backgroundVideoPath": "/media/background.mp4",
"heroStatusBarBg": "#1B4965",
"heroHeaderBg": "#1E8E9F",
"heroAccentLineColor": "#FF6F61",
"heroWelcomeTextColor": "#FF6F61",
"heroGuestTextColor": "#ffffff",
"idleBgColor": "#1a1a1a",
"idleTitleColor": "#ffffff",
"idleSuffixColor": "#d4af37"
}