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
+
+
+
+
+
+
+
+
+
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();
};
});
+
+
+
{#if $currentScreen === 'idle'}
-
+
{:else if $currentScreen === 'home'}
{:else if $currentScreen === 'restaurants'}
@@ -157,6 +183,7 @@
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 @@
-
-
+
-
-
Mojo Dojo Casa House
-
ROOM 201
+
+
{$resortName}
+
{$roomNumber}
-
+
-