apirrone commited on
Commit
c3bcf7e
·
0 Parent(s):

first commit!

Browse files
README.md ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Reachy Mini Clock
3
+ emoji: 👋
4
+ colorFrom: red
5
+ colorTo: blue
6
+ sdk: static
7
+ pinned: false
8
+ tags:
9
+ - reachy_mini
10
+ ---
index.html ADDED
@@ -0,0 +1,235 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html>
3
+
4
+ <head>
5
+ <meta charset="utf-8" />
6
+ <meta name="viewport" content="width=device-width" />
7
+ <title> Reachy Mini Clock </title>
8
+ <link rel="stylesheet" href="style.css" />
9
+ </head>
10
+
11
+ <body>
12
+ <div class="hero">
13
+ <div class="hero-content">
14
+ <div class="app-icon">🤖⚡</div>
15
+ <h1> Reachy Mini Clock </h1>
16
+ <p class="tagline">Enter your tagline here</p>
17
+ </div>
18
+ </div>
19
+
20
+ <div class="container">
21
+ <div class="main-card">
22
+ <div class="app-preview">
23
+ <div class="preview-image">
24
+ <div class="camera-feed">🛠️</div>
25
+ </div>
26
+ </div>
27
+ </div>
28
+ </div>
29
+
30
+ <div class="download-section">
31
+ <div class="download-card">
32
+ <h2>Install This App</h2>
33
+
34
+ <div class="dashboard-config">
35
+ <label for="dashboardUrl">Your Reachy Dashboard URL:</label>
36
+ <input type="url" id="dashboardUrl" value="http://localhost:8000"
37
+ placeholder="http://your-reachy-ip:8000" />
38
+ </div>
39
+
40
+ <button id="installBtn" class="install-btn primary">
41
+ <span class="btn-icon">📥</span>
42
+ Install Reachy Mini Clock to Reachy Mini
43
+ </button>
44
+
45
+ <div id="installStatus" class="install-status"></div>
46
+
47
+ </div>
48
+ </div>
49
+
50
+ <div class="footer">
51
+ <p>
52
+ 🤖 Reachy Mini Clock •
53
+ <a href="https://github.com/pollen-robotics" target="_blank">Pollen Robotics</a> •
54
+ <a href="https://huggingface.co/spaces/pollen-robotics/Reachy_Mini_Apps" target="_blank">Browse More
55
+ Apps</a>
56
+ </p>
57
+ </div>
58
+ </div>
59
+
60
+ <script>
61
+ // Get the current Hugging Face Space URL as the repository URL
62
+ function getCurrentSpaceUrl() {
63
+ // Get current page URL and convert to repository format
64
+ const currentUrl = window.location.href;
65
+
66
+ // Remove any trailing slashes and query parameters
67
+ const cleanUrl = currentUrl.split('?')[0].replace(/\/$/, '');
68
+
69
+ return cleanUrl;
70
+ }
71
+
72
+ // Parse TOML content to extract project name
73
+ function parseTomlProjectName(tomlContent) {
74
+ try {
75
+ const lines = tomlContent.split('\n');
76
+ let inProjectSection = false;
77
+
78
+ for (const line of lines) {
79
+ const trimmedLine = line.trim();
80
+
81
+ // Check if we're entering the [project] section
82
+ if (trimmedLine === '[project]') {
83
+ inProjectSection = true;
84
+ continue;
85
+ }
86
+
87
+ // Check if we're entering a different section
88
+ if (trimmedLine.startsWith('[') && trimmedLine !== '[project]') {
89
+ inProjectSection = false;
90
+ continue;
91
+ }
92
+
93
+ // If we're in the project section, look for the name field
94
+ if (inProjectSection && trimmedLine.startsWith('name')) {
95
+ const match = trimmedLine.match(/name\s*=\s*["']([^"']+)["']/);
96
+ if (match) {
97
+ // Convert to lowercase and replace invalid characters for app naming
98
+ return match[1].toLowerCase().replace(/[^a-z0-9-_]/g, '-');
99
+ }
100
+ }
101
+ }
102
+
103
+ throw new Error('Project name not found in pyproject.toml');
104
+ } catch (error) {
105
+ console.error('Error parsing pyproject.toml:', error);
106
+ return 'unknown-app';
107
+ }
108
+ }
109
+
110
+ // Fetch and parse pyproject.toml from the current space
111
+ async function getAppNameFromCurrentSpace() {
112
+ try {
113
+ // Fetch pyproject.toml from the current space
114
+ const response = await fetch('./pyproject.toml');
115
+ if (!response.ok) {
116
+ throw new Error(`Failed to fetch pyproject.toml: ${response.status}`);
117
+ }
118
+
119
+ const tomlContent = await response.text();
120
+ return parseTomlProjectName(tomlContent);
121
+ } catch (error) {
122
+ console.error('Error fetching app name from current space:', error);
123
+ // Fallback to extracting from URL if pyproject.toml is not accessible
124
+ const url = getCurrentSpaceUrl();
125
+ const parts = url.split('/');
126
+ const spaceName = parts[parts.length - 1];
127
+ return spaceName.toLowerCase().replace(/[^a-z0-9-_]/g, '-');
128
+ }
129
+ }
130
+
131
+ async function installToReachy() {
132
+ const dashboardUrl = document.getElementById('dashboardUrl').value.trim();
133
+ const statusDiv = document.getElementById('installStatus');
134
+ const installBtn = document.getElementById('installBtn');
135
+
136
+ if (!dashboardUrl) {
137
+ showStatus('error', 'Please enter your Reachy dashboard URL');
138
+ return;
139
+ }
140
+
141
+ try {
142
+ installBtn.disabled = true;
143
+ installBtn.innerHTML = '<span class="btn-icon">⏳</span>Installing...';
144
+ showStatus('loading', 'Connecting to your Reachy dashboard...');
145
+
146
+ // Test connection
147
+ const testResponse = await fetch(`${dashboardUrl}/api/status`, {
148
+ method: 'GET',
149
+ mode: 'cors',
150
+ });
151
+
152
+ if (!testResponse.ok) {
153
+ throw new Error('Cannot connect to dashboard. Make sure the URL is correct and the dashboard is running.');
154
+ }
155
+
156
+ showStatus('loading', 'Reading app configuration...');
157
+
158
+ // Get app name from pyproject.toml in current space
159
+ const appName = await getAppNameFromCurrentSpace();
160
+
161
+ // Get current space URL as repository URL
162
+ const repoUrl = getCurrentSpaceUrl();
163
+
164
+ showStatus('loading', `Starting installation of "${appName}"...`);
165
+
166
+ // Start installation
167
+ const installResponse = await fetch(`${dashboardUrl}/api/install`, {
168
+ method: 'POST',
169
+ mode: 'cors',
170
+ headers: {
171
+ 'Content-Type': 'application/json',
172
+ },
173
+ body: JSON.stringify({
174
+ url: repoUrl,
175
+ name: appName
176
+ })
177
+ });
178
+
179
+ const result = await installResponse.json();
180
+
181
+ if (installResponse.ok) {
182
+ showStatus('success', `✅ Installation started for "${appName}"! Check your dashboard for progress.`);
183
+ setTimeout(() => {
184
+ showStatus('info', `Open your dashboard at ${dashboardUrl} to see the installed app.`);
185
+ }, 3000);
186
+ } else {
187
+ throw new Error(result.detail || 'Installation failed');
188
+ }
189
+
190
+ } catch (error) {
191
+ console.error('Installation error:', error);
192
+ showStatus('error', `❌ ${error.message}`);
193
+ } finally {
194
+ installBtn.disabled = false;
195
+ installBtn.innerHTML = '<span class="btn-icon">📥</span>Install App to Reachy';
196
+ }
197
+ }
198
+
199
+ function showStatus(type, message) {
200
+ const statusDiv = document.getElementById('installStatus');
201
+ statusDiv.className = `install-status ${type}`;
202
+ statusDiv.textContent = message;
203
+ statusDiv.style.display = 'block';
204
+ }
205
+
206
+ function copyToClipboard() {
207
+ const repoUrl = document.getElementById('repoUrl').textContent;
208
+ navigator.clipboard.writeText(repoUrl).then(() => {
209
+ showStatus('success', '📋 Repository URL copied to clipboard!');
210
+ }).catch(() => {
211
+ showStatus('error', 'Failed to copy URL. Please copy manually.');
212
+ });
213
+ }
214
+
215
+ // Update the displayed repository URL on page load
216
+ document.addEventListener('DOMContentLoaded', () => {
217
+ // Auto-detect local dashboard
218
+ const isLocalhost = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
219
+ if (isLocalhost) {
220
+ document.getElementById('dashboardUrl').value = 'http://localhost:8000';
221
+ }
222
+
223
+ // Update the repository URL display if element exists
224
+ const repoUrlElement = document.getElementById('repoUrl');
225
+ if (repoUrlElement) {
226
+ repoUrlElement.textContent = getCurrentSpaceUrl();
227
+ }
228
+ });
229
+
230
+ // Event listeners
231
+ document.getElementById('installBtn').addEventListener('click', installToReachy);
232
+ </script>
233
+ </body>
234
+
235
+ </html>
pyproject.toml ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [build-system]
2
+ requires = ["setuptools>=61.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+
6
+ [project]
7
+ name = "reachy_mini_clock"
8
+ version = "0.1.0"
9
+ description = "Add your description here"
10
+ readme = "README.md"
11
+ requires-python = ">=3.10"
12
+ dependencies = [
13
+ "reachy-mini"
14
+ ]
15
+ keywords = ["reachy-mini-app"]
16
+
17
+ [project.entry-points."reachy_mini_apps"]
18
+ reachy_mini_clock = "reachy_mini_clock.main:ReachyMiniClock"
reachy_mini_clock.egg-info/PKG-INFO ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Metadata-Version: 2.4
2
+ Name: reachy_mini_clock
3
+ Version: 0.1.0
4
+ Summary: Add your description here
5
+ Keywords: reachy-mini-app
6
+ Requires-Python: >=3.10
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: reachy-mini
9
+
10
+ ---
11
+ title: Reachy Mini Clock
12
+ emoji: 👋
13
+ colorFrom: red
14
+ colorTo: blue
15
+ sdk: static
16
+ pinned: false
17
+ tags:
18
+ - reachy_mini
19
+ ---
reachy_mini_clock.egg-info/SOURCES.txt ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ README.md
2
+ pyproject.toml
3
+ reachy_mini_clock/__init__.py
4
+ reachy_mini_clock/main.py
5
+ reachy_mini_clock.egg-info/PKG-INFO
6
+ reachy_mini_clock.egg-info/SOURCES.txt
7
+ reachy_mini_clock.egg-info/dependency_links.txt
8
+ reachy_mini_clock.egg-info/entry_points.txt
9
+ reachy_mini_clock.egg-info/requires.txt
10
+ reachy_mini_clock.egg-info/top_level.txt
reachy_mini_clock.egg-info/dependency_links.txt ADDED
@@ -0,0 +1 @@
 
 
1
+
reachy_mini_clock.egg-info/entry_points.txt ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ [reachy_mini_apps]
2
+ reachy_mini_clock = reachy_mini_clock.main:ReachyMiniClock
reachy_mini_clock.egg-info/requires.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ reachy-mini
reachy_mini_clock.egg-info/top_level.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ reachy_mini_clock
reachy_mini_clock/__init__.py ADDED
File without changes
reachy_mini_clock/__pycache__/__init__.cpython-312.pyc ADDED
Binary file (165 Bytes). View file
 
reachy_mini_clock/__pycache__/main.cpython-312.pyc ADDED
Binary file (8.54 kB). View file
 
reachy_mini_clock/index.html ADDED
@@ -0,0 +1,553 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>Timezone Selector</title>
7
+ <style>
8
+ :root{
9
+ --bg: #0b1020;
10
+ --card: rgba(255,255,255,0.06);
11
+ --card-2: rgba(255,255,255,0.04);
12
+ --text: #e6e9f5;
13
+ --muted: #aab1c7;
14
+ --accent: #7aa2ff;
15
+ --accent-2: #9bffd1;
16
+ --ring: rgba(122,162,255,0.55);
17
+ --ok: #22c55e;
18
+ --warn: #fbbf24;
19
+ --radius-xl: 18px;
20
+ --radius-lg: 14px;
21
+ --shadow: 0 10px 30px rgba(0,0,0,0.35);
22
+ font-synthesis: style;
23
+ }
24
+ * { box-sizing: border-box; }
25
+ body{
26
+ margin:0;
27
+ min-height:100vh;
28
+ display:grid;
29
+ place-items:center;
30
+ background:
31
+ radial-gradient(1000px 600px at 10% -10%, #1a2357, transparent 60%),
32
+ radial-gradient(900px 500px at 110% 10%, #0f5f63, transparent 55%),
33
+ radial-gradient(1200px 800px at 50% 120%, #2a184f, transparent 60%),
34
+ var(--bg);
35
+ color:var(--text);
36
+ font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, "Helvetica Neue", Arial, sans-serif;
37
+ line-height:1.4;
38
+ padding:24px;
39
+ }
40
+ .app{
41
+ width:min(860px, 100%);
42
+ background: linear-gradient(180deg, var(--card), var(--card-2));
43
+ border: 1px solid rgba(255,255,255,0.08);
44
+ border-radius: var(--radius-xl);
45
+ box-shadow: var(--shadow);
46
+ overflow:hidden;
47
+ display:grid;
48
+ grid-template-columns: 1.1fr 0.9fr;
49
+ }
50
+ @media (max-width: 800px){
51
+ .app{ grid-template-columns: 1fr; }
52
+ }
53
+ header{
54
+ grid-column: 1 / -1;
55
+ padding:22px 22px 0 22px;
56
+ display:flex;
57
+ align-items:center;
58
+ justify-content:space-between;
59
+ gap:12px;
60
+ }
61
+ .title{
62
+ display:flex; gap:12px; align-items:center;
63
+ }
64
+ .logo{
65
+ width:42px; height:42px; border-radius:12px;
66
+ background: conic-gradient(from 120deg, var(--accent), var(--accent-2), #c084fc, var(--accent));
67
+ filter:saturate(1.2);
68
+ box-shadow: 0 6px 18px rgba(122,162,255,0.35);
69
+ }
70
+ h1{
71
+ margin:0;
72
+ font-size: clamp(18px, 2.6vw, 24px);
73
+ letter-spacing:0.2px;
74
+ }
75
+ .subtitle{
76
+ margin-top:2px; color:var(--muted); font-size:13px;
77
+ }
78
+ .body{
79
+ padding:18px 22px 22px 22px;
80
+ display:grid;
81
+ gap:14px;
82
+ }
83
+ .panel{
84
+ padding:16px;
85
+ background: rgba(255,255,255,0.04);
86
+ border:1px solid rgba(255,255,255,0.08);
87
+ border-radius: var(--radius-lg);
88
+ display:grid;
89
+ gap:12px;
90
+ }
91
+ label{
92
+ font-size:13px;
93
+ color:var(--muted);
94
+ display:flex; align-items:center; justify-content:space-between;
95
+ gap:8px;
96
+ }
97
+ .pill{
98
+ font-size:12px; color:#0b1020; background:#c7d2fe;
99
+ padding:2px 8px; border-radius:999px; font-weight:600;
100
+ }
101
+ .search{
102
+ position:relative; display:flex; align-items:center;
103
+ }
104
+ .search input{
105
+ width:100%;
106
+ background: rgba(0,0,0,0.25);
107
+ border:1px solid rgba(255,255,255,0.12);
108
+ color:var(--text);
109
+ padding:10px 12px 10px 36px;
110
+ border-radius:12px;
111
+ outline:none;
112
+ transition: border .15s, box-shadow .15s;
113
+ }
114
+ .search input:focus{
115
+ border-color: var(--accent);
116
+ box-shadow: 0 0 0 4px var(--ring);
117
+ }
118
+ .search svg{
119
+ position:absolute; left:10px; width:16px; height:16px; opacity:.75;
120
+ fill: none; stroke: var(--muted); stroke-width:2; stroke-linecap:round; stroke-linejoin:round;
121
+ }
122
+
123
+ select{
124
+ width:100%;
125
+ appearance:none;
126
+ background:
127
+ linear-gradient(180deg, rgba(255,255,255,0.06), rgba(255,255,255,0.02)),
128
+ rgba(0,0,0,0.25);
129
+ border:1px solid rgba(255,255,255,0.12);
130
+ color:var(--text);
131
+ padding:11px 40px 11px 12px;
132
+ border-radius:12px;
133
+ outline:none;
134
+ font-size:14px;
135
+ transition: border .15s, box-shadow .15s, transform .05s;
136
+ }
137
+ select:focus{
138
+ border-color: var(--accent);
139
+ box-shadow: 0 0 0 4px var(--ring);
140
+ }
141
+ .select-wrap{
142
+ position:relative;
143
+ }
144
+ .select-wrap::after{
145
+ content:"";
146
+ position:absolute; right:12px; top:50%; transform:translateY(-50%);
147
+ width:10px; height:10px; pointer-events:none;
148
+ border-right:2px solid var(--muted);
149
+ border-bottom:2px solid var(--muted);
150
+ transform: translateY(-60%) rotate(45deg);
151
+ opacity:.9;
152
+ }
153
+
154
+ .row{
155
+ display:flex; gap:10px; flex-wrap:wrap;
156
+ }
157
+ button{
158
+ border:none; cursor:pointer; color:#0b1020;
159
+ background: linear-gradient(135deg, var(--accent), var(--accent-2));
160
+ padding:10px 12px; border-radius:12px; font-weight:700; font-size:14px;
161
+ box-shadow: 0 6px 14px rgba(0,0,0,0.25);
162
+ transition: transform .06s ease, filter .2s ease;
163
+ white-space:nowrap;
164
+ }
165
+ button.secondary{
166
+ color:var(--text);
167
+ background: rgba(255,255,255,0.06);
168
+ border:1px solid rgba(255,255,255,0.12);
169
+ box-shadow:none;
170
+ font-weight:600;
171
+ }
172
+ button:active{ transform: translateY(1px) scale(0.99); }
173
+ button:hover{ filter:saturate(1.1) brightness(1.05); }
174
+
175
+ .preview{
176
+ padding:18px 22px 22px 0;
177
+ display:grid; align-content:start; gap:14px;
178
+ }
179
+ @media (max-width: 800px){
180
+ .preview{ padding:0 22px 22px 22px; }
181
+ }
182
+
183
+ .big{
184
+ padding:18px;
185
+ background: rgba(0,0,0,0.25);
186
+ border:1px dashed rgba(255,255,255,0.14);
187
+ border-radius: var(--radius-lg);
188
+ display:grid; gap:8px;
189
+ }
190
+ .big .time{
191
+ font-size: clamp(22px, 4vw, 30px);
192
+ font-weight:800; letter-spacing:0.3px;
193
+ }
194
+ .big .date{
195
+ color:var(--muted); font-size:14px;
196
+ }
197
+ .meta{
198
+ display:grid; grid-template-columns:1fr 1fr; gap:10px;
199
+ }
200
+ .kv{
201
+ padding:12px; border-radius:12px;
202
+ background: rgba(255,255,255,0.04);
203
+ border:1px solid rgba(255,255,255,0.08);
204
+ display:grid; gap:4px;
205
+ }
206
+ .kv .k{ color:var(--muted); font-size:12px; }
207
+ .kv .v{ font-weight:700; font-size:14px; overflow:hidden; text-overflow:ellipsis; }
208
+
209
+ .toast{
210
+ position:fixed; bottom:20px; left:50%; transform:translateX(-50%);
211
+ background:#0c1225; border:1px solid rgba(255,255,255,0.12); color:var(--text);
212
+ padding:10px 12px; border-radius:12px; box-shadow: var(--shadow);
213
+ opacity:0; pointer-events:none; transition: opacity .25s, transform .25s;
214
+ display:flex; align-items:center; gap:8px; font-size:14px;
215
+ }
216
+ .toast.show{ opacity:1; transform:translateX(-50%) translateY(-6px); }
217
+ .dot{
218
+ width:8px; height:8px; border-radius:50%;
219
+ background: var(--ok); box-shadow:0 0 10px rgba(34,197,94,0.8);
220
+ }
221
+ .hint{
222
+ font-size:12px; color:var(--muted);
223
+ }
224
+ footer{
225
+ grid-column:1/-1;
226
+ padding:0 22px 18px 22px;
227
+ color:var(--muted); font-size:12px;
228
+ display:flex; justify-content:space-between; gap:10px; flex-wrap:wrap;
229
+ }
230
+ a{ color:#c7d2fe; text-decoration:none; }
231
+ a:hover{ text-decoration:underline; }
232
+ </style>
233
+ </head>
234
+ <body>
235
+ <main class="app" role="application" aria-label="Timezone selector">
236
+ <header>
237
+ <div class="title">
238
+ <div class="logo" aria-hidden="true"></div>
239
+ <div>
240
+ <h1>Choose your timezone</h1>
241
+ <div class="subtitle">Search, preview current time, and save your preference.</div>
242
+ </div>
243
+ </div>
244
+ <div class="row">
245
+ <button id="detectBtn" class="secondary" type="button">Auto-detect</button>
246
+ <button id="saveBtn" type="button">Save</button>
247
+ </div>
248
+ </header>
249
+
250
+ <section class="body">
251
+ <div class="panel">
252
+ <label for="searchInput">
253
+ Search timezones
254
+ <span class="pill" id="countPill">0</span>
255
+ </label>
256
+ <div class="search">
257
+ <svg viewBox="0 0 24 24" aria-hidden="true">
258
+ <circle cx="11" cy="11" r="7"></circle>
259
+ <line x1="16.65" y1="16.65" x2="21" y2="21"></line>
260
+ </svg>
261
+ <input id="searchInput" type="text" placeholder="e.g., Paris, New York, Tokyo, UTC+1..." autocomplete="off" />
262
+ </div>
263
+ <div class="select-wrap">
264
+ <select id="tzSelect" size="10" aria-label="Timezones list"></select>
265
+ </div>
266
+ <div class="hint">Tip: type a city or region name. The list filters as you type.</div>
267
+ </div>
268
+
269
+ <div class="panel">
270
+ <label>Format</label>
271
+ <div class="row" role="radiogroup" aria-label="Time format">
272
+ <button type="button" class="secondary" id="fmt12">12-hour</button>
273
+ <button type="button" class="secondary" id="fmt24">24-hour</button>
274
+ <button type="button" class="secondary" id="showUtc">Show UTC offset</button>
275
+ </div>
276
+ </div>
277
+ </section>
278
+
279
+ <aside class="preview" aria-live="polite">
280
+ <div class="big">
281
+ <div class="time" id="previewTime">—</div>
282
+ <div class="date" id="previewDate">—</div>
283
+ </div>
284
+
285
+ <div class="meta">
286
+ <div class="kv">
287
+ <div class="k">Selected timezone</div>
288
+ <div class="v" id="previewTz">—</div>
289
+ </div>
290
+ <div class="kv">
291
+ <div class="k">UTC offset (now)</div>
292
+ <div class="v" id="previewOffset">—</div>
293
+ </div>
294
+ <div class="kv">
295
+ <div class="k">Your system timezone</div>
296
+ <div class="v" id="systemTz">—</div>
297
+ </div>
298
+ <div class="kv">
299
+ <div class="k">Saved preference</div>
300
+ <div class="v" id="savedTz">—</div>
301
+ </div>
302
+ </div>
303
+ </aside>
304
+
305
+ <footer>
306
+ <div>Uses the IANA timezone database from your browser.</div>
307
+ <div>Stored locally in this browser only.</div>
308
+ </footer>
309
+ </main>
310
+
311
+ <div class="toast" id="toast" role="status" aria-live="polite">
312
+ <div class="dot" aria-hidden="true"></div>
313
+ <div id="toastMsg">Saved.</div>
314
+ </div>
315
+
316
+ <script>
317
+ // ------- Data (fallback) -------
318
+ const FALLBACK_TIMEZONES = [
319
+ "UTC",
320
+ "Europe/Paris","Europe/London","Europe/Berlin","Europe/Madrid","Europe/Rome","Europe/Amsterdam",
321
+ "Africa/Cairo","Africa/Johannesburg",
322
+ "Asia/Dubai","Asia/Kolkata","Asia/Bangkok","Asia/Shanghai","Asia/Tokyo","Asia/Seoul","Asia/Singapore",
323
+ "Australia/Sydney","Australia/Perth",
324
+ "America/New_York","America/Chicago","America/Denver","America/Los_Angeles","America/Toronto",
325
+ "America/Sao_Paulo",
326
+ "Pacific/Auckland"
327
+ ];
328
+
329
+ // ------- Elements -------
330
+ const tzSelect = document.getElementById("tzSelect");
331
+ const searchInput = document.getElementById("searchInput");
332
+ const detectBtn = document.getElementById("detectBtn");
333
+ const saveBtn = document.getElementById("saveBtn");
334
+ const countPill = document.getElementById("countPill");
335
+
336
+ const previewTime = document.getElementById("previewTime");
337
+ const previewDate = document.getElementById("previewDate");
338
+ const previewTz = document.getElementById("previewTz");
339
+ const previewOffset = document.getElementById("previewOffset");
340
+ const systemTz = document.getElementById("systemTz");
341
+ const savedTz = document.getElementById("savedTz");
342
+
343
+ const fmt12Btn = document.getElementById("fmt12");
344
+ const fmt24Btn = document.getElementById("fmt24");
345
+ const showUtcBtn = document.getElementById("showUtc");
346
+
347
+ const toast = document.getElementById("toast");
348
+ const toastMsg = document.getElementById("toastMsg");
349
+
350
+ // ------- State -------
351
+ let allTimezones = [];
352
+ let use24h = true;
353
+ let showUtcOffset = true;
354
+ let tickTimer = null;
355
+
356
+ // ------- Helpers -------
357
+ function getTimezones() {
358
+ // Modern browsers expose supported timezones.
359
+ if (typeof Intl.supportedValuesOf === "function") {
360
+ try {
361
+ return Intl.supportedValuesOf("timeZone");
362
+ } catch {}
363
+ }
364
+ return FALLBACK_TIMEZONES;
365
+ }
366
+
367
+ function optionLabel(tz) {
368
+ if (!showUtcOffset) return tz;
369
+ const offset = currentUtcOffset(tz);
370
+ return `${tz} (UTC${offset})`;
371
+ }
372
+
373
+ function currentUtcOffset(tz) {
374
+ // Compute offset by comparing local time vs target time.
375
+ const now = new Date();
376
+ const fmt = new Intl.DateTimeFormat("en-US", {
377
+ timeZone: tz,
378
+ hour12: false,
379
+ year: "numeric", month: "2-digit", day: "2-digit",
380
+ hour: "2-digit", minute: "2-digit", second: "2-digit"
381
+ });
382
+ const parts = Object.fromEntries(fmt.formatToParts(now).map(p => [p.type, p.value]));
383
+ const asUTC = Date.UTC(
384
+ Number(parts.year),
385
+ Number(parts.month) - 1,
386
+ Number(parts.day),
387
+ Number(parts.hour),
388
+ Number(parts.minute),
389
+ Number(parts.second)
390
+ );
391
+ const diffMin = Math.round((asUTC - now.getTime()) / 60000);
392
+ const sign = diffMin <= 0 ? "+" : "-"; // note inverse because asUTC is target wall clock
393
+ const abs = Math.abs(diffMin);
394
+ const hh = String(Math.floor(abs / 60)).padStart(2, "0");
395
+ const mm = String(abs % 60).padStart(2, "0");
396
+ return `${sign}${hh}:${mm}`;
397
+ }
398
+
399
+ function renderList(filter = "") {
400
+ const q = filter.trim().toLowerCase();
401
+ tzSelect.innerHTML = "";
402
+
403
+ const filtered = q
404
+ ? allTimezones.filter(tz => tz.toLowerCase().includes(q) || optionLabel(tz).toLowerCase().includes(q))
405
+ : allTimezones;
406
+
407
+ filtered.forEach(tz => {
408
+ const opt = document.createElement("option");
409
+ opt.value = tz;
410
+ opt.textContent = optionLabel(tz);
411
+ tzSelect.appendChild(opt);
412
+ });
413
+
414
+ countPill.textContent = filtered.length;
415
+ if (filtered.length && !tzSelect.value) {
416
+ tzSelect.value = filtered[0].value;
417
+ }
418
+ updatePreview();
419
+ }
420
+
421
+ function formatPreview(now, tz) {
422
+ const timeFmt = new Intl.DateTimeFormat(undefined, {
423
+ timeZone: tz,
424
+ hour: "2-digit",
425
+ minute: "2-digit",
426
+ second: "2-digit",
427
+ hour12: !use24h
428
+ });
429
+ const dateFmt = new Intl.DateTimeFormat(undefined, {
430
+ timeZone: tz,
431
+ weekday: "long",
432
+ year: "numeric",
433
+ month: "long",
434
+ day: "numeric"
435
+ });
436
+ return { time: timeFmt.format(now), date: dateFmt.format(now) };
437
+ }
438
+
439
+ function updatePreview() {
440
+ const tz = tzSelect.value || allTimezones[0] || "UTC";
441
+ const now = new Date();
442
+
443
+ const { time, date } = formatPreview(now, tz);
444
+
445
+ previewTime.textContent = time;
446
+ previewDate.textContent = date;
447
+ previewTz.textContent = tz;
448
+ previewOffset.textContent = `UTC${currentUtcOffset(tz)}`;
449
+
450
+ const sys = Intl.DateTimeFormat().resolvedOptions().timeZone || "Unknown";
451
+ systemTz.textContent = sys;
452
+
453
+ const saved = localStorage.getItem("preferredTimeZone") || "Not set";
454
+ savedTz.textContent = saved;
455
+ }
456
+
457
+ function startTicker() {
458
+ if (tickTimer) clearInterval(tickTimer);
459
+ tickTimer = setInterval(updatePreview, 1000);
460
+ }
461
+
462
+ function showToast(message, ok = true) {
463
+ toastMsg.textContent = message;
464
+ toast.querySelector(".dot").style.background = ok ? "var(--ok)" : "var(--warn)";
465
+ toast.classList.add("show");
466
+ setTimeout(() => toast.classList.remove("show"), 1600);
467
+ }
468
+
469
+ // ------- Events -------
470
+ searchInput.addEventListener("input", e => renderList(e.target.value));
471
+ tzSelect.addEventListener("change", updatePreview);
472
+
473
+ detectBtn.addEventListener("click", () => {
474
+ const sys = Intl.DateTimeFormat().resolvedOptions().timeZone;
475
+ if (sys && allTimezones.includes(sys)) {
476
+ tzSelect.value = sys;
477
+ updatePreview();
478
+ showToast("Auto-detected your timezone.");
479
+ } else {
480
+ showToast("Could not detect timezone.", false);
481
+ }
482
+ });
483
+
484
+ saveBtn.addEventListener("click", () => {
485
+ if (!tzSelect.value) return;
486
+ localStorage.setItem("preferredTimeZone", tzSelect.value);
487
+ updatePreview();
488
+ showToast("Saved as preferred timezone.");
489
+ });
490
+
491
+ fmt12Btn.addEventListener("click", () => {
492
+ use24h = false;
493
+ fmt12Btn.style.borderColor = "var(--accent)";
494
+ fmt24Btn.style.borderColor = "rgba(255,255,255,0.12)";
495
+ updatePreview();
496
+ });
497
+
498
+ fmt24Btn.addEventListener("click", () => {
499
+ use24h = true;
500
+ fmt24Btn.style.borderColor = "var(--accent)";
501
+ fmt12Btn.style.borderColor = "rgba(255,255,255,0.12)";
502
+ updatePreview();
503
+ });
504
+
505
+ saveBtn.addEventListener("click", async () => {
506
+ const tz = tzSelect.value;
507
+ if (!tz) return;
508
+
509
+ // Save locally (UI preview)
510
+ localStorage.setItem("preferredTimeZone", tz);
511
+
512
+ // --- Send timezone to Python server ---
513
+ try {
514
+ const res = await fetch("/api/timezone", {
515
+ method: "POST",
516
+ headers: { "Content-Type": "application/json" },
517
+ body: JSON.stringify({ timezone: tz })
518
+ });
519
+
520
+ const data = await res.json();
521
+ if (!data.ok) {
522
+ showToast("Failed to send to Python", false);
523
+ return;
524
+ }
525
+
526
+ showToast("Timezone saved");
527
+ } catch (err) {
528
+ console.error(err);
529
+ showToast("Error contacting Python server", false);
530
+ }
531
+
532
+ updatePreview();
533
+ });
534
+
535
+
536
+ // ------- Init -------
537
+ allTimezones = getTimezones().slice().sort();
538
+ renderList("");
539
+ startTicker();
540
+
541
+ // Restore saved preference if present
542
+ const saved = localStorage.getItem("preferredTimeZone");
543
+ if (saved && allTimezones.includes(saved)) {
544
+ tzSelect.value = saved;
545
+ updatePreview();
546
+ }
547
+
548
+ // Initial button states
549
+ fmt24Btn.style.borderColor = "var(--accent)";
550
+ showUtcBtn.style.borderColor = "var(--accent)";
551
+ </script>
552
+ </body>
553
+ </html>
reachy_mini_clock/main.py ADDED
@@ -0,0 +1,165 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import http.server
2
+ import json
3
+ import threading
4
+ from pathlib import Path
5
+ from typing import Optional
6
+
7
+ import numpy as np
8
+ from reachy_mini import ReachyMini, ReachyMiniApp
9
+
10
+ _selected_timezone: Optional[str] = "Europe/Paris"
11
+ _tz_lock = threading.Lock()
12
+
13
+
14
+ def set_timezone(tz: str):
15
+ global _selected_timezone
16
+ with _tz_lock:
17
+ _selected_timezone = tz
18
+
19
+
20
+ def get_timezone() -> Optional[str]:
21
+ with _tz_lock:
22
+ return _selected_timezone
23
+
24
+
25
+ # --- HTTP Handler with a tiny JSON API ---
26
+ class Handler(http.server.SimpleHTTPRequestHandler):
27
+ # Serve files from the folder containing this script (adjust if needed)
28
+ def __init__(self, *args, **kwargs):
29
+ self.directory = str(Path(__file__).parent)
30
+ super().__init__(*args, directory=self.directory, **kwargs)
31
+
32
+ def log_message(self, fmt, *args):
33
+ # quieter default logging; keep your own prints if you want
34
+ pass
35
+
36
+ def _send_json(self, obj, status=200):
37
+ data = json.dumps(obj).encode("utf-8")
38
+ self.send_response(status)
39
+ self.send_header("Content-Type", "application/json; charset=utf-8")
40
+ self.send_header("Content-Length", str(len(data)))
41
+ self.end_headers()
42
+ self.wfile.write(data)
43
+
44
+ def do_GET(self):
45
+ print("-----------------------")
46
+ print(f"GET {self.path} (from client {self.client_address})")
47
+ print(self.headers)
48
+
49
+ if self.path == "/api/timezone":
50
+ tz = get_timezone()
51
+ self._send_json({"timezone": tz})
52
+ return
53
+
54
+ # otherwise serve static files (index.html, style.css, etc.)
55
+ super().do_GET()
56
+
57
+ def do_POST(self):
58
+ if self.path != "/api/timezone":
59
+ self.send_error(404)
60
+ return
61
+
62
+ length = int(self.headers.get("Content-Length", "0"))
63
+ body = self.rfile.read(length).decode("utf-8")
64
+ payload = json.loads(body)
65
+ tz = payload.get("timezone")
66
+
67
+ set_timezone(tz)
68
+
69
+ self._send_json({"ok": True})
70
+
71
+
72
+ def start_server(stop_event: threading.Event, host="localhost", port=8080):
73
+ httpd = http.server.HTTPServer((host, port), Handler)
74
+ httpd.timeout = 0.5 # so we can check stop_event regularly
75
+ print(f"Serving on http://{host}:{port}")
76
+
77
+ while not stop_event.is_set():
78
+ httpd.handle_request()
79
+
80
+ httpd.server_close()
81
+ print("HTTP server stopped.")
82
+
83
+
84
+ def get_time_from_timezone(tz: str):
85
+ from datetime import datetime
86
+
87
+ import pytz
88
+
89
+ timezone = pytz.timezone(tz)
90
+ return datetime.now(timezone).strftime("%H:%M")
91
+
92
+
93
+ def is_close(a: float, b: float, tol: float = 0.2) -> bool:
94
+ return abs(a - b) < tol
95
+
96
+
97
+ def apply_time_to_antennas(reachy_mini: ReachyMini, hour: int, minute: int):
98
+ # Example: map hour/minute to antenna positions
99
+ hour_angle = (hour % 12) / 12.0 # 0.0 to 1.0
100
+ minute_angle = minute / 60.0 # 0.0 to 1.0
101
+
102
+ # convert to radians
103
+ target_minute_angle = minute_angle * 2 * np.pi
104
+ target_hour_angle = hour_angle * 2 * np.pi + (minute_angle * 2 * np.pi) / 12.0
105
+
106
+ _, (current_hour, current_minute) = reachy_mini.get_current_joint_positions()
107
+
108
+ if is_close(current_hour, target_hour_angle) and is_close(
109
+ current_minute, target_minute_angle
110
+ ):
111
+ reachy_mini.set_target_antenna_joint_positions(
112
+ [target_hour_angle, target_minute_angle]
113
+ )
114
+ else:
115
+ reachy_mini.goto_target(
116
+ np.eye(4),
117
+ antennas=[target_hour_angle, target_minute_angle],
118
+ duration=1.0,
119
+ )
120
+
121
+
122
+ class ReachyMiniClock(ReachyMiniApp):
123
+ custom_app_url: str | None = "http://localhost:8080/index.html"
124
+
125
+ def run(self, reachy_mini: ReachyMini, stop_event: threading.Event):
126
+ # Start server in background
127
+ server_thread = threading.Thread(
128
+ target=start_server, args=(stop_event,), daemon=True
129
+ )
130
+ server_thread.start()
131
+ reachy_mini.goto_target(np.eye(4), antennas=[0, 0], duration=2.0)
132
+ last_tz = None
133
+ while not stop_event.is_set():
134
+ tz = get_timezone()
135
+ if tz and tz != last_tz:
136
+ last_tz = tz
137
+ print(f"[ReachyMiniClock] New timezone selected: {tz}")
138
+
139
+ current_time = get_time_from_timezone(tz)
140
+ # print("time:", current_time)
141
+ hour = int(current_time.split(":")[0])
142
+ minute = int(current_time.split(":")[1])
143
+ apply_time_to_antennas(reachy_mini, hour, minute)
144
+ stop_event.wait(1.0)
145
+
146
+ stop_event.set()
147
+ server_thread.join(timeout=1)
148
+
149
+
150
+ if __name__ == "__main__":
151
+ stop = threading.Event()
152
+
153
+ # Run Reachy app
154
+ with ReachyMini() as mini:
155
+ app = ReachyMiniClock()
156
+ try:
157
+ print("Running 'reachy_mini_clock' a ReachyMiniApp...")
158
+ print("Press Ctrl+C to stop the app.")
159
+ app.run(mini, stop)
160
+ except KeyboardInterrupt:
161
+ print("Stopping the app...")
162
+ stop.set()
163
+
164
+ stop.set()
165
+ print("App has stopped.")
style.css ADDED
@@ -0,0 +1,411 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ * {
2
+ margin: 0;
3
+ padding: 0;
4
+ box-sizing: border-box;
5
+ }
6
+
7
+ body {
8
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
9
+ line-height: 1.6;
10
+ color: #333;
11
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
12
+ min-height: 100vh;
13
+ }
14
+
15
+ .hero {
16
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
17
+ color: white;
18
+ padding: 4rem 2rem;
19
+ text-align: center;
20
+ }
21
+
22
+ .hero-content {
23
+ max-width: 800px;
24
+ margin: 0 auto;
25
+ }
26
+
27
+ .app-icon {
28
+ font-size: 4rem;
29
+ margin-bottom: 1rem;
30
+ display: inline-block;
31
+ }
32
+
33
+ .hero h1 {
34
+ font-size: 3rem;
35
+ font-weight: 700;
36
+ margin-bottom: 1rem;
37
+ background: linear-gradient(45deg, #fff, #f0f9ff);
38
+ background-clip: text;
39
+ -webkit-background-clip: text;
40
+ -webkit-text-fill-color: transparent;
41
+ }
42
+
43
+ .tagline {
44
+ font-size: 1.25rem;
45
+ opacity: 0.9;
46
+ max-width: 600px;
47
+ margin: 0 auto;
48
+ }
49
+
50
+ .container {
51
+ max-width: 1200px;
52
+ margin: 0 auto;
53
+ padding: 0 2rem;
54
+ position: relative;
55
+ z-index: 2;
56
+ }
57
+
58
+ .main-card {
59
+ background: white;
60
+ border-radius: 20px;
61
+ box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
62
+ margin-top: -2rem;
63
+ overflow: hidden;
64
+ margin-bottom: 3rem;
65
+ }
66
+
67
+ .app-preview {
68
+ background: linear-gradient(135deg, #1e3a8a, #3b82f6);
69
+ padding: 3rem;
70
+ color: white;
71
+ text-align: center;
72
+ position: relative;
73
+ }
74
+
75
+ .preview-image {
76
+ background: #000;
77
+ border-radius: 15px;
78
+ padding: 2rem;
79
+ max-width: 500px;
80
+ margin: 0 auto;
81
+ position: relative;
82
+ overflow: hidden;
83
+ }
84
+
85
+ .camera-feed {
86
+ font-size: 4rem;
87
+ margin-bottom: 1rem;
88
+ opacity: 0.7;
89
+ }
90
+
91
+ .detection-overlay {
92
+ position: absolute;
93
+ top: 50%;
94
+ left: 50%;
95
+ transform: translate(-50%, -50%);
96
+ width: 100%;
97
+ }
98
+
99
+ .bbox {
100
+ background: rgba(34, 197, 94, 0.9);
101
+ color: white;
102
+ padding: 0.5rem 1rem;
103
+ border-radius: 8px;
104
+ font-size: 0.9rem;
105
+ font-weight: 600;
106
+ margin: 0.5rem;
107
+ display: inline-block;
108
+ border: 2px solid #22c55e;
109
+ }
110
+
111
+ .app-details {
112
+ padding: 3rem;
113
+ }
114
+
115
+ .app-details h2 {
116
+ font-size: 2rem;
117
+ color: #1e293b;
118
+ margin-bottom: 2rem;
119
+ text-align: center;
120
+ }
121
+
122
+ .template-info {
123
+ display: grid;
124
+ grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
125
+ gap: 2rem;
126
+ margin-bottom: 3rem;
127
+ }
128
+
129
+ .info-box {
130
+ background: #f0f9ff;
131
+ border: 2px solid #e0f2fe;
132
+ border-radius: 12px;
133
+ padding: 2rem;
134
+ }
135
+
136
+ .info-box h3 {
137
+ color: #0c4a6e;
138
+ margin-bottom: 1rem;
139
+ font-size: 1.2rem;
140
+ }
141
+
142
+ .info-box p {
143
+ color: #0369a1;
144
+ line-height: 1.6;
145
+ }
146
+
147
+ .how-to-use {
148
+ background: #fefce8;
149
+ border: 2px solid #fde047;
150
+ border-radius: 12px;
151
+ padding: 2rem;
152
+ margin-top: 3rem;
153
+ }
154
+
155
+ .how-to-use h3 {
156
+ color: #a16207;
157
+ margin-bottom: 1.5rem;
158
+ font-size: 1.3rem;
159
+ text-align: center;
160
+ }
161
+
162
+ .steps {
163
+ display: flex;
164
+ flex-direction: column;
165
+ gap: 1.5rem;
166
+ }
167
+
168
+ .step {
169
+ display: flex;
170
+ align-items: flex-start;
171
+ gap: 1rem;
172
+ }
173
+
174
+ .step-number {
175
+ background: #eab308;
176
+ color: white;
177
+ width: 2rem;
178
+ height: 2rem;
179
+ border-radius: 50%;
180
+ display: flex;
181
+ align-items: center;
182
+ justify-content: center;
183
+ font-weight: bold;
184
+ flex-shrink: 0;
185
+ }
186
+
187
+ .step h4 {
188
+ color: #a16207;
189
+ margin-bottom: 0.5rem;
190
+ font-size: 1.1rem;
191
+ }
192
+
193
+ .step p {
194
+ color: #ca8a04;
195
+ }
196
+
197
+ .download-card {
198
+ background: white;
199
+ border-radius: 20px;
200
+ box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
201
+ padding: 3rem;
202
+ text-align: center;
203
+ }
204
+
205
+ .download-card h2 {
206
+ font-size: 2rem;
207
+ color: #1e293b;
208
+ margin-bottom: 1rem;
209
+ }
210
+
211
+ .download-card>p {
212
+ color: #64748b;
213
+ font-size: 1.1rem;
214
+ margin-bottom: 2rem;
215
+ }
216
+
217
+ .dashboard-config {
218
+ margin-bottom: 2rem;
219
+ text-align: left;
220
+ max-width: 400px;
221
+ margin-left: auto;
222
+ margin-right: auto;
223
+ }
224
+
225
+ .dashboard-config label {
226
+ display: block;
227
+ color: #374151;
228
+ font-weight: 600;
229
+ margin-bottom: 0.5rem;
230
+ }
231
+
232
+ .dashboard-config input {
233
+ width: 100%;
234
+ padding: 0.75rem 1rem;
235
+ border: 2px solid #e5e7eb;
236
+ border-radius: 8px;
237
+ font-size: 0.95rem;
238
+ transition: border-color 0.2s;
239
+ }
240
+
241
+ .dashboard-config input:focus {
242
+ outline: none;
243
+ border-color: #667eea;
244
+ }
245
+
246
+ .install-btn {
247
+ background: linear-gradient(135deg, #667eea, #764ba2);
248
+ color: white;
249
+ border: none;
250
+ padding: 1.25rem 3rem;
251
+ border-radius: 16px;
252
+ font-size: 1.2rem;
253
+ font-weight: 700;
254
+ cursor: pointer;
255
+ transition: all 0.3s ease;
256
+ display: inline-flex;
257
+ align-items: center;
258
+ gap: 0.75rem;
259
+ margin-bottom: 2rem;
260
+ box-shadow: 0 8px 25px rgba(102, 126, 234, 0.3);
261
+ }
262
+
263
+ .install-btn:hover:not(:disabled) {
264
+ transform: translateY(-3px);
265
+ box-shadow: 0 15px 35px rgba(102, 126, 234, 0.4);
266
+ }
267
+
268
+ .install-btn:disabled {
269
+ opacity: 0.7;
270
+ cursor: not-allowed;
271
+ transform: none;
272
+ }
273
+
274
+ .manual-option {
275
+ background: #f8fafc;
276
+ border-radius: 12px;
277
+ padding: 2rem;
278
+ margin-top: 2rem;
279
+ }
280
+
281
+ .manual-option h3 {
282
+ color: #1e293b;
283
+ margin-bottom: 1rem;
284
+ font-size: 1.2rem;
285
+ }
286
+
287
+ .manual-option>p {
288
+ color: #64748b;
289
+ margin-bottom: 1rem;
290
+ }
291
+
292
+ .btn-icon {
293
+ font-size: 1.1rem;
294
+ }
295
+
296
+ .install-status {
297
+ padding: 1rem;
298
+ border-radius: 8px;
299
+ font-size: 0.9rem;
300
+ text-align: center;
301
+ display: none;
302
+ margin-top: 1rem;
303
+ }
304
+
305
+ .install-status.success {
306
+ background: #dcfce7;
307
+ color: #166534;
308
+ border: 1px solid #bbf7d0;
309
+ }
310
+
311
+ .install-status.error {
312
+ background: #fef2f2;
313
+ color: #dc2626;
314
+ border: 1px solid #fecaca;
315
+ }
316
+
317
+ .install-status.loading {
318
+ background: #dbeafe;
319
+ color: #1d4ed8;
320
+ border: 1px solid #bfdbfe;
321
+ }
322
+
323
+ .install-status.info {
324
+ background: #e0f2fe;
325
+ color: #0369a1;
326
+ border: 1px solid #7dd3fc;
327
+ }
328
+
329
+ .manual-install {
330
+ background: #1f2937;
331
+ border-radius: 8px;
332
+ padding: 1rem;
333
+ margin-bottom: 1rem;
334
+ display: flex;
335
+ align-items: center;
336
+ gap: 1rem;
337
+ }
338
+
339
+ .manual-install code {
340
+ color: #10b981;
341
+ font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', monospace;
342
+ font-size: 0.85rem;
343
+ flex: 1;
344
+ overflow-x: auto;
345
+ }
346
+
347
+ .copy-btn {
348
+ background: #374151;
349
+ color: white;
350
+ border: none;
351
+ padding: 0.5rem 1rem;
352
+ border-radius: 6px;
353
+ font-size: 0.8rem;
354
+ cursor: pointer;
355
+ transition: background-color 0.2s;
356
+ }
357
+
358
+ .copy-btn:hover {
359
+ background: #4b5563;
360
+ }
361
+
362
+ .manual-steps {
363
+ color: #6b7280;
364
+ font-size: 0.9rem;
365
+ line-height: 1.8;
366
+ }
367
+
368
+ .footer {
369
+ text-align: center;
370
+ padding: 2rem;
371
+ color: white;
372
+ opacity: 0.8;
373
+ }
374
+
375
+ .footer a {
376
+ color: white;
377
+ text-decoration: none;
378
+ font-weight: 600;
379
+ }
380
+
381
+ .footer a:hover {
382
+ text-decoration: underline;
383
+ }
384
+
385
+ /* Responsive Design */
386
+ @media (max-width: 768px) {
387
+ .hero {
388
+ padding: 2rem 1rem;
389
+ }
390
+
391
+ .hero h1 {
392
+ font-size: 2rem;
393
+ }
394
+
395
+ .container {
396
+ padding: 0 1rem;
397
+ }
398
+
399
+ .app-details,
400
+ .download-card {
401
+ padding: 2rem;
402
+ }
403
+
404
+ .features-grid {
405
+ grid-template-columns: 1fr;
406
+ }
407
+
408
+ .download-options {
409
+ grid-template-columns: 1fr;
410
+ }
411
+ }