community: shared playlists, a signed revision and a pointer on the network, a reference over soulseek, an import through the normal door
This commit is contained in:
1 parent
984738135a
commit
13c44f731a
11 files changed
+1166
-3
No files matched your search
@@ -72,6 +72,11 @@
|
||||
friendshipFor,
|
||||
friendCount,
|
||||
pendingIn,
|
||||
sharePreview,
|
||||
shareImport,
|
||||
shareDismiss,
|
||||
sharedPublish,
|
||||
sharedWithdraw,
|
||||
} from "../lib/community.svelte.js";
|
||||
|
||||
onMount(() => {
|
||||
@@ -423,6 +428,10 @@
|
||||
const drawerLink = $derived(drawerUser ? linkFor(drawerUser) : null);
|
||||
const drawerRemote = $derived(drawerLink ? community.remoteProfiles[drawerLink.persona_key] : null);
|
||||
const drawerFriend = $derived(drawerUser ? friendshipFor(drawerUser) : null);
|
||||
async function importShare(card) {
|
||||
const id = await shareImport(card);
|
||||
if (id) goToView("playlist", { playlistId: id, label: card.name });
|
||||
}
|
||||
const isMe = (u) => !!me && (u ?? "").trim().toLowerCase() === me.trim().toLowerCase();
|
||||
$effect(() => {
|
||||
const lk = drawerLink;
|
||||
@@ -569,7 +578,36 @@
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if !convos.length}
|
||||
{#if community.shares.length}
|
||||
<div class="freqs">
|
||||
{#each community.shares as c (c.id)}
|
||||
{@const pv = community.sharePreviews[c.id]}
|
||||
<div class="card">
|
||||
<div class="top">
|
||||
<span class="mosaic-ph"><Icon name="playlist" size={22} /></span>
|
||||
<div>
|
||||
<div class="t">{c.name}</div>
|
||||
<div class="s">{c.entry_count} {c.entry_count === 1 ? "track" : "tracks"} · playlist · shared by <button class="lnkname" onclick={() => (community.drawer = c.from_username)}>{display(c.from_username)}</button> {fmtWhen(c.received_at * 1000)}</div>
|
||||
<div class="prov">
|
||||
<span class="src ss"><Icon name="disc" size={10} />SoulStream</span>
|
||||
<span>signed by {fpShort(c.owner_key)}</span>
|
||||
{#if pv && !pv.pending && !pv.error && !pv.withdrawn}<span>·</span><span class="chip ok"><Icon name="check" size={9} />verified · revision {pv.revision_seq}</span>{/if}
|
||||
{#if pv?.withdrawn}<span>·</span><span class="chip warn">withdrawn by the owner</span>{/if}
|
||||
{#if pv?.error}<span>·</span><span class="chip err">{pv.error}</span>{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bot">
|
||||
<button class="ax-fill sm" onclick={() => importShare(c)} disabled={!nodeUp || !!community.busy["import:" + c.id] || pv?.withdrawn}><Icon name="download" size={12} /> {community.busy["import:" + c.id] ? "Importing…" : "Import"}</button>
|
||||
<button class="ax-ghost sm" onclick={() => sharePreview(c)} disabled={!nodeUp || pv?.pending}>{pv?.pending ? "Fetching…" : "Verify"}</button>
|
||||
<button class="lnk" onclick={() => shareDismiss(c.id)}>dismiss</button>
|
||||
<span class="hint">tracks resolve against your library first; the rest become Soulseek streams</span>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if !convos.length && !community.shares.length && !pendingIn().length}
|
||||
<div class="empty">
|
||||
<Icon name="inbox" size={30} />
|
||||
<div class="big">No conversations yet</div>
|
||||
@@ -1044,6 +1082,26 @@
|
||||
</div>
|
||||
{#if key}
|
||||
<div class="yc b">
|
||||
<div class="panel">
|
||||
<h3><Icon name="playlist" size={13} /> Your public playlists <span class="chip off">{community.published.filter((p) => !p.withdrawn).length}</span></h3>
|
||||
{#if !community.published.length}
|
||||
<p class="hint">Nothing published yet. Open a playlist and choose <b>Share on SoulStream</b>: a signed manifest goes to the network under your ID — titles, artists, albums, durations, your notes; never a file, never a path.</p>
|
||||
{:else}
|
||||
{#each community.published as p (p.playlist_id)}
|
||||
<div class="pubrow">
|
||||
<Icon name="playlist" size={14} />
|
||||
<div class="ptx"><div class="nm">{p.name}</div><div class="hint">{p.entry_count} tracks · revision {p.revision_seq} · {p.withdrawn ? "withdrawn" : "published"} {fmtWhen(p.published_at * 1000)}</div></div>
|
||||
{#if p.withdrawn}
|
||||
<button class="ax-ghost sm" onclick={() => sharedPublish(p.playlist_id)} disabled={!nodeUp || !!community.busy["publish:" + p.playlist_id]}>Publish again</button>
|
||||
{:else}
|
||||
<button class="ax-ghost sm" onclick={() => sharedWithdraw(p.playlist_id)} disabled={!nodeUp || !!community.busy["publish:" + p.playlist_id]}>Withdraw</button>
|
||||
{/if}
|
||||
<button class="ax-ghost sm" onclick={() => goToView("playlist", { playlistId: p.playlist_id, label: p.name })}>Open</button>
|
||||
</div>
|
||||
{/each}
|
||||
<p class="hint">Withdrawing stops your pointer; copies others already fetched stay with them.</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="panel">
|
||||
<h3><Icon name="key" size={13} /> Your SoulStream ID</h3>
|
||||
<p class="hint">
|
||||
@@ -1704,6 +1762,10 @@
|
||||
.rp .nm { font-size: 15px; font-weight: 800; display: flex; align-items: center; gap: 8px; }
|
||||
.friendrow { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-top: 12px; }
|
||||
.mark.fr { color: var(--accent-hover); }
|
||||
.pubrow { display: flex; align-items: center; gap: 10px; padding: 8px 0; border-top: 1px solid var(--hair); }
|
||||
.pubrow .ptx { flex: 1; min-width: 0; }
|
||||
.pubrow .nm { font-size: 13px; font-weight: 800; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.mosaic-ph { width: 56px; height: 56px; border-radius: 8px; flex: none; display: grid; place-items: center; background: var(--accent-soft); color: var(--accent-hover); }
|
||||
.dsec { margin-top: 18px; padding-top: 14px; border-top: 1px solid var(--hair); }
|
||||
.dsec h4 { margin: 0 0 8px; font-size: 10.5px; font-weight: 900; letter-spacing: 0.1em; text-transform: uppercase; color: var(--text-muted); display: flex; align-items: center; gap: 8px; }
|
||||
.dsec h4 .src, .dsec h4 .chip { margin-left: auto; }
|
||||
|
||||
@@ -26,6 +26,16 @@
|
||||
import * as api from "../lib/api.js";
|
||||
import { findCatalogTrack } from "../lib/catalogMatch.js";
|
||||
import Icon from "../components/Icon.svelte";
|
||||
import {
|
||||
community,
|
||||
initCommunity,
|
||||
refreshPublished,
|
||||
publishedFor,
|
||||
sharedPublish,
|
||||
sharedWithdraw,
|
||||
shareSend,
|
||||
statusKind,
|
||||
} from "../lib/community.svelte.js";
|
||||
|
||||
const playlist = $derived(
|
||||
playlistStore.playlists.find((p) => p.id === ui.playlistId) ?? null,
|
||||
@@ -37,6 +47,54 @@
|
||||
let menuOpen = $state(false);
|
||||
let confirmDelete = $state(false);
|
||||
let exportOpen = $state(false);
|
||||
// ---- Share on SoulStream (shared_playlists.rs) ----
|
||||
let shareOpen = $state(false);
|
||||
let shareTo = $state("");
|
||||
let shareNote = $state("");
|
||||
const friendNames = $derived(community.friends.filter((f) => f.state === "friends").map((f) => f.username));
|
||||
const nodeUp = $derived(statusKind(community.freenet?.status) === "connected");
|
||||
const canPublish = $derived(nodeUp && !!community.persona?.persona && !community.busy["publish:" + playlist?.id]);
|
||||
async function openShare() {
|
||||
menuOpen = false;
|
||||
shareOpen = true;
|
||||
shareNote = "";
|
||||
await initCommunity();
|
||||
await refreshPublished();
|
||||
}
|
||||
async function doPublish() {
|
||||
shareNote = "";
|
||||
try {
|
||||
const r = await sharedPublish(playlist.id);
|
||||
shareNote = `Published revision ${r.revision_seq} — ${r.entry_count} tracks.`;
|
||||
} catch (e) {
|
||||
shareNote = String(e?.message ?? e);
|
||||
}
|
||||
}
|
||||
async function doWithdraw() {
|
||||
shareNote = "";
|
||||
try {
|
||||
await sharedWithdraw(playlist.id);
|
||||
shareNote = "Withdrawn. Copies others already fetched stay with them.";
|
||||
} catch (e) {
|
||||
shareNote = String(e?.message ?? e);
|
||||
}
|
||||
}
|
||||
async function sendShare() {
|
||||
const to = shareTo.trim();
|
||||
if (!to) return;
|
||||
shareNote = "";
|
||||
try {
|
||||
await shareSend(to, playlist.id);
|
||||
shareNote = `Sent to ${to} over Soulseek.`;
|
||||
} catch (e) {
|
||||
shareNote = String(e?.message ?? e);
|
||||
}
|
||||
}
|
||||
// The sheet escapes the content pane (a containing block) to the body.
|
||||
function portal(node) {
|
||||
document.body.appendChild(node);
|
||||
return { destroy: () => node.remove() };
|
||||
}
|
||||
let renaming = $state(false);
|
||||
let nameInput = $state("");
|
||||
let editingDesc = $state(false);
|
||||
@@ -782,6 +840,9 @@
|
||||
>
|
||||
<Icon name="download" size={15} /> Download all tracks
|
||||
</button>
|
||||
<button class="mi" onclick={openShare} title="Publish a signed copy under your SoulStream ID, or send it to someone">
|
||||
<Icon name="disc" size={15} /> Share on SoulStream
|
||||
</button>
|
||||
<div class="rule"></div>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
@@ -1026,6 +1087,45 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if shareOpen && playlist}
|
||||
{@const pub = publishedFor(playlist.id)}
|
||||
<div class="share-overlay" use:portal>
|
||||
<div class="share-scrim" role="presentation" onclick={() => (shareOpen = false)}></div>
|
||||
<div class="share-sheet" role="dialog" aria-label="Share on SoulStream">
|
||||
<h3>Share on SoulStream</h3>
|
||||
<p class="sh-hint">{playlist.name} · {playlist.track_count} tracks</p>
|
||||
<div class="sh-row">
|
||||
{#if pub && !pub.withdrawn}
|
||||
<span class="sh-chip ok">published · revision {pub.revision_seq}</span>
|
||||
{:else if pub}
|
||||
<span class="sh-chip warn">withdrawn</span>
|
||||
{:else}
|
||||
<span class="sh-chip">not published</span>
|
||||
{/if}
|
||||
<button class="pill" onclick={doPublish} disabled={!canPublish}>{community.busy["publish:" + playlist.id] ? "Publishing…" : pub && !pub.withdrawn ? "Publish update" : "Publish"}</button>
|
||||
{#if pub && !pub.withdrawn}<button class="pill ghost" onclick={doWithdraw} disabled={!canPublish}>Withdraw</button>{/if}
|
||||
</div>
|
||||
<p class="sh-hint">
|
||||
{#if !community.persona?.persona}
|
||||
Needs a SoulStream identity — Community → You.
|
||||
{:else if !nodeUp}
|
||||
Needs the Freenet node connected — Community → You.
|
||||
{:else}
|
||||
A signed manifest goes to the network under your SoulStream ID: titles, artists, albums, durations and your notes — never a file, never a path. Withdrawing stops pointing at it; copies others already fetched stay with them.
|
||||
{/if}
|
||||
</p>
|
||||
<div class="sh-row">
|
||||
<input placeholder="Send to a Soulseek username" bind:value={shareTo} disabled={!pub || pub.withdrawn} />
|
||||
<button class="pill" onclick={sendShare} disabled={!pub || pub.withdrawn || !shareTo.trim() || !!community.busy["share:" + playlist.id]}>Send</button>
|
||||
</div>
|
||||
{#if friendNames.length}
|
||||
<p class="sh-hint">Friends: {#each friendNames as f (f)}<button class="sh-lnk" onclick={() => (shareTo = f)}>{f}</button>{/each}</p>
|
||||
{/if}
|
||||
{#if shareNote}<p class="sh-hint sh-note">{shareNote}</p>{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
/* The glass bar cancels the pane's top padding so the page's box starts
|
||||
flush with the top of the pane and the banner can run under the frost;
|
||||
@@ -1954,4 +2054,20 @@
|
||||
}
|
||||
|
||||
/* Menus stay solid on purpose — see the note in Playlists.svelte. */
|
||||
|
||||
/* ── Share on SoulStream: a body-level sheet ── */
|
||||
.share-scrim { position: fixed; inset: 0; z-index: 60; background: rgba(0, 0, 0, 0.45); }
|
||||
.share-sheet { position: fixed; z-index: 61; left: 50%; top: 18%; transform: translateX(-50%); width: min(480px, calc(100vw - 40px)); background: var(--bg-elevated); border: 1px solid rgba(255, 255, 255, 0.1); border-radius: 14px; padding: 18px 20px 16px; box-shadow: 0 24px 60px rgba(0, 0, 0, 0.55); font-family: var(--app-font); font-size: 13px; color: var(--text-primary); }
|
||||
.share-sheet h3 { margin: 0 0 4px; font-size: 15px; font-weight: 800; }
|
||||
.sh-hint { margin: 6px 0 0; font-size: 11.5px; line-height: 1.5; color: var(--text-muted); }
|
||||
.sh-note { color: var(--text-secondary); }
|
||||
.sh-row { display: flex; align-items: center; gap: 8px; margin-top: 12px; flex-wrap: wrap; }
|
||||
.sh-row input { flex: 1; min-width: 160px; background: var(--bg-highlight); border: 1px solid transparent; border-radius: 8px; color: #fff; padding: 8px 12px; font: inherit; outline: none; }
|
||||
.sh-row input:focus { border-color: rgba(255, 255, 255, 0.28); }
|
||||
.sh-chip { font-size: 10px; font-weight: 900; letter-spacing: 0.05em; padding: 2px 8px; border-radius: 99px; background: var(--bg-highlight); color: var(--text-muted); }
|
||||
.sh-chip.ok { background: color-mix(in srgb, #3ddc84 14%, transparent); color: #5ee89a; }
|
||||
.sh-chip.warn { background: color-mix(in srgb, #e8a33d 13%, transparent); color: #e8a33d; }
|
||||
.sh-lnk { color: var(--text-secondary); font: inherit; font-size: 11.5px; font-weight: 700; text-decoration: underline; text-underline-offset: 2px; margin-right: 8px; }
|
||||
.sh-lnk:hover { color: #fff; }
|
||||
.share-sheet .pill.ghost { background: transparent; border: 1px solid rgba(255, 255, 255, 0.28); color: var(--text-primary); }
|
||||
</style>
|
||||
Reference in new issue
Block a user