diff --git a/app.php b/app.php new file mode 100644 index 0000000..df6c949 --- /dev/null +++ b/app.php @@ -0,0 +1,166 @@ + (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'); +} diff --git a/assets/css/custom.css b/assets/css/custom.css index 789132e..46d81b7 100644 --- a/assets/css/custom.css +++ b/assets/css/custom.css @@ -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 { - background: linear-gradient(-45deg, #ee7752, #e73c7e, #23a6d5, #23d5ab); - background-size: 400% 400%; - animation: gradient 15s ease infinite; - color: #212529; - font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; - font-size: 14px; - margin: 0; - min-height: 100vh; + background: var(--bg); + color: var(--text); + font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + letter-spacing: -0.01em; } -.main-wrapper { - display: flex; - align-items: center; - justify-content: center; - min-height: 100vh; - width: 100%; - padding: 20px; - box-sizing: border-box; +.shell-header { + background: rgba(11, 13, 16, 0.88); + backdrop-filter: blur(12px); +} + +.navbar-brand { + letter-spacing: -0.03em; +} + +.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; - 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; } -.chat-header { - padding: 1.5rem; - border-bottom: 1px solid rgba(0, 0, 0, 0.05); - background: rgba(255, 255, 255, 0.5); - font-weight: 700; - font-size: 1.1rem; - 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; +.hero-panel::after { + content: ""; + position: absolute; + inset: 0; + border: 1px solid rgba(255, 255, 255, 0.03); + border-radius: inherit; pointer-events: none; } -.blob { - position: absolute; - width: 500px; - height: 500px; - background: rgba(255, 255, 255, 0.2); - border-radius: 50%; - filter: blur(80px); - animation: move 20s infinite alternate cubic-bezier(0.45, 0, 0.55, 1); +.display-title { + font-size: clamp(2rem, 3vw, 3.5rem); + line-height: 1.02; + font-weight: 650; + max-width: 12ch; + letter-spacing: -0.05em; } -.blob-1 { - top: -10%; - left: -10%; - 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; +.eyebrow, +.small-label { + color: var(--muted); text-transform: uppercase; - font-size: 0.75rem; - letter-spacing: 1px; -} - -.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; + letter-spacing: 0.12em; + font-size: 0.72rem; font-weight: 600; - font-size: 0.9rem; } -.form-control { - width: 100%; - padding: 0.75rem 1rem; - border: 1px solid rgba(0, 0, 0, 0.1); - border-radius: 12px; - background: #fff; - transition: all 0.3s ease; - box-sizing: border-box; +.lead, +.text-secondary, +.form-text, +.form-label, +.form-control::placeholder, +textarea::placeholder { + color: var(--muted) !important; } -.form-control:focus { - outline: none; - border-color: #23a6d5; - box-shadow: 0 0 0 3px rgba(35, 166, 213, 0.1); +.meta-pills, +.control-stack { + display: grid; + gap: var(--space-3); } -.header-container { - display: flex; - justify-content: space-between; +.chip, +.badge-soft, +.value-pill, +.gamepad-state { + display: inline-flex; 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; - 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 { - background: rgba(255, 255, 255, 0.6); - padding: 2rem; - 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); +.spec-list > div:last-child { + border-bottom: 0; + padding-bottom: 0; } -.admin-card h3 { - margin-top: 0; - margin-bottom: 1.5rem; - font-weight: 700; +.spec-list span, +.mini-stat span, +.preset-metrics span { + color: var(--muted); + font-size: 0.82rem; } -.btn-delete { - background: #dc3545; - color: white; - border: none; - padding: 0.25rem 0.5rem; - border-radius: 4px; - cursor: pointer; +.spec-list.compact { + gap: var(--space-2); } -.btn-add { - background: #212529; - color: white; - border: none; - padding: 0.5rem 1rem; - border-radius: 4px; - cursor: pointer; - margin-top: 1rem; +.status-dot { + width: 10px; + height: 10px; + border-radius: 999px; + display: inline-block; + margin-top: 4px; } -.btn-save { - background: #0088cc; - color: white; - border: none; - padding: 0.8rem 1.5rem; - border-radius: 12px; - cursor: pointer; +.status-live { + background: var(--success); + box-shadow: 0 0 0 8px rgba(102, 187, 138, 0.12); +} + +.status-idle { + 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; - width: 100%; - transition: all 0.3s ease; + letter-spacing: -0.01em; } -.webhook-url { - font-size: 0.85em; - color: #555; - margin-top: 0.5rem; +.btn-light { + background: var(--accent); + color: var(--accent-ink); + border-color: var(--accent); } -.history-table-container { - overflow-x: auto; - background: rgba(255, 255, 255, 0.4); - padding: 1rem; - border-radius: 12px; - border: 1px solid rgba(255, 255, 255, 0.3); +.btn-light:hover, +.btn-light:focus { + background: #e3e7eb; + border-color: #e3e7eb; + color: var(--accent-ink); } -.history-table { - width: 100%; +.btn-accent { + background: #e6eaef; + color: #11151a; + border: 1px solid #e6eaef; } -.history-table-time { - width: 15%; - white-space: nowrap; - font-size: 0.85em; - color: #555; +.btn-accent:hover, +.btn-accent:focus { + background: #f2f4f6; + border-color: #f2f4f6; + color: #11151a; } -.history-table-user { - width: 35%; - background: rgba(255, 255, 255, 0.3); - border-radius: 8px; - padding: 8px; +.btn-outline-light { + border-color: var(--border-strong); } -.history-table-ai { - width: 50%; - background: rgba(255, 255, 255, 0.5); - border-radius: 8px; - padding: 8px; +.btn-link { + color: #cdd3db; } -.no-messages { - text-align: center; - color: #777; -} \ No newline at end of file +.btn-link:hover, +.btn-link:focus { + 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; + } +} diff --git a/assets/js/main.js b/assets/js/main.js index d349598..f6781e6 100644 --- a/assets/js/main.js +++ b/assets/js/main.js @@ -1,39 +1,310 @@ document.addEventListener('DOMContentLoaded', () => { - const chatForm = document.getElementById('chat-form'); - const chatInput = document.getElementById('chat-input'); - const chatMessages = document.getElementById('chat-messages'); + const appConfig = window.appConfig || {}; + const toastElement = document.getElementById('app-toast'); + const toastMessage = document.getElementById('toast-message'); + const toast = toastElement && window.bootstrap ? new window.bootstrap.Toast(toastElement, { delay: 3200 }) : null; - const appendMessage = (text, sender) => { - const msgDiv = document.createElement('div'); - msgDiv.classList.add('message', sender); - msgDiv.textContent = text; - chatMessages.appendChild(msgDiv); - chatMessages.scrollTop = chatMessages.scrollHeight; + const controls = { + angle: document.getElementById('angle_deg'), + intensity: document.getElementById('intensity_pct'), + pattern: document.getElementById('pattern_mode'), + duration: document.getElementById('duration_ms'), + name: document.getElementById('name'), + notes: document.getElementById('notes') }; - chatForm.addEventListener('submit', async (e) => { - e.preventDefault(); - const message = chatInput.value.trim(); - if (!message) return; + const ui = { + figure: document.getElementById('recliner-figure'), + angleValue: document.getElementById('angle-value'), + 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'); - chatInput.value = ''; + let knownGamepads = []; + 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 = ''; + 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 { - const response = await fetch('api/chat.php', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ message }) - }); - const data = await response.json(); - - // Artificial delay for realism - setTimeout(() => { - appendMessage(data.reply, 'bot'); - }, 500); + if (pattern === 'pulse') { + let remaining = duration; + while (remaining > 0) { + const burst = Math.min(160, remaining); + await playEffect(actuator, normalizedIntensity, burst); + remaining -= burst; + if (remaining > 0) { + await new Promise((resolve) => window.setTimeout(resolve, 120)); + } + } + } else { + await playEffect(actuator, normalizedIntensity, duration); + } + notify(`Running ${pattern} vibration at ${intensity}% for ${duration} ms.`); } catch (error) { - console.error('Error:', error); - appendMessage("Sorry, something went wrong. Please try again.", 'bot'); + console.error(error); + 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); } }); }); diff --git a/healthz.php b/healthz.php new file mode 100644 index 0000000..790fa84 --- /dev/null +++ b/healthz.php @@ -0,0 +1,25 @@ +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); +} diff --git a/index.php b/index.php index 7205f3d..862418a 100644 --- a/index.php +++ b/index.php @@ -1,150 +1,350 @@ ['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.'; +} ?> - - - New Style - - - - - - - - - + + + <?= e($pageTitle) ?> + + + + + + - - - - + + - - - - + + -
-
-

