39282-vm/index.php
Flatlogic Bot edc6d68e9e v4
2026-03-27 23:45:58 +00:00

349 lines
20 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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">0160° 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']) ?>;">
<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"></div>
<div class="recliner-back-inner"></div>
<div class="recliner-head recliner-block"></div>
<div class="recliner-leg recliner-block"></div>
<div class="recliner-footpad recliner-block"></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">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 (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">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="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>Couldnt 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>