361 lines
21 KiB
PHP
361 lines
21 KiB
PHP
<?php
|
||
declare(strict_types=1);
|
||
|
||
require_once __DIR__ . '/app.php';
|
||
|
||
@date_default_timezone_set('UTC');
|
||
|
||
ensure_recliner_schema();
|
||
$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'],
|
||
'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>
|
||
<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">
|
||
<meta property="og:title" content="<?= e($pageTitle) ?>">
|
||
<meta property="og:description" content="<?= e($pageDescription) ?>">
|
||
<meta property="twitter:title" content="<?= e($pageTitle) ?>">
|
||
<meta property="twitter:description" content="<?= e($pageDescription) ?>">
|
||
<?php if ($projectImageUrl): ?>
|
||
<meta property="og:image" content="<?= e($projectImageUrl) ?>">
|
||
<meta property="twitter:image" content="<?= e($projectImageUrl) ?>">
|
||
<?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>
|
||
<div class="toast-container position-fixed top-0 end-0 p-3">
|
||
<div id="app-toast" class="toast text-bg-dark border border-secondary-subtle" role="status" aria-live="polite" aria-atomic="true">
|
||
<div class="toast-header bg-dark text-light border-bottom border-secondary-subtle">
|
||
<strong class="me-auto">Recliner Haptics</strong>
|
||
<small>Now</small>
|
||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="toast" aria-label="Close"></button>
|
||
</div>
|
||
<div class="toast-body" id="toast-message"><?= e($toastMessage ?? '') ?></div>
|
||
</div>
|
||
</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>
|
||
<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 3D recline preview</h2>
|
||
<p class="text-secondary mb-0">A dimensional lounge model that tilts with the angle control and physically shakes in sync while the vibration test is running.</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="recliner-aurora" aria-hidden="true"></div>
|
||
<div class="axis-label axis-label-left">upright</div>
|
||
<div class="axis-label axis-label-right">full recline</div>
|
||
<div class="motion-indicator" id="motion-state">Preview idle</div>
|
||
<div class="recliner-figure" id="recliner-figure" role="img" aria-label="3D recliner preview responding to angle and vibration settings" data-pattern="<?= e($formData['pattern_mode']) ?>" style="--angle: <?= e((string) $formData['angle_deg']) ?>; --intensity: <?= e((string) $formData['intensity_pct']) ?>; --back-angle: 0; --leg-angle: 0; --head-angle: 0;">
|
||
<div class="recliner-rig" aria-hidden="true">
|
||
<div class="recliner-motion" id="recliner-motion">
|
||
<div class="recliner-glow"></div>
|
||
<div class="recliner-floor"></div>
|
||
<div class="recliner-plinth recliner-block"></div>
|
||
<div class="recliner-column recliner-block"></div>
|
||
<div class="recliner-base recliner-block"></div>
|
||
<div class="recliner-seat recliner-block"></div>
|
||
<div class="recliner-seat-pad"></div>
|
||
<div class="recliner-arm recliner-block"></div>
|
||
<div class="recliner-arm recliner-arm-secondary recliner-block"></div>
|
||
<div class="recliner-back recliner-block" id="part-back"></div>
|
||
<div class="recliner-back-inner" id="part-back-inner"></div>
|
||
<div class="recliner-head recliner-block" id="part-head"></div>
|
||
<div class="recliner-leg recliner-block" id="part-leg"></div>
|
||
<div class="recliner-footpad recliner-block" id="part-footpad"></div>
|
||
</div>
|
||
</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-4">
|
||
<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-4">
|
||
<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-4">
|
||
<div class="mini-stat"><span>Pattern</span><strong id="stat-pattern"><?= e(ucfirst($formData['pattern_mode'])) ?></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">Controls</div>
|
||
<h2 class="h4 mb-1">Part and Haptic Controls</h2>
|
||
<p class="text-secondary mb-0">Adjust individual recliner parts and test haptics.</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 (continuous)</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">Overall Recline</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="back_angle" class="form-label mb-0">Back Adjustment</label><span class="value-pill" id="back-angle-pill">0°</span></div>
|
||
<input type="range" class="form-range" min="-20" max="20" step="1" id="back_angle" name="back_angle" value="0">
|
||
</div>
|
||
<div class="control-group">
|
||
<div class="d-flex justify-content-between align-items-center mb-2"><label for="leg_angle" class="form-label mb-0">Leg Adjustment</label><span class="value-pill" id="leg-angle-pill">0°</span></div>
|
||
<input type="range" class="form-range" min="-20" max="20" step="1" id="leg_angle" name="leg_angle" value="0">
|
||
</div>
|
||
<div class="control-group">
|
||
<div class="d-flex justify-content-between align-items-center mb-2"><label for="head_angle" class="form-label mb-0">Head Adjustment</label><span class="value-pill" id="head-angle-pill">0°</span></div>
|
||
<input type="range" class="form-range" min="-20" max="20" step="1" id="head_angle" name="head_angle" value="0">
|
||
</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="control-group">
|
||
<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>
|
||
</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>
|
||
</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>
|
||
</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-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>
|
||
</main>
|
||
|
||
<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>
|
||
|
||
<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>
|
||
</html>
|