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
|
# Install system dependencies for CEC
|
||||||
RUN apk add --no-cache libcec-dev
|
RUN apk add --no-cache libcec-dev
|
||||||
|
|
||||||
# Copy package files
|
# Copy package files (package*.json matches package.json and package-lock.json if it exists)
|
||||||
COPY package.json package-lock.json ./
|
COPY control-service/package*.json ./
|
||||||
|
|
||||||
# Install dependencies
|
# Install dependencies
|
||||||
RUN npm ci
|
RUN npm install
|
||||||
|
|
||||||
# Copy source
|
# 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
|
EXPOSE 3001
|
||||||
|
|
||||||
|
|||||||
@ -10,8 +10,7 @@
|
|||||||
"lint": "eslint src"
|
"lint": "eslint src"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ws": "^8.14.2",
|
"ws": "^8.14.2"
|
||||||
"cec-client": "^1.0.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"eslint": "^8.54.0"
|
"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
|
// Main control service
|
||||||
import http from 'http';
|
import http from 'http';
|
||||||
import { WebSocketServer } from 'ws';
|
import { WebSocketServer } from 'ws';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
import CECHandler from './cec-handler.js';
|
import CECHandler from './cec-handler.js';
|
||||||
import CommandExecutor from './commands.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 PORT = parseInt(process.env.PORT || '3001', 10);
|
||||||
const CEC_DEVICE = process.env.CEC_DEVICE || '/dev/ttyAMA0';
|
const CEC_DEVICE = process.env.CEC_DEVICE || '/dev/ttyAMA0';
|
||||||
|
|
||||||
|
// Settings file path
|
||||||
|
const SETTINGS_FILE = path.join(__dirname, '../../settings.json');
|
||||||
|
|
||||||
// Initialize handlers
|
// Initialize handlers
|
||||||
const cec = new CECHandler(CEC_DEVICE);
|
const cec = new CECHandler(CEC_DEVICE);
|
||||||
const executor = new CommandExecutor({
|
const executor = new CommandExecutor({
|
||||||
@ -14,8 +22,134 @@ const executor = new CommandExecutor({
|
|||||||
kioskLaunchCommand: process.env.KIOSK_LAUNCH_COMMAND,
|
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
|
// Create HTTP server
|
||||||
const server = http.createServer((req, res) => {
|
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') {
|
if (req.url === '/health') {
|
||||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
res.end(JSON.stringify(executor.getHealth()));
|
res.end(JSON.stringify(executor.getHealth()));
|
||||||
@ -24,7 +158,7 @@ const server = http.createServer((req, res) => {
|
|||||||
|
|
||||||
if (req.url === '/' && req.method === 'GET') {
|
if (req.url === '/' && req.method === 'GET') {
|
||||||
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
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;
|
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;
|
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:
|
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 (SvelteKit)
|
||||||
frontend:
|
frontend:
|
||||||
build:
|
build:
|
||||||
@ -61,14 +6,12 @@ services:
|
|||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: hotel_pi_frontend
|
container_name: hotel_pi_frontend
|
||||||
environment:
|
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_WS_URL: ${VITE_WS_URL:-ws://localhost:3001}
|
||||||
VITE_WELCOME_NAME: ${WELCOME_NAME:-Guest}
|
|
||||||
VITE_IDLE_TIMEOUT: ${IDLE_TIMEOUT_MINUTES:-5}
|
|
||||||
ports:
|
ports:
|
||||||
- '5173:5173'
|
- '5173:5173'
|
||||||
depends_on:
|
depends_on:
|
||||||
- directus
|
- control-service
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
networks:
|
networks:
|
||||||
- hotel_pi_network
|
- hotel_pi_network
|
||||||
@ -76,8 +19,8 @@ services:
|
|||||||
# Control Service (Node.js)
|
# Control Service (Node.js)
|
||||||
control-service:
|
control-service:
|
||||||
build:
|
build:
|
||||||
context: ./control-service
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: control-service/Dockerfile
|
||||||
container_name: hotel_pi_control
|
container_name: hotel_pi_control
|
||||||
environment:
|
environment:
|
||||||
PORT: 3001
|
PORT: 3001
|
||||||
@ -89,15 +32,9 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
networks:
|
networks:
|
||||||
- hotel_pi_network
|
- hotel_pi_network
|
||||||
# Enable device access for Raspberry Pi (CEC)
|
# Note: For Raspberry Pi deployment, uncomment this to enable CEC device access:
|
||||||
devices:
|
# devices:
|
||||||
- /dev/ttyAMA0:/dev/ttyAMA0
|
# - /dev/ttyAMA0:/dev/ttyAMA0
|
||||||
|
|
||||||
volumes:
|
|
||||||
postgres_data:
|
|
||||||
driver: local
|
|
||||||
directus_uploads:
|
|
||||||
driver: local
|
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
hotel_pi_network:
|
hotel_pi_network:
|
||||||
|
|||||||
66
frontend/package-lock.json
generated
66
frontend/package-lock.json
generated
@ -15,6 +15,7 @@
|
|||||||
"prettier": "^3.1.0",
|
"prettier": "^3.1.0",
|
||||||
"prettier-plugin-svelte": "^3.0.3",
|
"prettier-plugin-svelte": "^3.0.3",
|
||||||
"svelte": "^4.2.2",
|
"svelte": "^4.2.2",
|
||||||
|
"terser": "^5.27.0",
|
||||||
"vite": "^5.0.0"
|
"vite": "^5.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -444,6 +445,17 @@
|
|||||||
"node": ">=6.0.0"
|
"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": {
|
"node_modules/@jridgewell/sourcemap-codec": {
|
||||||
"version": "1.5.5",
|
"version": "1.5.5",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||||
@ -917,6 +929,13 @@
|
|||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/camelcase": {
|
||||||
"version": "5.3.1",
|
"version": "5.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
|
||||||
@ -969,6 +988,13 @@
|
|||||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/css-tree": {
|
||||||
"version": "2.3.1",
|
"version": "2.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.3.1.tgz",
|
||||||
@ -1421,6 +1447,16 @@
|
|||||||
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
|
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/source-map-js": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||||
@ -1431,6 +1467,17 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/string-width": {
|
||||||
"version": "4.2.3",
|
"version": "4.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||||
@ -1496,6 +1543,25 @@
|
|||||||
"svelte": "^3.19.0 || ^4.0.0"
|
"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": {
|
"node_modules/vite": {
|
||||||
"version": "5.4.21",
|
"version": "5.4.21",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
|
||||||
|
|||||||
@ -15,6 +15,7 @@
|
|||||||
"@sveltejs/vite-plugin-svelte": "^3.0.0",
|
"@sveltejs/vite-plugin-svelte": "^3.0.0",
|
||||||
"svelte": "^4.2.2",
|
"svelte": "^4.2.2",
|
||||||
"vite": "^5.0.0",
|
"vite": "^5.0.0",
|
||||||
|
"terser": "^5.27.0",
|
||||||
"prettier": "^3.1.0",
|
"prettier": "^3.1.0",
|
||||||
"prettier-plugin-svelte": "^3.0.3"
|
"prettier-plugin-svelte": "^3.0.3"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -16,12 +16,21 @@
|
|||||||
} from './lib/store.js';
|
} from './lib/store.js';
|
||||||
import { fetchRestaurants, fetchAttractions } from './lib/api.js';
|
import { fetchRestaurants, fetchAttractions } from './lib/api.js';
|
||||||
import WebSocketManager from './lib/websocket.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 idleTimer;
|
||||||
let ws;
|
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() {
|
function resetIdleTimer() {
|
||||||
clearTimeout(idleTimer);
|
clearTimeout(idleTimer);
|
||||||
@ -87,6 +96,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
|
// Initialize configuration from CMS
|
||||||
|
await initConfig();
|
||||||
|
|
||||||
// Initialize WebSocket
|
// Initialize WebSocket
|
||||||
const wsUrl = import.meta.env.VITE_WS_URL || 'ws://localhost:3001';
|
const wsUrl = import.meta.env.VITE_WS_URL || 'ws://localhost:3001';
|
||||||
ws = new WebSocketManager(wsUrl);
|
ws = new WebSocketManager(wsUrl);
|
||||||
@ -134,13 +146,27 @@
|
|||||||
return () => {
|
return () => {
|
||||||
clearTimeout(idleTimer);
|
clearTimeout(idleTimer);
|
||||||
window.removeEventListener('keydown', handleKeyboardInput);
|
window.removeEventListener('keydown', handleKeyboardInput);
|
||||||
|
ws?.close();
|
||||||
|
unsubscribeTitle();
|
||||||
|
unsubscribeTimeout();
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<main class="app">
|
<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'}
|
{#if $currentScreen === 'idle'}
|
||||||
<IdleScreen {welcomeName} />
|
<IdleScreen />
|
||||||
{:else if $currentScreen === 'home'}
|
{:else if $currentScreen === 'home'}
|
||||||
<HomeScreen />
|
<HomeScreen />
|
||||||
{:else if $currentScreen === 'restaurants'}
|
{:else if $currentScreen === 'restaurants'}
|
||||||
@ -157,6 +183,7 @@
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
<style global>
|
<style global>
|
||||||
|
/* Global reset styles - intentionally global for fullscreen kiosk experience */
|
||||||
* {
|
* {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@ -210,6 +237,17 @@
|
|||||||
overflow: hidden;
|
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 {
|
.connection-warning {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: 1rem;
|
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>
|
<script>
|
||||||
import { selectedIndex, pushScreen } from '../lib/store.js';
|
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';
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
let currentTime = new Date();
|
let currentTime = new Date();
|
||||||
@ -172,29 +183,22 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="home-container">
|
<div class="home-container">
|
||||||
<!-- YouTube Background Video -->
|
<!-- Background Video is handled globally in App.svelte -->
|
||||||
<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
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Top Status Bar -->
|
<!-- Top Status Bar -->
|
||||||
<div class="status-bar">
|
<div class="status-bar" style="background-color: {$heroStatusBarBg};">
|
||||||
<div class="resort-name">Mojo Dojo Casa House</div>
|
<div class="resort-name">{$resortName}</div>
|
||||||
<div class="room-number">ROOM 201</div>
|
<div class="room-number">{$roomNumber}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Accent Line -->
|
<!-- Accent Line -->
|
||||||
<div class="accent-line"></div>
|
<div class="accent-line" style="background-color: {$heroAccentLineColor};"></div>
|
||||||
|
|
||||||
<!-- Header Bar -->
|
<!-- 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="header-left">
|
||||||
<div class="welcome">WELCOME</div>
|
<div class="welcome" style="color: {$heroWelcomeTextColor};">{$heroWelcomeText}</div>
|
||||||
<div class="guest-name">Guest</div>
|
<div class="guest-name" style="color: {$heroGuestTextColor};">{$heroGuestText}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
<div class="time-date">
|
<div class="time-date">
|
||||||
@ -407,15 +411,15 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.youtube-bg {
|
.background-video {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 0;
|
left: 0;
|
||||||
|
top: 0;
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
aspect-ratio: 16 / 9;
|
height: 100vh;
|
||||||
border: none;
|
object-fit: cover;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
object-fit: cover;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Top Status Bar */
|
/* Top Status Bar */
|
||||||
|
|||||||
@ -1,14 +1,14 @@
|
|||||||
<script>
|
<script>
|
||||||
export let welcomeName = 'Guest';
|
import { config, welcomeTitle, welcomePrefix, welcomeSuffix, location, idleBgColor, idleTitleColor, idleSuffixColor, resortName } from '../lib/configStore.js';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="container">
|
<div class="container" style="background: linear-gradient(135deg, {$idleBgColor} 0%, {$idleBgColor} 100%);">
|
||||||
<div class="status-bar">
|
<div class="status-bar">
|
||||||
<div class="resort-name">Resort</div>
|
<div class="resort-name">{$resortName}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<h1 class="welcome">WELCOME</h1>
|
<h1 class="welcome" style="color: {$idleTitleColor};">{$welcomePrefix}</h1>
|
||||||
<h2 class="guest-family">Guest Family</h2>
|
<h2 class="guest-family" style="color: {$idleSuffixColor};">{$welcomeTitle}{$welcomeSuffix}</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="bottom-bar">
|
<div class="bottom-bar">
|
||||||
<div class="bottom-text">Press a button on your remote to get started</div>
|
<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%);
|
background: linear-gradient(135deg, #ffffff 0%, #f5f5f5 100%);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
z-index: 1000;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-bar {
|
.status-bar {
|
||||||
@ -36,7 +37,8 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
border-bottom: 2px solid rgba(255, 255, 255, 0.3);
|
border-bottom: 2px solid rgba(255, 255, 255, 0.3);
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 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 {
|
.resort-name {
|
||||||
@ -53,7 +55,7 @@
|
|||||||
|
|
||||||
.content {
|
.content {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 2;
|
z-index: 1001;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -84,7 +86,6 @@
|
|||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
letter-spacing: 0.1em;
|
letter-spacing: 0.1em;
|
||||||
color: #333;
|
color: #333;
|
||||||
animation: fade-in 1s ease-out;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.guest-family {
|
.guest-family {
|
||||||
@ -94,16 +95,6 @@
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: -0.02em;
|
letter-spacing: -0.02em;
|
||||||
color: #333;
|
color: #333;
|
||||||
animation: fade-in 1.2s ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes fade-in {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.bottom-bar {
|
.bottom-bar {
|
||||||
@ -114,7 +105,8 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
border-top: 2px solid rgba(255, 255, 255, 0.3);
|
border-top: 2px solid rgba(255, 255, 255, 0.3);
|
||||||
box-shadow: 0 -4px 12px rgba(0, 0, 0, 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 {
|
.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,
|
port: 5173,
|
||||||
host: '0.0.0.0'
|
host: '0.0.0.0'
|
||||||
},
|
},
|
||||||
|
preview: {
|
||||||
|
port: 5173,
|
||||||
|
host: '0.0.0.0'
|
||||||
|
},
|
||||||
build: {
|
build: {
|
||||||
target: 'esnext',
|
target: 'esnext',
|
||||||
minify: 'terser'
|
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:up": "docker-compose up -d",
|
||||||
"docker:down": "docker-compose down",
|
"docker:down": "docker-compose down",
|
||||||
"docker:logs": "docker-compose logs -f",
|
"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": {
|
"dependencies": {
|
||||||
"concurrently": "^8.2.0"
|
"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