SOURCE / PINNED RELEASE
Made of little things.
Black Site Map Editor
- Release
- 32a57733b7e9…
- Author-recorded commit
- 3f54890f542f…
- License
- LICENSE
- Author’s source reference
- nostr://npub1ye5ptcxfyyxl5vjvdjar2ua3f0hynkjzpx552mu5snj3qmx5pzjscpknpr/wss%3A%2F%2Fgit.shakespeare.diy%2F/black-site-map-editor
Archive hash verified: 7a4b1abbac7db5b0…. The source-to-build association is the author’s claim; it has not been independently rebuilt.
import { html, nothing, type PropertyValues } from 'lit';
import { repeat } from 'lit/directives/repeat.js';
import { editor, guard, type DialogView } from '../editor-controller';
import { resolveMap, type MapRecord } from '../map-host';
import { mapDiscussion, postMapComment, type MapDiscussion } from '../map-social';
import { builtInTemplates } from '../model';
import { LightElement } from './light-element';
import './bs-map-preview';
import './bs-account';
type Tab = 'mine' | 'following' | 'global' | 'templates';
const person = (key: string) => `${key.slice(0, 12)}…${key.slice(-6)}`;
const date = (time: number) => new Date(time * 1000).toLocaleString();
export class BsLibraryDialog extends LightElement implements DialogView {
static properties = {
tab: { state: true },
selected: { state: true },
discussion: { state: true },
detailStatus: { state: true },
posting: { state: true },
};
declare tab: Tab;
declare selected?: MapRecord;
declare discussion?: MapDiscussion;
declare detailStatus: string;
declare posting: boolean;
#request = 0;
#viewer = '';
constructor() {
super();
this.tab = 'mine';
this.detailStatus = '';
this.posting = false;
this.watch((s) => [s.library, s.viewer, s.busy]);
}
connectedCallback() {
super.connectedCallback();
editor.ui.library = this;
}
disconnectedCallback() {
super.disconnectedCallback();
this.#request++;
}
updated(changed: PropertyValues) {
if (changed.has('selected') && this.selected) {
const scroll = this.querySelector('.map-browser-scroll');
if (scroll) scroll.scrollTop = 0;
}
if (this.#viewer !== editor.state.viewer) {
this.#viewer = editor.state.viewer;
this.#request++;
this.selected = undefined;
this.discussion = undefined;
if (this.isOpen) void this.#browse(this.#viewer ? 'mine' : 'global');
}
}
get #dialog() {
return this.querySelector<HTMLDialogElement>('dialog')!;
}
get isOpen() {
return Boolean(this.#dialog?.open);
}
show() {
this.selected = undefined;
this.discussion = undefined;
this.tab = editor.state.viewer ? 'mine' : 'templates';
if (!this.isOpen) this.#dialog.showModal();
}
close() {
this.#request++;
this.selected = undefined;
this.discussion = undefined;
if (this.isOpen) this.#dialog.close();
}
async #browse(tab: Tab) {
this.#request++;
this.tab = tab;
this.selected = undefined;
this.discussion = undefined;
if (tab === 'templates') editor.showTemplates();
else
await editor.browse(
tab === 'mine' ? editor.state.viewer : undefined,
false,
tab === 'following',
);
}
async #details(record: MapRecord) {
const request = ++this.#request;
this.selected = record;
this.discussion = undefined;
this.detailStatus = 'Loading comments, reactions and remixes…';
try {
const discussion = await mapDiscussion(record);
if (request !== this.#request || !this.isOpen) return;
this.discussion = discussion;
this.detailStatus = discussion.notice;
} catch {
if (request === this.#request)
this.detailStatus = 'Activity could not be loaded. Please retry.';
}
}
async #related(address: string, relay?: string | null) {
const request = ++this.#request;
const [, author, id] = address.split(':');
this.detailStatus = 'Opening related map…';
try {
const record = await resolveMap({ author, id, relays: relay ? [relay] : undefined });
if (request === this.#request && this.isOpen) await this.#details(record);
} catch (error) {
if (request === this.#request)
this.detailStatus =
error instanceof Error ? error.message : 'Related map unavailable.';
}
}
#action(task: () => Promise<unknown>) {
return async () => {
try {
await task();
} catch (error) {
this.detailStatus =
error instanceof Error ? error.message : 'This action failed. Please retry.';
editor.fail(error);
}
};
}
async #comment(event: Event) {
event.preventDefault();
if (!this.selected || this.posting || !editor.state.viewer) return;
const source = this.selected,
request = this.#request,
viewer = editor.state.viewer;
const input = this.querySelector<HTMLTextAreaElement>('#map-comment')!;
const text = input.value;
this.posting = true;
try {
const comment = await postMapComment(source, text);
if (request !== this.#request || viewer !== editor.state.viewer) return;
input.value = '';
if (this.discussion)
this.discussion = {
...this.discussion,
comments: [
...this.discussion.comments.filter((e) => e.id !== comment.id),
comment,
],
};
else await this.#details(source);
this.detailStatus = 'Comment published.';
} catch (error) {
if (request === this.#request)
this.detailStatus =
error instanceof Error ? error.message : 'Comment failed. Please retry.';
} finally {
this.posting = false;
}
}
#recordCard(record: MapRecord) {
const m = record.metadata;
return html`<article class="map-card">
<button
class="map-card-open"
@click=${() => this.#details(record)}
aria-label=${`View ${m.title}`}
>
<bs-map-preview .record=${record}></bs-map-preview>
<div class="map-card-copy">
<h3>${m.title}</h3>
<p>${m.summary || 'No briefing yet.'}</p>
<small
>${m.pubkey === editor.state.viewer ? 'Your map' : `By ${person(m.pubkey)}`}</small
>
<small
>${m.lineage ? 'Remixed map' : 'Original map'} ·
${new Date(m.createdAt * 1000).toLocaleDateString()}</small
>
</div>
</button>
</article>`;
}
#detailsView(record: MapRecord) {
const m = record.metadata,
own = m.pubkey === editor.state.viewer;
const discussion = this.discussion;
return html`<div class="map-detail">
<div class="map-detail-main">
<bs-map-preview .record=${record}></bs-map-preview>
<h1>${m.title}</h1>
<p class="map-briefing">${m.summary}</p>
<dl>
<dt>Creator</dt>
<dd title=${m.pubkey}>${person(m.pubkey)}</dd>
<dt>Updated</dt>
<dd>${date(m.createdAt)}</dd>
</dl>
${
m.content
? html`<h3>Notes & credits</h3>
<p class="map-notes">${m.content}</p>`
: nothing
}
<div class="map-tags">${m.hashtags.map((t) => html`<span>#${t}</span>`)}</div>
<div class="buttons map-actions">
${own ? html`<button class="primary" ?disabled=${editor.state.busy} @click=${this.#action(() => editor.openRecord(record, false))}>Edit map</button>` : nothing}
<button
?disabled=${!editor.state.viewer || editor.state.busy}
@click=${this.#action(() => editor.openRecord(record, true))}
>
Remix map
</button>
${
own
? html`<button
class="danger"
?disabled=${editor.state.busy}
@click=${this.#action(async () => {
if (await editor.deleteRecord(record)) {
this.#request++;
this.selected = undefined;
}
})}
>
Delete map
</button>`
: nothing
}
</div>
${!editor.state.viewer ? html`<p class="notice">Sign in through the host to remix maps or leave comments.</p>` : nothing}
<section>
<h2>Remix lineage</h2>
${
m.lineage
? html`<p>This map is a remix.</p>
<button
@click=${() => this.#related(m.lineage!.parent.address, m.lineage!.parent.relay)}
>
View parent map
</button>
${m.lineage.root.address !== m.lineage.parent.address ? html`<button @click=${() => this.#related(m.lineage!.root.address, m.lineage!.root.relay)}>View original map</button>` : nothing}
<p>Contributors: ${m.lineage.authors.map(person).join(', ')}</p>`
: html`<p>Original map</p>`
}
</section>
<section>
<h2>Remixes of this map</h2>
${
discussion
? discussion.remixes.length
? html`<div class="library-grid">
${repeat(
discussion.remixes,
(r) => r.metadata.address,
(r) => this.#recordCard(r),
)}
</div>`
: html`<p>No remixes found on available relays.</p>`
: html`<p>Loading remixes…</p>`
}
</section>
</div>
<aside class="map-detail-social">
<section>
<h2>Reactions</h2>
${
discussion
? html`<div class="reaction-counts">
<span
>↑
${discussion.reactions.find((r) => r.label === 'Upvotes')?.count ?? 0}
upvotes</span
><span
>↓
${discussion.reactions.find((r) => r.label === 'Downvotes')?.count ?? 0}
downvotes</span
>
${discussion.reactions.filter((r) => !['Upvotes', 'Downvotes'].includes(r.label)).map((r) => html`<span>${r.label} ${r.count}</span>`)}
</div>`
: html`<p>Loading reactions…</p>`
}
<p class="draft-info" role="status">${this.detailStatus}</p>
<button @click=${() => this.#details(record)}>Refresh activity</button>
</section>
<section>
<h2>Comments ${discussion ? `(${discussion.comments.length})` : ''}</h2>
<div class="comment-composer">
<label for="map-comment">Leave a comment</label
><textarea
id="map-comment"
rows="3"
maxlength="4000"
required
?disabled=${!editor.state.viewer || this.posting}
></textarea>
<button
type="button"
@click=${(e: Event) => this.#comment(e)}
?disabled=${!editor.state.viewer || this.posting}
>
${this.posting ? 'Posting…' : 'Post comment'}
</button>
</div>
${discussion?.comments.length === 0 ? html`<p>No comments found. Start the conversation.</p>` : nothing}
${discussion?.comments.map(
(c) =>
html`<article class="map-comment">
<small title=${c.pubkey}
>${person(c.pubkey)} · ${date(c.created_at)}</small
>
${c.tags.find((t) => t[0] === 'k')?.[1] === '1111' ? html`<small>Reply to ${person(c.tags.find((t) => t[0] === 'e')?.[1] ?? '')}</small>` : nothing}
<p>${c.content}</p>
</article>`,
)}
</section>
</aside>
</div>`;
}
render() {
const { viewer, busy, library } = editor.state;
return html`<dialog
id="library"
aria-labelledby="maps-title"
@cancel=${() => {
this.#request++;
this.selected = undefined;
}}
>
<div class="browser-minimum">
<b>Enlarge your map browser</b>
<p>
Maps needs at least 280 × 240 pixels. Expand this napplet or rotate your phone.
Your place is kept.
</p>
<button @click=${() => this.close()}>Back to editor</button>
</div>
<header class="dialog-head">
<div>
<h2 id="maps-title">${this.selected ? 'MAP DETAILS' : 'MAP LIBRARY'}</h2>
<p class="draft-info">Your maps, new ideas, and the people building them.</p>
</div>
<div class="library-account">
<bs-account></bs-account
><button id="library-close" @click=${() => this.close()}>Back to editor</button>
</div>
</header>
<div class="map-browser-scroll">
${
this.selected
? html`<button
class="back-to-maps"
@click=${() => {
this.#request++;
this.selected = undefined;
}}
>
← Back to maps</button
>${this.#detailsView(this.selected)}`
: html` <nav class="library-controls" aria-label="Map collections">
${(
[
['mine', 'my-maps', 'My maps'],
['following', 'following-maps', 'Following'],
['global', 'community-maps', 'Global'],
['templates', 'templates', 'Templates'],
] as const
).map(
([tab, id, label]) =>
html`<button
id=${id}
aria-current=${this.tab === tab ? 'page' : nothing}
class=${this.tab === tab ? 'active' : ''}
?disabled=${busy || (!viewer && (tab === 'mine' || tab === 'following'))}
@click=${guard(() => this.#browse(tab))}
>
${label}
</button>`,
)}
</nav>
${!viewer ? html`<p id="login-notice" class="notice">Sign in through the host to browse your maps and people you follow. Global maps and templates are available to everyone.</p>` : nothing}
<div class="library-utilities">
<button
id="restore-draft"
?disabled=${busy}
@click=${guard(() => editor.restoreDraft())}
>
Open saved draft</button
><button @click=${guard(() => this.#browse(this.tab))}>
Refresh maps
</button>
</div>
<p id="library-status" role="status">${library.status}</p>
<div id="library-grid" class="library-grid">
${
library.view === 'templates'
? builtInTemplates.map(
(t) =>
html`<article class="map-card template-card">
<h3>${t.name}</h3>
<p>${t.desc ?? ''}</p>
<button
@click=${guard(() => editor.useTemplate(t))}
>
Use template
</button>
</article>`,
)
: repeat(
library.records,
(r) => r.metadata.address,
(r) => this.#recordCard(r),
)
}
</div>
<button
id="load-more"
?hidden=${!library.loadMore}
@click=${guard(() => editor.loadMore())}
>
Load more
</button>`
}
</div>
</dialog>`;
}
}
customElements.define('bs-library-dialog', BsLibraryDialog);
