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: add Eye of GNOME blob viewer

Owais Jamil (Jun 16, 2026, 11:18 PM -0500) 6b7a41e1 d3f80a32

+1122 -63
+5
.changeset/fluffy-foxes-wish.md
··· 1 + --- 2 + 'intrepid-ibex': minor 3 + --- 4 + 5 + added a blob viewer made to feel like eye of gnome
+176
src/lib/atproto/blobs.svelte.ts
··· 1 + import { Client, ok, simpleFetchHandler } from '@atcute/client'; 2 + import type { Did } from '@atcute/lexicons/syntax'; 3 + import type {} from '@atcute/atproto'; 4 + import { errorMessage } from '$lib/utils/errors'; 5 + import type { AccountIdentity, BlobReference, RepoBlobSummary } from './types'; 6 + 7 + class RepoBlobState { 8 + blobs = $state<RepoBlobSummary[]>([]); 9 + selectedCid = $state<string | null>(null); 10 + isLoading = $state(false); 11 + isLoadingMore = $state(false); 12 + canLoadMore = $state(false); 13 + error = $state<string | null>(null); 14 + loadedDid = $state<string | null>(null); 15 + private cursor = $state<string | null>(null); 16 + 17 + // TODO: should be $derived 18 + get selectedBlob() { 19 + return this.blobs.find((blob) => blob.cid === this.selectedCid) ?? null; 20 + } 21 + 22 + // TODO: should be $derived 23 + get selectedIndex() { 24 + return this.blobs.findIndex((blob) => blob.cid === this.selectedCid); 25 + } 26 + 27 + async load(identity: AccountIdentity, selectedCid?: string | null) { 28 + if (this.loadedDid === identity.did && this.blobs.length > 0) { 29 + if (selectedCid) this.select(identity, selectedCid); 30 + return; 31 + } 32 + 33 + this.reset(); 34 + this.loadedDid = identity.did; 35 + this.isLoading = true; 36 + this.error = null; 37 + 38 + try { 39 + const page = await listBlobPage(identity); 40 + this.blobs = page.cids.map((cid) => blobSummary(identity, cid, null)); 41 + this.cursor = page.cursor ?? null; 42 + this.canLoadMore = Boolean(page.cursor); 43 + 44 + if (selectedCid) { 45 + this.select(identity, selectedCid); 46 + } else { 47 + this.selectedCid = this.blobs[0]?.cid ?? null; 48 + } 49 + } catch (unknownError) { 50 + if (selectedCid) { 51 + this.blobs = [blobSummary(identity, selectedCid, null)]; 52 + this.selectedCid = selectedCid; 53 + } 54 + this.error = errorMessage(unknownError, 'Could not load repository blobs.'); 55 + } finally { 56 + this.isLoading = false; 57 + } 58 + } 59 + 60 + async loadMore(identity: AccountIdentity) { 61 + if (!this.cursor || this.isLoadingMore) return; 62 + 63 + this.isLoadingMore = true; 64 + this.error = null; 65 + 66 + try { 67 + const page = await listBlobPage(identity, this.cursor); 68 + const knownCids = new Set(this.blobs.map((blob) => blob.cid)); 69 + const nextBlobs = page.cids.filter((cid) => !knownCids.has(cid)).map((cid) => blobSummary(identity, cid, null)); 70 + 71 + this.blobs = [...this.blobs, ...nextBlobs]; 72 + this.cursor = page.cursor ?? null; 73 + this.canLoadMore = Boolean(page.cursor); 74 + } catch (unknownError) { 75 + this.canLoadMore = false; 76 + this.error = errorMessage(unknownError, 'Could not load more blobs.'); 77 + } finally { 78 + this.isLoadingMore = false; 79 + } 80 + } 81 + 82 + openMedia(identity: AccountIdentity, media: BlobReference) { 83 + this.loadedDid = identity.did; 84 + const summary = blobSummary(identity, media.cid, media.sourceUri); 85 + 86 + if (!this.blobs.some((blob) => blob.cid === media.cid)) { 87 + this.blobs = [summary, ...this.blobs]; 88 + } 89 + 90 + this.selectedCid = media.cid; 91 + this.error = null; 92 + } 93 + 94 + select(identity: AccountIdentity, cid: string) { 95 + if (!this.blobs.some((blob) => blob.cid === cid)) { 96 + this.blobs = [blobSummary(identity, cid, null), ...this.blobs]; 97 + } 98 + 99 + this.selectedCid = cid; 100 + } 101 + 102 + selectPrevious() { 103 + if (this.selectedIndex <= 0) return; 104 + this.selectedCid = this.blobs[this.selectedIndex - 1]?.cid ?? this.selectedCid; 105 + } 106 + 107 + selectNext() { 108 + if (this.selectedIndex < 0 || this.selectedIndex >= this.blobs.length - 1) return; 109 + this.selectedCid = this.blobs[this.selectedIndex + 1]?.cid ?? this.selectedCid; 110 + } 111 + 112 + reset() { 113 + this.blobs = []; 114 + this.selectedCid = null; 115 + this.isLoading = false; 116 + this.isLoadingMore = false; 117 + this.canLoadMore = false; 118 + this.error = null; 119 + this.loadedDid = null; 120 + this.cursor = null; 121 + } 122 + } 123 + 124 + function isBlobObject(value: object): value is { ref: { $link: string } } { 125 + if (!('ref' in value)) return false; 126 + const { ref } = value; 127 + return ( 128 + typeof ref === 'object' && ref !== null && '$link' in ref && typeof (ref as { $link?: unknown }).$link === 'string' 129 + ); 130 + } 131 + 132 + async function listBlobPage(identity: AccountIdentity, cursor?: string | null) { 133 + const rpc = new Client({ handler: simpleFetchHandler({ service: identity.pds ?? 'https://public.api.bsky.app' }) }); 134 + return ok( 135 + rpc.get('com.atproto.sync.listBlobs', { 136 + params: { did: identity.did as Did, limit: 50, cursor: cursor ?? undefined } 137 + }) 138 + ); 139 + } 140 + 141 + function blobSummary(identity: AccountIdentity, cid: string, sourceUri: string | null): RepoBlobSummary { 142 + return { cid, sourceUri, rawUrl: rawBlobUrl(identity, cid) }; 143 + } 144 + 145 + function rawBlobUrl(identity: AccountIdentity, cid: string) { 146 + const service = identity.pds ?? 'https://public.api.bsky.app'; 147 + const url = new URL('/xrpc/com.atproto.sync.getBlob', service); 148 + url.searchParams.set('did', identity.did); 149 + url.searchParams.set('cid', cid); 150 + return url.toString(); 151 + } 152 + 153 + export function firstBlobReference(value: unknown, sourceUri: string | null): BlobReference | null { 154 + if (typeof value !== 'object' || value === null) return null; 155 + 156 + if (isBlobObject(value)) { 157 + return { cid: value.ref.$link, sourceUri }; 158 + } 159 + 160 + if (Array.isArray(value)) { 161 + for (const item of value) { 162 + const found = firstBlobReference(item, sourceUri); 163 + if (found) return found; 164 + } 165 + return null; 166 + } 167 + 168 + for (const child of Object.values(value)) { 169 + const found = firstBlobReference(child, sourceUri); 170 + if (found) return found; 171 + } 172 + 173 + return null; 174 + } 175 + 176 + export const repoBlobs = new RepoBlobState();
+25
src/lib/atproto/blobs.test.ts
··· 1 + import { describe, expect, it } from 'vitest'; 2 + import { firstBlobReference } from './blobs.svelte'; 3 + 4 + describe('ATProto blob helpers', () => { 5 + it('finds the first embedded blob CID in a post image record', () => { 6 + const blob = firstBlobReference( 7 + { 8 + $type: 'app.bsky.feed.post', 9 + embed: { 10 + $type: 'app.bsky.embed.images', 11 + images: [ 12 + { alt: 'screenshot', image: { ref: { $link: 'bafkreigoodimage' }, mimeType: 'image/png', size: 1234 } } 13 + ] 14 + } 15 + }, 16 + 'at://did:plc:abc123/app.bsky.feed.post/post1' 17 + ); 18 + 19 + expect(blob).toEqual({ cid: 'bafkreigoodimage', sourceUri: 'at://did:plc:abc123/app.bsky.feed.post/post1' }); 20 + }); 21 + 22 + it('returns null when a record has no blob ref', () => { 23 + expect(firstBlobReference({ text: 'plain record' }, 'at://did:plc:abc123/app.bsky.feed.post/post2')).toBeNull(); 24 + }); 25 + });
+26 -31
src/lib/atproto/repo.svelte.ts
··· 8 8 import { isRecordValue } from './types'; 9 9 import type { AccountIdentity, CollectionSummary, RepoRecordSummary, UnknownRecord } from './types'; 10 10 11 - export type { CollectionSummary, RepoRecordSummary } from './types'; 12 - 13 11 class RepoBrowserState { 14 12 collections = $state<CollectionSummary[]>([]); 15 13 selectedCollection = $state<string | null>(null); ··· 244 242 } 245 243 } 246 244 247 - export const repoBrowser = new RepoBrowserState(); 248 - 249 245 function createRepoClient(identity: AccountIdentity) { 250 246 return new Client({ handler: simpleFetchHandler({ service: identity.pds ?? 'https://public.api.bsky.app' }) }); 251 247 } ··· 265 261 return collections.includes('app.bsky.feed.post') ? 'app.bsky.feed.post' : (collections[0] ?? null); 266 262 } 267 263 268 - export function iconForCollection(name: string) { 269 - const appIcon = collectionIconMatch(name)?.icon; 270 - if (appIcon) return appIcon; 271 - if (name.includes('profile') || name.includes('actor')) return '/icons/humanity/places/user-home.svg'; 272 - if (name.includes('chat') || name.includes('convo')) return '/icons/humanity/apps/evolution-mail.svg'; 273 - if (name.includes('feed') || name.includes('post')) return '/icons/humanity/apps/internet-feed-reader.svg'; 274 - if (name.includes('graph') || name.includes('follow')) return '/icons/humanity/places/folder.svg'; 275 - return '/icons/humanity/mimes/text-x-generic.svg'; 276 - } 277 - 278 264 function collectionSummaryForName(name: string, loadedCount: number | null): CollectionSummary { 279 265 return { name, icon: iconForCollection(name), appLabel: appLabelForCollection(name), loadedCount }; 280 266 } ··· 297 283 collection: collectionFromUri(record.uri), 298 284 rkey: recordKeyFromUri(record.uri), 299 285 json: JSON.stringify(record.value, null, 2), 286 + value: record.value, 300 287 icon: iconForCollection(collectionFromUri(record.uri)), 301 288 appLabel: appLabelForCollection(collectionFromUri(record.uri)) 302 289 }; ··· 307 294 } 308 295 309 296 async function cacheLiveRecords( 310 - identity: AccountIdentity, 311 - collectionName: string, 297 + id: AccountIdentity, 298 + name: string, 312 299 records: readonly UnknownRecord[], 313 300 cursor: string | null 314 301 ) { ··· 317 304 const db = await getDatabase(); 318 305 await cacheFetchedRecords( 319 306 db, 320 - records.map((record) => toCachedRecordInput(identity, collectionName, record)) 307 + records.map((record) => toCachedRecordInput(id, name, record)) 321 308 ); 322 309 await updateCollectionSyncState(db, { 323 - accountDid: identity.did, 324 - repoDid: identity.did, 325 - collection: collectionName, 310 + accountDid: id.did, 311 + repoDid: id.did, 312 + collection: name, 326 313 cursor, 327 314 lastSyncedAt: new Date().toISOString(), 328 315 lastError: null 329 316 }); 330 - } catch (cacheError) { 331 - console.warn('Could not write records to local cache.', cacheError); 317 + } catch (err) { 318 + console.warn('Could not write records to local cache.', err); 332 319 } 333 320 } 334 321 335 - function toCachedRecordInput( 336 - identity: AccountIdentity, 337 - collectionName: string, 338 - record: UnknownRecord 339 - ): CachedRecordInput { 322 + function toCachedRecordInput(id: AccountIdentity, name: string, record: UnknownRecord): CachedRecordInput { 340 323 const value = isRecordValue(record.value) ? record.value : {}; 341 324 const text = stringifyField(value.text) ?? stringifyField(value.name) ?? stringifyField(value.displayName) ?? ''; 342 - const type = stringifyField(value.$type) ?? collectionName; 325 + const type = stringifyField(value.$type) ?? name; 343 326 const createdAt = stringifyField(value.createdAt); 344 327 const indexedAt = stringifyField(value.indexedAt); 345 328 const updatedAt = stringifyField(value.updatedAt); 346 329 347 330 return { 348 - accountDid: identity.did, 349 - repoDid: identity.did, 350 - collection: collectionName, 331 + accountDid: id.did, 332 + repoDid: id.did, 333 + collection: name, 351 334 rkey: recordKeyFromUri(record.uri), 352 335 uri: record.uri, 353 336 cid: record.cid, ··· 388 371 minute: '2-digit' 389 372 }).format(date); 390 373 } 374 + 375 + export function iconForCollection(name: string) { 376 + const appIcon = collectionIconMatch(name)?.icon; 377 + if (appIcon) return appIcon; 378 + if (name.includes('profile') || name.includes('actor')) return '/icons/humanity/places/user-home.svg'; 379 + if (name.includes('chat') || name.includes('convo')) return '/icons/humanity/apps/evolution-mail.svg'; 380 + if (name.includes('feed') || name.includes('post')) return '/icons/humanity/apps/internet-feed-reader.svg'; 381 + if (name.includes('graph') || name.includes('follow')) return '/icons/humanity/places/folder.svg'; 382 + return '/icons/humanity/mimes/text-x-generic.svg'; 383 + } 384 + 385 + export const repoBrowser = new RepoBrowserState();
+9 -1
src/lib/atproto/routes.test.ts
··· 1 1 /** Move to test/routes.test.ts */ 2 2 import { describe, expect, it } from 'vitest'; 3 - import { collectionPath, identityPath, recordPath, repoPath } from './routes'; 3 + import { blobPath, blobsPath, collectionPath, identityPath, recordPath, repoPath } from './routes'; 4 4 5 5 describe('ATProto route helpers', () => { 6 6 it('builds canonical repo routes', () => { ··· 15 15 16 16 it('builds canonical identity inspector routes', () => { 17 17 expect(identityPath('did:plc:abc123')).toBe('/repos/did:plc:abc123/identity'); 18 + }); 19 + 20 + it('builds canonical blob browser routes', () => { 21 + expect(blobsPath('did:plc:abc123')).toBe('/repos/did:plc:abc123/blobs'); 22 + }); 23 + 24 + it('encodes blob CIDs in canonical blob routes', () => { 25 + expect(blobPath('did:plc:abc123', 'bafy/test')).toBe('/repos/did:plc:abc123/blobs/bafy%2Ftest'); 18 26 }); 19 27 20 28 it('encodes record keys in canonical record routes', () => {
+7 -7
src/lib/atproto/routes.ts
··· 1 - export type RecordRouteParams = { did: string; collection: string; rkey: string }; 1 + import type { CollectionRouteParams, RecordRouteParams } from './types'; 2 2 3 - export type CollectionRouteParams = Pick<RecordRouteParams, 'did' | 'collection'>; 3 + export const repoPath = (did: string) => `/repos/${did}`; 4 4 5 - export function repoPath(did: string) { 6 - return `/repos/${did}`; 7 - } 5 + export const identityPath = (did: string) => `${repoPath(did)}/identity`; 8 6 9 - export function identityPath(did: string) { 10 - return `${repoPath(did)}/identity`; 7 + export const blobsPath = (did: string) => `${repoPath(did)}/blobs`; 8 + 9 + export function blobPath(did: string, cid: string) { 10 + return `${blobsPath(did)}/${encodeURIComponent(cid)}`; 11 11 } 12 12 13 13 export function collectionPath({ did, collection }: CollectionRouteParams) {
+11 -11
src/lib/atproto/types.ts
··· 17 17 service?: DidService[]; 18 18 }; 19 19 20 - export type DidService = { 21 - id?: string; 22 - type?: string; 23 - serviceEndpoint?: string | string[] | Record<string, unknown>; 24 - }; 20 + export type DidService = { id?: string; type?: string; serviceEndpoint?: string | string[] | Record<string, unknown> }; 25 21 26 - export type DidVerificationMethod = { 27 - id?: string; 28 - type?: string; 29 - controller?: string; 30 - publicKeyMultibase?: string; 31 - }; 22 + export type DidVerificationMethod = { id?: string; type?: string; controller?: string; publicKeyMultibase?: string }; 32 23 33 24 export type CollectionSummary = { name: string; icon: string; appLabel: string | null; loadedCount: number | null }; 34 25 ··· 42 33 collection: string; 43 34 rkey: string; 44 35 json: string; 36 + value: unknown; 45 37 icon: string; 46 38 appLabel: string | null; 47 39 }; 40 + 41 + export type BlobReference = { cid: string; sourceUri: string | null }; 42 + 43 + export type RepoBlobSummary = { cid: string; rawUrl: string; sourceUri: string | null }; 44 + 45 + export type RecordRouteParams = { did: string; collection: string; rkey: string }; 46 + 47 + export type CollectionRouteParams = Pick<RecordRouteParams, 'did' | 'collection'>; 48 48 49 49 export type UnknownRecord = { uri: string; cid: string; value: unknown }; 50 50
+14 -3
src/lib/components/CollectionBrowser.svelte
··· 1 1 <script lang="ts"> 2 2 import { goto } from '$app/navigation'; 3 3 import { onMount } from 'svelte'; 4 + import { firstBlobReference, repoBlobs } from '$lib/atproto/blobs.svelte'; 4 5 import { accountSetup } from '$lib/atproto/setup.svelte'; 5 6 import { repoBrowser } from '$lib/atproto/repo.svelte'; 6 - import { identityPath, recordPath } from '$lib/atproto/routes'; 7 + import { blobPath, identityPath, recordPath } from '$lib/atproto/routes'; 7 8 import type { CollectionSummary, RepoRecordSummary } from '$lib/atproto/types'; 8 9 import { windowManager } from '$lib/window-manager.svelte'; 9 10 import { SvelteMap } from 'svelte/reactivity'; 10 11 11 12 let searchQuery = $state(''); 12 - 13 13 const collectionGroups = $derived.by(() => groupCollections(repoBrowser.collections)); 14 14 15 15 onMount(() => { ··· 43 43 } 44 44 45 45 function openRecord(record: RepoRecordSummary) { 46 + const identity = accountSetup.identity; 47 + const blob = firstBlobReference(record.value, record.uri); 48 + 49 + if (identity && blob) { 50 + repoBrowser.selectedRecord = record; 51 + repoBlobs.openMedia(identity, blob); 52 + windowManager.setTitle('eog', `${blob.cid} - Eye of GNOME`, '/icons/humanity/apps/eog.svg'); 53 + windowManager.open('eog'); 54 + void goto(blobPath(identity.did, blob.cid), { keepFocus: true, noScroll: true }); 55 + return; 56 + } 57 + 46 58 repoBrowser.selectedRecord = record; 47 59 windowManager.setTitle('gedit', `${record.rkey}.json - gedit`, record.icon); 48 60 windowManager.open('gedit'); 49 61 50 - const identity = accountSetup.identity; 51 62 if (identity) { 52 63 // eslint-disable-next-line svelte/no-navigation-without-resolve 53 64 void goto(recordPath({ did: identity.did, collection: record.collection, rkey: record.rkey }), {
+374
src/lib/components/EyeOfGnome.svelte
··· 1 + <script lang="ts"> 2 + import { repoBlobs } from '$lib/atproto/blobs.svelte'; 3 + import { accountSetup } from '$lib/atproto/setup.svelte'; 4 + 5 + let previewMode = $state<'image' | 'video' | 'unsupported'>('image'); 6 + let copied = $state(false); 7 + 8 + const selectedBlob = $derived(repoBlobs.selectedBlob); 9 + const canGoPrevious = $derived(repoBlobs.selectedIndex > 0); 10 + const canGoNext = $derived(repoBlobs.selectedIndex >= 0 && repoBlobs.selectedIndex < repoBlobs.blobs.length - 1); 11 + 12 + function selectBlob(cid: string) { 13 + const identity = accountSetup.identity; 14 + if (!identity) return; 15 + repoBlobs.select(identity, cid); 16 + resetPreview(); 17 + } 18 + 19 + function selectPrevious() { 20 + repoBlobs.selectPrevious(); 21 + resetPreview(); 22 + } 23 + 24 + function selectNext() { 25 + repoBlobs.selectNext(); 26 + resetPreview(); 27 + } 28 + 29 + function resetPreview() { 30 + previewMode = 'image'; 31 + copied = false; 32 + } 33 + 34 + function loadMore() { 35 + const identity = accountSetup.identity; 36 + if (!identity) return; 37 + void repoBlobs.loadMore(identity); 38 + } 39 + 40 + async function copyCid() { 41 + if (!selectedBlob) return; 42 + await navigator.clipboard.writeText(selectedBlob.cid); 43 + copied = true; 44 + } 45 + 46 + function openRawBlob() { 47 + if (!selectedBlob) return; 48 + window.open(selectedBlob.rawUrl, '_blank', 'noopener,noreferrer'); 49 + } 50 + 51 + function openSourceUri() { 52 + if (!selectedBlob?.sourceUri) return; 53 + window.open(selectedBlob.sourceUri, '_blank', 'noopener,noreferrer'); 54 + } 55 + </script> 56 + 57 + <div class="eog-viewer"> 58 + <aside class="blob-sidebar" aria-label="Repository blobs"> 59 + <header> 60 + <img src="/icons/humanity/apps/eog.svg" alt="" width="32" height="32" /> 61 + <div> 62 + <h2>Image Viewer</h2> 63 + <p>{accountSetup.identity?.handle ?? 'at:// blobs'}</p> 64 + </div> 65 + </header> 66 + 67 + <div class="blob-list"> 68 + {#if repoBlobs.isLoading} 69 + <p class="message">Loading blobs...</p> 70 + {:else if repoBlobs.blobs.length === 0} 71 + <p class="message">No public blobs found.</p> 72 + {:else} 73 + {#each repoBlobs.blobs as blob (blob.cid)} 74 + <button 75 + type="button" 76 + class:active={blob.cid === repoBlobs.selectedCid} 77 + aria-current={blob.cid === repoBlobs.selectedCid ? 'true' : undefined} 78 + onclick={() => selectBlob(blob.cid)}> 79 + <img src="/icons/humanity/mimes/image-x-generic.svg" alt="" width="24" height="24" /> 80 + <span>{blob.cid}</span> 81 + </button> 82 + {/each} 83 + 84 + {#if repoBlobs.canLoadMore} 85 + <button class="load-more" type="button" disabled={repoBlobs.isLoadingMore} onclick={loadMore}> 86 + {repoBlobs.isLoadingMore ? 'Loading...' : 'Load more'} 87 + </button> 88 + {/if} 89 + {/if} 90 + </div> 91 + </aside> 92 + 93 + <section class="preview-pane" aria-label="Blob preview"> 94 + <div class="eog-toolbar" aria-label="Image viewer controls"> 95 + <button type="button" disabled={!canGoPrevious} onclick={selectPrevious}> 96 + <span aria-hidden="true">◀</span> 97 + Previous 98 + </button> 99 + <button type="button" disabled={!canGoNext} onclick={selectNext}> 100 + Next 101 + <span aria-hidden="true">▶</span> 102 + </button> 103 + <button type="button" disabled={!selectedBlob} onclick={copyCid}>{copied ? 'Copied' : 'Copy CID'}</button> 104 + <button type="button" disabled={!selectedBlob} onclick={openRawBlob}>Raw</button> 105 + </div> 106 + 107 + <div class="preview-stage"> 108 + {#if repoBlobs.error} 109 + <p class="message error">{repoBlobs.error}</p> 110 + {/if} 111 + 112 + {#if selectedBlob} 113 + {#if previewMode === 'image'} 114 + <img 115 + class="blob-preview" 116 + src={selectedBlob.rawUrl} 117 + alt={`Blob ${selectedBlob.cid}`} 118 + onerror={() => (previewMode = 'video')} /> 119 + {:else if previewMode === 'video'} 120 + <video class="blob-preview" src={selectedBlob.rawUrl} controls onerror={() => (previewMode = 'unsupported')}> 121 + <track kind="captions" /> 122 + </video> 123 + {:else} 124 + <div class="unsupported-preview"> 125 + <img src="/icons/humanity/status/image-missing.svg" alt="" width="48" height="48" /> 126 + <h3>Preview unavailable</h3> 127 + <p>This blob could not be rendered as an image or video by the browser.</p> 128 + <button type="button" onclick={openRawBlob}>Open raw blob</button> 129 + </div> 130 + {/if} 131 + {:else if !repoBlobs.isLoading} 132 + <div class="unsupported-preview"> 133 + <img src="/icons/humanity/apps/eog.svg" alt="" width="48" height="48" /> 134 + <h3>No blob selected</h3> 135 + <p>Select a CID from the list to preview it.</p> 136 + </div> 137 + {/if} 138 + </div> 139 + 140 + <footer class="blob-status"> 141 + {#if selectedBlob} 142 + <span>{selectedBlob.cid}</span> 143 + {#if selectedBlob.sourceUri} 144 + <button type="button" onclick={openSourceUri}>{selectedBlob.sourceUri}</button> 145 + {/if} 146 + {:else} 147 + <span>{repoBlobs.blobs.length} blobs</span> 148 + {/if} 149 + </footer> 150 + </section> 151 + </div> 152 + 153 + <style> 154 + .eog-viewer { 155 + display: grid; 156 + grid-template-columns: minmax(12rem, 17rem) minmax(0, 1fr); 157 + height: 100%; 158 + overflow: hidden; 159 + border: 1px solid #9f8158; 160 + background: #efe0c6; 161 + } 162 + 163 + .blob-sidebar { 164 + display: grid; 165 + grid-template-rows: auto minmax(0, 1fr); 166 + min-width: 0; 167 + min-height: 0; 168 + background: linear-gradient(90deg, #cbb28a, #e4d1b1); 169 + border-right: 1px solid #92724a; 170 + } 171 + 172 + .blob-sidebar header { 173 + display: grid; 174 + grid-template-columns: 32px minmax(0, 1fr); 175 + gap: var(--space-2); 176 + align-items: center; 177 + padding: var(--space-3); 178 + border-bottom: 1px solid #a88c62; 179 + box-shadow: 0 1px 0 rgb(255 255 255 / 0.45); 180 + } 181 + 182 + .blob-sidebar h2 { 183 + font-size: var(--text-3); 184 + line-height: var(--leading-tight); 185 + } 186 + 187 + .blob-sidebar p, 188 + .message, 189 + .unsupported-preview p, 190 + .blob-status { 191 + color: var(--text-muted); 192 + font-size: var(--text-1); 193 + } 194 + 195 + .blob-list { 196 + display: grid; 197 + align-content: start; 198 + gap: 1px; 199 + min-height: 0; 200 + overflow: auto; 201 + padding: var(--space-2); 202 + } 203 + 204 + .blob-list button { 205 + display: grid; 206 + grid-template-columns: 24px minmax(0, 1fr); 207 + gap: var(--space-2); 208 + align-items: center; 209 + width: 100%; 210 + padding: var(--space-2); 211 + color: #2c180d; 212 + background: rgb(255 255 255 / 0.28); 213 + border: 1px solid transparent; 214 + border-radius: var(--radius-2); 215 + font: inherit; 216 + font-size: var(--text-1); 217 + text-align: left; 218 + cursor: default; 219 + } 220 + 221 + .blob-list button:hover, 222 + .blob-list button.active { 223 + color: white; 224 + background: linear-gradient(#e48631, #b65312); 225 + border-color: #f4b46b #8d3c0b #713009 #f1a35e; 226 + text-shadow: 0 1px 0 rgb(0 0 0 / 0.55); 227 + } 228 + 229 + .blob-list button:disabled { 230 + opacity: 0.62; 231 + } 232 + 233 + .blob-list span { 234 + overflow: hidden; 235 + text-overflow: ellipsis; 236 + white-space: nowrap; 237 + } 238 + 239 + .blob-list .load-more { 240 + display: block; 241 + grid-template-columns: 1fr; 242 + margin-top: var(--space-2); 243 + text-align: center; 244 + } 245 + 246 + .preview-pane { 247 + display: grid; 248 + grid-template-rows: auto minmax(0, 1fr) auto; 249 + min-width: 0; 250 + min-height: 0; 251 + background: #fff8ec; 252 + } 253 + 254 + .eog-toolbar { 255 + display: flex; 256 + flex-wrap: wrap; 257 + gap: var(--space-1); 258 + padding: var(--space-2); 259 + background: linear-gradient(#efe0c6, #ccb28a); 260 + border-bottom: 1px solid #96734a; 261 + } 262 + 263 + .eog-toolbar button { 264 + display: inline-flex; 265 + align-items: center; 266 + gap: var(--space-1); 267 + min-height: 1.7rem; 268 + padding: 0 var(--space-3); 269 + color: #2c180d; 270 + background: linear-gradient(#fff8e8, #d3b17d); 271 + border: 1px solid #9d7a4b; 272 + border-radius: var(--radius-2); 273 + box-shadow: var(--shadow-raised); 274 + font: inherit; 275 + font-size: var(--text-1); 276 + font-weight: 700; 277 + } 278 + 279 + .eog-toolbar button:disabled { 280 + opacity: 0.55; 281 + } 282 + 283 + .preview-stage { 284 + display: grid; 285 + place-items: center; 286 + min-width: 0; 287 + min-height: 0; 288 + overflow: auto; 289 + padding: var(--space-3); 290 + background: 291 + linear-gradient(45deg, #e7d8bd 25%, transparent 25%), linear-gradient(-45deg, #e7d8bd 25%, transparent 25%), 292 + linear-gradient(45deg, transparent 75%, #e7d8bd 75%), linear-gradient(-45deg, transparent 75%, #e7d8bd 75%), 293 + #f9efe0; 294 + background-position: 295 + 0 0, 296 + 0 10px, 297 + 10px -10px, 298 + -10px 0; 299 + background-size: 20px 20px; 300 + } 301 + 302 + .blob-preview { 303 + display: block; 304 + max-width: 100%; 305 + max-height: 100%; 306 + object-fit: contain; 307 + background: #fffdf8; 308 + border: 1px solid #8e7450; 309 + box-shadow: 0 1px 3px rgb(55 32 14 / 0.32); 310 + } 311 + 312 + .unsupported-preview, 313 + .message { 314 + max-width: 28rem; 315 + padding: var(--space-4); 316 + text-align: center; 317 + background: #fff8ec; 318 + border: 1px solid #b89564; 319 + border-radius: var(--radius-2); 320 + box-shadow: var(--shadow-raised); 321 + } 322 + 323 + .unsupported-preview h3 { 324 + margin-top: var(--space-2); 325 + font-size: var(--text-4); 326 + } 327 + 328 + .unsupported-preview button, 329 + .blob-status button { 330 + color: #8a3f0b; 331 + background: transparent; 332 + border: 0; 333 + font: inherit; 334 + font-weight: 700; 335 + text-decoration: underline; 336 + } 337 + 338 + .unsupported-preview button { 339 + margin-top: var(--space-2); 340 + } 341 + 342 + .message.error { 343 + color: #721c0d; 344 + background: #f7d6c9; 345 + border-color: #b55a38; 346 + } 347 + 348 + .blob-status { 349 + display: flex; 350 + gap: var(--space-3); 351 + align-items: center; 352 + min-width: 0; 353 + padding: var(--space-1) var(--space-2); 354 + background: linear-gradient(#eadbbf, #cbb18a); 355 + border-top: 1px solid #a88b63; 356 + } 357 + 358 + .blob-status span, 359 + .blob-status button { 360 + overflow: hidden; 361 + text-overflow: ellipsis; 362 + white-space: nowrap; 363 + } 364 + 365 + @media (max-width: 760px) { 366 + .eog-viewer { 367 + grid-template-columns: 1fr; 368 + } 369 + 370 + .blob-sidebar { 371 + display: none; 372 + } 373 + } 374 + </style>
+11 -2
src/lib/window-manager.svelte.ts
··· 1 1 /* Split up this file into windows/types.ts & windows/manager.svelte.ts */ 2 2 3 - export type WindowId = 'main' | 'about-computer' | 'gedit' | 'document-viewer' | 'identity-inspector'; 3 + export type WindowId = 'main' | 'about-computer' | 'gedit' | 'document-viewer' | 'identity-inspector' | 'eog'; 4 4 5 5 export type ManagedWindow = { 6 6 id: WindowId; ··· 58 58 isMinimized: false, 59 59 isMaximized: false, 60 60 zIndex: 5 61 + }, 62 + { 63 + id: 'eog', 64 + title: 'Eye of GNOME', 65 + icon: '/icons/humanity/apps/eog.svg', 66 + isOpen: false, 67 + isMinimized: false, 68 + isMaximized: false, 69 + zIndex: 6 61 70 } 62 71 ]); 63 72 64 - private nextZIndex = 5; 73 + private nextZIndex = 6; 65 74 66 75 get openWindows() { 67 76 return this.windows.filter((window) => window.isOpen);
+88 -8
src/routes/+layout.svelte
··· 6 6 import { goto } from '$app/navigation'; 7 7 import { resolve } from '$app/paths'; 8 8 import { page } from '$app/state'; 9 + import { repoBlobs } from '$lib/atproto/blobs.svelte'; 9 10 import { repoBrowser } from '$lib/atproto/repo.svelte'; 10 11 import { accountSetup } from '$lib/atproto/setup.svelte'; 11 12 import favicon from '$lib/assets/favicon.svg'; ··· 17 18 import BootSplash from '$lib/components/BootSplash.svelte'; 18 19 import DesktopIcon from '$lib/components/DesktopIcon.svelte'; 19 20 import DocumentViewer from '$lib/components/DocumentViewer.svelte'; 21 + import EyeOfGnome from '$lib/components/EyeOfGnome.svelte'; 20 22 import Gedit from '$lib/components/Gedit.svelte'; 21 23 import GnomePanel from '$lib/components/GnomePanel.svelte'; 22 24 import IdentityInspector from '$lib/components/IdentityInspector.svelte'; ··· 58 60 const geditWindow = $derived(windowManager.getWindow('gedit')); 59 61 const documentViewerWindow = $derived(windowManager.getWindow('document-viewer')); 60 62 const identityInspectorWindow = $derived(windowManager.getWindow('identity-inspector')); 63 + const eogWindow = $derived(windowManager.getWindow('eog')); 61 64 const shortcuts = $derived([ 62 65 { 63 66 label: 'ibex Home', ··· 92 95 } 93 96 }, 94 97 { 98 + label: 'Image Viewer', 99 + icon: '/icons/humanity/apps/eog.svg', 100 + selected: eogWindow?.isOpen && !eogWindow.isMinimized, 101 + onactivate: () => { 102 + if (accountSetup.identity) { 103 + void goto(resolve(`/repos/${accountSetup.identity.did}/blobs`), { keepFocus: true, noScroll: true }); 104 + return; 105 + } 106 + 107 + windowManager.restore('main'); 108 + void goto(resolve('/browse')); 109 + } 110 + }, 111 + { 95 112 label: 'Computer', 96 113 icon: '/icons/humanity/devices/computer.svg', 97 114 selected: showAboutComputer, ··· 128 145 repoBrowser.selectedRecord.icon 129 146 ); 130 147 } 148 + 149 + if (repoBlobs.selectedCid) { 150 + windowManager.setTitle('eog', `${repoBlobs.selectedCid} - Eye of GNOME`, '/icons/humanity/apps/eog.svg'); 151 + } 131 152 }); 132 153 133 154 $effect(() => { 134 155 const route = repoRouteFromParams(); 135 156 if (bootStatus !== 'ready' || !route) return; 136 157 137 - const routeKey = [route.did, route.app, route.collection, route.rkey].filter(Boolean).join('/'); 158 + const routeKey = [route.did, route.app, route.collection, route.rkey, route.cid].filter(Boolean).join('/'); 138 159 if (handledRepoRoute === routeKey) return; 139 160 140 161 handledRepoRoute = routeKey; ··· 196 217 accountSetup.load(); 197 218 } 198 219 199 - async function openRepoRoute(route: { did: string; app?: string; collection?: string; rkey?: string }) { 220 + async function openRepoRoute(route: { 221 + did: string; 222 + app?: string; 223 + collection?: string; 224 + rkey?: string; 225 + cid?: string; 226 + }) { 200 227 try { 201 228 const { hydratePublicIdentity } = await import('$lib/atproto/identity'); 202 229 const identity = await hydratePublicIdentity(route.did); ··· 211 238 return; 212 239 } 213 240 241 + if (route.app === 'blobs') { 242 + await repoBrowser.load(identity); 243 + await repoBlobs.load(identity, route.cid); 244 + windowManager.setTitle( 245 + 'eog', 246 + route.cid ? `${route.cid} - Eye of GNOME` : `${identity.handle} - Eye of GNOME` 247 + ); 248 + windowManager.open('eog'); 249 + return; 250 + } 251 + 214 252 if (route.collection && route.rkey) { 215 253 await repoBrowser.openRecordRoute(identity, route.collection, route.rkey); 216 254 if (repoBrowser.selectedRecord) { ··· 235 273 const { did, collection, rkey } = page.params; 236 274 237 275 if (!did) return null; 238 - return { did, app: page.route.id === '/repos/[did]/identity' ? 'identity' : undefined, collection, rkey }; 276 + return { 277 + did, 278 + app: 279 + page.route.id === '/repos/[did]/identity' 280 + ? 'identity' 281 + : page.route.id?.startsWith('/repos/[did]/blobs') 282 + ? 'blobs' 283 + : undefined, 284 + collection, 285 + rkey, 286 + cid: page.params.cid 287 + }; 239 288 } 240 289 241 290 async function waitForMinimumBootTime(startedAt: number) { ··· 378 427 </div> 379 428 {/if} 380 429 430 + {#if eogWindow?.isOpen && !eogWindow.isMinimized} 431 + <div class="eog-window" class:maximized={eogWindow.isMaximized} style:z-index={eogWindow.zIndex}> 432 + <AppWindow 433 + windowId="eog" 434 + title={eogWindow.title} 435 + icon="/icons/humanity/apps/eog.svg" 436 + address={accountSetup.identity ? `/repos/${accountSetup.identity.did}/blobs` : undefined} 437 + showMenubar={false} 438 + showToolbar={false} 439 + maximized={eogWindow.isMaximized} 440 + onfocus={() => windowManager.focus('eog')} 441 + onminimize={() => windowManager.minimize('eog')} 442 + onmaximize={() => windowManager.toggleMaximize('eog')} 443 + onclose={() => windowManager.close('eog')}> 444 + <EyeOfGnome /> 445 + </AppWindow> 446 + </div> 447 + {/if} 448 + 381 449 {#if showStickyNote} 382 450 <StickyNote onclose={() => (showStickyNote = false)} /> 383 451 {/if} ··· 435 503 .about-window.maximized, 436 504 .document-viewer-window.maximized, 437 505 .gedit-window.maximized, 438 - .identity-inspector-window.maximized { 506 + .identity-inspector-window.maximized, 507 + .eog-window.maximized { 439 508 position: fixed; 440 509 top: 1.75rem; 441 510 right: 0; ··· 449 518 .about-window, 450 519 .document-viewer-window, 451 520 .gedit-window, 452 - .identity-inspector-window { 521 + .identity-inspector-window, 522 + .eog-window { 453 523 position: absolute; 454 524 z-index: 3; 455 525 } ··· 482 552 height: min(39rem, calc(100vh - 5rem)); 483 553 } 484 554 555 + .eog-window { 556 + top: min(5.5rem, 10vh); 557 + left: min(18rem, 22vw); 558 + width: min(54rem, calc(100vw - 2rem)); 559 + height: min(38rem, calc(100vh - 5rem)); 560 + } 561 + 485 562 .about-window :global(.app-window), 486 563 .document-viewer-window :global(.app-window), 487 564 .gedit-window :global(.app-window), 488 - .identity-inspector-window :global(.app-window) { 565 + .identity-inspector-window :global(.app-window), 566 + .eog-window :global(.app-window) { 489 567 height: 100%; 490 568 } 491 569 ··· 497 575 498 576 .about-window, 499 577 .gedit-window, 500 - .identity-inspector-window { 578 + .identity-inspector-window, 579 + .eog-window { 501 580 left: auto; 502 581 right: var(--space-3); 503 582 } ··· 519 598 520 599 .about-window, 521 600 .gedit-window, 522 - .identity-inspector-window { 601 + .identity-inspector-window, 602 + .eog-window { 523 603 top: var(--space-3); 524 604 right: var(--space-3); 525 605 left: var(--space-3);
+1
src/routes/repos/[did]/blobs/+page.svelte
··· 1 + <script lang="ts"></script>
+1
src/routes/repos/[did]/blobs/[cid]/+page.svelte
··· 1 + <script lang="ts"></script>
+374
static/icons/humanity/apps/eog.svg
··· 1 + <?xml version="1.0" encoding="UTF-8" standalone="no"?> 2 + <!-- Created with Inkscape (http://www.inkscape.org/) --> 3 + 4 + <svg 5 + xmlns:svg="http://www.w3.org/2000/svg" 6 + xmlns="http://www.w3.org/2000/svg" 7 + xmlns:xlink="http://www.w3.org/1999/xlink" 8 + version="1.1" 9 + width="48" 10 + height="48" 11 + id="svg4456"> 12 + <defs 13 + id="defs4458"> 14 + <linearGradient 15 + x1="16.626165" 16 + y1="15.298182" 17 + x2="20.054544" 18 + y2="24.627615" 19 + id="linearGradient3167" 20 + xlink:href="#linearGradient8265-821-176-38-919-66-249-7-7-8-6" 21 + gradientUnits="userSpaceOnUse" 22 + gradientTransform="matrix(0.9578231,0,0,1.0182827,1.0122465,1.9221214)" /> 23 + <linearGradient 24 + id="linearGradient8265-821-176-38-919-66-249-7-7-8-6"> 25 + <stop 26 + id="stop2687-1-9-0-2" 27 + style="stop-color:#ffffff;stop-opacity:1" 28 + offset="0" /> 29 + <stop 30 + id="stop2689-5-4-3-1" 31 + style="stop-color:#ffffff;stop-opacity:0" 32 + offset="1" /> 33 + </linearGradient> 34 + <linearGradient 35 + x1="16.142857" 36 + y1="4.000001" 37 + x2="16.142857" 38 + y2="44.000256" 39 + id="linearGradient3164" 40 + xlink:href="#linearGradient2223-4-6-9-7" 41 + gradientUnits="userSpaceOnUse" 42 + gradientTransform="matrix(0.89748763,0,0,0.69235859,2.4796365,9.3833884)" /> 43 + <linearGradient 44 + id="linearGradient2223-4-6-9-7"> 45 + <stop 46 + id="stop2225-6-4-7-2" 47 + style="stop-color:#ffffff;stop-opacity:1" 48 + offset="0" /> 49 + <stop 50 + id="stop2229-2-5-5-8" 51 + style="stop-color:#ffffff;stop-opacity:0" 52 + offset="1" /> 53 + </linearGradient> 54 + <linearGradient 55 + x1="12.578789" 56 + y1="2.9165411" 57 + x2="12.213111" 58 + y2="47.278698" 59 + id="linearGradient3178" 60 + xlink:href="#linearGradient2238-2-3-3-1" 61 + gradientUnits="userSpaceOnUse" 62 + gradientTransform="matrix(0.9546411,0,0,0.8965269,1.0874565,5.3852394)" /> 63 + <linearGradient 64 + id="linearGradient2238-2-3-3-1"> 65 + <stop 66 + id="stop2240-1-6-7-0" 67 + style="stop-color:#ffffff;stop-opacity:1" 68 + offset="0" /> 69 + <stop 70 + id="stop2242-7-3-7-2" 71 + style="stop-color:#ffffff;stop-opacity:0" 72 + offset="1" /> 73 + </linearGradient> 74 + <linearGradient 75 + x1="23.070969" 76 + y1="36.046932" 77 + x2="23.070969" 78 + y2="33.296398" 79 + id="linearGradient3181" 80 + xlink:href="#linearGradient2223-4-6-9" 81 + gradientUnits="userSpaceOnUse" 82 + gradientTransform="matrix(0.9910545,0,0,1.0114758,0.22049648,5.8120924)" /> 83 + <linearGradient 84 + id="linearGradient2223-4-6-9"> 85 + <stop 86 + id="stop2225-6-4-7" 87 + style="stop-color:#ffffff;stop-opacity:1" 88 + offset="0" /> 89 + <stop 90 + id="stop2229-2-5-5" 91 + style="stop-color:#ffffff;stop-opacity:0" 92 + offset="1" /> 93 + </linearGradient> 94 + <radialGradient 95 + cx="7.4956832" 96 + cy="8.4497671" 97 + r="19.99999" 98 + fx="7.4956832" 99 + fy="8.4497671" 100 + id="radialGradient3528" 101 + xlink:href="#linearGradient4157-401-351-3-5-9" 102 + gradientUnits="userSpaceOnUse" 103 + gradientTransform="matrix(0,1.7102082,-2.9205166,-7.2163996e-8,48.677783,-0.8870404)" /> 104 + <linearGradient 105 + id="linearGradient4157-401-351-3-5-9"> 106 + <stop 107 + id="stop3790-0-0-9" 108 + style="stop-color:#4a5653;stop-opacity:1" 109 + offset="0" /> 110 + <stop 111 + id="stop3792-0-2-3" 112 + style="stop-color:#141414;stop-opacity:1" 113 + offset="1" /> 114 + </linearGradient> 115 + <linearGradient 116 + x1="16.142857" 117 + y1="4.000001" 118 + x2="16.142857" 119 + y2="44.000256" 120 + id="linearGradient3530" 121 + xlink:href="#linearGradient3610-302-5-8-6" 122 + gradientUnits="userSpaceOnUse" 123 + gradientTransform="matrix(0.9487726,0,0,0.7436444,1.2294609,8.1525372)" /> 124 + <linearGradient 125 + id="linearGradient3610-302-5-8-6"> 126 + <stop 127 + id="stop3796-3-0-0" 128 + style="stop-color:#323232;stop-opacity:1" 129 + offset="0" /> 130 + <stop 131 + id="stop3798-8-6-5" 132 + style="stop-color:#000000;stop-opacity:1" 133 + offset="1" /> 134 + </linearGradient> 135 + <linearGradient 136 + x1="23.999996" 137 + y1="7.9600015" 138 + x2="23.999996" 139 + y2="43.865456" 140 + id="linearGradient3189" 141 + xlink:href="#linearGradient3994-617-9-9" 142 + gradientUnits="userSpaceOnUse" 143 + gradientTransform="translate(-3.5243988e-6,-6.6e-6)" /> 144 + <linearGradient 145 + id="linearGradient3994-617-9-9"> 146 + <stop 147 + id="stop4324-9-7" 148 + style="stop-color:#f0f0f0;stop-opacity:1" 149 + offset="0" /> 150 + <stop 151 + id="stop2860-4-4" 152 + style="stop-color:#d7d7d8;stop-opacity:1" 153 + offset="0.08552461" /> 154 + <stop 155 + id="stop2862-5-9" 156 + style="stop-color:#b2b2b3;stop-opacity:1" 157 + offset="0.92166406" /> 158 + <stop 159 + id="stop4326-1-1" 160 + style="stop-color:#979798;stop-opacity:1" 161 + offset="1" /> 162 + </linearGradient> 163 + <linearGradient 164 + x1="10.014208" 165 + y1="44.960102" 166 + x2="10.014208" 167 + y2="2.8764627" 168 + id="linearGradient3191" 169 + xlink:href="#linearGradient4008-764-3-0" 170 + gradientUnits="userSpaceOnUse" 171 + gradientTransform="matrix(0.9574517,0,0,0.9034707,1.0211765,4.3056244)" /> 172 + <linearGradient 173 + id="linearGradient4008-764-3-0"> 174 + <stop 175 + id="stop4334-7-6" 176 + style="stop-color:#595959;stop-opacity:1" 177 + offset="0" /> 178 + <stop 179 + id="stop4336-8-0" 180 + style="stop-color:#b3b3b3;stop-opacity:1" 181 + offset="1" /> 182 + </linearGradient> 183 + <radialGradient 184 + cx="605.71429" 185 + cy="486.64789" 186 + r="117.14286" 187 + fx="605.71429" 188 + fy="486.64789" 189 + id="radialGradient3194" 190 + xlink:href="#linearGradient5060-6-6-5" 191 + gradientUnits="userSpaceOnUse" 192 + gradientTransform="matrix(-0.0655873,0,0,0.02470588,47.692222,31.941633)" /> 193 + <linearGradient 194 + id="linearGradient5060-6-6-5"> 195 + <stop 196 + id="stop5062-3-0-3" 197 + style="stop-color:#000000;stop-opacity:1" 198 + offset="0" /> 199 + <stop 200 + id="stop5064-1-4-9" 201 + style="stop-color:#000000;stop-opacity:0" 202 + offset="1" /> 203 + </linearGradient> 204 + <radialGradient 205 + cx="605.71429" 206 + cy="486.64789" 207 + r="117.14286" 208 + fx="605.71429" 209 + fy="486.64789" 210 + id="radialGradient3197" 211 + xlink:href="#linearGradient5060-6-6-5" 212 + gradientUnits="userSpaceOnUse" 213 + gradientTransform="matrix(0.0655873,0,0,0.02470588,0.30778809,31.941633)" /> 214 + <linearGradient 215 + id="linearGradient5048-7-7-5"> 216 + <stop 217 + id="stop5050-5-6-4" 218 + style="stop-color:#000000;stop-opacity:0" 219 + offset="0" /> 220 + <stop 221 + id="stop5056-9-0-1" 222 + style="stop-color:#000000;stop-opacity:1" 223 + offset="0.5" /> 224 + <stop 225 + id="stop5052-6-9-5" 226 + style="stop-color:#000000;stop-opacity:0" 227 + offset="1" /> 228 + </linearGradient> 229 + <linearGradient 230 + x1="302.85715" 231 + y1="366.64789" 232 + x2="302.85715" 233 + y2="609.50507" 234 + id="linearGradient4454" 235 + xlink:href="#linearGradient5048-7-7-5" 236 + gradientUnits="userSpaceOnUse" 237 + gradientTransform="matrix(0.0655873,0,0,0.02470588,0.29487768,31.941633)" /> 238 + </defs> 239 + <g 240 + id="layer1"> 241 + <rect 242 + width="31.669291" 243 + height="6.0000005" 244 + x="8.1653643" 245 + y="41" 246 + id="rect2512-9-5" 247 + style="opacity:0.40206185;fill:url(#linearGradient4454);fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:1;marker:none;visibility:visible;display:inline;overflow:visible" /> 248 + <path 249 + d="m 39.834648,41.0002 c 0,0 0,5.999669 0,5.999669 C 43.212231,47.011159 48,45.655648 48,43.999648 48,42.343649 44.230872,41.0002 39.834648,41.0002 z" 250 + id="path2514-7-4" 251 + style="opacity:0.40206185;fill:url(#radialGradient3197);fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:1;marker:none;visibility:visible;display:inline;overflow:visible" /> 252 + <path 253 + d="m 8.1653516,41.0002 c 0,0 0,5.999669 0,5.999669 C 4.7877691,47.011159 0,45.655648 0,43.999648 0,42.343649 3.7691281,41.0002 8.1653516,41.0002 z" 254 + id="path2516-8-9" 255 + style="opacity:0.40206185;fill:url(#radialGradient3194);fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:1;marker:none;visibility:visible;display:inline;overflow:visible" /> 256 + <rect 257 + width="44.997868" 258 + height="36.997868" 259 + rx="1.4989328" 260 + ry="1.4989328" 261 + x="1.5010664" 262 + y="7.5010614" 263 + id="rect2551-5-8" 264 + style="fill:url(#linearGradient3189);fill-opacity:1;fill-rule:evenodd;stroke:url(#linearGradient3191);stroke-width:1.00213289;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;stroke-dashoffset:0" /> 265 + <rect 266 + width="37" 267 + height="28.999998" 268 + rx="0" 269 + ry="0" 270 + x="5.5" 271 + y="11.500002" 272 + id="rect1314-3-3-1" 273 + style="color:#000000;fill:url(#radialGradient3528);fill-opacity:1;fill-rule:nonzero;stroke:url(#linearGradient3530);stroke-width:0.99999988;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;stroke-dashoffset:0;marker:none;visibility:visible;display:inline;overflow:visible;enable-background:accumulate" /> 274 + <rect 275 + width="38.997868" 276 + height="30.997868" 277 + rx="0" 278 + ry="0" 279 + x="4.5010815" 280 + y="10.50106" 281 + id="rect2221-3-8" 282 + style="opacity:0.4;fill:none;stroke:url(#linearGradient3181);stroke-width:1.00213289;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;stroke-dashoffset:0" /> 283 + <rect 284 + width="42.996269" 285 + height="34.996269" 286 + rx="0.49813175" 287 + ry="0.49813175" 288 + x="2.5018752" 289 + y="8.5018625" 290 + id="rect2556-8-5" 291 + style="opacity:0.8;fill:none;stroke:url(#linearGradient3178);stroke-width:1.00373149;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;stroke-dashoffset:0" /> 292 + <rect 293 + width="35" 294 + height="26.999998" 295 + rx="0" 296 + ry="0" 297 + x="6.5193462" 298 + y="12.499996" 299 + id="rect1314-3-3-9" 300 + style="opacity:0.1;color:#000000;fill:none;stroke:url(#linearGradient3164);stroke-width:0.99999994;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;stroke-dashoffset:0;marker:none;visibility:visible;display:inline;overflow:visible;enable-background:accumulate" /> 301 + <path 302 + d="m 6.5000003,13.499998 34.9999997,0" 303 + id="path2233" 304 + style="opacity:0.1;fill:none;stroke:#b9ffeb;stroke-width:1px;stroke-linecap:square;stroke-linejoin:miter;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;stroke-dashoffset:0;marker:none;visibility:visible;display:inline;overflow:visible;enable-background:accumulate" /> 305 + <path 306 + d="m 6.5000003,17.499998 34.9999997,0" 307 + id="path2235" 308 + style="opacity:0.1;fill:none;stroke:#b9ffeb;stroke-width:1px;stroke-linecap:square;stroke-linejoin:miter;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;stroke-dashoffset:0;marker:none;visibility:visible;display:inline;overflow:visible;enable-background:accumulate" /> 309 + <path 310 + d="m 6.5000003,21.499998 34.9999997,0" 311 + id="path2237" 312 + style="opacity:0.1;fill:none;stroke:#b9ffeb;stroke-width:1px;stroke-linecap:square;stroke-linejoin:miter;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;stroke-dashoffset:0;marker:none;visibility:visible;display:inline;overflow:visible;enable-background:accumulate" /> 313 + <path 314 + d="m 6.5000003,25.499998 34.9999997,0" 315 + id="path2239" 316 + style="opacity:0.1;fill:none;stroke:#b9ffeb;stroke-width:1px;stroke-linecap:square;stroke-linejoin:miter;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;stroke-dashoffset:0;marker:none;visibility:visible;display:inline;overflow:visible;enable-background:accumulate" /> 317 + <path 318 + d="m 6.5000003,29.499998 34.9999997,0" 319 + id="path2241" 320 + style="opacity:0.1;fill:none;stroke:#b9ffeb;stroke-width:1px;stroke-linecap:square;stroke-linejoin:miter;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;stroke-dashoffset:0;marker:none;visibility:visible;display:inline;overflow:visible;enable-background:accumulate" /> 321 + <path 322 + d="m 6.5000003,33.499998 34.9999997,0" 323 + id="path2243" 324 + style="opacity:0.1;fill:none;stroke:#b9ffeb;stroke-width:1px;stroke-linecap:square;stroke-linejoin:miter;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;stroke-dashoffset:0;marker:none;visibility:visible;display:inline;overflow:visible;enable-background:accumulate" /> 325 + <path 326 + d="m 6.5000003,37.499999 34.9999997,0" 327 + id="path2245" 328 + style="opacity:0.1;fill:none;stroke:#b9ffeb;stroke-width:1px;stroke-linecap:square;stroke-linejoin:miter;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;stroke-dashoffset:0;marker:none;visibility:visible;display:inline;overflow:visible;enable-background:accumulate" /> 329 + <path 330 + d="m 9.511811,39.534817 0,-27.060833" 331 + id="path2239-3" 332 + style="opacity:0.1;fill:none;stroke:#b9ffeb;stroke-width:0.87929899px;stroke-linecap:square;stroke-linejoin:miter;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;stroke-dashoffset:0;marker:none;visibility:visible;display:inline;overflow:visible;enable-background:accumulate" /> 333 + <path 334 + d="m 14.511811,39.516801 0,-27.060834" 335 + id="path2239-3-7" 336 + style="opacity:0.1;fill:none;stroke:#b9ffeb;stroke-width:0.87929899px;stroke-linecap:square;stroke-linejoin:miter;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;stroke-dashoffset:0;marker:none;visibility:visible;display:inline;overflow:visible;enable-background:accumulate" /> 337 + <path 338 + d="m 19.509465,39.542265 0,-27.060833" 339 + id="path2239-3-7-3" 340 + style="opacity:0.1;fill:none;stroke:#b9ffeb;stroke-width:0.87929899px;stroke-linecap:square;stroke-linejoin:miter;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;stroke-dashoffset:0;marker:none;visibility:visible;display:inline;overflow:visible;enable-background:accumulate" /> 341 + <path 342 + d="m 24.482802,39.516801 0,-27.060833" 343 + id="path2239-3-2" 344 + style="opacity:0.1;fill:none;stroke:#b9ffeb;stroke-width:0.87929899px;stroke-linecap:square;stroke-linejoin:miter;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;stroke-dashoffset:0;marker:none;visibility:visible;display:inline;overflow:visible;enable-background:accumulate" /> 345 + <path 346 + d="m 29.482802,39.498785 0,-27.060834" 347 + id="path2239-3-7-6" 348 + style="opacity:0.1;fill:none;stroke:#b9ffeb;stroke-width:0.87929899px;stroke-linecap:square;stroke-linejoin:miter;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;stroke-dashoffset:0;marker:none;visibility:visible;display:inline;overflow:visible;enable-background:accumulate" /> 349 + <path 350 + d="m 34.480456,39.524249 0,-27.060833" 351 + id="path2239-3-7-3-5" 352 + style="opacity:0.1;fill:none;stroke:#b9ffeb;stroke-width:0.87929899px;stroke-linecap:square;stroke-linejoin:miter;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;stroke-dashoffset:0;marker:none;visibility:visible;display:inline;overflow:visible;enable-background:accumulate" /> 353 + <path 354 + d="m 39.484853,39.508859 0,-27.060834" 355 + id="path2239-3-7-3-5-2" 356 + style="opacity:0.1;fill:none;stroke:#b9ffeb;stroke-width:0.87929899px;stroke-linecap:square;stroke-linejoin:miter;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;stroke-dashoffset:0;marker:none;visibility:visible;display:inline;overflow:visible;enable-background:accumulate" /> 357 + <path 358 + d="M 23.907489,18 C 14.388417,18 10,25.968546 10,25.968546 c 0.127197,0 2.811644,8.073334 13.784141,8.03129 C 34.953343,33.957316 38,25.968546 38,25.968546 38,25.968546 33.426561,18 23.907489,18 z m 2.374449,1.882334 c 6.405828,0.913528 9.035243,6.023468 9.035243,6.023468 0,0 -1.356473,5.003759 -8.356829,6.086212 2.112488,-1.044462 3.597598,-3.172329 3.700441,-5.647001 l -1.54185,-0.09412 c -0.123259,2.771619 -2.366348,4.988184 -5.14978,4.988184 -2.86272,0 -5.180617,-2.326449 -5.180617,-5.20779 0,-2.701254 2.011787,-4.940621 4.625551,-5.207789 0.174246,-0.01782 0.376142,0 0.555066,0 1.682516,0 3.156486,0.793356 4.101322,2.039194 l 1.171806,-0.84705 c -0.765587,-0.962907 -1.779448,-1.707203 -2.960353,-2.133311 z m -4.903083,0.125489 c -2.391136,1.003039 -4.008811,3.31802 -4.008811,6.05484 0,2.499758 1.358387,4.712788 3.422908,5.835234 -6.353499,-1.271935 -8.14097,-5.897979 -8.14097,-5.897979 0,0 2.141598,-4.817889 8.726873,-5.992095 z m 2.590308,2.007822 c -2.198644,0 -3.977974,1.802707 -3.977974,4.015645 0,2.212941 1.77933,3.984273 3.977974,3.984273 2.137187,0 3.851024,-1.70005 3.947137,-3.827411 L 23.660793,25.968546 27.0837,23.552884 c -0.730753,-0.923547 -1.85168,-1.537239 -3.114537,-1.537239 z" 359 + id="rect4271" 360 + style="fill:#91ffe0;fill-opacity:1;stroke:none" /> 361 + <path 362 + d="m 4.0952365,7.9999964 c -1.15732,0 -2.09524,0.997126 -2.09524,2.2274916 l 0,16.292525 c 0.003,0.137064 0.0592,0.26668 0.15589,0.357384 0.0967,0.0907 0.2247,0.134203 0.35295,0.119936 L 45.610876,19.742069 c 0.22172,-0.03717 0.38602,-0.238719 0.38912,-0.47732 l 0,-9.037261 c 0,-1.2303656 -0.93792,-2.2274916 -2.09524,-2.2274916 l -39.8095195,0 0,0 z" 363 + id="path3333-3-0" 364 + style="opacity:0.2;fill:url(#linearGradient3167);fill-opacity:1;fill-rule:evenodd;stroke:none" /> 365 + <path 366 + d="M 23.90625,17 C 13.83591,17 9.125,25.5 9.125,25.5 a 0.98787398,0.98787398 0 0 0 0.03125,1 c 0.00303,0.0061 0.027224,0.02295 0.03125,0.03125 0.022816,0.04703 0.045926,0.120054 0.09375,0.21875 0.1125263,0.232226 0.2940906,0.561825 0.53125,0.96875 0.474319,0.81385 1.205759,1.917344 2.3125,3 2.213482,2.165313 5.927438,4.303201 11.65625,4.28125 5.819754,-0.02215 9.655163,-2.166162 11.96875,-4.3125 2.313587,-2.146338 3.1875,-4.375 3.1875,-4.375 a 0.98787398,0.98787398 0 0 0 -0.09375,-0.84375 c 0,0 -4.86716,-8.46875 -14.9375,-8.46875 z m 6.3125,5.28125 c 2.625912,1.502392 3.712945,3.311861 3.9375,3.71875 -0.07712,0.243485 -0.209971,0.857288 -1.125,1.90625 -0.551236,0.63192 -1.33153,1.27109 -2.375,1.84375 0.567104,-1.011571 0.949149,-2.151394 1,-3.375 a 0.98787398,0.98787398 0 0 0 -0.9375,-1.03125 L 29.1875,25.25 a 0.98787398,0.98787398 0 0 0 -0.625,0.1875 0.98787398,0.98787398 0 0 0 -0.59375,-0.25 l -1.375,-0.0625 1.0625,-0.75 a 0.98787398,0.98787398 0 0 0 0.375,-0.5 0.98787398,0.98787398 0 0 0 0.59375,-0.1875 l 1.1875,-0.84375 a 0.98787398,0.98787398 0 0 0 0.40625,-0.5625 z M 17.3125,22.5625 c -0.554579,1.057765 -0.9375,2.223383 -0.9375,3.5 0,1.273816 0.355108,2.460438 0.90625,3.53125 -2.548818,-1.463641 -3.257847,-3.13284 -3.4375,-3.5625 0.198952,-0.414263 1.033198,-1.999831 3.46875,-3.46875 z" 367 + id="path3568" 368 + style="opacity:0.16000001;fill:#91ffe0;fill-opacity:1;stroke:none" /> 369 + <path 370 + d="m 23.90625,15.9375 c -10.668081,0 -15.71875,9.03125 -15.71875,9.03125 A 2.0590295,2.0590295 0 0 0 8.21875,27 c 5.428e-4,0.0021 -0.00402,0.0035 0.03125,0.0625 0.021114,0.04406 0.025134,0.07914 0.0625,0.15625 0.1185971,0.244755 0.3071295,0.624328 0.5625,1.0625 0.510741,0.876344 1.308883,2.022304 2.5,3.1875 2.382234,2.330392 6.414426,4.616709 12.40625,4.59375 6.074732,-0.02313 10.205793,-2.291444 12.6875,-4.59375 2.481707,-2.302306 3.46875,-4.78125 3.46875,-4.78125 a 2.0590295,2.0590295 0 0 0 -0.15625,-1.75 c 0,0 -5.206919,-9 -15.875,-9 z m 6.53125,7.78125 c 0.404763,0.282949 0.873276,0.553979 1.1875,0.84375 a 2.0590295,2.0590295 0 0 0 -0.84375,-0.28125 l -1.03125,-0.0625 0.6875,-0.5 z M 15.375,25.6875 c -0.0057,0.128772 -0.0625,0.244975 -0.0625,0.375 0,0.140624 0.05598,0.266884 0.0625,0.40625 -0.114598,-0.179637 -0.213856,-0.272943 -0.28125,-0.40625 0.06305,-0.113424 0.180744,-0.22273 0.28125,-0.375 z m 17.28125,0.125 c 0.06809,0.09999 0.170058,0.202964 0.21875,0.28125 -0.03237,0.0737 -0.106551,0.155035 -0.15625,0.25 a 2.0590295,2.0590295 0 0 0 -0.0625,-0.53125 z" 371 + id="path3568-1" 372 + style="opacity:0.07000002;fill:#91ffe0;fill-opacity:1;stroke:none" /> 373 + </g> 374 + </svg>