Something went wrong. Try again.
[PRE-ALPHA] an open source web-based 2D/3D graphing calculator cartesium.org
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819<script lang="ts"> import type { CartesiumDocumentV1, DocumentKind, StoredDocument } from '#lib/document/cartesium-document.js'; import type { PublishedDocumentSummary } from '#lib/publishing/at-library.js'; import type { PrivateDocumentSummary } from '#lib/publishing/space-documents.js';
let { documents, activeDocumentId, privateDocuments = [], activePrivateDocumentRkey = null, spacesEnabled = false, privateLoading = false, privateError = '', privateWarning = '', privateCursor = null, status = '', currentKind, publisherDid, publisherHandle, publishedGraphs, publishedCursor, publishedLoading, publishedError, dark, onclose, onopenlocal, oncopylocal, onrenamelocal, onduplicatelocal, ondeletelocal, onexportlocal, onrefreshprivate, onloadmoreprivate, onopenprivate, onrenameprivate, onduplicateprivate, ondeleteprivate, onexportprivate, onsignin, onrefreshpublished, onloadmorepublished, onopenpublished, oncopypublication, ondeletepublished, previewurl, publicationurl }: { documents: StoredDocument<CartesiumDocumentV1>[]; activeDocumentId: string | null; privateDocuments?: PrivateDocumentSummary[]; activePrivateDocumentRkey?: string | null; spacesEnabled?: boolean; privateLoading?: boolean; privateError?: string; privateWarning?: string; privateCursor?: string | null; status?: string; currentKind: DocumentKind; publisherDid: string | null; publisherHandle: string | null; publishedGraphs: PublishedDocumentSummary[]; publishedCursor: string | null; publishedLoading: boolean; publishedError: string; dark: boolean; onclose: () => void; onopenlocal: (id: string, kind: DocumentKind) => void; oncopylocal?: (id: string, kind: DocumentKind) => void; onrenamelocal: (id: string, kind: DocumentKind) => void; onduplicatelocal: (id: string, kind: DocumentKind) => void; ondeletelocal: (id: string, kind: DocumentKind) => void; onexportlocal: (id: string, kind: DocumentKind) => void; onrefreshprivate?: () => void; onloadmoreprivate?: () => void; onopenprivate?: (rkey: string, kind: DocumentKind) => void; onrenameprivate?: (rkey: string, kind: DocumentKind) => void; onduplicateprivate?: (rkey: string, kind: DocumentKind) => void; ondeleteprivate?: (rkey: string, kind: DocumentKind) => void; onexportprivate?: (rkey: string, kind: DocumentKind) => void; onsignin?: () => void; onrefreshpublished: () => void; onloadmorepublished: () => void; onopenpublished: (graph: PublishedDocumentSummary) => void; oncopypublication: (graph: PublishedDocumentSummary) => void; ondeletepublished: (graph: PublishedDocumentSummary) => void; previewurl: (graph: PublishedDocumentSummary) => string; publicationurl: (graph: PublishedDocumentSummary) => string; } = $props();
let tab = $state<'drafts' | 'private' | 'published'>('drafts'); let filter = $state<DocumentKind | 'all'>(); let activeFilter = $derived(filter ?? currentKind); let filterOptions = $derived(['2d', '3d', 'all'] as Array<DocumentKind | 'all'>);
let filteredDocuments = $derived( documents.filter((document) => activeFilter === 'all' || document.document.kind === activeFilter) );
let filteredPrivateDocuments = $derived( privateDocuments.filter((document) => activeFilter === 'all' || document.kind === activeFilter) );
function formatDate(value: string): string { const date = new Date(value);
return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(date); }</script>
<svelte:window onkeydown={(event) => event.key === 'Escape' && onclose()} />
<section class:dark class="library" aria-label="My graphs"> <header> <div> <p class="eyebrow">Cartesium</p> <h2>My graphs</h2> </div> <button type="button" class="close" onclick={onclose} aria-label="Close graph library">×</button> </header>
<nav class="tabs" aria-label="Graph storage"> <button type="button" class:active={tab === 'drafts'} aria-current={tab === 'drafts' ? 'page' : undefined} onclick={() => (tab = 'drafts')} > On this browser <span>{documents.length}</span> </button> {#if spacesEnabled && publisherDid} <button type="button" class:active={tab === 'private'} aria-current={tab === 'private' ? 'page' : undefined} onclick={() => { tab = 'private'; if (privateDocuments.length === 0 && !privateLoading) onrefreshprivate?.(); }} > Private <span>{privateDocuments.length}</span> </button> {/if} <button type="button" class:active={tab === 'published'} aria-current={tab === 'published' ? 'page' : undefined} onclick={() => { tab = 'published'; if (publisherDid && publishedGraphs.length === 0 && !publishedLoading) { onrefreshpublished(); } }} > Published <span>{publisherDid ? publishedGraphs.length : '—'}</span> </button> </nav>
<div class="content"> {#if status}<p class="library-status" role="status">{status}</p>{/if} {#if tab === 'drafts'} <div class="section-heading"> <div> <h3>Drafts in this browser</h3> <p>Private, device-local copies. They are not synced to your AT Protocol account.</p> </div> </div> <nav class="filters" aria-label="Draft type"> {#each filterOptions as option} <button type="button" class:active={activeFilter === option} aria-pressed={activeFilter === option} onclick={() => (filter = option)} > {option === 'all' ? 'All' : option.toUpperCase()} </button> {/each} </nav> {#if filteredDocuments.length === 0} <div class="empty"> <h3>{activeFilter === 'all' ? 'No saved drafts yet' : `No saved ${activeFilter.toUpperCase()} drafts yet`}</h3> <p>Use Save or Save as in the expression panel to keep a graph in this browser.</p> </div> {:else} <div class="cards"> {#each filteredDocuments as document (document.id)} <article class:current={document.id === activeDocumentId}> <div class="card-main"> <div> <h3>{document.document.snapshot.name || 'Untitled graph'}</h3> <p> <span class="kind-badge">{document.document.kind.toUpperCase()}</span> Updated {formatDate(document.updatedAt)} {#if document.id === activeDocumentId}<strong>Open now</strong>{/if} </p> </div> <button type="button" class="primary" onclick={() => onopenlocal(document.id, document.document.kind)}> Open </button> </div> <div class="actions"> <button type="button" onclick={() => onrenamelocal(document.id, document.document.kind)}>Rename</button> <button type="button" onclick={() => onduplicatelocal(document.id, document.document.kind)}>Duplicate</button> <button type="button" onclick={() => onexportlocal(document.id, document.document.kind)}>Export JSON</button> {#if spacesEnabled && publisherDid} <button type="button" onclick={() => oncopylocal?.(document.id, document.document.kind)}>Copy to Private</button> {/if} <button type="button" class="danger" onclick={() => ondeletelocal(document.id, document.document.kind)}> Delete </button> </div> </article> {/each} </div> {/if} {:else if tab === 'private'} <div class="section-heading"> <div> <h3>Private Space documents</h3> <p>Mutable documents synced to your AT Protocol account. Space access is not end-to-end encryption.</p> </div> <button type="button" onclick={onrefreshprivate} disabled={privateLoading}>Refresh</button> </div> <nav class="filters" aria-label="Private document type"> {#each filterOptions as option} <button type="button" class:active={activeFilter === option} aria-pressed={activeFilter === option} onclick={() => (filter = option)} > {option === 'all' ? 'All' : option.toUpperCase()} </button> {/each} </nav> {#if privateWarning}<p class="inline-warning" role="status">{privateWarning}</p>{/if} {#if privateLoading && privateDocuments.length === 0} <div class="empty"><p>Loading private documents…</p></div> {:else if privateError && privateDocuments.length === 0} <div class="empty error"> <h3>Could not load private documents</h3> <p>{privateError}</p> <button type="button" onclick={onrefreshprivate}>Try again</button> </div> {:else if filteredPrivateDocuments.length === 0} <div class="empty"> <h3>{activeFilter === 'all' ? 'No private documents yet' : `No private ${activeFilter.toUpperCase()} documents yet`}</h3> <p>Save a new document while signed in to create one in your personal Space.</p> </div> {:else} <div class="cards"> {#each filteredPrivateDocuments as document (document.uri)} <article class:current={document.rkey === activePrivateDocumentRkey}> <div class="card-main"> <div> <h3>{document.title}</h3> <p> <span class="kind-badge">{document.kind.toUpperCase()}</span> Updated {formatDate(document.updatedAt)} {#if document.rkey === activePrivateDocumentRkey}<strong>Open now</strong>{/if} </p> </div> <button type="button" class="primary" onclick={() => onopenprivate?.(document.rkey, document.kind)}> Open </button> </div> <div class="actions"> <button type="button" onclick={() => onrenameprivate?.(document.rkey, document.kind)}>Rename</button> <button type="button" onclick={() => onduplicateprivate?.(document.rkey, document.kind)}>Duplicate</button> <button type="button" onclick={() => onexportprivate?.(document.rkey, document.kind)}>Export JSON</button> <button type="button" class="danger" onclick={() => ondeleteprivate?.(document.rkey, document.kind)}> Delete </button> </div> </article> {/each} </div> {#if privateError}<p class="inline-error">{privateError}</p>{/if} {#if privateCursor} <button type="button" class="load-more" onclick={onloadmoreprivate} disabled={privateLoading} > {privateLoading ? 'Loading…' : 'Load more'} </button> {/if} {/if} {:else} <div class="section-heading"> <div> <h3>Publications</h3> <p> Immutable public records stored in your PDS. Editing one opens a new local copy. </p> </div> {#if publisherDid} <button type="button" onclick={onrefreshpublished} disabled={publishedLoading}> Refresh </button> {/if} </div> {#if !publisherDid} <div class="empty"> <h3>Sign in to see your publications</h3> <p>Your public graphs are read directly from your AT Protocol repository.</p> <button type="button" class="primary" onclick={onsignin ?? (() => undefined)}>Sign in with AT Protocol</button> </div> {:else if publishedLoading && publishedGraphs.length === 0} <div class="empty"><p>Loading publications…</p></div> {:else if publishedError && publishedGraphs.length === 0} <div class="empty error"> <h3>Could not load publications</h3> <p>{publishedError}</p> <button type="button" onclick={onrefreshpublished}>Try again</button> </div> {:else if publishedGraphs.length === 0} <div class="empty"> <h3>No published graphs</h3> <p>Publish from the calculator to create an immutable public record in your PDS.</p> </div> {:else} <p class="account-note"> Showing records for <strong>{publisherHandle ? `@${publisherHandle}` : publisherDid}</strong> </p> <div class="cards published"> {#each publishedGraphs as graph (graph.uri)} <article> <img src={previewurl(graph)} alt="" loading="lazy" /> <div class="card-main"> <div> <h3>{graph.title}</h3> <p class="publication-meta"> <span class="kind-badge">{graph.kind.toUpperCase()}</span> <span class="version-badge" title={`Publication protocol ${graph.version.toUpperCase()}`} aria-label={`Publication protocol ${graph.version.toUpperCase()}`} >{graph.version.toUpperCase()}</span> </p> <p class="publication-date"> {graph.createdAt ? `Published ${formatDate(graph.createdAt)}` : 'Published document'} <span class="visibility-badge">{graph.discoverability}</span> </p> </div> <button type="button" class="primary" onclick={() => onopenpublished(graph)}> Edit as draft </button> </div> <div class="actions"> <button type="button" onclick={() => oncopypublication(graph)}>Copy link</button> <a href={publicationurl(graph)} target="_blank" rel="noreferrer"> Open public page </a> <button type="button" class="danger" onclick={() => ondeletepublished(graph)} > Delete publication </button> </div> </article> {/each} </div> {#if publishedError}<p class="inline-error">{publishedError}</p>{/if} {#if publishedCursor} <button type="button" class="load-more" onclick={onloadmorepublished} disabled={publishedLoading} > {publishedLoading ? 'Loading…' : 'Load more'} </button> {/if} {/if} {/if} </div></section>
<style> .library { position: absolute; inset: 0; z-index: 40; display: grid; grid-template-rows: auto auto minmax(0, 1fr); background: #f8fafc; color: #0f172a; }
header { display: flex; align-items: center; justify-content: space-between; padding: 18px clamp(18px, 4vw, 52px) 14px; border-bottom: 1px solid #cbd5e1; background: #0f172a; color: white; }
.eyebrow { margin: 0 0 2px; color: #93c5fd; font-size: 11px; font-weight: 750; letter-spacing: 0.12em; text-transform: uppercase; }
h2 { margin: 0; font-size: 24px; }
.close { display: grid; width: 40px; height: 40px; place-items: center; padding: 0; border: 1px solid #475569; border-radius: 8px; background: #1e293b; color: white; font-size: 27px; line-height: 1; cursor: pointer; }
.tabs { display: flex; gap: 4px; padding: 10px clamp(18px, 4vw, 52px) 0; border-bottom: 1px solid #cbd5e1; background: white; }
.tabs button { padding: 10px 14px 12px; border: 0; border-bottom: 3px solid transparent; background: transparent; color: #64748b; font-weight: 700; cursor: pointer; }
.tabs button.active { border-bottom-color: #2563eb; color: #1d4ed8; }
.tabs span { display: inline-block; min-width: 22px; margin-left: 5px; padding: 1px 6px; border-radius: 999px; background: #e2e8f0; color: #475569; font-size: 11px; }
.content { overflow-y: auto; padding: 24px clamp(18px, 4vw, 52px) 48px; }
.section-heading { display: flex; max-width: 1100px; align-items: start; justify-content: space-between; gap: 20px; margin: 0 auto 18px; }
.section-heading h3, .section-heading p { margin: 0; }
.section-heading p, .account-note { margin-top: 5px; color: #64748b; font-size: 13px; }
.filters { display: flex; max-width: 1100px; gap: 4px; margin: 0 auto 14px; }
.filters button { padding: 6px 11px; border-color: #cbd5e1; border-radius: 999px; font-size: 12px; }
.filters button.active { border-color: #2563eb; background: #eff6ff; color: #1d4ed8; }
.cards { display: grid; max-width: 1100px; gap: 12px; margin: 0 auto; }
article { padding: 16px; border: 1px solid #cbd5e1; border-radius: 10px; background: white; box-shadow: 0 2px 7px rgb(15 23 42 / 6%); }
article.current { border-color: #60a5fa; box-shadow: 0 0 0 2px rgb(147 197 253 / 35%); }
.card-main { display: flex; align-items: center; justify-content: space-between; gap: 18px; }
.card-main h3, .card-main p { margin: 0; }
.card-main h3 { font-size: 16px; overflow-wrap: anywhere; }
.card-main p { margin-top: 5px; color: #64748b; font-size: 12px; }
.kind-badge, .version-badge { display: inline-block; padding: 2px 5px; border-radius: 4px; background: #e2e8f0; color: #475569; font-size: 10px; font-weight: 800; letter-spacing: .04em; }
.kind-badge { margin-right: 6px; }
.visibility-badge { display: inline-block; margin-left: 7px; padding: 2px 5px; border: 1px solid #cbd5e1; border-radius: 999px; color: #475569; font-size: 10px; font-weight: 700; text-transform: capitalize; }
.card-main strong { margin-left: 9px; color: #1d4ed8; }
.actions { display: flex; flex-wrap: wrap; gap: 7px; padding-top: 12px; margin-top: 12px; border-top: 1px solid #e2e8f0; }
button, .actions a { padding: 7px 10px; border: 1px solid #94a3b8; border-radius: 6px; background: white; color: #334155; font-weight: 650; text-decoration: none; cursor: pointer; }
button:hover:not(:disabled), button:focus-visible:not(:disabled), .actions a:hover, .actions a:focus-visible { border-color: #3b82f6; color: #1d4ed8; outline: 0; }
button.primary { border-color: #2563eb; background: #2563eb; color: white; }
button.danger { margin-left: auto; color: #be123c; }
button:disabled { cursor: default; opacity: 0.5; }
.empty { max-width: 620px; padding: 45px 24px; border: 1px dashed #94a3b8; border-radius: 12px; margin: 38px auto; background: white; text-align: center; }
.empty h3, .empty p { margin: 0; }
.empty p { margin-top: 7px; color: #64748b; }
.empty button { margin-top: 18px; }
.error, .inline-error { color: #9f1239; }
.inline-warning { max-width: 1100px; padding: 9px 11px; border: 1px solid #fbbf24; border-radius: 7px; margin: 0 auto 12px; background: #fffbeb; color: #92400e; font-size: 12px; }
.dark .inline-warning { border-color: #92400e; background: #422006; color: #fde68a; }
.library-status { max-width: 1100px; padding: 9px 11px; border: 1px solid #93c5fd; border-radius: 7px; margin: 0 auto 12px; background: #eff6ff; color: #1e40af; font-size: 12px; }
.dark .library-status { border-color: #1d4ed8; background: #172554; color: #bfdbfe; }
.account-note { max-width: 1100px; margin: 0 auto 12px; }
.published { grid-template-columns: repeat(auto-fill, minmax(min(100%, 310px), 1fr)); }
.published article { display: flex; min-width: 0; flex-direction: column; }
.published img { width: 100%; aspect-ratio: 1200 / 630; margin-bottom: 14px; border: 1px solid #e2e8f0; border-radius: 6px; background: #f1f5f9; object-fit: cover; }
.published .card-main { align-items: start; }
.published .actions { margin-top: auto; }
.published button.danger { margin-left: 0; }
.inline-error { max-width: 1100px; margin: 14px auto; }
.load-more { display: block; margin: 22px auto 0; }
@media (max-width: 600px) { .content { padding-top: 18px; }
.card-main, .section-heading { align-items: stretch; flex-direction: column; }
.card-main .primary { align-self: start; }
button.danger { margin-left: 0; } }
.library.dark { background: #0f172a; color: #e2e8f0; }
.library.dark .tabs, .library.dark article, .library.dark .empty { border-color: #334155; background: #111827; color: #e2e8f0; }
.library.dark .actions { border-color: #334155; }
.library.dark .section-heading p, .library.dark .card-main p, .library.dark .empty p, .library.dark .account-note { color: #94a3b8; }
.library.dark button:not(.primary), .library.dark .actions a { border-color: #475569; background: #0f172a; color: #e2e8f0; }
.library.dark .tabs button { border-color: transparent; background: transparent; color: #94a3b8; }
.library.dark .tabs button.active { border-bottom-color: #60a5fa; color: #93c5fd; }
.library.dark .tabs span { background: #334155; color: #cbd5e1; }
.library.dark .filters button.active { border-color: #60a5fa; background: #172554; color: #bfdbfe; }
.library.dark .kind-badge { background: #334155; color: #cbd5e1; }</style>