browse the protocol like its 2008 ibex.desertthunder.dev
ubuntu atproto svelte
7

Configure Feed

Select the types of activity you want to include in your feed.

feat: repo browser

* about OS

Owais Jamil (Jun 8, 2026, 2:15 PM -0500) 3f364498 bb932a0f

+695 -94
+176
src/lib/atproto/repo.svelte.ts
··· 1 + import { Client, ok, simpleFetchHandler } from '@atcute/client'; 2 + import type { ActorIdentifier, Nsid } from '@atcute/lexicons/syntax'; 3 + import type {} from '@atcute/atproto'; 4 + import type { AccountIdentity } from './identity'; 5 + 6 + export type CollectionSummary = { name: string; icon: string; loadedCount: number | null }; 7 + 8 + export type RepoRecordSummary = { 9 + uri: string; 10 + cid: string; 11 + title: string; 12 + body: string; 13 + author: string; 14 + modified: string; 15 + }; 16 + 17 + class RepoBrowserState { 18 + collections = $state<CollectionSummary[]>([]); 19 + selectedCollection = $state<string | null>(null); 20 + records = $state<RepoRecordSummary[]>([]); 21 + isLoadingCollections = $state(false); 22 + isLoadingRecords = $state(false); 23 + error = $state<string | null>(null); 24 + loadedDid = $state<string | null>(null); 25 + 26 + get selectedSummary() { 27 + return this.collections.find((collection) => collection.name === this.selectedCollection) ?? null; 28 + } 29 + 30 + async load(identity: AccountIdentity) { 31 + if (this.loadedDid === identity.did && this.collections.length > 0) { 32 + return; 33 + } 34 + 35 + this.reset(); 36 + this.loadedDid = identity.did; 37 + this.isLoadingCollections = true; 38 + this.error = null; 39 + 40 + try { 41 + const rpc = createRepoClient(identity); 42 + const repo = await ok( 43 + rpc.get('com.atproto.repo.describeRepo', { params: { repo: identity.did as ActorIdentifier } }) 44 + ); 45 + 46 + this.collections = repo.collections 47 + .sort() 48 + .map((name) => ({ name, icon: iconForCollection(name), loadedCount: null })); 49 + this.selectedCollection = preferredCollection(this.collections.map((collection) => collection.name)); 50 + 51 + if (this.selectedCollection) { 52 + await this.selectCollection(identity, this.selectedCollection); 53 + } 54 + } catch (unknownError) { 55 + this.error = messageFromError(unknownError, 'Could not load repository collections.'); 56 + } finally { 57 + this.isLoadingCollections = false; 58 + } 59 + } 60 + 61 + async selectCollection(identity: AccountIdentity, collectionName: string) { 62 + this.selectedCollection = collectionName; 63 + this.isLoadingRecords = true; 64 + this.error = null; 65 + 66 + try { 67 + const rpc = createRepoClient(identity); 68 + const response = await ok( 69 + rpc.get('com.atproto.repo.listRecords', { 70 + params: { 71 + repo: identity.did as ActorIdentifier, 72 + collection: collectionName as Nsid, 73 + limit: 25, 74 + reverse: true 75 + } 76 + }) 77 + ); 78 + 79 + this.records = response.records.map((record) => summarizeRecord(record, identity.handle)); 80 + this.collections = this.collections.map((collection) => 81 + collection.name === collectionName ? { ...collection, loadedCount: response.records.length } : collection 82 + ); 83 + } catch (unknownError) { 84 + this.records = []; 85 + this.error = messageFromError(unknownError, `Could not load records for ${collectionName}.`); 86 + } finally { 87 + this.isLoadingRecords = false; 88 + } 89 + } 90 + 91 + reset() { 92 + this.collections = []; 93 + this.selectedCollection = null; 94 + this.records = []; 95 + this.isLoadingCollections = false; 96 + this.isLoadingRecords = false; 97 + this.error = null; 98 + this.loadedDid = null; 99 + } 100 + } 101 + 102 + export const repoBrowser = new RepoBrowserState(); 103 + 104 + function createRepoClient(identity: AccountIdentity) { 105 + return new Client({ handler: simpleFetchHandler({ service: identity.pds ?? 'https://public.api.bsky.app' }) }); 106 + } 107 + 108 + function preferredCollection(collections: string[]) { 109 + return collections.includes('app.bsky.feed.post') ? 'app.bsky.feed.post' : (collections[0] ?? null); 110 + } 111 + 112 + export function iconForCollection(name: string) { 113 + if (name.includes('profile') || name.includes('actor')) return '/icons/humanity/places/user-home.svg'; 114 + if (name.includes('chat') || name.includes('convo')) return '/icons/humanity/apps/evolution-mail.svg'; 115 + if (name.includes('feed') || name.includes('post')) return '/icons/humanity/apps/internet-feed-reader.svg'; 116 + if (name.includes('graph') || name.includes('follow')) return '/icons/humanity/places/folder.svg'; 117 + return '/icons/humanity/mimes/text-x-generic.svg'; 118 + } 119 + 120 + type UnknownRecord = { uri: string; cid: string; value: unknown }; 121 + 122 + function summarizeRecord(record: UnknownRecord, handle: string): RepoRecordSummary { 123 + const value = isRecordValue(record.value) ? record.value : {}; 124 + const text = stringifyField(value.text) ?? stringifyField(value.name) ?? stringifyField(value.displayName); 125 + const type = stringifyField(value.$type) ?? collectionFromUri(record.uri); 126 + const createdAt = stringifyField(value.createdAt) ?? stringifyField(value.indexedAt); 127 + const title = text ? truncate(text.replaceAll('\n', ' '), 54) : recordKeyFromUri(record.uri); 128 + const body = text ?? `Record type: ${type}`; 129 + 130 + return { 131 + uri: record.uri, 132 + cid: record.cid, 133 + title, 134 + body: truncate(body.replaceAll('\n', ' '), 140), 135 + author: `@${handle}`, 136 + modified: formatRecordTime(createdAt) 137 + }; 138 + } 139 + 140 + function isRecordValue(value: unknown): value is Record<string, unknown> { 141 + return typeof value === 'object' && value !== null; 142 + } 143 + 144 + function stringifyField(value: unknown) { 145 + return typeof value === 'string' && value.length > 0 ? value : null; 146 + } 147 + 148 + function truncate(value: string, maxLength: number) { 149 + return value.length > maxLength ? `${value.slice(0, maxLength - 1)}…` : value; 150 + } 151 + 152 + function collectionFromUri(uri: string) { 153 + return uri.split('/')[3] ?? 'unknown.collection'; 154 + } 155 + 156 + function recordKeyFromUri(uri: string) { 157 + return uri.split('/').at(-1) ?? uri; 158 + } 159 + 160 + function formatRecordTime(value: string | null) { 161 + if (!value) return '—'; 162 + 163 + const date = new Date(value); 164 + if (Number.isNaN(date.getTime())) return '—'; 165 + 166 + return new Intl.DateTimeFormat(undefined, { 167 + month: 'short', 168 + day: 'numeric', 169 + hour: 'numeric', 170 + minute: '2-digit' 171 + }).format(date); 172 + } 173 + 174 + function messageFromError(error: unknown, fallback: string) { 175 + return error instanceof Error ? error.message : fallback; 176 + }
+142
src/lib/components/AboutComputer.svelte
··· 1 + <script lang="ts"> 2 + import { accountSetup } from '$lib/atproto/setup.svelte'; 3 + </script> 4 + 5 + <section class="about-computer" aria-labelledby="about-title"> 6 + <div class="hero"> 7 + <img src="/icons/humanity/devices/computer.svg" alt="" width="64" height="64" /> 8 + <div> 9 + <p class="eyebrow">About this computer</p> 10 + <h2 id="about-title">Intrepid Ibex Browser</h2> 11 + <p>Ubuntu 8.10-inspired AT Protocol workstation</p> 12 + </div> 13 + </div> 14 + 15 + <div class="details-panel"> 16 + <dl> 17 + <div> 18 + <dt>Operating system</dt> 19 + <dd>Intrepid Ibex Desktop 0.1</dd> 20 + </div> 21 + <div> 22 + <dt>Desktop environment</dt> 23 + <dd>Classic GNOME-style shell</dd> 24 + </div> 25 + <div> 26 + <dt>Protocol</dt> 27 + <dd>AT Protocol, public read-only mode</dd> 28 + </div> 29 + <div> 30 + <dt>Account</dt> 31 + <dd>{accountSetup.identity ? `@${accountSetup.identity.handle}` : 'No account configured'}</dd> 32 + </div> 33 + <div> 34 + <dt>DID</dt> 35 + <dd>{accountSetup.identity?.did ?? '—'}</dd> 36 + </div> 37 + <div> 38 + <dt>PDS</dt> 39 + <dd>{accountSetup.identity?.pds ?? '—'}</dd> 40 + </div> 41 + </dl> 42 + </div> 43 + 44 + <p class="note">Humanity icons, Ubuntu fonts, tan window chrome, and public ATProto repo browsing.</p> 45 + </section> 46 + 47 + <style> 48 + .about-computer { 49 + display: grid; 50 + gap: var(--space-4); 51 + height: 100%; 52 + padding: var(--space-5); 53 + background: 54 + radial-gradient(circle at 18% 18%, rgb(244 186 98 / 0.35), transparent 10rem), linear-gradient(#fff8eb, #decaac); 55 + } 56 + 57 + .hero { 58 + display: grid; 59 + grid-template-columns: 64px minmax(0, 1fr); 60 + gap: var(--space-4); 61 + align-items: center; 62 + padding: var(--space-4); 63 + background: linear-gradient(#fffaf0, #ead8ba); 64 + border: 1px solid #b29165; 65 + border-radius: var(--radius-3); 66 + box-shadow: var(--shadow-raised); 67 + } 68 + 69 + .hero img { 70 + filter: drop-shadow(0 2px 2px rgb(0 0 0 / 0.24)); 71 + } 72 + 73 + .eyebrow { 74 + color: var(--orange-700); 75 + font-size: var(--text-0); 76 + font-weight: 700; 77 + letter-spacing: 0.04em; 78 + text-transform: uppercase; 79 + } 80 + 81 + h2 { 82 + margin-top: var(--space-1); 83 + font-size: var(--text-5); 84 + line-height: var(--leading-tight); 85 + } 86 + 87 + .hero p:last-child, 88 + .note { 89 + margin-top: var(--space-1); 90 + color: var(--text-muted); 91 + font-size: var(--text-2); 92 + } 93 + 94 + .details-panel { 95 + padding: var(--space-4); 96 + background: #fffdf7; 97 + border: 1px solid #b29165; 98 + border-radius: var(--radius-2); 99 + box-shadow: var(--shadow-sunken); 100 + } 101 + 102 + dl { 103 + display: grid; 104 + gap: var(--space-2); 105 + } 106 + 107 + dl div { 108 + display: grid; 109 + grid-template-columns: 10rem minmax(0, 1fr); 110 + gap: var(--space-3); 111 + } 112 + 113 + dt { 114 + color: var(--text-muted); 115 + font-size: var(--text-1); 116 + font-weight: 700; 117 + } 118 + 119 + dd { 120 + overflow-wrap: anywhere; 121 + font-size: var(--text-1); 122 + } 123 + 124 + dl div:nth-last-child(-n + 2) dd { 125 + font-family: var(--font-mono); 126 + } 127 + 128 + .note { 129 + align-self: end; 130 + } 131 + 132 + @media (max-width: 640px) { 133 + .about-computer { 134 + padding: var(--space-3); 135 + } 136 + 137 + .hero, 138 + dl div { 139 + grid-template-columns: 1fr; 140 + } 141 + } 142 + </style>
+101 -26
src/lib/components/AppWindow.svelte
··· 1 1 <script lang="ts"> 2 2 import type { Snippet } from 'svelte'; 3 - type Props = { title: string; icon: string }; 4 3 5 - let { title, icon, children }: Props & { children: Snippet } = $props(); 4 + type Props = { 5 + title: string; 6 + icon: string; 7 + address?: string; 8 + movable?: boolean; 9 + showMenubar?: boolean; 10 + showToolbar?: boolean; 11 + onclose?: () => void; 12 + }; 13 + 14 + let { 15 + title, 16 + icon, 17 + address = 'at://ubuntu.example/app.bsky.feed.post', 18 + movable = true, 19 + showMenubar = true, 20 + showToolbar = true, 21 + onclose, 22 + children 23 + }: Props & { children: Snippet } = $props(); 24 + 25 + let x = $state(0); 26 + let y = $state(0); 27 + let drag = $state<{ pointerId: number; startX: number; startY: number; originX: number; originY: number } | null>( 28 + null 29 + ); 30 + 31 + function startDrag(event: PointerEvent) { 32 + if (!movable || event.button !== 0 || event.target instanceof HTMLButtonElement) { 33 + return; 34 + } 35 + 36 + drag = { pointerId: event.pointerId, startX: event.clientX, startY: event.clientY, originX: x, originY: y }; 37 + 38 + (event.currentTarget as HTMLElement).setPointerCapture(event.pointerId); 39 + } 40 + 41 + function moveDrag(event: PointerEvent) { 42 + if (!drag || event.pointerId !== drag.pointerId) { 43 + return; 44 + } 45 + 46 + x = drag.originX + event.clientX - drag.startX; 47 + y = drag.originY + event.clientY - drag.startY; 48 + } 49 + 50 + function stopDrag(event: PointerEvent) { 51 + if (!drag || event.pointerId !== drag.pointerId) { 52 + return; 53 + } 54 + 55 + (event.currentTarget as HTMLElement).releasePointerCapture(event.pointerId); 56 + drag = null; 57 + } 6 58 </script> 7 59 8 - <section class="app-window" aria-label={title}> 9 - <header class="titlebar"> 60 + <section class="app-window" class:dragging={drag} aria-label={title} style:transform={`translate(${x}px, ${y}px)`}> 61 + <header 62 + class="titlebar" 63 + role="group" 64 + aria-label="Window title bar" 65 + onpointerdown={startDrag} 66 + onpointermove={moveDrag} 67 + onpointerup={stopDrag} 68 + onpointercancel={stopDrag}> 10 69 <div class="window-title"> 11 70 <img src={icon} alt="" width="18" height="18" /> 12 71 <h1>{title}</h1> 13 72 </div> 14 - <div class="window-controls" aria-hidden="true"> 15 - <span></span> 16 - <span></span> 17 - <span></span> 73 + <div class="window-controls" aria-label="Window controls"> 74 + <button type="button" aria-label="Minimize" tabindex="-1"></button> 75 + <button type="button" aria-label="Maximize" tabindex="-1"></button> 76 + <button type="button" aria-label="Close" tabindex={onclose ? 0 : -1} onclick={onclose}></button> 18 77 </div> 19 78 </header> 20 79 21 - <nav class="menubar" aria-label="Application menu"> 22 - <button type="button">Collection</button> 23 - <button type="button">Navigate</button> 24 - <button type="button">View</button> 25 - <button type="button">Bookmarks</button> 26 - <button type="button">Help</button> 27 - </nav> 80 + {#if showMenubar} 81 + <nav class="menubar" aria-label="Application menu"> 82 + <button type="button">Collection</button> 83 + <button type="button">Navigate</button> 84 + <button type="button">View</button> 85 + <button type="button">Bookmarks</button> 86 + <button type="button">Help</button> 87 + </nav> 88 + {/if} 28 89 29 - <div class="toolbar" aria-label="Application toolbar"> 30 - <button type="button"><span aria-hidden="true">◀</span> Back</button> 31 - <button type="button"><span aria-hidden="true">▶</span> Forward</button> 32 - <button type="button" 33 - ><img src="/icons/humanity/actions/mail-send-receive.svg" alt="" width="18" height="18" /> Sync</button> 34 - <label> 35 - <span class="sr-only">Address</span> 36 - <input value="at://ubuntu.example/app.bsky.feed.post" readonly /> 37 - </label> 38 - </div> 90 + {#if showToolbar} 91 + <div class="toolbar" aria-label="Application toolbar"> 92 + <button type="button"><span aria-hidden="true">◀</span> Back</button> 93 + <button type="button"><span aria-hidden="true">▶</span> Forward</button> 94 + <button type="button" 95 + ><img src="/icons/humanity/actions/mail-send-receive.svg" alt="" width="18" height="18" /> Sync</button> 96 + <label> 97 + <span class="sr-only">Address</span> 98 + <input value={address} readonly /> 99 + </label> 100 + </div> 101 + {/if} 39 102 40 103 <div class="content"> 41 104 {@render children()} ··· 51 114 border: 1px solid #2a170d; 52 115 border-radius: var(--radius-3) var(--radius-3) var(--radius-2) var(--radius-2); 53 116 box-shadow: var(--shadow-window); 117 + will-change: transform; 118 + } 119 + 120 + .app-window.dragging { 121 + user-select: none; 54 122 } 55 123 56 124 .titlebar { ··· 65 133 linear-gradient(var(--window-title-start), var(--window-title-end)); 66 134 border-bottom: 1px solid #1b0f09; 67 135 text-shadow: 0 1px 1px rgb(0 0 0 / 0.9); 136 + cursor: move; 137 + touch-action: none; 68 138 } 69 139 70 140 .window-title { ··· 88 158 gap: 5px; 89 159 } 90 160 91 - .window-controls span { 161 + .window-controls button { 92 162 width: 14px; 93 163 height: 14px; 94 164 border: 1px solid #1e120a; 95 165 border-radius: var(--radius-round); 96 166 background: radial-gradient(circle at 35% 30%, #fff4d0, #cf7b1f 45%, #6d3412 78%); 97 167 box-shadow: 0 1px 0 rgb(255 255 255 / 0.24) inset; 168 + cursor: default; 169 + } 170 + 171 + .window-controls button:hover { 172 + filter: brightness(1.12); 98 173 } 99 174 100 175 .menubar,
+80 -55
src/lib/components/CollectionBrowser.svelte
··· 1 1 <script lang="ts"> 2 - const collections = [ 3 - { 4 - name: 'app.bsky.feed.post', 5 - count: '18,204', 6 - icon: '/icons/humanity/apps/internet-feed-reader.svg', 7 - active: true 8 - }, 9 - { name: 'app.bsky.actor.profile', count: '1,282', icon: '/icons/humanity/places/user-home.svg' }, 10 - { name: 'app.bsky.graph.follow', count: '6,914', icon: '/icons/humanity/places/folder.svg' }, 11 - { name: 'chat.bsky.convo.defs', count: '126', icon: '/icons/humanity/apps/evolution-mail.svg' }, 12 - { name: 'com.atproto.repo.strongRef', count: '42', icon: '/icons/humanity/mimes/text-x-generic.svg' } 13 - ]; 2 + import { onMount } from 'svelte'; 3 + import { accountSetup } from '$lib/atproto/setup.svelte'; 4 + import { repoBrowser } from '$lib/atproto/repo.svelte'; 14 5 15 - const records = [ 16 - { 17 - author: '@intrepid-ibex.test', 18 - time: '10:08', 19 - title: 'Classic GNOME shell mockup', 20 - body: 'Panels, textured chrome, collection folders, and a very 2008 address bar are now static and ready to wire into AT Protocol data.' 21 - }, 22 - { 23 - author: '@desktop.example', 24 - time: '09:44', 25 - title: 'Humanity icon import', 26 - body: 'Using archived Ubuntu Humanity assets for launchers, desktop shortcuts, window icons, and status tray affordances.' 27 - }, 28 - { 29 - author: '@repo.tools', 30 - time: 'Yesterday', 31 - title: 'Collection-first browsing', 32 - body: 'Each application can become a focused browser for a different collection namespace without losing the GNOME desktop metaphor.' 6 + onMount(() => { 7 + const identity = accountSetup.identity; 8 + 9 + if (identity) { 10 + repoBrowser.load(identity); 33 11 } 34 - ]; 12 + }); 13 + 14 + function selectCollection(collectionName: string) { 15 + const identity = accountSetup.identity; 16 + 17 + if (identity) { 18 + repoBrowser.selectCollection(identity, collectionName); 19 + } 20 + } 35 21 </script> 36 22 37 23 <div class="collection-browser"> 38 24 <aside class="sidebar" aria-label="Collections"> 39 25 <header> 40 26 <h2>Collections</h2> 41 - <p>at:// repo folders</p> 27 + <p>{accountSetup.identity?.handle ?? 'at:// repo folders'}</p> 42 28 </header> 43 29 44 30 <ul> 45 - {#each collections as collection (collection.name)} 46 - <li> 47 - <button class:active={collection.active} type="button"> 48 - <img src={collection.icon} alt="" width="24" height="24" /> 49 - <span>{collection.name}</span> 50 - <small>{collection.count}</small> 51 - </button> 52 - </li> 53 - {/each} 31 + {#if repoBrowser.isLoadingCollections} 32 + <li class="empty-row">Loading collections…</li> 33 + {:else} 34 + {#each repoBrowser.collections as collection (collection.name)} 35 + <li> 36 + <button 37 + class:active={collection.name === repoBrowser.selectedCollection} 38 + type="button" 39 + onclick={() => selectCollection(collection.name)}> 40 + <img src={collection.icon} alt="" width="24" height="24" /> 41 + <span>{collection.name}</span> 42 + <small>{collection.loadedCount ?? 'repo'}</small> 43 + </button> 44 + </li> 45 + {/each} 46 + {/if} 54 47 </ul> 55 48 </aside> 56 49 57 50 <section class="record-pane" aria-label="Collection records"> 58 51 <div class="summary-card"> 59 - <img src="/icons/humanity/apps/internet-feed-reader.svg" alt="" width="48" height="48" /> 52 + <img 53 + src={repoBrowser.selectedSummary?.icon ?? '/icons/humanity/apps/internet-feed-reader.svg'} 54 + alt="" 55 + width="48" 56 + height="48" /> 60 57 <div> 61 58 <p class="eyebrow">Selected collection</p> 62 - <h2>app.bsky.feed.post</h2> 63 - <p>Browse posts as if they were tidy Nautilus files.</p> 59 + <h2>{repoBrowser.selectedCollection ?? 'No collection selected'}</h2> 60 + <p> 61 + {#if repoBrowser.isLoadingRecords} 62 + Fetching public records from {accountSetup.identity?.pds ?? 'the public API'}… 63 + {:else} 64 + Browse records as if they were tidy Nautilus files. 65 + {/if} 66 + </p> 64 67 </div> 65 68 </div> 66 69 ··· 71 74 <span>Modified</span> 72 75 </header> 73 76 74 - {#each records as record (record.title)} 75 - <article> 76 - <img src="/icons/humanity/mimes/text-x-generic.svg" alt="" width="32" height="32" /> 77 - <div> 78 - <h3>{record.title}</h3> 79 - <p>{record.body}</p> 80 - </div> 81 - <strong>{record.author}</strong> 82 - <time>{record.time}</time> 83 - </article> 84 - {/each} 77 + {#if repoBrowser.error} 78 + <p class="message error">{repoBrowser.error}</p> 79 + {:else if repoBrowser.isLoadingRecords} 80 + <p class="message">Loading records…</p> 81 + {:else if repoBrowser.records.length === 0} 82 + <p class="message">No public records found for this collection.</p> 83 + {:else} 84 + {#each repoBrowser.records as record (record.uri)} 85 + <article> 86 + <img src="/icons/humanity/mimes/text-x-generic.svg" alt="" width="32" height="32" /> 87 + <div> 88 + <h3>{record.title}</h3> 89 + <p>{record.body}</p> 90 + </div> 91 + <strong>{record.author}</strong> 92 + <time>{record.modified}</time> 93 + </article> 94 + {/each} 95 + {/if} 85 96 </div> 86 97 </section> 87 98 </div> ··· 160 171 .sidebar small { 161 172 font-size: var(--text-0); 162 173 opacity: 0.82; 174 + } 175 + 176 + .empty-row, 177 + .message { 178 + padding: var(--space-3); 179 + color: var(--text-muted); 180 + font-size: var(--text-1); 181 + } 182 + 183 + .message.error { 184 + color: #721c0d; 185 + background: #f7d6c9; 186 + border: 1px solid #b55a38; 187 + border-radius: var(--radius-2); 163 188 } 164 189 165 190 .record-pane {
+9 -3
src/lib/components/DesktopIcon.svelte
··· 1 1 <script lang="ts"> 2 - type Props = { label: string; icon: string; selected?: boolean }; 2 + type Props = { label: string; icon: string; selected?: boolean; onactivate?: () => void }; 3 3 4 - let { label, icon, selected = false }: Props = $props(); 4 + let { label, icon, selected = false, onactivate }: Props = $props(); 5 5 </script> 6 6 7 - <button class:selected class="desktop-icon" type="button" aria-label={label}> 7 + <button 8 + class:selected 9 + class="desktop-icon" 10 + type="button" 11 + aria-label={label} 12 + onclick={onactivate} 13 + ondblclick={onactivate}> 8 14 <img src={icon} alt="" width="48" height="48" /> 9 15 <span>{label}</span> 10 16 </button>
+137 -7
src/lib/components/GnomePanel.svelte
··· 1 1 <script lang="ts"> 2 + import { accountSetup } from '$lib/atproto/setup.svelte'; 3 + import { repoBrowser } from '$lib/atproto/repo.svelte'; 4 + 2 5 const launchers = [ 3 6 { label: 'Browser', icon: '/icons/humanity/apps/web-browser.svg' }, 4 7 { label: 'Mail', icon: '/icons/humanity/apps/evolution-mail.svg' }, 5 8 { label: 'Terminal', icon: '/icons/humanity/apps/utilities-terminal.svg' } 6 9 ]; 10 + 11 + function changeAccount() { 12 + repoBrowser.reset(); 13 + accountSetup.reset(); 14 + } 7 15 </script> 8 16 9 17 <header class="gnome-panel" aria-label="GNOME top panel"> ··· 12 20 <img src="/icons/humanity/apps/system-file-manager.svg" alt="" width="20" height="20" /> 13 21 <span>Applications</span> 14 22 </button> 15 - <button type="button">Places</button> 16 - <button type="button">System</button> 23 + 24 + <details class="panel-menu"> 25 + <summary>Places</summary> 26 + <div class="menu-popover places-popover"> 27 + <p class="menu-heading">Collections</p> 28 + {#if repoBrowser.collections.length > 0} 29 + {#each repoBrowser.collections.slice(0, 10) as collection (collection.name)} 30 + <button 31 + type="button" 32 + onclick={() => 33 + accountSetup.identity && repoBrowser.selectCollection(accountSetup.identity, collection.name)}> 34 + <img src={collection.icon} alt="" width="16" height="16" /> 35 + <span>{collection.name}</span> 36 + </button> 37 + {/each} 38 + {:else} 39 + <span class="menu-empty">Collections appear here after setup.</span> 40 + {/if} 41 + </div> 42 + </details> 43 + 44 + <details class="panel-menu"> 45 + <summary>System</summary> 46 + <div class="menu-popover system-popover"> 47 + {#if accountSetup.identity} 48 + <p class="menu-heading">Signed in as @{accountSetup.identity.handle}</p> 49 + {/if} 50 + <button type="button" onclick={changeAccount}> 51 + <img src="/icons/humanity/places/user-home.svg" alt="" width="16" height="16" /> 52 + <span>Change Account…</span> 53 + </button> 54 + </div> 55 + </details> 17 56 </nav> 18 57 19 58 <div class="panel-launchers" aria-label="Launchers"> ··· 29 68 <section class="window-list" aria-label="Open windows"> 30 69 <button class="active-window" type="button"> 31 70 <img src="/icons/humanity/apps/internet-feed-reader.svg" alt="" width="16" height="16" /> 32 - <span>AT Protocol Collections</span> 71 + <span>{accountSetup.isConfigured ? 'AT Protocol Collections' : 'AT Protocol Account Setup'}</span> 33 72 </button> 34 73 </section> 35 74 36 75 <aside class="panel-tray" aria-label="Status tray"> 76 + {#if accountSetup.identity} 77 + <button class="account-chip" type="button" onclick={changeAccount} title="Change account"> 78 + {#if accountSetup.identity.avatar} 79 + <img src={accountSetup.identity.avatar} alt="" width="16" height="16" /> 80 + {:else} 81 + <img src="/icons/humanity/places/user-home.svg" alt="" width="16" height="16" /> 82 + {/if} 83 + <span>@{accountSetup.identity.handle}</span> 84 + </button> 85 + {/if} 37 86 <img src="/icons/humanity/status/network-wireless-encrypted.svg" alt="Network connected" width="16" height="16" /> 38 87 <img src="/icons/humanity/status/audio-volume-medium.svg" alt="Volume" width="16" height="16" /> 39 88 <time datetime="2008-10-30T10:10">Thu Oct 30, 10:10 AM</time> ··· 42 91 43 92 <style> 44 93 .gnome-panel { 94 + position: relative; 95 + z-index: 10; 45 96 display: flex; 46 97 align-items: center; 47 98 gap: var(--space-2); ··· 71 122 } 72 123 73 124 .panel-menus button, 125 + .panel-menus summary, 74 126 .panel-launchers button, 75 - .active-window { 127 + .active-window, 128 + .account-chip { 76 129 display: inline-flex; 77 130 align-items: center; 78 131 gap: var(--space-1); ··· 84 137 } 85 138 86 139 .panel-menus button:hover, 87 - .panel-launchers button:hover { 140 + .panel-menus summary:hover, 141 + .panel-menu[open] summary, 142 + .panel-launchers button:hover, 143 + .account-chip:hover { 88 144 background: rgb(255 238 203 / 0.16); 89 145 border-color: rgb(255 255 255 / 0.18) rgb(0 0 0 / 0.35) rgb(0 0 0 / 0.45) rgb(255 255 255 / 0.16); 90 146 } 91 147 148 + .panel-menu { 149 + position: relative; 150 + } 151 + 152 + .panel-menu summary { 153 + list-style: none; 154 + } 155 + 156 + .panel-menu summary::-webkit-details-marker { 157 + display: none; 158 + } 159 + 160 + .menu-popover { 161 + position: absolute; 162 + top: calc(100% + 3px); 163 + left: 0; 164 + display: grid; 165 + gap: 1px; 166 + min-width: 17rem; 167 + padding: var(--space-1); 168 + color: var(--text); 169 + background: linear-gradient(#f4e6cc, #cfb58c); 170 + border: 1px solid #4d2c17; 171 + box-shadow: 0 10px 22px rgb(0 0 0 / 0.42); 172 + text-shadow: none; 173 + } 174 + 175 + .menu-popover button { 176 + justify-content: flex-start; 177 + width: 100%; 178 + height: 1.6rem; 179 + color: var(--text); 180 + } 181 + 182 + .menu-popover button:hover { 183 + color: white; 184 + background: var(--selection); 185 + border-color: transparent; 186 + } 187 + 188 + .menu-popover span { 189 + overflow: hidden; 190 + text-overflow: ellipsis; 191 + white-space: nowrap; 192 + } 193 + 194 + .menu-heading, 195 + .menu-empty { 196 + padding: var(--space-1) var(--space-2); 197 + color: var(--text-muted); 198 + font-size: var(--text-0); 199 + font-weight: 700; 200 + } 201 + 202 + .system-popover { 203 + min-width: 13rem; 204 + } 205 + 92 206 .applications-menu { 93 207 font-weight: 650; 94 208 } ··· 132 246 gap: var(--space-2); 133 247 } 134 248 249 + .account-chip { 250 + max-width: 12rem; 251 + padding: 0 var(--space-1); 252 + } 253 + 254 + .account-chip img { 255 + border-radius: var(--radius-1); 256 + } 257 + 258 + .account-chip span { 259 + overflow: hidden; 260 + text-overflow: ellipsis; 261 + white-space: nowrap; 262 + } 263 + 135 264 .panel-tray time { 136 265 font-variant-numeric: tabular-nums; 137 266 } 138 267 139 268 @media (max-width: 760px) { 140 - .panel-menus button:not(.applications-menu), 141 - .window-list { 269 + .panel-menus .panel-menu, 270 + .window-list, 271 + .account-chip span { 142 272 display: none; 143 273 } 144 274 }
+50 -3
src/routes/+layout.svelte
··· 2 2 import { onMount } from 'svelte'; 3 3 import { accountSetup } from '$lib/atproto/setup.svelte'; 4 4 import favicon from '$lib/assets/favicon.svg'; 5 + import AboutComputer from '$lib/components/AboutComputer.svelte'; 5 6 import AppWindow from '$lib/components/AppWindow.svelte'; 6 7 import DesktopIcon from '$lib/components/DesktopIcon.svelte'; 7 8 import GnomePanel from '$lib/components/GnomePanel.svelte'; ··· 9 10 import '$lib/styles/style.css'; 10 11 11 12 let { children } = $props(); 13 + let showAboutComputer = $state(false); 12 14 13 - const shortcuts = [ 15 + const shortcuts = $derived([ 14 16 { label: 'ibex Home', icon: '/icons/humanity/places/user-home.svg', selected: true }, 15 17 { label: 'Collections', icon: '/icons/humanity/places/folder.svg' }, 16 - { label: 'Computer', icon: '/icons/humanity/devices/computer.svg' }, 18 + { 19 + label: 'Computer', 20 + icon: '/icons/humanity/devices/computer.svg', 21 + selected: showAboutComputer, 22 + onactivate: () => (showAboutComputer = true) 23 + }, 17 24 { label: 'Trash', icon: '/icons/humanity/places/user-trash.svg' } 18 - ]; 25 + ]); 19 26 20 27 const windowTitle = $derived( 21 28 accountSetup.isConfigured ? 'AT Protocol Collections - Intrepid Ibex' : 'AT Protocol Account Setup' ··· 55 62 </AppWindow> 56 63 </div> 57 64 65 + {#if showAboutComputer} 66 + <div class="about-window"> 67 + <AppWindow 68 + title="About This Computer" 69 + icon="/icons/humanity/devices/computer.svg" 70 + showMenubar={false} 71 + showToolbar={false} 72 + onclose={() => (showAboutComputer = false)}> 73 + <AboutComputer /> 74 + </AppWindow> 75 + </div> 76 + {/if} 77 + 78 + <!-- TODO: make this closeable --> 58 79 <aside class="sticky-note" aria-label="Design note"> 59 80 <h2>Intrepid Ibex</h2> 60 81 <p>This app is a recreation of the spirit of Ubuntu 8.10</p> ··· 101 122 height: 100%; 102 123 } 103 124 125 + .about-window { 126 + position: absolute; 127 + top: min(8rem, 16vh); 128 + left: min(46rem, 52vw); 129 + z-index: 3; 130 + width: min(34rem, calc(100vw - 2rem)); 131 + height: min(28rem, calc(100vh - 5rem)); 132 + } 133 + 134 + .about-window :global(.app-window) { 135 + height: 100%; 136 + } 137 + 104 138 .sticky-note { 105 139 align-self: end; 106 140 width: min(18rem, 100%); ··· 132 166 padding: var(--space-3); 133 167 } 134 168 169 + .about-window { 170 + left: auto; 171 + right: var(--space-3); 172 + } 173 + 135 174 .sticky-note { 136 175 display: none; 137 176 } ··· 149 188 .primary-window { 150 189 height: calc(100vh - 4.25rem); 151 190 min-height: 0; 191 + } 192 + 193 + .about-window { 194 + top: var(--space-3); 195 + right: var(--space-3); 196 + left: var(--space-3); 197 + width: auto; 198 + height: min(28rem, calc(100vh - 6rem)); 152 199 } 153 200 } 154 201 </style>