good progress
This commit is contained in:
parent
c5cf527b50
commit
af86f78ec3
213
CMS_CONFIG.md
Normal file
213
CMS_CONFIG.md
Normal 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
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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"
|
||||
|
||||
520
control-service/public/cms.html
Normal file
520
control-service/public/cms.html
Normal 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>
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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
76
directus/settings-seed.js
Normal 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
221
directus/setup-settings.js
Normal 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();
|
||||
@ -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:
|
||||
|
||||
66
frontend/package-lock.json
generated
66
frontend/package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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"
|
||||
},
|
||||
|
||||
@ -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;
|
||||
|
||||
154
frontend/src/components/ConfigDebug.svelte
Normal file
154
frontend/src/components/ConfigDebug.svelte
Normal 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>
|
||||
@ -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 */
|
||||
|
||||
@ -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
109
frontend/src/lib/config.js
Normal 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;
|
||||
}
|
||||
76
frontend/src/lib/configStore.js
Normal file
76
frontend/src/lib/configStore.js
Normal 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();
|
||||
}
|
||||
@ -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
BIN
media/background.mp4
Normal file
Binary file not shown.
@ -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
25
settings.json
Normal 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"
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user