experiments in a post-browser web
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>