v1
This commit is contained in:
parent
c100e5f554
commit
16b7d41fc5
166
app.php
Normal file
166
app.php
Normal file
@ -0,0 +1,166 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once __DIR__ . '/db/config.php';
|
||||||
|
|
||||||
|
const RECLINER_PATTERNS = ['continuous', 'pulse'];
|
||||||
|
|
||||||
|
function project_meta(): array
|
||||||
|
{
|
||||||
|
$projectName = $_SERVER['PROJECT_NAME'] ?? getenv('PROJECT_NAME') ?: 'Recliner Haptics Studio';
|
||||||
|
$projectDescription = $_SERVER['PROJECT_DESCRIPTION'] ?? getenv('PROJECT_DESCRIPTION') ?: 'Browser-based recliner simulator with gamepad vibration controls, saved presets, and a live recline angle visualizer.';
|
||||||
|
$projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? getenv('PROJECT_IMAGE_URL') ?: '';
|
||||||
|
|
||||||
|
return [
|
||||||
|
'name' => (string) $projectName,
|
||||||
|
'description' => (string) $projectDescription,
|
||||||
|
'image' => (string) $projectImageUrl,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensure_recliner_schema(): void
|
||||||
|
{
|
||||||
|
db()->exec(
|
||||||
|
"CREATE TABLE IF NOT EXISTS recliner_presets (
|
||||||
|
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
name VARCHAR(120) NOT NULL,
|
||||||
|
angle_deg TINYINT UNSIGNED NOT NULL,
|
||||||
|
intensity_pct TINYINT UNSIGNED NOT NULL,
|
||||||
|
pattern_mode VARCHAR(20) NOT NULL,
|
||||||
|
duration_ms SMALLINT UNSIGNED NOT NULL,
|
||||||
|
notes VARCHAR(255) DEFAULT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function default_preset(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => 'Demo Lounge',
|
||||||
|
'angle_deg' => 112,
|
||||||
|
'intensity_pct' => 44,
|
||||||
|
'pattern_mode' => 'continuous',
|
||||||
|
'duration_ms' => 1400,
|
||||||
|
'notes' => 'Balanced demo profile for a calm showroom preview.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function validate_preset_input(array $input): array
|
||||||
|
{
|
||||||
|
$errors = [];
|
||||||
|
|
||||||
|
$name = trim((string) ($input['name'] ?? ''));
|
||||||
|
if ($name === '' || strlen($name) < 2 || strlen($name) > 120) {
|
||||||
|
$errors['name'] = 'Preset name must be between 2 and 120 characters.';
|
||||||
|
}
|
||||||
|
|
||||||
|
$angle = filter_var($input['angle_deg'] ?? null, FILTER_VALIDATE_INT, [
|
||||||
|
'options' => ['min_range' => 0, 'max_range' => 160],
|
||||||
|
]);
|
||||||
|
if ($angle === false) {
|
||||||
|
$errors['angle_deg'] = 'Angle must be between 0° and 160°.';
|
||||||
|
}
|
||||||
|
|
||||||
|
$intensity = filter_var($input['intensity_pct'] ?? null, FILTER_VALIDATE_INT, [
|
||||||
|
'options' => ['min_range' => 0, 'max_range' => 100],
|
||||||
|
]);
|
||||||
|
if ($intensity === false) {
|
||||||
|
$errors['intensity_pct'] = 'Intensity must be between 0% and 100%.';
|
||||||
|
}
|
||||||
|
|
||||||
|
$duration = filter_var($input['duration_ms'] ?? null, FILTER_VALIDATE_INT, [
|
||||||
|
'options' => ['min_range' => 100, 'max_range' => 5000],
|
||||||
|
]);
|
||||||
|
if ($duration === false) {
|
||||||
|
$errors['duration_ms'] = 'Duration must be between 100 ms and 5000 ms.';
|
||||||
|
}
|
||||||
|
|
||||||
|
$pattern = (string) ($input['pattern_mode'] ?? 'continuous');
|
||||||
|
if (!in_array($pattern, RECLINER_PATTERNS, true)) {
|
||||||
|
$errors['pattern_mode'] = 'Choose a supported vibration pattern.';
|
||||||
|
}
|
||||||
|
|
||||||
|
$notes = trim((string) ($input['notes'] ?? ''));
|
||||||
|
if (strlen($notes) > 255) {
|
||||||
|
$errors['notes'] = 'Notes must stay under 255 characters.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'errors' => $errors,
|
||||||
|
'data' => [
|
||||||
|
'name' => $name,
|
||||||
|
'angle_deg' => $angle === false ? 0 : (int) $angle,
|
||||||
|
'intensity_pct' => $intensity === false ? 0 : (int) $intensity,
|
||||||
|
'pattern_mode' => $pattern,
|
||||||
|
'duration_ms' => $duration === false ? 1000 : (int) $duration,
|
||||||
|
'notes' => $notes,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function save_preset(array $data): int
|
||||||
|
{
|
||||||
|
$stmt = db()->prepare(
|
||||||
|
'INSERT INTO recliner_presets (name, angle_deg, intensity_pct, pattern_mode, duration_ms, notes)
|
||||||
|
VALUES (:name, :angle_deg, :intensity_pct, :pattern_mode, :duration_ms, :notes)'
|
||||||
|
);
|
||||||
|
|
||||||
|
$stmt->bindValue(':name', $data['name'], PDO::PARAM_STR);
|
||||||
|
$stmt->bindValue(':angle_deg', $data['angle_deg'], PDO::PARAM_INT);
|
||||||
|
$stmt->bindValue(':intensity_pct', $data['intensity_pct'], PDO::PARAM_INT);
|
||||||
|
$stmt->bindValue(':pattern_mode', $data['pattern_mode'], PDO::PARAM_STR);
|
||||||
|
$stmt->bindValue(':duration_ms', $data['duration_ms'], PDO::PARAM_INT);
|
||||||
|
$stmt->bindValue(':notes', $data['notes'] !== '' ? $data['notes'] : null, $data['notes'] !== '' ? PDO::PARAM_STR : PDO::PARAM_NULL);
|
||||||
|
$stmt->execute();
|
||||||
|
|
||||||
|
return (int) db()->lastInsertId();
|
||||||
|
}
|
||||||
|
|
||||||
|
function get_recent_presets(int $limit = 8): array
|
||||||
|
{
|
||||||
|
$limit = max(1, min(24, $limit));
|
||||||
|
$stmt = db()->query(
|
||||||
|
'SELECT id, name, angle_deg, intensity_pct, pattern_mode, duration_ms, notes, created_at
|
||||||
|
FROM recliner_presets
|
||||||
|
ORDER BY created_at DESC, id DESC
|
||||||
|
LIMIT ' . $limit
|
||||||
|
);
|
||||||
|
|
||||||
|
return $stmt->fetchAll() ?: [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function get_preset(int $id): ?array
|
||||||
|
{
|
||||||
|
$stmt = db()->prepare(
|
||||||
|
'SELECT id, name, angle_deg, intensity_pct, pattern_mode, duration_ms, notes, created_at
|
||||||
|
FROM recliner_presets
|
||||||
|
WHERE id = :id
|
||||||
|
LIMIT 1'
|
||||||
|
);
|
||||||
|
$stmt->bindValue(':id', $id, PDO::PARAM_INT);
|
||||||
|
$stmt->execute();
|
||||||
|
$preset = $stmt->fetch();
|
||||||
|
|
||||||
|
return $preset ?: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function preset_tone(int $angle): string
|
||||||
|
{
|
||||||
|
if ($angle >= 135) {
|
||||||
|
return 'Deep recline';
|
||||||
|
}
|
||||||
|
if ($angle >= 90) {
|
||||||
|
return 'Lounge';
|
||||||
|
}
|
||||||
|
if ($angle >= 45) {
|
||||||
|
return 'Reading';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Upright';
|
||||||
|
}
|
||||||
|
|
||||||
|
function e(?string $value): string
|
||||||
|
{
|
||||||
|
return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8');
|
||||||
|
}
|
||||||
@ -1,403 +1,542 @@
|
|||||||
|
:root {
|
||||||
|
--bg: #0b0d10;
|
||||||
|
--surface: #12161b;
|
||||||
|
--surface-2: #171c22;
|
||||||
|
--surface-3: #1d232b;
|
||||||
|
--border: #2a3038;
|
||||||
|
--border-strong: #39414b;
|
||||||
|
--text: #edf1f5;
|
||||||
|
--muted: #9aa4af;
|
||||||
|
--accent: #d4d8de;
|
||||||
|
--accent-ink: #0f1318;
|
||||||
|
--success: #66bb8a;
|
||||||
|
--danger: #ef8c8c;
|
||||||
|
--shadow: 0 20px 48px rgba(0, 0, 0, 0.28);
|
||||||
|
--radius-sm: 8px;
|
||||||
|
--radius-md: 12px;
|
||||||
|
--radius-lg: 16px;
|
||||||
|
--space-1: 4px;
|
||||||
|
--space-2: 8px;
|
||||||
|
--space-3: 12px;
|
||||||
|
--space-4: 16px;
|
||||||
|
--space-5: 24px;
|
||||||
|
--space-6: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background: linear-gradient(-45deg, #ee7752, #e73c7e, #23a6d5, #23d5ab);
|
background: var(--bg);
|
||||||
background-size: 400% 400%;
|
color: var(--text);
|
||||||
animation: gradient 15s ease infinite;
|
font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
color: #212529;
|
letter-spacing: -0.01em;
|
||||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
|
||||||
font-size: 14px;
|
|
||||||
margin: 0;
|
|
||||||
min-height: 100vh;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-wrapper {
|
.shell-header {
|
||||||
display: flex;
|
background: rgba(11, 13, 16, 0.88);
|
||||||
align-items: center;
|
backdrop-filter: blur(12px);
|
||||||
justify-content: center;
|
}
|
||||||
min-height: 100vh;
|
|
||||||
width: 100%;
|
.navbar-brand {
|
||||||
padding: 20px;
|
letter-spacing: -0.03em;
|
||||||
box-sizing: border-box;
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inset-panel {
|
||||||
|
background: var(--surface-2);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-panel {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes gradient {
|
|
||||||
0% {
|
|
||||||
background-position: 0% 50%;
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
background-position: 100% 50%;
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
background-position: 0% 50%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-container {
|
|
||||||
width: 100%;
|
|
||||||
max-width: 600px;
|
|
||||||
background: rgba(255, 255, 255, 0.85);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
|
||||||
border-radius: 20px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
height: 85vh;
|
|
||||||
box-shadow: 0 20px 40px rgba(0,0,0,0.2);
|
|
||||||
backdrop-filter: blur(15px);
|
|
||||||
-webkit-backdrop-filter: blur(15px);
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-header {
|
.hero-panel::after {
|
||||||
padding: 1.5rem;
|
content: "";
|
||||||
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
|
position: absolute;
|
||||||
background: rgba(255, 255, 255, 0.5);
|
inset: 0;
|
||||||
font-weight: 700;
|
border: 1px solid rgba(255, 255, 255, 0.03);
|
||||||
font-size: 1.1rem;
|
border-radius: inherit;
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-messages {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: 1.5rem;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Custom Scrollbar */
|
|
||||||
::-webkit-scrollbar {
|
|
||||||
width: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-scrollbar-track {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb {
|
|
||||||
background: rgba(255, 255, 255, 0.3);
|
|
||||||
border-radius: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb:hover {
|
|
||||||
background: rgba(255, 255, 255, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.message {
|
|
||||||
max-width: 85%;
|
|
||||||
padding: 0.85rem 1.1rem;
|
|
||||||
border-radius: 16px;
|
|
||||||
line-height: 1.5;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
box-shadow: 0 4px 15px rgba(0,0,0,0.05);
|
|
||||||
animation: fadeIn 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes fadeIn {
|
|
||||||
from { opacity: 0; transform: translateY(20px) scale(0.95); }
|
|
||||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.message.visitor {
|
|
||||||
align-self: flex-end;
|
|
||||||
background: linear-gradient(135deg, #212529 0%, #343a40 100%);
|
|
||||||
color: #fff;
|
|
||||||
border-bottom-right-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message.bot {
|
|
||||||
align-self: flex-start;
|
|
||||||
background: #ffffff;
|
|
||||||
color: #212529;
|
|
||||||
border-bottom-left-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-input-area {
|
|
||||||
padding: 1.25rem;
|
|
||||||
background: rgba(255, 255, 255, 0.5);
|
|
||||||
border-top: 1px solid rgba(0, 0, 0, 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-input-area form {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-input-area input {
|
|
||||||
flex: 1;
|
|
||||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 0.75rem 1rem;
|
|
||||||
outline: none;
|
|
||||||
background: rgba(255, 255, 255, 0.9);
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-input-area input:focus {
|
|
||||||
border-color: #23a6d5;
|
|
||||||
box-shadow: 0 0 0 3px rgba(35, 166, 213, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-input-area button {
|
|
||||||
background: #212529;
|
|
||||||
color: #fff;
|
|
||||||
border: none;
|
|
||||||
padding: 0.75rem 1.5rem;
|
|
||||||
border-radius: 12px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-weight: 600;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-input-area button:hover {
|
|
||||||
background: #000;
|
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: 0 5px 15px rgba(0,0,0,0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Background Animations */
|
|
||||||
.bg-animations {
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
z-index: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.blob {
|
.display-title {
|
||||||
position: absolute;
|
font-size: clamp(2rem, 3vw, 3.5rem);
|
||||||
width: 500px;
|
line-height: 1.02;
|
||||||
height: 500px;
|
font-weight: 650;
|
||||||
background: rgba(255, 255, 255, 0.2);
|
max-width: 12ch;
|
||||||
border-radius: 50%;
|
letter-spacing: -0.05em;
|
||||||
filter: blur(80px);
|
|
||||||
animation: move 20s infinite alternate cubic-bezier(0.45, 0, 0.55, 1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.blob-1 {
|
.eyebrow,
|
||||||
top: -10%;
|
.small-label {
|
||||||
left: -10%;
|
color: var(--muted);
|
||||||
background: rgba(238, 119, 82, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.blob-2 {
|
|
||||||
bottom: -10%;
|
|
||||||
right: -10%;
|
|
||||||
background: rgba(35, 166, 213, 0.4);
|
|
||||||
animation-delay: -7s;
|
|
||||||
width: 600px;
|
|
||||||
height: 600px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.blob-3 {
|
|
||||||
top: 40%;
|
|
||||||
left: 30%;
|
|
||||||
background: rgba(231, 60, 126, 0.3);
|
|
||||||
animation-delay: -14s;
|
|
||||||
width: 450px;
|
|
||||||
height: 450px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes move {
|
|
||||||
0% { transform: translate(0, 0) rotate(0deg) scale(1); }
|
|
||||||
33% { transform: translate(150px, 100px) rotate(120deg) scale(1.1); }
|
|
||||||
66% { transform: translate(-50px, 200px) rotate(240deg) scale(0.9); }
|
|
||||||
100% { transform: translate(0, 0) rotate(360deg) scale(1); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-link {
|
|
||||||
font-size: 14px;
|
|
||||||
color: #fff;
|
|
||||||
text-decoration: none;
|
|
||||||
background: rgba(0, 0, 0, 0.2);
|
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
border-radius: 8px;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-link:hover {
|
|
||||||
background: rgba(0, 0, 0, 0.4);
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Admin Styles */
|
|
||||||
.admin-container {
|
|
||||||
max-width: 900px;
|
|
||||||
margin: 3rem auto;
|
|
||||||
padding: 2.5rem;
|
|
||||||
background: rgba(255, 255, 255, 0.85);
|
|
||||||
backdrop-filter: blur(20px);
|
|
||||||
-webkit-backdrop-filter: blur(20px);
|
|
||||||
border-radius: 24px;
|
|
||||||
box-shadow: 0 20px 50px rgba(0,0,0,0.15);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.4);
|
|
||||||
position: relative;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-container h1 {
|
|
||||||
margin-top: 0;
|
|
||||||
color: #212529;
|
|
||||||
font-weight: 800;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: separate;
|
|
||||||
border-spacing: 0 8px;
|
|
||||||
margin-top: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table th {
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
padding: 1rem;
|
|
||||||
color: #6c757d;
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
font-size: 0.75rem;
|
letter-spacing: 0.12em;
|
||||||
letter-spacing: 1px;
|
font-size: 0.72rem;
|
||||||
}
|
|
||||||
|
|
||||||
.table td {
|
|
||||||
background: #fff;
|
|
||||||
padding: 1rem;
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table tr td:first-child { border-radius: 12px 0 0 12px; }
|
|
||||||
.table tr td:last-child { border-radius: 0 12px 12px 0; }
|
|
||||||
|
|
||||||
.form-group {
|
|
||||||
margin-bottom: 1.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group label {
|
|
||||||
display: block;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-control {
|
.lead,
|
||||||
width: 100%;
|
.text-secondary,
|
||||||
padding: 0.75rem 1rem;
|
.form-text,
|
||||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
.form-label,
|
||||||
border-radius: 12px;
|
.form-control::placeholder,
|
||||||
background: #fff;
|
textarea::placeholder {
|
||||||
transition: all 0.3s ease;
|
color: var(--muted) !important;
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-control:focus {
|
.meta-pills,
|
||||||
outline: none;
|
.control-stack {
|
||||||
border-color: #23a6d5;
|
display: grid;
|
||||||
box-shadow: 0 0 0 3px rgba(35, 166, 213, 0.1);
|
gap: var(--space-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-container {
|
.chip,
|
||||||
display: flex;
|
.badge-soft,
|
||||||
justify-content: space-between;
|
.value-pill,
|
||||||
|
.gamepad-state {
|
||||||
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
color: var(--text);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.5rem 0.8rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-links {
|
.badge-soft {
|
||||||
|
padding: 0.45rem 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spec-list {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spec-list > div {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 1rem;
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-4);
|
||||||
|
padding-bottom: var(--space-2);
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-card {
|
.spec-list > div:last-child {
|
||||||
background: rgba(255, 255, 255, 0.6);
|
border-bottom: 0;
|
||||||
padding: 2rem;
|
padding-bottom: 0;
|
||||||
border-radius: 20px;
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.5);
|
|
||||||
margin-bottom: 2.5rem;
|
|
||||||
box-shadow: 0 10px 30px rgba(0,0,0,0.05);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-card h3 {
|
.spec-list span,
|
||||||
margin-top: 0;
|
.mini-stat span,
|
||||||
margin-bottom: 1.5rem;
|
.preset-metrics span {
|
||||||
font-weight: 700;
|
color: var(--muted);
|
||||||
|
font-size: 0.82rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-delete {
|
.spec-list.compact {
|
||||||
background: #dc3545;
|
gap: var(--space-2);
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
padding: 0.25rem 0.5rem;
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-add {
|
.status-dot {
|
||||||
background: #212529;
|
width: 10px;
|
||||||
color: white;
|
height: 10px;
|
||||||
border: none;
|
border-radius: 999px;
|
||||||
padding: 0.5rem 1rem;
|
display: inline-block;
|
||||||
border-radius: 4px;
|
margin-top: 4px;
|
||||||
cursor: pointer;
|
|
||||||
margin-top: 1rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-save {
|
.status-live {
|
||||||
background: #0088cc;
|
background: var(--success);
|
||||||
color: white;
|
box-shadow: 0 0 0 8px rgba(102, 187, 138, 0.12);
|
||||||
border: none;
|
}
|
||||||
padding: 0.8rem 1.5rem;
|
|
||||||
border-radius: 12px;
|
.status-idle {
|
||||||
cursor: pointer;
|
background: #66707a;
|
||||||
|
box-shadow: 0 0 0 8px rgba(102, 112, 122, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recliner-stage {
|
||||||
|
position: relative;
|
||||||
|
background: #101419;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
min-height: 360px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-fade {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background-image:
|
||||||
|
linear-gradient(to right, rgba(255, 255, 255, 0.04) 1px, transparent 1px),
|
||||||
|
linear-gradient(to bottom, rgba(255, 255, 255, 0.04) 1px, transparent 1px);
|
||||||
|
background-size: 36px 36px;
|
||||||
|
mask-image: linear-gradient(180deg, rgba(0,0,0,0.65), transparent 95%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.axis-label {
|
||||||
|
position: absolute;
|
||||||
|
top: 18px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.axis-label-left { left: 18px; }
|
||||||
|
.axis-label-right { right: 18px; }
|
||||||
|
|
||||||
|
.recliner-figure {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
bottom: 48px;
|
||||||
|
width: 320px;
|
||||||
|
height: 230px;
|
||||||
|
transform: translateX(calc(-50% - (var(--angle) * 0.18px))) translateY(calc(var(--angle) * 0.06px));
|
||||||
|
}
|
||||||
|
|
||||||
|
.recliner-shadow,
|
||||||
|
.recliner-seat,
|
||||||
|
.recliner-arm,
|
||||||
|
.recliner-back,
|
||||||
|
.recliner-head,
|
||||||
|
.recliner-leg,
|
||||||
|
.recliner-base {
|
||||||
|
position: absolute;
|
||||||
|
background: var(--surface-3);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recliner-shadow {
|
||||||
|
left: 78px;
|
||||||
|
right: 26px;
|
||||||
|
bottom: 6px;
|
||||||
|
height: 18px;
|
||||||
|
border: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.32);
|
||||||
|
filter: blur(14px);
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recliner-seat {
|
||||||
|
left: 92px;
|
||||||
|
bottom: 78px;
|
||||||
|
width: 130px;
|
||||||
|
height: 52px;
|
||||||
|
border-radius: 14px 14px 10px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recliner-arm {
|
||||||
|
left: 120px;
|
||||||
|
bottom: 118px;
|
||||||
|
width: 82px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recliner-back {
|
||||||
|
left: 106px;
|
||||||
|
bottom: 108px;
|
||||||
|
width: 34px;
|
||||||
|
height: 116px;
|
||||||
|
border-radius: 14px;
|
||||||
|
transform-origin: 18px calc(100% - 10px);
|
||||||
|
transform: rotate(calc(-92deg + (var(--angle) * 0.5deg)));
|
||||||
|
}
|
||||||
|
|
||||||
|
.recliner-head {
|
||||||
|
left: 90px;
|
||||||
|
bottom: 188px;
|
||||||
|
width: 48px;
|
||||||
|
height: 30px;
|
||||||
|
border-radius: 14px;
|
||||||
|
transform: rotate(calc(-14deg + (var(--angle) * 0.1deg)));
|
||||||
|
}
|
||||||
|
|
||||||
|
.recliner-leg {
|
||||||
|
left: 202px;
|
||||||
|
bottom: 88px;
|
||||||
|
width: 24px;
|
||||||
|
height: 96px;
|
||||||
|
border-radius: 14px;
|
||||||
|
transform-origin: 12px 10px;
|
||||||
|
transform: rotate(calc(10deg + (var(--angle) * 0.42deg)));
|
||||||
|
}
|
||||||
|
|
||||||
|
.recliner-base {
|
||||||
|
left: 112px;
|
||||||
|
bottom: 44px;
|
||||||
|
width: 112px;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.angle-indicator {
|
||||||
|
position: absolute;
|
||||||
|
right: 12px;
|
||||||
|
top: 16px;
|
||||||
|
display: inline-flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: rgba(11, 13, 16, 0.74);
|
||||||
|
}
|
||||||
|
|
||||||
|
.angle-indicator span {
|
||||||
|
font-size: 1.85rem;
|
||||||
|
font-weight: 650;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.angle-indicator small {
|
||||||
|
color: var(--muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.09em;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-row {
|
||||||
|
margin-top: -4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-stat {
|
||||||
|
height: 100%;
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-stat strong {
|
||||||
|
display: block;
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-group {
|
||||||
|
padding-bottom: var(--space-3);
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-group:last-child {
|
||||||
|
padding-bottom: 0;
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-range::-webkit-slider-runnable-track {
|
||||||
|
background: #242a32;
|
||||||
|
height: 4px;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-range::-webkit-slider-thumb {
|
||||||
|
margin-top: -6px;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
background: var(--accent);
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-range::-moz-range-track {
|
||||||
|
background: #242a32;
|
||||||
|
height: 4px;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-range::-moz-range-thumb {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border: 0;
|
||||||
|
background: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control,
|
||||||
|
.form-select,
|
||||||
|
textarea {
|
||||||
|
background: #0f1318 !important;
|
||||||
|
border: 1px solid var(--border-strong) !important;
|
||||||
|
color: var(--text) !important;
|
||||||
|
border-radius: 10px !important;
|
||||||
|
padding: 0.75rem 0.9rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control:focus,
|
||||||
|
.form-select:focus,
|
||||||
|
textarea:focus,
|
||||||
|
.btn:focus,
|
||||||
|
.form-range:focus {
|
||||||
|
box-shadow: 0 0 0 0.2rem rgba(212, 216, 222, 0.12) !important;
|
||||||
|
border-color: #58616d !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 0.78rem 1rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
width: 100%;
|
letter-spacing: -0.01em;
|
||||||
transition: all 0.3s ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.webhook-url {
|
.btn-light {
|
||||||
font-size: 0.85em;
|
background: var(--accent);
|
||||||
color: #555;
|
color: var(--accent-ink);
|
||||||
margin-top: 0.5rem;
|
border-color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-table-container {
|
.btn-light:hover,
|
||||||
overflow-x: auto;
|
.btn-light:focus {
|
||||||
background: rgba(255, 255, 255, 0.4);
|
background: #e3e7eb;
|
||||||
padding: 1rem;
|
border-color: #e3e7eb;
|
||||||
border-radius: 12px;
|
color: var(--accent-ink);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-table {
|
.btn-accent {
|
||||||
width: 100%;
|
background: #e6eaef;
|
||||||
|
color: #11151a;
|
||||||
|
border: 1px solid #e6eaef;
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-table-time {
|
.btn-accent:hover,
|
||||||
width: 15%;
|
.btn-accent:focus {
|
||||||
white-space: nowrap;
|
background: #f2f4f6;
|
||||||
font-size: 0.85em;
|
border-color: #f2f4f6;
|
||||||
color: #555;
|
color: #11151a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-table-user {
|
.btn-outline-light {
|
||||||
width: 35%;
|
border-color: var(--border-strong);
|
||||||
background: rgba(255, 255, 255, 0.3);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 8px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-table-ai {
|
.btn-link {
|
||||||
width: 50%;
|
color: #cdd3db;
|
||||||
background: rgba(255, 255, 255, 0.5);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 8px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.no-messages {
|
.btn-link:hover,
|
||||||
text-align: center;
|
.btn-link:focus {
|
||||||
color: #777;
|
color: #f0f3f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-list {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-card,
|
||||||
|
.empty-state {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 16px;
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-card.is-active {
|
||||||
|
border-color: #5c6570;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-metrics {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-danger {
|
||||||
|
background: rgba(239, 140, 140, 0.12);
|
||||||
|
color: #ffd9d9;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
color: #f4f7fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-detail-specs > div {
|
||||||
|
padding: 12px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
footer {
|
||||||
|
background: rgba(255, 255, 255, 0.01);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 991.98px) {
|
||||||
|
.display-title {
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recliner-stage {
|
||||||
|
min-height: 320px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recliner-figure {
|
||||||
|
width: 280px;
|
||||||
|
height: 220px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recliner-seat {
|
||||||
|
left: 76px;
|
||||||
|
width: 122px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recliner-base {
|
||||||
|
left: 92px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 575.98px) {
|
||||||
|
.recliner-stage {
|
||||||
|
min-height: 280px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recliner-figure {
|
||||||
|
width: 240px;
|
||||||
|
height: 200px;
|
||||||
|
bottom: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recliner-seat {
|
||||||
|
left: 58px;
|
||||||
|
bottom: 72px;
|
||||||
|
width: 112px;
|
||||||
|
height: 46px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recliner-back {
|
||||||
|
left: 68px;
|
||||||
|
bottom: 98px;
|
||||||
|
height: 104px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recliner-head {
|
||||||
|
left: 56px;
|
||||||
|
bottom: 170px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recliner-leg {
|
||||||
|
left: 168px;
|
||||||
|
height: 86px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recliner-base {
|
||||||
|
left: 76px;
|
||||||
|
width: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.angle-indicator {
|
||||||
|
right: 8px;
|
||||||
|
top: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -1,39 +1,310 @@
|
|||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
const chatForm = document.getElementById('chat-form');
|
const appConfig = window.appConfig || {};
|
||||||
const chatInput = document.getElementById('chat-input');
|
const toastElement = document.getElementById('app-toast');
|
||||||
const chatMessages = document.getElementById('chat-messages');
|
const toastMessage = document.getElementById('toast-message');
|
||||||
|
const toast = toastElement && window.bootstrap ? new window.bootstrap.Toast(toastElement, { delay: 3200 }) : null;
|
||||||
|
|
||||||
const appendMessage = (text, sender) => {
|
const controls = {
|
||||||
const msgDiv = document.createElement('div');
|
angle: document.getElementById('angle_deg'),
|
||||||
msgDiv.classList.add('message', sender);
|
intensity: document.getElementById('intensity_pct'),
|
||||||
msgDiv.textContent = text;
|
pattern: document.getElementById('pattern_mode'),
|
||||||
chatMessages.appendChild(msgDiv);
|
duration: document.getElementById('duration_ms'),
|
||||||
chatMessages.scrollTop = chatMessages.scrollHeight;
|
name: document.getElementById('name'),
|
||||||
|
notes: document.getElementById('notes')
|
||||||
};
|
};
|
||||||
|
|
||||||
chatForm.addEventListener('submit', async (e) => {
|
const ui = {
|
||||||
e.preventDefault();
|
figure: document.getElementById('recliner-figure'),
|
||||||
const message = chatInput.value.trim();
|
angleValue: document.getElementById('angle-value'),
|
||||||
if (!message) return;
|
anglePill: document.getElementById('angle-pill'),
|
||||||
|
intensityPill: document.getElementById('intensity-pill'),
|
||||||
|
durationPill: document.getElementById('duration-pill'),
|
||||||
|
statAngle: document.getElementById('stat-angle'),
|
||||||
|
statIntensity: document.getElementById('stat-intensity'),
|
||||||
|
statPattern: document.getElementById('stat-pattern'),
|
||||||
|
statDuration: document.getElementById('stat-duration'),
|
||||||
|
saveAngle: document.getElementById('save-angle'),
|
||||||
|
saveIntensity: document.getElementById('save-intensity'),
|
||||||
|
savePattern: document.getElementById('save-pattern'),
|
||||||
|
saveDuration: document.getElementById('save-duration'),
|
||||||
|
summaryAngle: document.getElementById('summary-angle'),
|
||||||
|
summaryIntensity: document.getElementById('summary-intensity'),
|
||||||
|
summaryPattern: document.getElementById('summary-pattern'),
|
||||||
|
summaryTone: document.getElementById('summary-tone'),
|
||||||
|
reclineMode: document.getElementById('recline-mode'),
|
||||||
|
gamepadState: document.getElementById('gamepad-state'),
|
||||||
|
gamepadSelect: document.getElementById('gamepad-select'),
|
||||||
|
testButton: document.getElementById('test-vibration'),
|
||||||
|
resetButton: document.getElementById('reset-defaults')
|
||||||
|
};
|
||||||
|
|
||||||
appendMessage(message, 'visitor');
|
let knownGamepads = [];
|
||||||
chatInput.value = '';
|
let scanTimer = null;
|
||||||
|
|
||||||
|
const notify = (message) => {
|
||||||
|
if (!message) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (toastMessage) {
|
||||||
|
toastMessage.textContent = message;
|
||||||
|
}
|
||||||
|
if (toast) {
|
||||||
|
toast.show();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const presetTone = (angle) => {
|
||||||
|
if (angle >= 135) return 'Deep recline';
|
||||||
|
if (angle >= 90) return 'Lounge';
|
||||||
|
if (angle >= 45) return 'Reading';
|
||||||
|
return 'Upright';
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentState = () => ({
|
||||||
|
angle: Number(controls.angle?.value || 0),
|
||||||
|
intensity: Number(controls.intensity?.value || 0),
|
||||||
|
pattern: controls.pattern?.value || 'continuous',
|
||||||
|
duration: Number(controls.duration?.value || 1000)
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateVisualization = () => {
|
||||||
|
const state = currentState();
|
||||||
|
const tone = presetTone(state.angle);
|
||||||
|
if (ui.figure) {
|
||||||
|
ui.figure.style.setProperty('--angle', String(state.angle));
|
||||||
|
ui.figure.style.setProperty('--intensity', String(state.intensity));
|
||||||
|
}
|
||||||
|
|
||||||
|
const map = {
|
||||||
|
angleValue: `${state.angle}`,
|
||||||
|
anglePill: `${state.angle}°`,
|
||||||
|
intensityPill: `${state.intensity}%`,
|
||||||
|
durationPill: `${state.duration} ms`,
|
||||||
|
statAngle: `${state.angle}°`,
|
||||||
|
statIntensity: `${state.intensity}%`,
|
||||||
|
statPattern: capitalize(state.pattern),
|
||||||
|
statDuration: `${state.duration} ms`,
|
||||||
|
saveAngle: `${state.angle}°`,
|
||||||
|
saveIntensity: `${state.intensity}%`,
|
||||||
|
savePattern: capitalize(state.pattern),
|
||||||
|
saveDuration: `${state.duration} ms`,
|
||||||
|
summaryAngle: `${state.angle}°`,
|
||||||
|
summaryIntensity: `${state.intensity}%`,
|
||||||
|
summaryPattern: capitalize(state.pattern),
|
||||||
|
summaryTone: tone,
|
||||||
|
reclineMode: tone
|
||||||
|
};
|
||||||
|
|
||||||
|
Object.entries(map).forEach(([key, value]) => {
|
||||||
|
if (ui[key]) {
|
||||||
|
ui[key].textContent = value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const capitalize = (value) => value.charAt(0).toUpperCase() + value.slice(1);
|
||||||
|
|
||||||
|
const applyPresetState = (preset) => {
|
||||||
|
if (!preset) return;
|
||||||
|
if (controls.angle && preset.angle_deg !== undefined) controls.angle.value = preset.angle_deg;
|
||||||
|
if (controls.intensity && preset.intensity_pct !== undefined) controls.intensity.value = preset.intensity_pct;
|
||||||
|
if (controls.pattern && preset.pattern_mode) controls.pattern.value = preset.pattern_mode;
|
||||||
|
if (controls.duration && preset.duration_ms !== undefined) controls.duration.value = preset.duration_ms;
|
||||||
|
if (controls.name && preset.name !== undefined) controls.name.value = preset.name;
|
||||||
|
if (controls.notes && preset.notes !== undefined) controls.notes.value = preset.notes;
|
||||||
|
updateVisualization();
|
||||||
|
};
|
||||||
|
|
||||||
|
const getActuator = (gamepad) => {
|
||||||
|
if (!gamepad) return null;
|
||||||
|
if (gamepad.vibrationActuator) return gamepad.vibrationActuator;
|
||||||
|
if (Array.isArray(gamepad.hapticActuators) && gamepad.hapticActuators.length > 0) {
|
||||||
|
return gamepad.hapticActuators[0];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectedGamepad = () => {
|
||||||
|
if (!ui.gamepadSelect || !knownGamepads.length) return null;
|
||||||
|
const selectedIndex = Number(ui.gamepadSelect.value || knownGamepads[0].index);
|
||||||
|
return knownGamepads.find((gamepad) => gamepad.index === selectedIndex) || knownGamepads[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
const refreshGamepads = () => {
|
||||||
|
if (!navigator.getGamepads) {
|
||||||
|
if (ui.gamepadState) {
|
||||||
|
ui.gamepadState.textContent = 'Gamepad API unavailable';
|
||||||
|
}
|
||||||
|
if (ui.gamepadSelect) {
|
||||||
|
ui.gamepadSelect.innerHTML = '<option>Unsupported browser</option>';
|
||||||
|
ui.gamepadSelect.disabled = true;
|
||||||
|
}
|
||||||
|
if (ui.testButton) {
|
||||||
|
ui.testButton.disabled = true;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
knownGamepads = Array.from(navigator.getGamepads()).filter(Boolean);
|
||||||
|
const previousValue = ui.gamepadSelect ? ui.gamepadSelect.value : '';
|
||||||
|
|
||||||
|
if (ui.gamepadSelect) {
|
||||||
|
ui.gamepadSelect.innerHTML = '';
|
||||||
|
if (!knownGamepads.length) {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.textContent = 'No controller detected';
|
||||||
|
option.value = '';
|
||||||
|
ui.gamepadSelect.appendChild(option);
|
||||||
|
ui.gamepadSelect.disabled = true;
|
||||||
|
} else {
|
||||||
|
knownGamepads.forEach((gamepad) => {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
const supported = getActuator(gamepad) ? 'haptics ready' : 'no haptics';
|
||||||
|
option.value = String(gamepad.index);
|
||||||
|
option.textContent = `${gamepad.id} (${supported})`;
|
||||||
|
ui.gamepadSelect.appendChild(option);
|
||||||
|
});
|
||||||
|
ui.gamepadSelect.disabled = false;
|
||||||
|
const hasPrevious = knownGamepads.some((gamepad) => String(gamepad.index) === previousValue);
|
||||||
|
ui.gamepadSelect.value = hasPrevious ? previousValue : String(knownGamepads[0].index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const active = selectedGamepad();
|
||||||
|
const actuator = getActuator(active);
|
||||||
|
if (ui.gamepadState) {
|
||||||
|
if (!active) {
|
||||||
|
ui.gamepadState.textContent = 'Connect a controller to enable rumble';
|
||||||
|
} else if (!actuator) {
|
||||||
|
ui.gamepadState.textContent = 'Controller found, but haptics are unavailable';
|
||||||
|
} else {
|
||||||
|
ui.gamepadState.textContent = 'Controller connected and ready';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ui.testButton) {
|
||||||
|
ui.testButton.disabled = !active || !actuator;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const playEffect = async (actuator, intensity, duration) => {
|
||||||
|
if (typeof actuator.playEffect === 'function') {
|
||||||
|
await actuator.playEffect('dual-rumble', {
|
||||||
|
startDelay: 0,
|
||||||
|
duration,
|
||||||
|
weakMagnitude: Math.min(1, intensity * 0.8 + 0.1),
|
||||||
|
strongMagnitude: intensity
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof actuator.pulse === 'function') {
|
||||||
|
await actuator.pulse(intensity, duration);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const runVibration = async () => {
|
||||||
|
const gamepad = selectedGamepad();
|
||||||
|
const actuator = getActuator(gamepad);
|
||||||
|
if (!gamepad || !actuator) {
|
||||||
|
notify('No compatible haptic controller is currently selected.');
|
||||||
|
refreshGamepads();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { intensity, duration, pattern } = currentState();
|
||||||
|
const normalizedIntensity = Math.max(0, Math.min(1, intensity / 100));
|
||||||
|
ui.testButton.disabled = true;
|
||||||
|
ui.testButton.textContent = 'Testing…';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('api/chat.php', {
|
if (pattern === 'pulse') {
|
||||||
method: 'POST',
|
let remaining = duration;
|
||||||
headers: { 'Content-Type': 'application/json' },
|
while (remaining > 0) {
|
||||||
body: JSON.stringify({ message })
|
const burst = Math.min(160, remaining);
|
||||||
});
|
await playEffect(actuator, normalizedIntensity, burst);
|
||||||
const data = await response.json();
|
remaining -= burst;
|
||||||
|
if (remaining > 0) {
|
||||||
// Artificial delay for realism
|
await new Promise((resolve) => window.setTimeout(resolve, 120));
|
||||||
setTimeout(() => {
|
}
|
||||||
appendMessage(data.reply, 'bot');
|
}
|
||||||
}, 500);
|
} else {
|
||||||
|
await playEffect(actuator, normalizedIntensity, duration);
|
||||||
|
}
|
||||||
|
notify(`Running ${pattern} vibration at ${intensity}% for ${duration} ms.`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error:', error);
|
console.error(error);
|
||||||
appendMessage("Sorry, something went wrong. Please try again.", 'bot');
|
notify('The browser detected the controller, but vibration could not start.');
|
||||||
|
} finally {
|
||||||
|
ui.testButton.textContent = 'Test vibration';
|
||||||
|
refreshGamepads();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const wirePresetButtons = () => {
|
||||||
|
document.querySelectorAll('.apply-preset').forEach((button) => {
|
||||||
|
button.addEventListener('click', () => {
|
||||||
|
applyPresetState({
|
||||||
|
name: button.dataset.name || '',
|
||||||
|
angle_deg: button.dataset.angle || 0,
|
||||||
|
intensity_pct: button.dataset.intensity || 0,
|
||||||
|
pattern_mode: button.dataset.pattern || 'continuous',
|
||||||
|
duration_ms: button.dataset.duration || 1000,
|
||||||
|
notes: button.dataset.notes || ''
|
||||||
|
});
|
||||||
|
window.location.hash = 'simulator';
|
||||||
|
notify(`Loaded preset “${button.dataset.name || 'Preset'}” into the simulator.`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (ui.testButton) {
|
||||||
|
ui.testButton.addEventListener('click', runVibration);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ui.resetButton) {
|
||||||
|
ui.resetButton.addEventListener('click', () => {
|
||||||
|
applyPresetState(appConfig.defaults || {});
|
||||||
|
notify('Demo defaults restored.');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[controls.angle, controls.intensity, controls.pattern, controls.duration].forEach((element) => {
|
||||||
|
if (element) {
|
||||||
|
element.addEventListener('input', updateVisualization);
|
||||||
|
element.addEventListener('change', updateVisualization);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (ui.gamepadSelect) {
|
||||||
|
ui.gamepadSelect.addEventListener('change', refreshGamepads);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('gamepadconnected', (event) => {
|
||||||
|
refreshGamepads();
|
||||||
|
notify(`Connected: ${event.gamepad.id}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('gamepaddisconnected', (event) => {
|
||||||
|
refreshGamepads();
|
||||||
|
notify(`Disconnected: ${event.gamepad.id}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
applyPresetState(appConfig.initialPreset || {});
|
||||||
|
wirePresetButtons();
|
||||||
|
refreshGamepads();
|
||||||
|
updateVisualization();
|
||||||
|
|
||||||
|
if (appConfig.toastMessage) {
|
||||||
|
notify(appConfig.toastMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (navigator.getGamepads) {
|
||||||
|
scanTimer = window.setInterval(refreshGamepads, 1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('beforeunload', () => {
|
||||||
|
if (scanTimer) {
|
||||||
|
window.clearInterval(scanTimer);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
25
healthz.php
Normal file
25
healthz.php
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once __DIR__ . '/app.php';
|
||||||
|
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|
||||||
|
try {
|
||||||
|
ensure_recliner_schema();
|
||||||
|
$count = (int) db()->query('SELECT COUNT(*) FROM recliner_presets')->fetchColumn();
|
||||||
|
|
||||||
|
echo json_encode([
|
||||||
|
'status' => 'ok',
|
||||||
|
'database' => 'connected',
|
||||||
|
'preset_count' => $count,
|
||||||
|
'timestamp_utc' => gmdate('c'),
|
||||||
|
], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode([
|
||||||
|
'status' => 'error',
|
||||||
|
'message' => 'Health check failed.',
|
||||||
|
'timestamp_utc' => gmdate('c'),
|
||||||
|
], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||||
|
}
|
||||||
464
index.php
464
index.php
@ -1,150 +1,350 @@
|
|||||||
<?php
|
<?php
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
@ini_set('display_errors', '1');
|
|
||||||
@error_reporting(E_ALL);
|
require_once __DIR__ . '/app.php';
|
||||||
|
|
||||||
@date_default_timezone_set('UTC');
|
@date_default_timezone_set('UTC');
|
||||||
|
|
||||||
$phpVersion = PHP_VERSION;
|
ensure_recliner_schema();
|
||||||
$now = date('Y-m-d H:i:s');
|
$meta = project_meta();
|
||||||
|
$pageTitle = $meta['name'] === 'Recliner Haptics Studio' ? $meta['name'] : $meta['name'] . ' | Recliner Haptics Studio';
|
||||||
|
$pageDescription = $meta['description'];
|
||||||
|
$projectImageUrl = $meta['image'];
|
||||||
|
|
||||||
|
$errors = [];
|
||||||
|
$formData = default_preset();
|
||||||
|
$selectedPreset = null;
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'save_preset') {
|
||||||
|
$validation = validate_preset_input($_POST);
|
||||||
|
$errors = $validation['errors'];
|
||||||
|
$formData = $validation['data'];
|
||||||
|
|
||||||
|
if ($errors === []) {
|
||||||
|
$savedId = save_preset($formData);
|
||||||
|
header('Location: /index.php?saved=' . $savedId . '&preset=' . $savedId . '#presets');
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$presetId = filter_input(INPUT_GET, 'preset', FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]);
|
||||||
|
if ($presetId) {
|
||||||
|
$selectedPreset = get_preset((int) $presetId);
|
||||||
|
if ($selectedPreset && $_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
|
$formData = [
|
||||||
|
'name' => (string) $selectedPreset['name'],
|
||||||
|
'angle_deg' => (int) $selectedPreset['angle_deg'],
|
||||||
|
'intensity_pct' => (int) $selectedPreset['intensity_pct'],
|
||||||
|
'pattern_mode' => (string) $selectedPreset['pattern_mode'],
|
||||||
|
'duration_ms' => (int) $selectedPreset['duration_ms'],
|
||||||
|
'notes' => (string) ($selectedPreset['notes'] ?? ''),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$recentPresets = get_recent_presets(8);
|
||||||
|
$assetVersion = (string) max(@filemtime(__DIR__ . '/assets/css/custom.css') ?: time(), @filemtime(__DIR__ . '/assets/js/main.js') ?: time());
|
||||||
|
$toastMessage = null;
|
||||||
|
if (isset($_GET['saved']) && ctype_digit((string) $_GET['saved'])) {
|
||||||
|
$toastMessage = 'Preset saved. You can reopen it anytime from Recent presets.';
|
||||||
|
}
|
||||||
?>
|
?>
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>New Style</title>
|
<title><?= e($pageTitle) ?></title>
|
||||||
<?php
|
<meta name="description" content="<?= e($pageDescription) ?>">
|
||||||
// Read project preview data from environment
|
<meta name="theme-color" content="#0b0d10">
|
||||||
$projectDescription = $_SERVER['PROJECT_DESCRIPTION'] ?? '';
|
<meta property="og:title" content="<?= e($pageTitle) ?>">
|
||||||
$projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? '';
|
<meta property="og:description" content="<?= e($pageDescription) ?>">
|
||||||
?>
|
<meta property="twitter:title" content="<?= e($pageTitle) ?>">
|
||||||
<?php if ($projectDescription): ?>
|
<meta property="twitter:description" content="<?= e($pageDescription) ?>">
|
||||||
<!-- Meta description -->
|
|
||||||
<meta name="description" content='<?= htmlspecialchars($projectDescription) ?>' />
|
|
||||||
<!-- Open Graph meta tags -->
|
|
||||||
<meta property="og:description" content="<?= htmlspecialchars($projectDescription) ?>" />
|
|
||||||
<!-- Twitter meta tags -->
|
|
||||||
<meta property="twitter:description" content="<?= htmlspecialchars($projectDescription) ?>" />
|
|
||||||
<?php endif; ?>
|
|
||||||
<?php if ($projectImageUrl): ?>
|
<?php if ($projectImageUrl): ?>
|
||||||
<!-- Open Graph image -->
|
<meta property="og:image" content="<?= e($projectImageUrl) ?>">
|
||||||
<meta property="og:image" content="<?= htmlspecialchars($projectImageUrl) ?>" />
|
<meta property="twitter:image" content="<?= e($projectImageUrl) ?>">
|
||||||
<!-- Twitter image -->
|
|
||||||
<meta property="twitter:image" content="<?= htmlspecialchars($projectImageUrl) ?>" />
|
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="stylesheet" href="/assets/css/custom.css?v=<?= e($assetVersion) ?>">
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap" rel="stylesheet">
|
|
||||||
<style>
|
|
||||||
:root {
|
|
||||||
--bg-color-start: #6a11cb;
|
|
||||||
--bg-color-end: #2575fc;
|
|
||||||
--text-color: #ffffff;
|
|
||||||
--card-bg-color: rgba(255, 255, 255, 0.01);
|
|
||||||
--card-border-color: rgba(255, 255, 255, 0.1);
|
|
||||||
}
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
font-family: 'Inter', sans-serif;
|
|
||||||
background: linear-gradient(45deg, var(--bg-color-start), var(--bg-color-end));
|
|
||||||
color: var(--text-color);
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
min-height: 100vh;
|
|
||||||
text-align: center;
|
|
||||||
overflow: hidden;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
body::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
background-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100"><path d="M-10 10L110 10M10 -10L10 110" stroke-width="1" stroke="rgba(255,255,255,0.05)"/></svg>');
|
|
||||||
animation: bg-pan 20s linear infinite;
|
|
||||||
z-index: -1;
|
|
||||||
}
|
|
||||||
@keyframes bg-pan {
|
|
||||||
0% { background-position: 0% 0%; }
|
|
||||||
100% { background-position: 100% 100%; }
|
|
||||||
}
|
|
||||||
main {
|
|
||||||
padding: 2rem;
|
|
||||||
}
|
|
||||||
.card {
|
|
||||||
background: var(--card-bg-color);
|
|
||||||
border: 1px solid var(--card-border-color);
|
|
||||||
border-radius: 16px;
|
|
||||||
padding: 2rem;
|
|
||||||
backdrop-filter: blur(20px);
|
|
||||||
-webkit-backdrop-filter: blur(20px);
|
|
||||||
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
.loader {
|
|
||||||
margin: 1.25rem auto 1.25rem;
|
|
||||||
width: 48px;
|
|
||||||
height: 48px;
|
|
||||||
border: 3px solid rgba(255, 255, 255, 0.25);
|
|
||||||
border-top-color: #fff;
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: spin 1s linear infinite;
|
|
||||||
}
|
|
||||||
@keyframes spin {
|
|
||||||
from { transform: rotate(0deg); }
|
|
||||||
to { transform: rotate(360deg); }
|
|
||||||
}
|
|
||||||
.hint {
|
|
||||||
opacity: 0.9;
|
|
||||||
}
|
|
||||||
.sr-only {
|
|
||||||
position: absolute;
|
|
||||||
width: 1px; height: 1px;
|
|
||||||
padding: 0; margin: -1px;
|
|
||||||
overflow: hidden;
|
|
||||||
clip: rect(0, 0, 0, 0);
|
|
||||||
white-space: nowrap; border: 0;
|
|
||||||
}
|
|
||||||
h1 {
|
|
||||||
font-size: 3rem;
|
|
||||||
font-weight: 700;
|
|
||||||
margin: 0 0 1rem;
|
|
||||||
letter-spacing: -1px;
|
|
||||||
}
|
|
||||||
p {
|
|
||||||
margin: 0.5rem 0;
|
|
||||||
font-size: 1.1rem;
|
|
||||||
}
|
|
||||||
code {
|
|
||||||
background: rgba(0,0,0,0.2);
|
|
||||||
padding: 2px 6px;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
||||||
}
|
|
||||||
footer {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 1rem;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<main>
|
<div class="toast-container position-fixed top-0 end-0 p-3">
|
||||||
<div class="card">
|
<div id="app-toast" class="toast text-bg-dark border border-secondary-subtle" role="status" aria-live="polite" aria-atomic="true">
|
||||||
<h1>Analyzing your requirements and generating your website…</h1>
|
<div class="toast-header bg-dark text-light border-bottom border-secondary-subtle">
|
||||||
<div class="loader" role="status" aria-live="polite" aria-label="Applying initial changes">
|
<strong class="me-auto">Recliner Haptics</strong>
|
||||||
<span class="sr-only">Loading…</span>
|
<small>Now</small>
|
||||||
|
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="toast" aria-label="Close"></button>
|
||||||
</div>
|
</div>
|
||||||
<p class="hint"><?= ($_SERVER['HTTP_HOST'] ?? '') === 'appwizzy.com' ? 'AppWizzy' : 'Flatlogic' ?> AI is collecting your requirements and applying the first changes.</p>
|
<div class="toast-body" id="toast-message"><?= e($toastMessage ?? '') ?></div>
|
||||||
<p class="hint">This page will update automatically as the plan is implemented.</p>
|
</div>
|
||||||
<p>Runtime: PHP <code><?= htmlspecialchars($phpVersion) ?></code> — UTC <code><?= htmlspecialchars($now) ?></code></p>
|
</div>
|
||||||
|
|
||||||
|
<header class="border-bottom border-secondary-subtle sticky-top shell-header">
|
||||||
|
<nav class="navbar navbar-expand-lg navbar-dark">
|
||||||
|
<div class="container-xxl py-2">
|
||||||
|
<a class="navbar-brand fw-semibold" href="/index.php">Recliner Haptics</a>
|
||||||
|
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#mainNav" aria-controls="mainNav" aria-expanded="false" aria-label="Toggle navigation">
|
||||||
|
<span class="navbar-toggler-icon"></span>
|
||||||
|
</button>
|
||||||
|
<div class="collapse navbar-collapse" id="mainNav">
|
||||||
|
<ul class="navbar-nav ms-auto align-items-lg-center gap-lg-2">
|
||||||
|
<li class="nav-item"><a class="nav-link" href="#simulator">Simulator</a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="#presets">Recent presets</a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="/healthz.php">Health</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="py-4 py-lg-5">
|
||||||
|
<div class="container-xxl">
|
||||||
|
<section class="hero-panel panel p-4 p-lg-5 mb-4 mb-lg-5">
|
||||||
|
<div class="row g-4 align-items-center">
|
||||||
|
<div class="col-lg-8">
|
||||||
|
<div class="eyebrow mb-3">Browser web app · Gamepad vibration demo</div>
|
||||||
|
<h1 class="display-title mb-3">Simulate a recliner experience with live angle control and haptic testing.</h1>
|
||||||
|
<p class="lead text-secondary mb-4">Connect a compatible gamepad in Chrome or Edge, tune the recline angle, choose a vibration pattern, and save reusable presets for demos or prototyping.</p>
|
||||||
|
<div class="d-flex flex-wrap gap-2 meta-pills">
|
||||||
|
<span class="chip">0–160° recline visualizer</span>
|
||||||
|
<span class="chip">Continuous + pulse rumble</span>
|
||||||
|
<span class="chip">Saved preset library</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-4">
|
||||||
|
<div class="panel inset-panel p-3 h-100">
|
||||||
|
<div class="d-flex justify-content-between align-items-start mb-3">
|
||||||
|
<div>
|
||||||
|
<div class="small-label">Loaded state</div>
|
||||||
|
<div class="h5 mb-0"><?= e($selectedPreset['name'] ?? $formData['name']) ?></div>
|
||||||
|
</div>
|
||||||
|
<span class="status-dot <?= $selectedPreset ? 'status-live' : 'status-idle' ?>"></span>
|
||||||
|
</div>
|
||||||
|
<div class="spec-list">
|
||||||
|
<div><span>Angle</span><strong id="summary-angle"><?= e((string) $formData['angle_deg']) ?>°</strong></div>
|
||||||
|
<div><span>Intensity</span><strong id="summary-intensity"><?= e((string) $formData['intensity_pct']) ?>%</strong></div>
|
||||||
|
<div><span>Pattern</span><strong id="summary-pattern"><?= e(ucfirst($formData['pattern_mode'])) ?></strong></div>
|
||||||
|
<div><span>Tone</span><strong id="summary-tone"><?= e(preset_tone((int) $formData['angle_deg'])) ?></strong></div>
|
||||||
|
</div>
|
||||||
|
<p class="small text-secondary mb-0 mt-3">Tip: the browser requires a user interaction before triggering vibration. Press <strong>Test vibration</strong> after connecting the controller.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<form method="post" class="row g-4 align-items-start" id="preset-form">
|
||||||
|
<input type="hidden" name="action" value="save_preset">
|
||||||
|
|
||||||
|
<div class="col-xl-7" id="simulator">
|
||||||
|
<section class="panel p-3 p-md-4 mb-4">
|
||||||
|
<div class="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-4">
|
||||||
|
<div>
|
||||||
|
<div class="small-label">Visualizer</div>
|
||||||
|
<h2 class="h4 mb-1">Live recline preview</h2>
|
||||||
|
<p class="text-secondary mb-0">A restrained 2D profile that updates instantly as you move the angle and intensity controls.</p>
|
||||||
|
</div>
|
||||||
|
<div class="badge badge-soft" id="recline-mode"><?= e(preset_tone((int) $formData['angle_deg'])) ?></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="recliner-stage mb-4">
|
||||||
|
<div class="grid-fade"></div>
|
||||||
|
<div class="axis-label axis-label-left">upright</div>
|
||||||
|
<div class="axis-label axis-label-right">full recline</div>
|
||||||
|
<div class="recliner-figure" id="recliner-figure" style="--angle: <?= e((string) $formData['angle_deg']) ?>; --intensity: <?= e((string) $formData['intensity_pct']) ?>;">
|
||||||
|
<div class="recliner-shadow"></div>
|
||||||
|
<div class="recliner-seat"></div>
|
||||||
|
<div class="recliner-arm"></div>
|
||||||
|
<div class="recliner-back" id="recliner-back"></div>
|
||||||
|
<div class="recliner-head"></div>
|
||||||
|
<div class="recliner-leg" id="recliner-leg"></div>
|
||||||
|
<div class="recliner-base"></div>
|
||||||
|
<div class="angle-indicator"><span id="angle-value"><?= e((string) $formData['angle_deg']) ?></span><small>degrees</small></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-3 stat-row">
|
||||||
|
<div class="col-6 col-md-3">
|
||||||
|
<div class="mini-stat"><span>Recline</span><strong id="stat-angle"><?= e((string) $formData['angle_deg']) ?>°</strong></div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-md-3">
|
||||||
|
<div class="mini-stat"><span>Rumble</span><strong id="stat-intensity"><?= e((string) $formData['intensity_pct']) ?>%</strong></div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-md-3">
|
||||||
|
<div class="mini-stat"><span>Pattern</span><strong id="stat-pattern"><?= e(ucfirst($formData['pattern_mode'])) ?></strong></div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-md-3">
|
||||||
|
<div class="mini-stat"><span>Duration</span><strong id="stat-duration"><?= e((string) $formData['duration_ms']) ?> ms</strong></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel p-3 p-md-4">
|
||||||
|
<div class="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-4">
|
||||||
|
<div>
|
||||||
|
<div class="small-label">Gamepad</div>
|
||||||
|
<h2 class="h4 mb-1">Haptic control surface</h2>
|
||||||
|
<p class="text-secondary mb-0">Select a connected controller, then test the current preset with one click.</p>
|
||||||
|
</div>
|
||||||
|
<div class="gamepad-state" id="gamepad-state">Scanning for controllers…</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-3 align-items-end mb-4">
|
||||||
|
<div class="col-md-7">
|
||||||
|
<label for="gamepad-select" class="form-label">Detected gamepad</label>
|
||||||
|
<select class="form-select" id="gamepad-select" aria-describedby="gamepad-help"></select>
|
||||||
|
<div id="gamepad-help" class="form-text">Works best in Chrome or Edge with a gamepad that exposes <code>vibrationActuator</code> support.</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-5">
|
||||||
|
<label class="form-label d-block">Action</label>
|
||||||
|
<button type="button" class="btn btn-accent w-100" id="test-vibration">Test vibration</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="control-stack">
|
||||||
|
<div class="control-group">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-2"><label for="angle_deg" class="form-label mb-0">Recline angle</label><span class="value-pill" id="angle-pill"><?= e((string) $formData['angle_deg']) ?>°</span></div>
|
||||||
|
<input type="range" class="form-range" min="0" max="160" step="1" id="angle_deg" name="angle_deg" value="<?= e((string) $formData['angle_deg']) ?>">
|
||||||
|
</div>
|
||||||
|
<div class="control-group">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-2"><label for="intensity_pct" class="form-label mb-0">Vibration intensity</label><span class="value-pill" id="intensity-pill"><?= e((string) $formData['intensity_pct']) ?>%</span></div>
|
||||||
|
<input type="range" class="form-range" min="0" max="100" step="1" id="intensity_pct" name="intensity_pct" value="<?= e((string) $formData['intensity_pct']) ?>">
|
||||||
|
</div>
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="pattern_mode" class="form-label">Pattern</label>
|
||||||
|
<select class="form-select" id="pattern_mode" name="pattern_mode">
|
||||||
|
<option value="continuous" <?= $formData['pattern_mode'] === 'continuous' ? 'selected' : '' ?>>Continuous</option>
|
||||||
|
<option value="pulse" <?= $formData['pattern_mode'] === 'pulse' ? 'selected' : '' ?>>Pulse</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-2"><label for="duration_ms" class="form-label mb-0">Duration</label><span class="value-pill" id="duration-pill"><?= e((string) $formData['duration_ms']) ?> ms</span></div>
|
||||||
|
<input type="range" class="form-range mt-3" min="100" max="5000" step="100" id="duration_ms" name="duration_ms" value="<?= e((string) $formData['duration_ms']) ?>">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-xl-5">
|
||||||
|
<section class="panel p-3 p-md-4 mb-4">
|
||||||
|
<div class="small-label">Save preset</div>
|
||||||
|
<h2 class="h4 mb-1">Name and store the current configuration</h2>
|
||||||
|
<p class="text-secondary mb-4">Create a reusable preset library so your simulator feels like a real tool, not just a single test screen.</p>
|
||||||
|
|
||||||
|
<?php if ($errors): ?>
|
||||||
|
<div class="alert alert-danger border-0 mb-4" role="alert">
|
||||||
|
<strong>Couldn’t save preset.</strong>
|
||||||
|
<ul class="mb-0 mt-2 ps-3">
|
||||||
|
<?php foreach ($errors as $error): ?>
|
||||||
|
<li><?= e($error) ?></li>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="name" class="form-label">Preset name</label>
|
||||||
|
<input type="text" class="form-control <?= isset($errors['name']) ? 'is-invalid' : '' ?>" id="name" name="name" maxlength="120" value="<?= e($formData['name']) ?>" placeholder="Evening Relaxation">
|
||||||
|
<?php if (isset($errors['name'])): ?><div class="invalid-feedback"><?= e($errors['name']) ?></div><?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="notes" class="form-label">Operator notes</label>
|
||||||
|
<textarea class="form-control <?= isset($errors['notes']) ? 'is-invalid' : '' ?>" id="notes" name="notes" rows="4" maxlength="255" placeholder="Useful for showroom demos, comfort testing, or accessibility rehearsal."><?= e($formData['notes']) ?></textarea>
|
||||||
|
<?php if (isset($errors['notes'])): ?><div class="invalid-feedback"><?= e($errors['notes']) ?></div><?php endif; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel inset-panel p-3 mb-4">
|
||||||
|
<div class="small-label mb-2">Current profile</div>
|
||||||
|
<div class="spec-list compact">
|
||||||
|
<div><span>Angle</span><strong id="save-angle"><?= e((string) $formData['angle_deg']) ?>°</strong></div>
|
||||||
|
<div><span>Intensity</span><strong id="save-intensity"><?= e((string) $formData['intensity_pct']) ?>%</strong></div>
|
||||||
|
<div><span>Pattern</span><strong id="save-pattern"><?= e(ucfirst($formData['pattern_mode'])) ?></strong></div>
|
||||||
|
<div><span>Duration</span><strong id="save-duration"><?= e((string) $formData['duration_ms']) ?> ms</strong></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-grid gap-2">
|
||||||
|
<button type="submit" class="btn btn-light">Save preset</button>
|
||||||
|
<button type="button" class="btn btn-outline-light" id="reset-defaults">Reset to demo defaults</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel p-3 p-md-4" id="presets">
|
||||||
|
<div class="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-4">
|
||||||
|
<div>
|
||||||
|
<div class="small-label">Preset library</div>
|
||||||
|
<h2 class="h4 mb-1">Recent presets</h2>
|
||||||
|
<p class="text-secondary mb-0">Load a saved setup back into the simulator or open a dedicated detail view.</p>
|
||||||
|
</div>
|
||||||
|
<span class="badge badge-soft"><?= count($recentPresets) ?> stored</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if ($recentPresets): ?>
|
||||||
|
<div class="preset-list">
|
||||||
|
<?php foreach ($recentPresets as $preset): ?>
|
||||||
|
<article class="preset-card <?= $selectedPreset && (int) $selectedPreset['id'] === (int) $preset['id'] ? 'is-active' : '' ?>">
|
||||||
|
<div class="d-flex justify-content-between gap-3 mb-2">
|
||||||
|
<div>
|
||||||
|
<h3 class="h6 mb-1"><?= e($preset['name']) ?></h3>
|
||||||
|
<p class="text-secondary small mb-0"><?= e(preset_tone((int) $preset['angle_deg'])) ?> · <?= e(date('M j, Y · H:i', strtotime((string) $preset['created_at']))) ?> UTC</p>
|
||||||
|
</div>
|
||||||
|
<span class="badge badge-soft"><?= e((string) $preset['angle_deg']) ?>°</span>
|
||||||
|
</div>
|
||||||
|
<div class="preset-metrics mb-3">
|
||||||
|
<span><?= e((string) $preset['intensity_pct']) ?>% intensity</span>
|
||||||
|
<span><?= e(ucfirst((string) $preset['pattern_mode'])) ?></span>
|
||||||
|
<span><?= e((string) $preset['duration_ms']) ?> ms</span>
|
||||||
|
</div>
|
||||||
|
<?php if (!empty($preset['notes'])): ?>
|
||||||
|
<p class="text-secondary small mb-3"><?= e($preset['notes']) ?></p>
|
||||||
|
<?php endif; ?>
|
||||||
|
<div class="d-flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-sm btn-outline-light apply-preset"
|
||||||
|
data-id="<?= e((string) $preset['id']) ?>"
|
||||||
|
data-name="<?= e($preset['name']) ?>"
|
||||||
|
data-angle="<?= e((string) $preset['angle_deg']) ?>"
|
||||||
|
data-intensity="<?= e((string) $preset['intensity_pct']) ?>"
|
||||||
|
data-pattern="<?= e((string) $preset['pattern_mode']) ?>"
|
||||||
|
data-duration="<?= e((string) $preset['duration_ms']) ?>"
|
||||||
|
data-notes="<?= e((string) ($preset['notes'] ?? '')) ?>"
|
||||||
|
>Apply in simulator</button>
|
||||||
|
<a class="btn btn-sm btn-link text-decoration-none px-0" href="/preset.php?id=<?= e((string) $preset['id']) ?>">View detail</a>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<div class="empty-state">
|
||||||
|
<h3 class="h6 mb-2">No presets yet</h3>
|
||||||
|
<p class="text-secondary mb-0">Tune the simulator, save your first profile, and it will appear here for quick reuse.</p>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
<footer>
|
|
||||||
Page updated: <?= htmlspecialchars($now) ?> (UTC)
|
<footer class="py-4 border-top border-secondary-subtle">
|
||||||
|
<div class="container-xxl d-flex flex-column flex-lg-row justify-content-between gap-2">
|
||||||
|
<p class="text-secondary small mb-0">Built for browser-based recliner demos with connected gamepads and saved comfort presets.</p>
|
||||||
|
<p class="text-secondary small mb-0">Use Chrome or Edge, connect the controller first, then press a button on the device so the browser exposes it.</p>
|
||||||
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
window.appConfig = {
|
||||||
|
toastMessage: <?= json_encode($toastMessage, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?>,
|
||||||
|
initialPreset: <?= json_encode($formData, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?>,
|
||||||
|
selectedPresetId: <?= json_encode($selectedPreset['id'] ?? null) ?>,
|
||||||
|
defaults: <?= json_encode(default_preset(), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?>
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous" defer></script>
|
||||||
|
<script src="/assets/js/main.js?v=<?= e($assetVersion) ?>" defer></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
114
preset.php
Normal file
114
preset.php
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once __DIR__ . '/app.php';
|
||||||
|
|
||||||
|
ensure_recliner_schema();
|
||||||
|
$meta = project_meta();
|
||||||
|
$presetId = filter_input(INPUT_GET, 'id', FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]);
|
||||||
|
$preset = $presetId ? get_preset((int) $presetId) : null;
|
||||||
|
http_response_code($preset ? 200 : 404);
|
||||||
|
$assetVersion = (string) max(@filemtime(__DIR__ . '/assets/css/custom.css') ?: time(), @filemtime(__DIR__ . '/assets/js/main.js') ?: time());
|
||||||
|
$pageTitle = ($preset ? $preset['name'] . ' · ' : '') . $meta['name'];
|
||||||
|
$pageDescription = $preset
|
||||||
|
? 'Preset detail for ' . $preset['name'] . ' with a saved recline angle of ' . $preset['angle_deg'] . ' degrees.'
|
||||||
|
: $meta['description'];
|
||||||
|
?>
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title><?= e($pageTitle) ?></title>
|
||||||
|
<meta name="description" content="<?= e($pageDescription) ?>">
|
||||||
|
<meta name="theme-color" content="#0b0d10">
|
||||||
|
<?php if (!empty($meta['image'])): ?>
|
||||||
|
<meta property="og:image" content="<?= e($meta['image']) ?>">
|
||||||
|
<meta property="twitter:image" content="<?= e($meta['image']) ?>">
|
||||||
|
<?php endif; ?>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
|
||||||
|
<link rel="stylesheet" href="/assets/css/custom.css?v=<?= e($assetVersion) ?>">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="border-bottom border-secondary-subtle shell-header">
|
||||||
|
<nav class="navbar navbar-expand-lg navbar-dark">
|
||||||
|
<div class="container-xxl py-2">
|
||||||
|
<a class="navbar-brand fw-semibold" href="/index.php">Recliner Haptics</a>
|
||||||
|
<div class="ms-auto d-flex gap-2">
|
||||||
|
<a class="btn btn-sm btn-outline-light" href="/index.php#presets">Back to presets</a>
|
||||||
|
<?php if ($preset): ?>
|
||||||
|
<a class="btn btn-sm btn-light" href="/index.php?preset=<?= e((string) $preset['id']) ?>#simulator">Open in simulator</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="py-4 py-lg-5">
|
||||||
|
<div class="container-xl">
|
||||||
|
<?php if (!$preset): ?>
|
||||||
|
<section class="panel p-4 p-lg-5 text-center mx-auto" style="max-width: 720px;">
|
||||||
|
<div class="small-label">Preset detail</div>
|
||||||
|
<h1 class="h3 mb-3">Preset not found</h1>
|
||||||
|
<p class="text-secondary mb-4">The requested preset does not exist yet or may have been removed.</p>
|
||||||
|
<a class="btn btn-light" href="/index.php#presets">Return to the simulator</a>
|
||||||
|
</section>
|
||||||
|
<?php else: ?>
|
||||||
|
<section class="hero-panel panel p-4 p-lg-5 mb-4">
|
||||||
|
<div class="row g-4 align-items-center">
|
||||||
|
<div class="col-lg-8">
|
||||||
|
<div class="eyebrow mb-3">Preset detail · #<?= e((string) $preset['id']) ?></div>
|
||||||
|
<h1 class="display-title mb-3"><?= e($preset['name']) ?></h1>
|
||||||
|
<p class="lead text-secondary mb-4">A saved recliner profile for repeatable haptic demos. Use this screen to review the settings, then reopen it in the simulator to test with a connected controller.</p>
|
||||||
|
<div class="d-flex flex-wrap gap-2 meta-pills">
|
||||||
|
<span class="chip"><?= e((string) $preset['angle_deg']) ?>° angle</span>
|
||||||
|
<span class="chip"><?= e((string) $preset['intensity_pct']) ?>% intensity</span>
|
||||||
|
<span class="chip"><?= e(ucfirst((string) $preset['pattern_mode'])) ?> rumble</span>
|
||||||
|
<span class="chip"><?= e((string) $preset['duration_ms']) ?> ms</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-4">
|
||||||
|
<div class="panel inset-panel p-3 h-100">
|
||||||
|
<div class="small-label mb-3">Profile tone</div>
|
||||||
|
<div class="h4 mb-2"><?= e(preset_tone((int) $preset['angle_deg'])) ?></div>
|
||||||
|
<p class="text-secondary small mb-0">Saved on <?= e(date('F j, Y H:i', strtotime((string) $preset['created_at']))) ?> UTC.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="row g-4">
|
||||||
|
<div class="col-lg-7">
|
||||||
|
<section class="panel p-4 h-100">
|
||||||
|
<div class="small-label mb-3">Settings overview</div>
|
||||||
|
<div class="spec-list preset-detail-specs">
|
||||||
|
<div><span>Recline angle</span><strong><?= e((string) $preset['angle_deg']) ?>°</strong></div>
|
||||||
|
<div><span>Vibration intensity</span><strong><?= e((string) $preset['intensity_pct']) ?>%</strong></div>
|
||||||
|
<div><span>Pattern</span><strong><?= e(ucfirst((string) $preset['pattern_mode'])) ?></strong></div>
|
||||||
|
<div><span>Duration</span><strong><?= e((string) $preset['duration_ms']) ?> ms</strong></div>
|
||||||
|
<div><span>Saved at</span><strong><?= e((string) $preset['created_at']) ?></strong></div>
|
||||||
|
</div>
|
||||||
|
<hr class="border-secondary-subtle my-4">
|
||||||
|
<div class="small-label mb-2">Operator notes</div>
|
||||||
|
<p class="text-secondary mb-0"><?= e($preset['notes'] ?: 'No notes added for this preset.') ?></p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-5">
|
||||||
|
<section class="panel p-4 h-100 d-flex flex-column justify-content-between">
|
||||||
|
<div>
|
||||||
|
<div class="small-label mb-3">Next action</div>
|
||||||
|
<h2 class="h4 mb-2">Load this profile into the simulator</h2>
|
||||||
|
<p class="text-secondary mb-4">Jump back into the main workspace with these saved values prefilled. Then press <strong>Test vibration</strong> to drive the controller.</p>
|
||||||
|
</div>
|
||||||
|
<div class="d-grid gap-2">
|
||||||
|
<a class="btn btn-light" href="/index.php?preset=<?= e((string) $preset['id']) ?>#simulator">Open in simulator</a>
|
||||||
|
<a class="btn btn-outline-light" href="/index.php#presets">Browse recent presets</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Loading…
x
Reference in New Issue
Block a user