Back to Black Site Map Editor
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.

src/components/bs-library-dialog.ts
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);