experiments in a post-browser web
at main 369 lines 9.3 kB view raw
1<!DOCTYPE html> 2<html> 3<head> 4 <meta charset="UTF-8"> 5 <meta http-equiv="Content-Security-Policy" content="script-src 'self' 'unsafe-inline'; img-src 'self' data: blob:;"> 6 <title>Image Gallery</title> 7 <link rel="stylesheet" href="peek://theme/variables.css"> 8 <style> 9 * { 10 box-sizing: border-box; 11 } 12 13 body { 14 font-family: var(--font-family, system-ui, -apple-system, sans-serif); 15 background: var(--background, #1e1e1e); 16 color: var(--foreground, #e0e0e0); 17 margin: 0; 18 padding: 20px; 19 min-height: 100vh; 20 } 21 22 .header { 23 display: flex; 24 justify-content: space-between; 25 align-items: center; 26 margin-bottom: 20px; 27 padding-bottom: 15px; 28 border-bottom: 1px solid var(--border, #333); 29 } 30 31 h1 { 32 margin: 0; 33 font-size: 1.5rem; 34 font-weight: 500; 35 } 36 37 .status { 38 font-size: 0.85rem; 39 color: var(--foreground-muted, #888); 40 } 41 42 .status.connected { 43 color: var(--success, #4caf50); 44 } 45 46 .actions { 47 display: flex; 48 gap: 10px; 49 margin-bottom: 20px; 50 } 51 52 button { 53 background: var(--button-background, #333); 54 color: var(--button-foreground, #e0e0e0); 55 border: 1px solid var(--border, #444); 56 padding: 8px 16px; 57 border-radius: 6px; 58 cursor: pointer; 59 font-size: 0.9rem; 60 transition: background 0.15s; 61 } 62 63 button:hover { 64 background: var(--button-hover-background, #444); 65 } 66 67 button:active { 68 background: var(--button-active-background, #555); 69 } 70 71 .drop-zone { 72 border: 2px dashed var(--border, #444); 73 border-radius: 12px; 74 padding: 40px; 75 text-align: center; 76 margin-bottom: 20px; 77 transition: border-color 0.2s, background 0.2s; 78 } 79 80 .drop-zone.dragover { 81 border-color: var(--accent, #007acc); 82 background: var(--accent-background, rgba(0, 122, 204, 0.1)); 83 } 84 85 .drop-zone p { 86 margin: 0 0 10px 0; 87 color: var(--foreground-muted, #888); 88 } 89 90 .gallery { 91 display: grid; 92 grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); 93 gap: 15px; 94 } 95 96 .gallery-item { 97 background: var(--surface, #252525); 98 border-radius: 8px; 99 overflow: hidden; 100 border: 1px solid var(--border, #333); 101 transition: transform 0.15s, box-shadow 0.15s; 102 } 103 104 .gallery-item:hover { 105 transform: translateY(-2px); 106 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); 107 } 108 109 .gallery-item img { 110 width: 100%; 111 height: 150px; 112 object-fit: cover; 113 display: block; 114 } 115 116 .gallery-item .info { 117 padding: 10px; 118 } 119 120 .gallery-item .name { 121 font-size: 0.9rem; 122 white-space: nowrap; 123 overflow: hidden; 124 text-overflow: ellipsis; 125 } 126 127 .gallery-item .meta { 128 font-size: 0.75rem; 129 color: var(--foreground-muted, #888); 130 margin-top: 4px; 131 } 132 133 .empty-state { 134 text-align: center; 135 padding: 60px 20px; 136 color: var(--foreground-muted, #888); 137 } 138 139 .empty-state p { 140 margin: 10px 0; 141 } 142 143 #file-input { 144 display: none; 145 } 146 </style> 147</head> 148<body> 149 <div class="header"> 150 <h1>📷 Image Gallery</h1> 151 <span id="status" class="status">Checking...</span> 152 </div> 153 154 <div class="actions"> 155 <button id="add-btn">Add Image</button> 156 <button id="refresh-btn">Refresh</button> 157 </div> 158 159 <div id="drop-zone" class="drop-zone"> 160 <p>Drag and drop images here</p> 161 <p>or click "Add Image" to browse</p> 162 </div> 163 164 <div id="gallery" class="gallery"></div> 165 166 <input type="file" id="file-input" accept="image/*" multiple> 167 168 <script type="module"> 169 // Feature detection 170 const hasPeekAPI = typeof window.app !== 'undefined'; 171 const api = hasPeekAPI ? window.app : null; 172 173 // In-memory storage for standalone mode 174 const localImages = new Map(); 175 176 // Update status indicator 177 const statusEl = document.getElementById('status'); 178 if (hasPeekAPI) { 179 statusEl.textContent = 'Connected to Peek'; 180 statusEl.classList.add('connected'); 181 } else { 182 statusEl.textContent = 'Standalone mode'; 183 } 184 185 /** 186 * Store an image 187 */ 188 async function storeImage(id, imageData) { 189 if (hasPeekAPI) { 190 await api.datastore.setRow('example_images', id, imageData); 191 } else { 192 localImages.set(id, imageData); 193 } 194 } 195 196 /** 197 * Get all stored images 198 */ 199 async function getStoredImages() { 200 if (hasPeekAPI) { 201 const result = await api.datastore.getTable('example_images'); 202 return result.success ? result.data : {}; 203 } else { 204 return Object.fromEntries(localImages); 205 } 206 } 207 208 /** 209 * Generate unique ID 210 */ 211 function generateId() { 212 return `img_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; 213 } 214 215 /** 216 * Format file size 217 */ 218 function formatSize(bytes) { 219 if (bytes < 1024) return bytes + ' B'; 220 if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'; 221 return (bytes / (1024 * 1024)).toFixed(1) + ' MB'; 222 } 223 224 /** 225 * Format timestamp 226 */ 227 function formatDate(timestamp) { 228 return new Date(timestamp).toLocaleDateString(undefined, { 229 month: 'short', 230 day: 'numeric', 231 hour: '2-digit', 232 minute: '2-digit' 233 }); 234 } 235 236 /** 237 * Read file as base64 238 */ 239 function readFileAsBase64(file) { 240 return new Promise((resolve, reject) => { 241 const reader = new FileReader(); 242 reader.onload = () => { 243 // Remove data URL prefix to get just base64 244 const base64 = reader.result.split(',')[1]; 245 resolve(base64); 246 }; 247 reader.onerror = reject; 248 reader.readAsDataURL(file); 249 }); 250 } 251 252 /** 253 * Handle file(s) being added 254 */ 255 async function handleFiles(files) { 256 for (const file of files) { 257 if (!file.type.startsWith('image/')) { 258 console.log('Skipping non-image:', file.name); 259 continue; 260 } 261 262 try { 263 const base64 = await readFileAsBase64(file); 264 const id = generateId(); 265 const imageData = { 266 data: base64, 267 mimeType: file.type, 268 name: file.name, 269 size: file.size, 270 timestamp: Date.now() 271 }; 272 273 await storeImage(id, imageData); 274 console.log('[gallery] Stored image:', id, file.name); 275 } catch (err) { 276 console.error('[gallery] Failed to store image:', file.name, err); 277 } 278 } 279 280 await renderGallery(); 281 } 282 283 /** 284 * Render the gallery 285 */ 286 async function renderGallery() { 287 const galleryEl = document.getElementById('gallery'); 288 const images = await getStoredImages(); 289 const imageEntries = Object.entries(images); 290 291 if (imageEntries.length === 0) { 292 galleryEl.innerHTML = ` 293 <div class="empty-state"> 294 <p>No images yet</p> 295 <p>Add images by dragging them here or using the "Add Image" button</p> 296 </div> 297 `; 298 return; 299 } 300 301 // Sort by timestamp, newest first 302 imageEntries.sort((a, b) => (b[1].timestamp || 0) - (a[1].timestamp || 0)); 303 304 galleryEl.innerHTML = imageEntries.map(([id, img]) => ` 305 <div class="gallery-item" data-id="${id}"> 306 <img src="data:${img.mimeType};base64,${img.data}" alt="${img.name || 'Image'}"> 307 <div class="info"> 308 <div class="name">${img.name || 'Untitled'}</div> 309 <div class="meta"> 310 ${img.size ? formatSize(img.size) : ''} 311 ${img.timestamp ? '• ' + formatDate(img.timestamp) : ''} 312 </div> 313 </div> 314 </div> 315 `).join(''); 316 } 317 318 // File input handler 319 const fileInput = document.getElementById('file-input'); 320 document.getElementById('add-btn').addEventListener('click', () => { 321 fileInput.click(); 322 }); 323 fileInput.addEventListener('change', (e) => { 324 if (e.target.files.length > 0) { 325 handleFiles(e.target.files); 326 e.target.value = ''; // Reset for next selection 327 } 328 }); 329 330 // Refresh button 331 document.getElementById('refresh-btn').addEventListener('click', renderGallery); 332 333 // Drag and drop 334 const dropZone = document.getElementById('drop-zone'); 335 336 dropZone.addEventListener('dragover', (e) => { 337 e.preventDefault(); 338 dropZone.classList.add('dragover'); 339 }); 340 341 dropZone.addEventListener('dragleave', () => { 342 dropZone.classList.remove('dragover'); 343 }); 344 345 dropZone.addEventListener('drop', (e) => { 346 e.preventDefault(); 347 dropZone.classList.remove('dragover'); 348 349 const files = Array.from(e.dataTransfer.files); 350 if (files.length > 0) { 351 handleFiles(files); 352 } 353 }); 354 355 // Listen for new images from the extension 356 if (hasPeekAPI) { 357 api.subscribe('example:image-added', (msg) => { 358 console.log('[gallery] Image added event:', msg); 359 renderGallery(); 360 }, api.scopes.GLOBAL); 361 } 362 363 // Initial render 364 renderGallery(); 365 366 console.log('[gallery] Initialized - Peek API:', hasPeekAPI); 367 </script> 368</body> 369</html>