diff --git a/CMS_CONFIG.md b/CMS_CONFIG.md new file mode 100644 index 0000000..6337be5 --- /dev/null +++ b/CMS_CONFIG.md @@ -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 + + +{#if $configLoading} +

Loading configuration...

+{:else} +

Welcome, {$welcomeTitle}!

+

Location: {$location}

+{/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 diff --git a/control-service/Dockerfile b/control-service/Dockerfile index 7fce266..da102a9 100644 --- a/control-service/Dockerfile +++ b/control-service/Dockerfile @@ -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 diff --git a/control-service/package.json b/control-service/package.json index 1319c93..74001b5 100644 --- a/control-service/package.json +++ b/control-service/package.json @@ -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" diff --git a/control-service/public/cms.html b/control-service/public/cms.html new file mode 100644 index 0000000..6fdc6af --- /dev/null +++ b/control-service/public/cms.html @@ -0,0 +1,520 @@ + + + + + + Hotel Pi - Settings + + + +
+

πŸŽ›οΈ Hotel Pi Settings

+

Configure your kiosk display

+ +
+ +
+ +
+

Display Settings

+ +
+ + +

Shown as the guest/hero name (e.g., "Guest's Family")

+
+ +
+ + +

First line above the hero name

+
+ +
+ + +

Shown after the guest name on idle screen

+
+ +
+ + +

Displayed on both welcome and hero pages

+
+ +
+ + +

Displayed on hero page top bar

+
+ +
+ + +

Primary accent color for UI

+
+ +
+ + +

Time before returning to idle screen

+
+
+ + +
+

Hero Page

+ +
+ + +

Path to video file (e.g., /media/background.mp4)

+
+ +
+ + +

Large text displayed on hero page (e.g., "WELCOME")

+
+ +
+ + +

Guest name displayed below welcome text

+
+
+ + +
+

Color Themes

+ +
+ +
+ +
+ + +

Top bar background color

+
+ +
+ + +

Main header gradient base color

+
+ +
+ + +

Divider line between status and header

+
+ +
+ + +

Color of "WELCOME" text

+
+ +
+ + +

Color of guest name text

+
+ +
+ +
+ +
+ + +

Idle screen background

+
+ +
+ + +

Guest name color on idle screen

+
+ +
+ + +

Family suffix color on idle screen

+
+
+ + +
+

Location Settings

+ +
+ + +
+ +
+ + +

Used when manual location is selected

+
+
+ + +
+

Features

+ +
+
+ + +
+
+ +
+
+ + +
+
+ +
+
+ + +
+
+
+ + +
+ + +
+
+
+ + + + diff --git a/control-service/src/server.js b/control-service/src/server.js index 14e1f79..94854dd 100644 --- a/control-service/src/server.js +++ b/control-service/src/server.js @@ -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; } diff --git a/directus/schema.js b/directus/schema.js index e29d71c..f91d9e3 100644 --- a/directus/schema.js +++ b/directus/schema.js @@ -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; diff --git a/directus/settings-seed.js b/directus/settings-seed.js new file mode 100644 index 0000000..6efa700 --- /dev/null +++ b/directus/settings-seed.js @@ -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, + }; +} diff --git a/directus/setup-settings.js b/directus/setup-settings.js new file mode 100644 index 0000000..587f8a7 --- /dev/null +++ b/directus/setup-settings.js @@ -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(); diff --git a/docker-compose.yml b/docker-compose.yml index 590d0bb..004d38e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 9ba8045..3f678d1 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index 52ebd12..7f2b457 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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" }, diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index e5d3ba0..a2665e1 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -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(); }; });
+ +
diff --git a/frontend/src/components/HomeScreen.svelte b/frontend/src/components/HomeScreen.svelte index 35bb9d8..fe856be 100644 --- a/frontend/src/components/HomeScreen.svelte +++ b/frontend/src/components/HomeScreen.svelte @@ -1,5 +1,16 @@
- -