mobile bluesky app made with flutter
lazurite.stormlightlabs.org/
mobile
bluesky
flutter
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.0, maximum-scale=1.0, user-scalable=no" />
6 <title>Compose - Lazurite</title>
7 <link rel="preconnect" href="https://fonts.googleapis.com" />
8 <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
9 <link href="https://fonts.googleapis.com/css2?family=Lora:wght@400;500;600;700&display=swap" rel="stylesheet" />
10 <link
11 href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,100..1000;1,9..40,100..1000&display=swap"
12 rel="stylesheet" />
13 <link rel="stylesheet" href="styles.css" />
14 <style>
15 .compose-header {
16 display: flex;
17 align-items: center;
18 justify-content: space-between;
19 padding: 12px 16px;
20 border-bottom: 1px solid var(--border);
21 background-color: var(--bg);
22 position: sticky;
23 top: 0;
24 z-index: 50;
25 }
26
27 .compose-header-cancel {
28 background: none;
29 border: none;
30 color: var(--text-secondary);
31 font-size: 15px;
32 font-weight: 500;
33 cursor: pointer;
34 }
35
36 .compose-header-title {
37 font-size: 18px;
38 font-weight: 600;
39 color: var(--text-primary);
40 font-family: var(--font-heading);
41 }
42
43 .compose-header-post {
44 padding: 8px 20px;
45 border-radius: 9999px;
46 border: none;
47 background-color: var(--accent-primary);
48 color: white;
49 font-weight: 600;
50 font-size: 14px;
51 cursor: pointer;
52 transition: background-color 0.2s ease;
53 }
54
55 .compose-header-post:hover {
56 background-color: var(--accent-primary-hover);
57 }
58
59 .compose-header-post:disabled {
60 opacity: 0.5;
61 cursor: not-allowed;
62 }
63
64 .compose-body {
65 padding: 16px;
66 display: flex;
67 gap: 12px;
68 min-height: 200px;
69 }
70
71 .compose-textarea {
72 flex: 1;
73 border: none;
74 background: transparent;
75 color: var(--text-primary);
76 font-size: 16px;
77 font-family: var(--font-body);
78 resize: none;
79 outline: none;
80 min-height: 160px;
81 line-height: 1.5;
82 }
83
84 .compose-textarea::placeholder {
85 color: var(--text-muted);
86 }
87
88 /* Reply Context */
89 .reply-context {
90 padding: 12px 16px;
91 border-bottom: 1px solid var(--border);
92 display: flex;
93 align-items: center;
94 gap: 8px;
95 background-color: var(--surface);
96 }
97
98 .reply-context-icon {
99 width: 16px;
100 height: 16px;
101 color: var(--text-muted);
102 }
103
104 .reply-context-text {
105 font-size: 13px;
106 color: var(--text-secondary);
107 }
108
109 .reply-context-handle {
110 color: var(--accent-primary);
111 font-weight: 500;
112 }
113
114 /* Media Attachments */
115 .compose-media {
116 padding: 0 16px 16px;
117 padding-left: 68px;
118 }
119
120 .media-grid {
121 display: grid;
122 grid-template-columns: repeat(2, 1fr);
123 gap: 8px;
124 }
125
126 .media-item {
127 position: relative;
128 border-radius: 12px;
129 overflow: hidden;
130 background: linear-gradient(135deg, var(--surface) 0%, var(--surface-variant) 100%);
131 aspect-ratio: 4 / 3;
132 display: flex;
133 align-items: center;
134 justify-content: center;
135 color: var(--text-muted);
136 font-size: 13px;
137 }
138
139 .media-item-remove {
140 position: absolute;
141 top: 6px;
142 right: 6px;
143 width: 24px;
144 height: 24px;
145 border-radius: 50%;
146 background-color: rgba(0, 0, 0, 0.6);
147 border: none;
148 color: white;
149 cursor: pointer;
150 display: flex;
151 align-items: center;
152 justify-content: center;
153 }
154
155 .media-item-remove svg {
156 width: 14px;
157 height: 14px;
158 }
159
160 .media-item-alt {
161 position: absolute;
162 bottom: 6px;
163 left: 6px;
164 padding: 2px 8px;
165 border-radius: 4px;
166 background-color: rgba(0, 0, 0, 0.6);
167 color: white;
168 font-size: 11px;
169 font-weight: 600;
170 cursor: pointer;
171 }
172
173 .media-item-alt.has-alt {
174 background-color: var(--accent-primary);
175 }
176
177 /* Toolbar */
178 .compose-toolbar {
179 display: flex;
180 align-items: center;
181 justify-content: space-between;
182 padding: 12px 16px;
183 border-top: 1px solid var(--border);
184 position: sticky;
185 bottom: 0;
186 background-color: var(--bg);
187 }
188
189 .toolbar-actions {
190 display: flex;
191 gap: 4px;
192 }
193
194 .toolbar-btn {
195 width: 40px;
196 height: 40px;
197 border-radius: 50%;
198 border: none;
199 background: transparent;
200 color: var(--accent-primary);
201 cursor: pointer;
202 display: flex;
203 align-items: center;
204 justify-content: center;
205 transition: background-color 0.2s ease;
206 }
207
208 .toolbar-btn:hover {
209 background-color: var(--surface);
210 }
211
212 .toolbar-btn svg {
213 width: 22px;
214 height: 22px;
215 }
216
217 .toolbar-btn.disabled {
218 color: var(--text-muted);
219 cursor: not-allowed;
220 }
221
222 /* Character Counter */
223 .char-counter {
224 display: flex;
225 align-items: center;
226 gap: 8px;
227 }
228
229 .char-counter-ring {
230 width: 28px;
231 height: 28px;
232 position: relative;
233 }
234
235 .char-counter-ring svg {
236 width: 28px;
237 height: 28px;
238 transform: rotate(-90deg);
239 }
240
241 .char-counter-ring circle {
242 fill: none;
243 stroke-width: 2.5;
244 }
245
246 .char-counter-bg {
247 stroke: var(--surface-variant);
248 }
249
250 .char-counter-fill {
251 stroke: var(--accent-primary);
252 stroke-dasharray: 75.4;
253 stroke-dashoffset: 30;
254 stroke-linecap: round;
255 transition:
256 stroke-dashoffset 0.2s ease,
257 stroke 0.2s ease;
258 }
259
260 .char-counter-fill.warning {
261 stroke: var(--accent-warning);
262 }
263
264 .char-counter-fill.danger {
265 stroke: var(--accent-error);
266 }
267
268 .char-counter-text {
269 font-size: 13px;
270 color: var(--text-muted);
271 font-variant-numeric: tabular-nums;
272 }
273
274 /* Drafts */
275 .drafts-divider {
276 height: 8px;
277 background-color: var(--surface);
278 border-top: 1px solid var(--border);
279 border-bottom: 1px solid var(--border);
280 }
281
282 .drafts-header {
283 display: flex;
284 align-items: center;
285 justify-content: space-between;
286 padding: 16px;
287 }
288
289 .drafts-title {
290 font-size: 16px;
291 font-weight: 600;
292 color: var(--text-primary);
293 }
294
295 .drafts-count {
296 font-size: 13px;
297 color: var(--text-muted);
298 }
299
300 .draft-item {
301 padding: 12px 16px;
302 border-bottom: 1px solid var(--border);
303 cursor: pointer;
304 transition: background-color 0.2s ease;
305 }
306
307 .draft-item:hover {
308 background-color: var(--surface);
309 }
310
311 .draft-item-text {
312 font-size: 15px;
313 color: var(--text-primary);
314 line-height: 1.4;
315 display: -webkit-box;
316 line-clamp: 2;
317 -webkit-line-clamp: 2;
318 -webkit-box-orient: vertical;
319 overflow: hidden;
320 }
321
322 .draft-item-meta {
323 display: flex;
324 align-items: center;
325 gap: 8px;
326 margin-top: 6px;
327 font-size: 12px;
328 color: var(--text-muted);
329 }
330
331 .draft-item-badge {
332 padding: 2px 6px;
333 border-radius: 4px;
334 background-color: var(--surface-variant);
335 font-size: 11px;
336 font-weight: 600;
337 color: var(--text-secondary);
338 }
339
340 .draft-item-badge.scheduled {
341 background-color: var(--accent-primary);
342 color: white;
343 }
344
345 /* Schedule Pill */
346 .schedule-pill {
347 display: inline-flex;
348 align-items: center;
349 gap: 6px;
350 padding: 6px 12px;
351 border-radius: 9999px;
352 background-color: var(--surface);
353 border: 1px solid var(--border);
354 font-size: 13px;
355 color: var(--text-secondary);
356 margin-left: 68px;
357 margin-bottom: 12px;
358 cursor: pointer;
359 transition: all 0.2s ease;
360 }
361
362 .schedule-pill:hover {
363 border-color: var(--accent-primary);
364 color: var(--accent-primary);
365 }
366
367 .schedule-pill svg {
368 width: 14px;
369 height: 14px;
370 }
371
372 .schedule-pill.active {
373 background-color: var(--accent-primary);
374 border-color: var(--accent-primary);
375 color: white;
376 }
377 </style>
378 </head>
379 <body>
380 <div class="mobile-container">
381 <!-- Compose Header -->
382 <div class="compose-header">
383 <button class="compose-header-cancel">Cancel</button>
384 <span class="compose-header-title">New Post</span>
385 <button class="compose-header-post">Post</button>
386 </div>
387
388 <!-- Reply Context (shown when replying) -->
389 <div class="reply-context">
390 <svg
391 class="reply-context-icon"
392 viewBox="0 0 24 24"
393 fill="none"
394 stroke="currentColor"
395 stroke-width="2"
396 stroke-linecap="round"
397 stroke-linejoin="round">
398 <polyline points="9 14 4 9 9 4" />
399 <path d="M20 20v-7a4 4 0 0 0-4-4H4" />
400 </svg>
401 <span class="reply-context-text">Replying to <span class="reply-context-handle">@alice.bsky.social</span></span>
402 </div>
403
404 <!-- Compose Body -->
405 <div class="compose-body">
406 <div class="avatar avatar-sm">JD</div>
407 <textarea class="compose-textarea" placeholder="What's on your mind?" rows="6">
408Excited to share my latest project built with the AT Protocol! Check it out and let me know what you think.</textarea
409 >
410 </div>
411
412 <!-- Schedule Pill -->
413 <div class="schedule-pill">
414 <svg
415 viewBox="0 0 24 24"
416 fill="none"
417 stroke="currentColor"
418 stroke-width="2"
419 stroke-linecap="round"
420 stroke-linejoin="round">
421 <circle cx="12" cy="12" r="10" />
422 <polyline points="12 6 12 12 16 14" />
423 </svg>
424 Schedule for later
425 </div>
426
427 <!-- Media Attachments -->
428 <div class="compose-media">
429 <div class="media-grid">
430 <div class="media-item">
431 [Photo 1]
432 <button class="media-item-remove">
433 <svg
434 viewBox="0 0 24 24"
435 fill="none"
436 stroke="currentColor"
437 stroke-width="2"
438 stroke-linecap="round"
439 stroke-linejoin="round">
440 <line x1="18" y1="6" x2="6" y2="18" />
441 <line x1="6" y1="6" x2="18" y2="18" />
442 </svg>
443 </button>
444 <span class="media-item-alt has-alt">ALT</span>
445 </div>
446 <div class="media-item">
447 [Photo 2]
448 <button class="media-item-remove">
449 <svg
450 viewBox="0 0 24 24"
451 fill="none"
452 stroke="currentColor"
453 stroke-width="2"
454 stroke-linecap="round"
455 stroke-linejoin="round">
456 <line x1="18" y1="6" x2="6" y2="18" />
457 <line x1="6" y1="6" x2="18" y2="18" />
458 </svg>
459 </button>
460 <span class="media-item-alt">ALT</span>
461 </div>
462 </div>
463 </div>
464
465 <!-- Toolbar -->
466 <div class="compose-toolbar">
467 <div class="toolbar-actions">
468 <!-- Image -->
469 <button class="toolbar-btn" title="Add image">
470 <svg
471 viewBox="0 0 24 24"
472 fill="none"
473 stroke="currentColor"
474 stroke-width="2"
475 stroke-linecap="round"
476 stroke-linejoin="round">
477 <rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
478 <circle cx="8.5" cy="8.5" r="1.5" />
479 <polyline points="21 15 16 10 5 21" />
480 </svg>
481 </button>
482 <!-- Drafts -->
483 <button class="toolbar-btn" title="Drafts">
484 <svg
485 viewBox="0 0 24 24"
486 fill="none"
487 stroke="currentColor"
488 stroke-width="2"
489 stroke-linecap="round"
490 stroke-linejoin="round">
491 <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
492 <polyline points="14 2 14 8 20 8" />
493 <line x1="16" y1="13" x2="8" y2="13" />
494 <line x1="16" y1="17" x2="8" y2="17" />
495 </svg>
496 </button>
497 <!-- Schedule -->
498 <button class="toolbar-btn" title="Schedule">
499 <svg
500 viewBox="0 0 24 24"
501 fill="none"
502 stroke="currentColor"
503 stroke-width="2"
504 stroke-linecap="round"
505 stroke-linejoin="round">
506 <circle cx="12" cy="12" r="10" />
507 <polyline points="12 6 12 12 16 14" />
508 </svg>
509 </button>
510 </div>
511
512 <div class="char-counter">
513 <span class="char-counter-text">192</span>
514 <div class="char-counter-ring">
515 <svg viewBox="0 0 28 28">
516 <circle class="char-counter-bg" cx="14" cy="14" r="12" />
517 <circle class="char-counter-fill" cx="14" cy="14" r="12" />
518 </svg>
519 </div>
520 </div>
521 </div>
522
523 <!-- Drafts Section (shown when drafts toolbar button is tapped) -->
524 <div class="drafts-divider"></div>
525
526 <div class="drafts-header">
527 <span class="drafts-title">Drafts</span>
528 <span class="drafts-count">3 drafts</span>
529 </div>
530
531 <div class="draft-item">
532 <div class="draft-item-text">
533 Working on a thread about decentralised identity and why it matters for the open web...
534 </div>
535 <div class="draft-item-meta">
536 <span>2 hours ago</span>
537 <span class="draft-item-badge">Draft</span>
538 </div>
539 </div>
540
541 <div class="draft-item">
542 <div class="draft-item-text">Hot take: the best developer experience is the one you don't notice</div>
543 <div class="draft-item-meta">
544 <span>Yesterday</span>
545 <span class="draft-item-badge scheduled">Mar 18, 9:00 AM</span>
546 </div>
547 </div>
548
549 <div class="draft-item">
550 <div class="draft-item-text">Quick review of the new AT Protocol SDK features that shipped this week</div>
551 <div class="draft-item-meta">
552 <span>3 days ago</span>
553 <span class="draft-item-badge">Draft</span>
554 </div>
555 </div>
556 </div>
557
558 <script>
559 if (localStorage.getItem("theme")) {
560 const t = localStorage.getItem("theme");
561 if (t !== "light") document.documentElement.setAttribute("data-theme", t);
562 }
563 </script>
564 </body>
565</html>