Analyzing your requirements and generating your website…

-
- Loading… +
+
+
+ Recliner Haptics + Now +
-

AI is collecting your requirements and applying the first changes.

-

This page will update automatically as the plan is implemented.

-

Runtime: PHP — UTC

+
+
+
+ +
+ +
+ +
+
+
+
+
+
Browser web app · Gamepad vibration demo
+

Simulate a recliner experience with live angle control and haptic testing.

+

Connect a compatible gamepad in Chrome or Edge, tune the recline angle, choose a vibration pattern, and save reusable presets for demos or prototyping.

+
+ 0–160° recline visualizer + Continuous + pulse rumble + Saved preset library +
+
+
+
+
+
+
Loaded state
+
+
+ +
+
+
Angle°
+
Intensity%
+
Pattern
+
Tone
+
+

Tip: the browser requires a user interaction before triggering vibration. Press Test vibration after connecting the controller.

+
+
+
+
+ +
+ + +
+
+
+
+
Visualizer
+

Live recline preview

+

A restrained 2D profile that updates instantly as you move the angle and intensity controls.

+
+
+
+ +
+
+
upright
+
full recline
+
+
+
+
+
+
+
+
+
degrees
+
+
+ +
+
+
Recline°
+
+
+
Rumble%
+
+
+
Pattern
+
+
+
Duration ms
+
+
+
+ +
+
+
+
Gamepad
+

Haptic control surface

+

Select a connected controller, then test the current preset with one click.

+
+
Scanning for controllers…
+
+ +
+
+ + +
Works best in Chrome or Edge with a gamepad that exposes vibrationActuator support.
+
+
+ + +
+
+ +
+
+
°
+ +
+
+
%
+ +
+
+
+ + +
+
+
ms
+ +
+
+
+
+
+ +
+
+
Save preset
+

Name and store the current configuration

+

Create a reusable preset library so your simulator feels like a real tool, not just a single test screen.

+ + + + + +
+ + +
+
+
+ + +
+
+ +
+
Current profile
+
+
Angle°
+
Intensity%
+
Pattern
+
Duration ms
+
+
+ +
+ + +
+
+ +
+
+
+
Preset library
+

Recent presets

+

Load a saved setup back into the simulator or open a dedicated detail view.

+
+ stored +
+ + +
+ +
+
+
+

+

· UTC

+
+ ° +
+
+ % intensity + + ms +
+ +

+ +
+ + View detail +
+
+ +
+ +
+

No presets yet

+

Tune the simulator, save your first profile, and it will appear here for quick reuse.

+
+ +
+
+
-