forked from Keelrleek/SoulStream
2595 lines
184 KiB
Svelte
2595 lines
184 KiB
Svelte
<script>
|
||
// The Community area — the people around your music, on two networks
|
||
// that are never confused for each other. Soulseek is the "here right
|
||
// now" network: rooms, PMs, presence. SoulStream (over Freenet) is the
|
||
// "kept" network: a signed identity, a published profile, later friends
|
||
// and Spaces. Every card names which one it came from; presence dots are
|
||
// Soulseek's alone.
|
||
//
|
||
// Shipped from the approved mockup (research/community-mockups,
|
||
// 2026-09-02: pills, grooves identicon, compact density, persona
|
||
// ambience). Only what has a real backend is live here; what does not
|
||
// says so in its own words rather than showing placeholder data.
|
||
import { onMount, untrack } from "svelte";
|
||
import Icon from "../components/Icon.svelte";
|
||
import Identicon, { hueOf } from "../components/Identicon.svelte";
|
||
import WhoHas from "../components/WhoHas.svelte";
|
||
import { rankPeople, reasonParts } from "../lib/discover.js";
|
||
import Soulseek from "./Soulseek.svelte";
|
||
import * as api from "../lib/api.js";
|
||
import {
|
||
app,
|
||
ui,
|
||
slsk,
|
||
downloads,
|
||
network,
|
||
goToView,
|
||
searchNetworkFor,
|
||
openChat,
|
||
sendDm,
|
||
joinRoom,
|
||
leaveRoom,
|
||
sayRoom,
|
||
addBuddy,
|
||
removeBuddy,
|
||
loadBuddies,
|
||
} from "../lib/state.svelte.js";
|
||
import {
|
||
community,
|
||
setSection,
|
||
adapterLabel,
|
||
adapterConnected,
|
||
statusKind,
|
||
startNode,
|
||
stopNode,
|
||
initCommunity,
|
||
setFreenetEnabled,
|
||
setLoadRemoteImages,
|
||
detectNode,
|
||
createPersona,
|
||
exportPersona,
|
||
importPersona,
|
||
rotatePersona,
|
||
removePersona,
|
||
loadProfile,
|
||
publishProfile,
|
||
pickAndSetAvatar,
|
||
clearAvatar,
|
||
metaOf,
|
||
setContactMeta,
|
||
toggleRoomFav,
|
||
personaKey,
|
||
loadUserInfo,
|
||
loadSimilar,
|
||
refreshInterests,
|
||
setInterest,
|
||
refreshLinks,
|
||
linkSelf,
|
||
unlinkSelf,
|
||
checkLink,
|
||
linkFor,
|
||
loadRemoteProfile,
|
||
friendRequest,
|
||
friendAccept,
|
||
friendDecline,
|
||
friendRemove,
|
||
friendForget,
|
||
friendshipFor,
|
||
friendCount,
|
||
pendingIn,
|
||
sharePreview,
|
||
shareImport,
|
||
shareDismiss,
|
||
followNews,
|
||
followUpdate,
|
||
unfollow,
|
||
sharedPublish,
|
||
sharedWithdraw,
|
||
publishInbox,
|
||
checkInbox,
|
||
friendRequestByKey,
|
||
refreshSpaces,
|
||
openSpace,
|
||
createSpace,
|
||
postToSpace,
|
||
inviteToSpace,
|
||
removeFromSpace,
|
||
pinToSpace,
|
||
leaveSpace,
|
||
setShareListens,
|
||
refreshHoldings,
|
||
holdingsFor,
|
||
setShareHoldings,
|
||
openBoard,
|
||
proposeToBoard,
|
||
decideProposal,
|
||
overlapFor,
|
||
computeOverlap,
|
||
forgetOverlap,
|
||
} from "../lib/community.svelte.js";
|
||
|
||
onMount(() => {
|
||
void initCommunity();
|
||
if (app.connection === "online") void loadBuddies();
|
||
});
|
||
|
||
// ---- the fused hub -------------------------------------------------------
|
||
// Search, Transfers and Browse are the Soulseek hub rendered embedded;
|
||
// its old Chat / Users / Rooms tabs map onto this page's own sections.
|
||
const HUB_SECTIONS = new Set(["search", "transfers", "browse"]);
|
||
const hubOpen = $derived(HUB_SECTIONS.has(community.section));
|
||
function fromHub(tab) {
|
||
const map = { chat: "inbox", users: "people", rooms: "rooms" };
|
||
const s = map[tab] ?? tab;
|
||
if (s !== community.section) setSection(s);
|
||
}
|
||
// The hub's routable ids (the download ring, "get on soulseek", Home's
|
||
// CTA) arrive as ui.view; a repeat jump bumps ui.hubJump.
|
||
$effect(() => {
|
||
const v = ui.view;
|
||
void ui.hubJump;
|
||
const s = v === "search" ? "search" : v === "downloads" ? "transfers" : null;
|
||
if (s) untrack(() => setSection(s));
|
||
});
|
||
const TRANSFER_DONE = new Set(["complete", "failed", "cancelled"]);
|
||
const transfersActive = $derived(
|
||
Object.values(downloads.items).filter((d) => !TRANSFER_DONE.has(d.state)).length
|
||
+ Object.values(network.uploads).filter((u) => u.state === "transferring").length,
|
||
);
|
||
|
||
// ---- small helpers -------------------------------------------------------
|
||
const key = $derived(personaKey());
|
||
const profile = $derived(community.profile);
|
||
const avatarUrl = $derived(
|
||
profile?.avatar_png_base64 ? `data:image/png;base64,${profile.avatar_png_base64}` : null,
|
||
);
|
||
const hue = $derived(key ? hueOf(key) : null);
|
||
const freenetOn = $derived(!!community.freenet?.enabled);
|
||
const nodeUp = $derived(adapterConnected(community.freenet?.status));
|
||
// A boolean, deliberately: reading community.inbox?.published straight from
|
||
// an effect makes that effect depend on the whole record, and a check
|
||
// REPLACES the record — which re-ran the effect, which checked again. A
|
||
// derived only notifies when its value actually flips.
|
||
const inboxLive = $derived(!!community.inbox?.published);
|
||
const online = $derived(app.connection === "online");
|
||
const me = $derived(app.username ?? "");
|
||
const ownLink = $derived(community.links?.own ?? null);
|
||
/** The stored link is only "current" for the signed-in account and the
|
||
* persona this device holds now — a different login or key is unlinked. */
|
||
const ownLinkCurrent = $derived(
|
||
!!ownLink && !!key && !!me && ownLink.username_normalized === me.trim().toLowerCase() && ownLink.persona_key === key,
|
||
);
|
||
|
||
const display = (u) => community.contacts[u]?.petname || u;
|
||
const isContact = (u) => slsk.buddies.includes(u);
|
||
/** Soulseek's word for a user's state, from what this device last heard. */
|
||
function presenceOf(u) {
|
||
const st = slsk.buddyState[u];
|
||
if (!st) return null;
|
||
if (st.exists === false) return null;
|
||
if (st.status === 2) return "online";
|
||
if (st.status === 1) return "away";
|
||
return "off";
|
||
}
|
||
const presenceFromStatus = (s) => (s === 2 ? "online" : s === 1 ? "away" : "off");
|
||
const presenceWord = { online: "online", away: "away", off: "offline" };
|
||
|
||
const fmtClock = (ms) =>
|
||
new Date(ms).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||
function fmtWhen(ms) {
|
||
if (!ms) return "";
|
||
const d = new Date(ms);
|
||
const now = new Date();
|
||
const day = 86400000;
|
||
if (d.toDateString() === now.toDateString()) return fmtClock(ms);
|
||
if (now - d < 2 * day && new Date(now - day).toDateString() === d.toDateString()) return "Yesterday";
|
||
if (now - d < 6 * day) return d.toLocaleDateString([], { weekday: "short" });
|
||
return d.toLocaleDateString([], { month: "short", day: "numeric" });
|
||
}
|
||
function fmtSeen(ms) {
|
||
if (!ms) return null;
|
||
const d = new Date(ms);
|
||
const now = new Date();
|
||
if (d.toDateString() === now.toDateString()) return `today · ${fmtClock(ms)}`;
|
||
return `${fmtWhen(ms)} · ${fmtClock(ms)}`;
|
||
}
|
||
const dayLabel = (ms) => {
|
||
const d = new Date(ms);
|
||
const now = new Date();
|
||
if (d.toDateString() === now.toDateString()) return "Today";
|
||
if (new Date(now - 86400000).toDateString() === d.toDateString()) return "Yesterday";
|
||
return d.toLocaleDateString([], { month: "long", day: "numeric" });
|
||
};
|
||
const byteLen = (s) => new TextEncoder().encode(s ?? "").length;
|
||
const fpShort = (k) => (k ? `${k.slice(0, 4)}…${k.slice(-4)}` : "");
|
||
const fpGroups = (k) => (k ? k.match(/.{1,4}/g) : []);
|
||
const fmtCount = (n) => (n ?? 0).toLocaleString();
|
||
|
||
async function copyKey() {
|
||
if (!key) return;
|
||
try {
|
||
await navigator.clipboard.writeText(key);
|
||
copied = true;
|
||
setTimeout(() => (copied = false), 1400);
|
||
} catch {
|
||
// clipboard refused — the key is still on screen to select
|
||
}
|
||
}
|
||
let copied = $state(false);
|
||
|
||
// Hand-offs into the Soulseek hub: browse lives there.
|
||
function browseUser(u) {
|
||
slsk.browseRequest = u;
|
||
setSection("browse");
|
||
}
|
||
function messageUser(u) {
|
||
openChat(u);
|
||
setSection("inbox");
|
||
community.drawer = null;
|
||
}
|
||
|
||
// ---- inbox ---------------------------------------------------------------
|
||
const convos = $derived(
|
||
Object.entries(slsk.chats)
|
||
.map(([u, c]) => ({
|
||
u,
|
||
last: c.messages[c.messages.length - 1] ?? null,
|
||
unread: c.unread,
|
||
mine: c.messages.some((m) => m.me),
|
||
}))
|
||
.sort((a, b) => (b.last?.ts ?? 0) - (a.last?.ts ?? 0)),
|
||
);
|
||
/** People who wrote first and are not contacts — held apart, not hidden. */
|
||
const requests = $derived(convos.filter((c) => !isContact(c.u) && !c.mine));
|
||
const threads = $derived(convos.filter((c) => isContact(c.u) || c.mine));
|
||
const unreadTotal = $derived(convos.reduce((n, c) => n + c.unread, 0));
|
||
let requestsOpen = $state(false);
|
||
const activeChat = $derived(slsk.activeChat ? slsk.chats[slsk.activeChat] : null);
|
||
$effect(() => {
|
||
// Landing on Inbox with nothing selected opens the freshest thread.
|
||
if (community.section === "inbox" && !slsk.activeChat && convos.length) {
|
||
untrack(() => openChat(convos[0].u));
|
||
}
|
||
});
|
||
let dmText = $state("");
|
||
let dmState = $state({ sending: false, failed: "" });
|
||
async function sendCurrent() {
|
||
const to = slsk.activeChat;
|
||
const text = dmText.trim();
|
||
if (!to || !text) return;
|
||
dmState = { sending: true, failed: "" };
|
||
try {
|
||
await sendDm(to, text);
|
||
dmText = "";
|
||
dmState = { sending: false, failed: "" };
|
||
} catch (e) {
|
||
dmState = { sending: false, failed: text };
|
||
}
|
||
}
|
||
let msgsEl = $state(null);
|
||
$effect(() => {
|
||
const n = activeChat?.messages.length;
|
||
if (n && msgsEl) msgsEl.scrollTop = msgsEl.scrollHeight;
|
||
});
|
||
function groupedMessages(list) {
|
||
const out = [];
|
||
let lastDay = "";
|
||
for (const m of list) {
|
||
const d = dayLabel(m.ts);
|
||
if (d !== lastDay) {
|
||
out.push({ divider: d });
|
||
lastDay = d;
|
||
}
|
||
out.push(m);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
// ---- rooms ---------------------------------------------------------------
|
||
let roomQuery = $state("");
|
||
let roomText = $state("");
|
||
const joinedNames = $derived(Object.keys(slsk.joined));
|
||
const activeRoom = $derived(slsk.activeRoom ? slsk.joined[slsk.activeRoom] : null);
|
||
const directory = $derived.by(() => {
|
||
const q = roomQuery.trim().toLowerCase();
|
||
return [...slsk.rooms]
|
||
.filter((r) => !q || r.name.toLowerCase().includes(q))
|
||
.sort((a, b) => (b.users ?? 0) - (a.users ?? 0))
|
||
.slice(0, 80);
|
||
});
|
||
const favRooms = $derived(community.roomFavs.filter((n) => !slsk.joined[n]));
|
||
/** When this device first saw each room this session — "history since". */
|
||
const roomSince = $state({});
|
||
$effect(() => {
|
||
for (const n of joinedNames) roomSince[n] ??= Date.now();
|
||
});
|
||
const roster = $derived.by(() => {
|
||
const users = activeRoom?.users ?? [];
|
||
const here = users.filter((u) => u.username !== me);
|
||
return {
|
||
contacts: here.filter((u) => isContact(u.username)),
|
||
others: here.filter((u) => !isContact(u.username)),
|
||
};
|
||
});
|
||
let roomEl = $state(null);
|
||
$effect(() => {
|
||
const n = activeRoom?.messages.length;
|
||
if (n && roomEl) roomEl.scrollTop = roomEl.scrollHeight;
|
||
});
|
||
async function sayCurrent() {
|
||
const r = slsk.activeRoom;
|
||
const t = roomText.trim();
|
||
if (!r || !t) return;
|
||
await sayRoom(r, t);
|
||
roomText = "";
|
||
}
|
||
const roomCount = (name) => slsk.rooms.find((r) => r.name === name)?.users;
|
||
|
||
// ---- people --------------------------------------------------------------
|
||
let peopleQuery = $state("");
|
||
let peopleGroup = $state("all");
|
||
let addName = $state("");
|
||
let adding = $state(false);
|
||
const groups = $derived(
|
||
[...new Set(Object.values(community.contacts).map((c) => c.group).filter(Boolean))].sort(),
|
||
);
|
||
const people = $derived.by(() => {
|
||
const q = peopleQuery.trim().toLowerCase();
|
||
return slsk.buddies
|
||
.map((u) => ({ u, meta: metaOf(u), st: slsk.buddyState[u] ?? {}, presence: presenceOf(u) }))
|
||
.filter((p) => !q || p.u.toLowerCase().includes(q) || (p.meta.petname ?? "").toLowerCase().includes(q))
|
||
.filter((p) =>
|
||
peopleGroup === "all"
|
||
? true
|
||
: peopleGroup === "trusted"
|
||
? p.meta.trusted
|
||
: peopleGroup === "friends"
|
||
? friendshipFor(p.u)?.state === "friends"
|
||
: peopleGroup === "favorite"
|
||
? p.meta.favorite
|
||
: p.meta.group === peopleGroup,
|
||
)
|
||
.sort((a, b) => display(a.u).localeCompare(display(b.u)));
|
||
});
|
||
const peopleOnline = $derived(people.filter((p) => p.presence === "online"));
|
||
const peopleAway = $derived(people.filter((p) => p.presence === "away"));
|
||
const peopleOff = $derived(people.filter((p) => p.presence !== "online" && p.presence !== "away"));
|
||
async function addContact(u) {
|
||
const name = (u ?? addName).trim();
|
||
if (!name) return;
|
||
adding = true;
|
||
try {
|
||
await addBuddy(name);
|
||
addName = "";
|
||
} finally {
|
||
adding = false;
|
||
}
|
||
}
|
||
|
||
// ---- discover ------------------------------------------------------------
|
||
// The ranked shelf: the four silos below scored as one list, each card
|
||
// naming the shelves it drew from. Contacts and friends are left off —
|
||
// they have shelves of their own; this one is for people not yet sorted.
|
||
const ranked = $derived.by(() =>
|
||
rankPeople({
|
||
me,
|
||
skip: (u) => isContact(u) || friendshipFor(u)?.state === "friends",
|
||
rooms: slsk.joined,
|
||
activeRoom: slsk.activeRoom,
|
||
downloads: Object.values(downloads.items),
|
||
uploads: Object.values(network.uploads),
|
||
similar: community.similar?.users ?? [],
|
||
overlap: community.overlap,
|
||
space: community.space ? { name: community.space.name, members: community.space.members } : null,
|
||
}),
|
||
);
|
||
const roomPeople = $derived.by(() => {
|
||
if (!activeRoom) return [];
|
||
return activeRoom.users
|
||
.filter((u) => u.username !== me && !isContact(u.username))
|
||
.slice(0, 24);
|
||
});
|
||
const recentPeers = $derived.by(() => {
|
||
const tally = {};
|
||
for (const d of Object.values(downloads.items)) {
|
||
if (!d.username || isContact(d.username)) continue;
|
||
(tally[d.username] ??= { u: d.username, from: 0, to: 0 }).from += 1;
|
||
}
|
||
for (const u of Object.values(network.uploads)) {
|
||
if (!u.username || isContact(u.username)) continue;
|
||
(tally[u.username] ??= { u: u.username, from: 0, to: 0 }).to += 1;
|
||
}
|
||
return Object.values(tally)
|
||
.sort((a, b) => b.from + b.to - (a.from + a.to))
|
||
.slice(0, 24);
|
||
});
|
||
|
||
// ---- you -----------------------------------------------------------------
|
||
let nameDraft = $state("");
|
||
let bioDraft = $state("");
|
||
let seededSeq = $state(null);
|
||
$effect(() => {
|
||
// Seed the form from the published profile once per revision, so a
|
||
// publish (or a first load) fills it and typing is never clobbered.
|
||
const p = community.profile;
|
||
const seq = p?.sequence ?? 0;
|
||
if (seq === seededSeq) return;
|
||
untrack(() => {
|
||
nameDraft = p?.display_name ?? "";
|
||
bioDraft = p?.bio ?? "";
|
||
seededSeq = seq;
|
||
});
|
||
});
|
||
const dirty = $derived(
|
||
nameDraft !== (profile?.display_name ?? "") || bioDraft !== (profile?.bio ?? ""),
|
||
);
|
||
const nameBytes = $derived(byteLen(nameDraft));
|
||
const bioBytes = $derived(byteLen(bioDraft));
|
||
const canPublish = $derived(
|
||
nodeUp && !!key && dirty && nameBytes <= 64 && bioBytes <= 280 && !community.busy.publish,
|
||
);
|
||
let passExport = $state("");
|
||
let passImport = $state("");
|
||
let importForce = $state(false);
|
||
let exportDone = $state(null);
|
||
let confirmRotate = $state(false);
|
||
let confirmRemove = $state(false);
|
||
let disclosuresOpen = $state(false);
|
||
async function doExport() {
|
||
const r = await exportPersona(passExport);
|
||
if (r) {
|
||
exportDone = r;
|
||
passExport = "";
|
||
}
|
||
}
|
||
async function doImport() {
|
||
await importPersona(passImport, importForce);
|
||
passImport = "";
|
||
importForce = false;
|
||
}
|
||
const status = $derived(adapterLabel(community.freenet?.status));
|
||
|
||
// ---- the person drawer ---------------------------------------------------
|
||
const drawerUser = $derived(community.drawer);
|
||
const drawerMeta = $derived(drawerUser ? metaOf(drawerUser) : null);
|
||
const drawerState = $derived(drawerUser ? (slsk.buddyState[drawerUser] ?? null) : null);
|
||
let petDraft = $state("");
|
||
let groupDraft = $state("");
|
||
let noteDraft = $state("");
|
||
$effect(() => {
|
||
const m = drawerMeta;
|
||
untrack(() => {
|
||
petDraft = m?.petname ?? "";
|
||
groupDraft = m?.group ?? "";
|
||
noteDraft = m?.note ?? "";
|
||
});
|
||
});
|
||
// The native card is fetched only for a surfaced profile (the design's
|
||
// rule), once per session per person, and only while signed in.
|
||
$effect(() => {
|
||
const u = drawerUser;
|
||
if (u && online) untrack(() => loadUserInfo(u));
|
||
});
|
||
const drawerInfo = $derived(drawerUser ? community.userInfo[drawerUser] : null);
|
||
const drawerInterests = $derived(drawerUser ? community.interests[drawerUser] : null);
|
||
const drawerPhoto = $derived(
|
||
drawerInfo?.picture_png_base64 ? `data:image/png;base64,${drawerInfo.picture_png_base64}` : null,
|
||
);
|
||
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 });
|
||
}
|
||
// The app polls the inbox for the whole session (startInboxWatch, in
|
||
// community.svelte.js) — arriving here only asks for a fresh read now, so
|
||
// the page opens on current state instead of waiting out the interval.
|
||
$effect(() => {
|
||
if (!nodeUp || !key || !inboxLive) return;
|
||
untrack(() => void checkInbox());
|
||
});
|
||
let addKey = $state("");
|
||
// ---- spaces ------------------------------------------------------------
|
||
let spaceName = $state("");
|
||
let spacePrivate = $state(true);
|
||
let spaceText = $state("");
|
||
let spaceInviteKey = $state("");
|
||
let spaceTab = $state("chat");
|
||
/* A listen is a point in time, not a state: nothing on the wire says
|
||
"still playing". So the room reads one as live for a while and then
|
||
lets it go quiet, rather than claiming someone is listening because
|
||
their app never got to say they stopped. */
|
||
const LISTEN_LIVE_MINS = 15;
|
||
// Newest first, which is the opposite of the chat's order: a trail is
|
||
// read from the top, a conversation from the bottom.
|
||
const listensNewestFirst = $derived([...(sv?.listens ?? [])].sort((a, b) => b.created_at - a.created_at));
|
||
const nowPlaying = $derived.by(() => {
|
||
const seen = new Map();
|
||
const cutoff = Date.now() / 1000 - LISTEN_LIVE_MINS * 60;
|
||
for (const l of listensNewestFirst) {
|
||
if (!seen.has(l.author)) seen.set(l.author, { ...l, live: l.created_at >= cutoff });
|
||
}
|
||
// Whoever is playing leads; the rest are the room's most recent, and
|
||
// only enough of them to stay a band rather than a second list.
|
||
return [...seen.values()].sort((a, b) => Number(b.live) - Number(a.live) || b.created_at - a.created_at).slice(0, 8);
|
||
});
|
||
// Day separators and run-grouping are decided once, here, so the markup
|
||
// stays a list and the rules stay readable.
|
||
const trail = $derived.by(() => {
|
||
let lastDay = "";
|
||
let lastAuthor = "";
|
||
return listensNewestFirst.map((l) => {
|
||
const d = fmtWhen(l.created_at * 1000);
|
||
const dayLabel = new Date(l.created_at * 1000).toDateString();
|
||
const day = dayLabel === lastDay ? null : (lastDay = dayLabel) && (isToday(l.created_at) ? "Today" : d);
|
||
const run = !day && l.author === lastAuthor;
|
||
lastAuthor = l.author;
|
||
return { ...l, day, run };
|
||
});
|
||
});
|
||
const isToday = (secs) => new Date(secs * 1000).toDateString() === new Date().toDateString();
|
||
function ago(secs) {
|
||
const m = Math.max(0, Math.round(Date.now() / 1000 - secs) / 60);
|
||
if (m < 60) return `${Math.max(1, Math.round(m))}m ago`;
|
||
const h = m / 60;
|
||
return h < 24 ? `${Math.round(h)}h ago` : `${Math.round(h / 24)}d ago`;
|
||
}
|
||
/* The cover has not been resolved for a track someone else played, and
|
||
fetching one per row would be a lot of network for a list. A hue keyed
|
||
off the album gives the trail a stable colour per record instead. */
|
||
function artWash(seed) {
|
||
let h = 0x811c9dc5;
|
||
for (const c of String(seed)) { h ^= c.charCodeAt(0); h = Math.imul(h, 0x01000193) >>> 0; }
|
||
const hue = h % 360;
|
||
return `linear-gradient(140deg,hsl(${hue} 42% 26%),hsl(${(hue + 40) % 360} 38% 16%))`;
|
||
}
|
||
// The one action a listen affords: go and find it. Artist and title are
|
||
// the pair every other dead end in the app searches on, and
|
||
// `searchNetworkFor` is that road — it seeds the query and switches the
|
||
// pill, so this needs no path of its own.
|
||
const findListen = (l) => void searchNetworkFor(`${l.artist} ${l.title}`.trim());
|
||
let spaceCreating = $state(false);
|
||
const sv = $derived(community.space);
|
||
const spaceOwner = $derived(sv?.my_role === "owner");
|
||
// Holdings: the room's lists are read when a Space opens (stale members
|
||
// in the background; the event brings the result back), and the ask box
|
||
// answers from the cache.
|
||
const hv = $derived(sv ? holdingsFor(sv.owner, sv.space_id) : null);
|
||
let askArtist = $state("");
|
||
let askAlbum = $state("");
|
||
let asked = $state(null);
|
||
$effect(() => {
|
||
const id = sv?.id;
|
||
if (!id) return;
|
||
untrack(() => void refreshHoldings(sv.owner, sv.space_id));
|
||
});
|
||
let spaceEl = $state(null);
|
||
$effect(() => {
|
||
const n = sv?.messages.length;
|
||
if (n && spaceEl) spaceEl.scrollTop = spaceEl.scrollHeight;
|
||
});
|
||
// The open Space is re-read every 30 s while the section is showing.
|
||
$effect(() => {
|
||
if (community.section !== "spaces" || !sv || !nodeUp) return;
|
||
const { owner, space_id } = sv;
|
||
const t = setInterval(() => void openSpace(owner, space_id), 30_000);
|
||
return () => clearInterval(t);
|
||
});
|
||
async function sayInSpace() {
|
||
const t = spaceText.trim();
|
||
if (!t || !sv) return;
|
||
await postToSpace(sv.owner, sv.space_id, t);
|
||
spaceText = "";
|
||
}
|
||
async function makeSpace() {
|
||
const n = spaceName.trim();
|
||
if (!n) return;
|
||
await createSpace(n, "", spacePrivate);
|
||
spaceName = "";
|
||
spaceCreating = false;
|
||
}
|
||
const whoIs = (m) => (m.is_me ? "you" : m.username ? display(m.username) : fpShort(m.key ?? m.author));
|
||
const pinnedPreview = $derived(sv?.pinned_uuid ? community.sharePreviews["pin:" + sv.pinned_uuid] : null);
|
||
$effect(() => {
|
||
const s0 = sv;
|
||
if (s0?.pinned_uuid && !community.sharePreviews["pin:" + s0.pinned_uuid]) {
|
||
untrack(() => sharePreview({ id: "pin:" + s0.pinned_uuid, owner_key: s0.pinned_owner, playlist_uuid: s0.pinned_uuid }));
|
||
}
|
||
});
|
||
// ---- the proposal board (F7). The composer is a working copy of the pinned
|
||
// playlist that records each act as an op; the Rust side dry-runs the same
|
||
// ops against the exact base before anything is signed.
|
||
const bv = $derived(community.board);
|
||
const boardKey = $derived(sv ? `${sv.id}|${sv.pinned_uuid ?? ""}` : "");
|
||
const boardBusy = $derived(!!(community.busy["board:propose"] || community.busy["board:decide"] || (sv && community.busy["board:" + sv.space_id])));
|
||
let boardDraft = $state(null);
|
||
let boardQuery = $state("");
|
||
let boardHits = $state([]);
|
||
let boardReason = $state({});
|
||
let freshCount = 0;
|
||
let boardTimer;
|
||
$effect(() => {
|
||
const k = boardKey;
|
||
if (spaceTab !== "proposals" || !k || k.endsWith("|")) return;
|
||
untrack(() => {
|
||
boardDraft = null;
|
||
void openBoard(sv.owner, sv.space_id);
|
||
});
|
||
});
|
||
function applyOp(list, op) {
|
||
if (op.kind === "add") {
|
||
list.splice(Math.min(op.position, list.length), 0, { entry_id: op.token, title: op.title, artist: op.artist, album: op.album, duration_secs: op.duration_secs, fresh: true });
|
||
return true;
|
||
}
|
||
const i = list.findIndex((e) => e.entry_id === op.entry_id);
|
||
if (i < 0) return false;
|
||
if (op.kind === "remove") {
|
||
list.splice(i, 1);
|
||
return true;
|
||
}
|
||
const [e] = list.splice(i, 1);
|
||
list.splice(Math.min(op.to_position, list.length), 0, e);
|
||
return true;
|
||
}
|
||
function startDraft() {
|
||
if (!bv) return;
|
||
boardDraft = { base: bv.base, list: bv.entries.map((e) => ({ ...e })), ops: [], note: "" };
|
||
boardQuery = "";
|
||
boardHits = [];
|
||
}
|
||
function draftApply(op) {
|
||
if (!boardDraft || boardDraft.ops.length >= 32) return;
|
||
if (applyOp(boardDraft.list, op)) boardDraft.ops.push(op);
|
||
}
|
||
function draftUndo() {
|
||
if (!boardDraft?.ops.length) return;
|
||
boardDraft.ops.pop();
|
||
const list = bv.entries.map((e) => ({ ...e }));
|
||
for (const op of boardDraft.ops) applyOp(list, op);
|
||
boardDraft.list = list;
|
||
}
|
||
function draftAdd(h) {
|
||
draftApply({ kind: "add", position: boardDraft.list.length, title: h.title, artist: h.artist, album: h.album ?? "", duration_secs: h.duration_secs ?? null, token: `new:${++freshCount}` });
|
||
boardQuery = "";
|
||
boardHits = [];
|
||
}
|
||
function searchLibrary() {
|
||
clearTimeout(boardTimer);
|
||
const q = boardQuery.trim();
|
||
if (q.length < 2) {
|
||
boardHits = [];
|
||
return;
|
||
}
|
||
boardTimer = setTimeout(async () => {
|
||
try {
|
||
boardHits = await api.libraryTrackSearch(q);
|
||
} catch {
|
||
boardHits = [];
|
||
}
|
||
}, 220);
|
||
}
|
||
async function sendDraft() {
|
||
if (!boardDraft?.ops.length || !sv) return;
|
||
const ops = boardDraft.ops.map((o) => (o.kind === "add" ? { kind: "add", position: o.position, title: o.title, artist: o.artist, album: o.album, duration_secs: o.duration_secs, token: o.token } : { ...o }));
|
||
try {
|
||
await proposeToBoard(sv.owner, sv.space_id, boardDraft.base, ops, boardDraft.note);
|
||
boardDraft = null;
|
||
} catch {
|
||
// boardError shows it
|
||
}
|
||
}
|
||
const decideOn = (p, accept) =>
|
||
decideProposal(sv.owner, sv.space_id, p.id, accept, boardReason[p.id] || (!accept && p.stale ? "stale base — re-submit against the current revision" : "")).catch(() => {});
|
||
const whoProposed = (p) => (p.is_me ? "you" : p.username ? display(p.username) : `SoulStream ID ${fpShort(p.author)}`);
|
||
const drawerOverlap = $derived(drawerUser ? overlapFor(drawerUser) : null);
|
||
async function importPinned() {
|
||
if (!sv?.pinned_uuid) return;
|
||
const id = await shareImport({ id: "pin:" + sv.pinned_uuid, owner_key: sv.pinned_owner, playlist_uuid: sv.pinned_uuid, name: pinnedPreview?.name ?? "Pinned playlist" });
|
||
if (id) goToView("playlist", { playlistId: id, label: pinnedPreview?.name ?? "Pinned playlist" });
|
||
}
|
||
const isMe = (u) => !!me && (u ?? "").trim().toLowerCase() === me.trim().toLowerCase();
|
||
$effect(() => {
|
||
const lk = drawerLink;
|
||
if (lk) untrack(() => loadRemoteProfile(lk.persona_key));
|
||
});
|
||
$effect(() => {
|
||
for (const f of pendingIn()) if (!f.username) untrack(() => loadRemoteProfile(f.persona_key));
|
||
});
|
||
let likeDraft = $state("");
|
||
// Discover's similar-users shelf asks the server once per session.
|
||
$effect(() => {
|
||
if (community.section === "discover" && online) {
|
||
untrack(() => loadSimilar());
|
||
untrack(() => refreshInterests());
|
||
}
|
||
});
|
||
function saveMeta(patch = {}) {
|
||
if (!drawerUser) return;
|
||
void setContactMeta({
|
||
...metaOf(drawerUser),
|
||
petname: petDraft,
|
||
group: groupDraft,
|
||
note: noteDraft,
|
||
...patch,
|
||
});
|
||
}
|
||
function closeDrawer() {
|
||
community.drawer = null;
|
||
}
|
||
// The drawer must escape the content pane: the pane is a containing
|
||
// block (its band clip and scroll timeline), so a fixed child stays
|
||
// inside it and under the Now Playing panel — and the glass bar's fog
|
||
// clone would carry a second copy. Body-level, it is the one overlay.
|
||
function portal(node) {
|
||
document.body.appendChild(node);
|
||
return { destroy: () => node.remove() };
|
||
}
|
||
function onKey(e) {
|
||
if (e.key === "Escape" && community.drawer) closeDrawer();
|
||
}
|
||
</script>
|
||
|
||
<svelte:window onkeydown={onKey} />
|
||
|
||
<div class="community" style:--hue={hue ?? 200}>
|
||
{#if hue !== null}
|
||
<div class="amb" aria-hidden="true"></div>
|
||
{/if}
|
||
|
||
<!-- ── the identity strip ─────────────────────────────────────────── -->
|
||
<div class="who">
|
||
{#if key}
|
||
<Identicon seed={key} size={48} photo={avatarUrl} ring presence={online ? "online" : "off"} />
|
||
{:else}
|
||
<span class="noid"><Icon name="users" size={22} /></span>
|
||
{/if}
|
||
<div class="whotx">
|
||
<div class="name">
|
||
{profile?.display_name || (key ? "You" : "You, on Soulseek")}
|
||
{#if key}<span class="src ss"><Icon name="disc" size={10} />SoulStream</span>{/if}
|
||
</div>
|
||
<div class="id">
|
||
{#if key}
|
||
<button class="fp" title="Copy your full SoulStream ID" onclick={copyKey}>
|
||
<span class="k">SoulStream ID</span>
|
||
<span class="mono">{fpGroups(key).slice(0, 4).join("·")}·…·{key.slice(-4)}</span>
|
||
<span class="cp"><Icon name={copied ? "check" : "copy"} size={12} /></span>
|
||
</button>
|
||
<span class="muted">·</span>
|
||
{/if}
|
||
<span class="sl">
|
||
<Icon name="globe" size={12} />
|
||
{#if me}<b>{me}</b> on Soulseek{:else}not signed in to Soulseek{/if}
|
||
<span class="chip {online ? 'ok' : 'off'}">{online ? "online" : "offline"}</span>
|
||
{#if ownLinkCurrent}<span class="chip acc" title="Verified through a Soulseek message on {new Date(ownLink.verified_at * 1000).toLocaleString()}"><Icon name="link" size={10} />linked</span>{/if}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<div class="right">
|
||
<div class="st">
|
||
{#if profile}
|
||
<span class="chip ok"><Icon name="check" size={10} />{profile.from_ledger ? "profile published" : `profile synced as of ${fmtClock(community.profileAt)}`} · rev {profile.sequence}</span>
|
||
{:else if key}
|
||
<span class="chip off">nothing published yet</span>
|
||
{:else if freenetOn}
|
||
<span class="chip {status.tone}">{status.text}</span>
|
||
{:else}
|
||
<span class="chip off">SoulStream identity off</span>
|
||
{/if}
|
||
<span class="hint">{friendCount()} friends · {slsk.buddies.length} contacts · {joinedNames.length} rooms · {convos.length} conversations</span>
|
||
</div>
|
||
<button class="ax-ghost" onclick={() => setSection("you")}>
|
||
<Icon name={key ? "pencil" : "sparkle"} size={13} />
|
||
{key ? "Edit profile" : "Set up identity"}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ── the pills ──────────────────────────────────────────────────── -->
|
||
<div class="qtabs">
|
||
<button class="qtab" class:on={community.section === "search"} onclick={() => setSection("search")}>Search</button>
|
||
<button class="qtab" class:on={community.section === "transfers"} onclick={() => setSection("transfers")}>
|
||
Transfers{#if transfersActive}<span class="n">{transfersActive}</span>{/if}
|
||
</button>
|
||
<button class="qtab" class:on={community.section === "browse"} onclick={() => setSection("browse")}>Browse</button>
|
||
<span class="pillsep" aria-hidden="true"></span>
|
||
<button class="qtab" class:on={community.section === "inbox"} onclick={() => setSection("inbox")}>
|
||
Inbox{#if unreadTotal}<span class="n">{unreadTotal}</span>{/if}
|
||
</button>
|
||
<button class="qtab" class:on={community.section === "rooms"} onclick={() => setSection("rooms")}>
|
||
Rooms{#if joinedNames.length}<span class="n">{joinedNames.length}</span>{/if}
|
||
</button>
|
||
<button class="qtab" class:on={community.section === "people"} onclick={() => setSection("people")}>
|
||
People{#if slsk.buddies.length}<span class="n">{slsk.buddies.length}</span>{/if}
|
||
</button>
|
||
<button class="qtab" class:on={community.section === "discover"} onclick={() => setSection("discover")}>Discover</button>
|
||
<button class="qtab" class:on={community.section === "spaces"} onclick={() => setSection("spaces")}>
|
||
Spaces<span class="chip up">beta</span>
|
||
</button>
|
||
<span class="pillsep" aria-hidden="true"></span>
|
||
<button class="qtab you" class:on={community.section === "you"} onclick={() => setSection("you")}>
|
||
{#if key}<Identicon seed={key} size={22} photo={avatarUrl} />{:else}<Icon name="users" size={14} />{/if}
|
||
You
|
||
</button>
|
||
</div>
|
||
|
||
<!-- ── SEARCH · TRANSFERS · BROWSE — the hub, embedded ─────────────── -->
|
||
{#if hubOpen}
|
||
<Soulseek embedded section={community.section} onsection={fromHub} />
|
||
|
||
<!-- ── INBOX ──────────────────────────────────────────────────────── -->
|
||
{:else if community.section === "inbox"}
|
||
{#if pendingIn().length}
|
||
<div class="freqs">
|
||
{#each pendingIn() as f (f.id)}
|
||
<div class="card inv">
|
||
<div class="top">
|
||
<button class="avbtn" onclick={() => (community.drawer = f.username)}><Identicon seed={f.persona_key} size={44} presence={presenceOf(f.username)} /></button>
|
||
<div>
|
||
<div class="t">{f.username ? display(f.username) : community.remoteProfiles[f.persona_key]?.display_name || `SoulStream ID ${fpShort(f.persona_key)}`} wants to be friends</div>
|
||
<div class="s">{f.username ? `${f.username} · ` : ""}SoulStream ID {fpShort(f.persona_key)} · asked {fmtWhen(f.updated_at * 1000)}</div>
|
||
<div class="prov"><span class="src ss"><Icon name="disc" size={10} />SoulStream</span><span>{f.carry === "inbox" ? "sealed · via your encrypted inbox · signed by their key" : "signed request · their ID proven over Soulseek"}</span><span>·</span><span>open until {fmtWhen(f.expires_at * 1000)}</span></div>
|
||
</div>
|
||
</div>
|
||
<div class="bot">
|
||
<button class="ax-fill sm" onclick={() => friendAccept(f.id)} disabled={!online || !nodeUp || !!community.busy["friend:" + f.id]}>Accept</button>
|
||
<button class="ax-ghost sm" onclick={() => friendDecline(f.id)} disabled={!!community.busy["friend:" + f.id]}>Decline</button>
|
||
<span class="hint">friends are two signed answers — yours goes back to them over Soulseek</span>
|
||
</div>
|
||
</div>
|
||
{/each}
|
||
</div>
|
||
{/if}
|
||
{#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 {#if c.from_username}<button class="lnkname" onclick={() => (community.drawer = c.from_username)}>{display(c.from_username)}</button>{:else}SoulStream ID {fpShort(c.from_key)}{/if} {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 c.carry === "inbox"}<span>·</span><span><Icon name="lock" size={9} /> sealed · via your encrypted inbox</span>{/if}
|
||
{#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 followNews().length}
|
||
<div class="freqs">
|
||
{#each followNews() as f (f.owner_key + ":" + f.playlist_uuid)}
|
||
{@const busy = !!community.busy["follow:" + f.playlist_uuid]}
|
||
<div class="card">
|
||
<div class="top">
|
||
<span class="mosaic-ph"><Icon name="playlist" size={22} /></span>
|
||
<div>
|
||
<div class="t">{f.name}</div>
|
||
<div class="s">
|
||
{#if f.added.length}<b>{f.added.length} new {f.added.length === 1 ? "track" : "tracks"}</b> since you imported it{#if f.removed}{" · "}{f.removed} removed{/if}{:else}the owner removed {f.removed} {f.removed === 1 ? "track" : "tracks"} — your copy keeps {f.removed === 1 ? "it" : "them"}{/if}
|
||
{" · "}revision {f.seen_seq} · checked {fmtWhen(f.checked_at * 1000)}
|
||
</div>
|
||
<div class="prov">
|
||
<span class="src ss"><Icon name="disc" size={10} />SoulStream</span>
|
||
<span>signed by {fpShort(f.owner_key)}</span>
|
||
<span>·</span>
|
||
<span>your copy: <button class="lnkname" onclick={() => goToView("playlist", { playlistId: f.playlist_id, label: f.name })}>{f.name}</button></span>
|
||
</div>
|
||
{#if f.added.length}
|
||
<div class="s">{f.added.slice(0, 3).map((e) => `${e.artist} — ${e.title}`).join(" · ")}{#if f.added.length > 3} · and {f.added.length - 3} more{/if}</div>
|
||
{/if}
|
||
</div>
|
||
</div>
|
||
<div class="bot">
|
||
<button class="ax-fill sm" onclick={() => followUpdate(f)} disabled={busy}><Icon name="download" size={12} /> {busy ? "Adding…" : f.added.length ? `Add ${f.added.length} ${f.added.length === 1 ? "track" : "tracks"}` : "OK"}</button>
|
||
<button class="lnk" onclick={() => unfollow(f)} disabled={busy}>stop following</button>
|
||
<span class="hint">new tracks resolve against your library first; the rest become Soulseek streams — nothing is removed from your copy</span>
|
||
</div>
|
||
</div>
|
||
{/each}
|
||
</div>
|
||
{/if}
|
||
{#if !convos.length && !community.shares.length && !pendingIn().length && !followNews().length}
|
||
<div class="empty">
|
||
<Icon name="inbox" size={30} />
|
||
<div class="big">No conversations yet</div>
|
||
<div class="sub">
|
||
Message someone from People, a room roster, or a search result. Conversations are kept on this
|
||
device only — Soulseek stores nothing for you.
|
||
</div>
|
||
</div>
|
||
{:else}
|
||
<div class="split">
|
||
<div class="col">
|
||
{#if requests.length}
|
||
<button class="lrow req" onclick={() => (requestsOpen = !requestsOpen)}>
|
||
<span class="stack">
|
||
{#each requests.slice(0, 3) as r (r.u)}<Identicon seed={r.u} size={26} />{/each}
|
||
</span>
|
||
<span class="tx">
|
||
<span class="l1"><span class="nm">Message requests</span><span class="chip off">{requests.length}</span></span>
|
||
<span class="l2">from people not in your contacts</span>
|
||
</span>
|
||
<span class="r"><Icon name={requestsOpen ? "chevron-down" : "chevron-right"} size={14} /></span>
|
||
</button>
|
||
{#if requestsOpen}
|
||
{#each requests as c (c.u)}
|
||
<button class="lrow" class:on={slsk.activeChat === c.u} onclick={() => openChat(c.u)}>
|
||
<Identicon seed={c.u} size={36} presence={presenceOf(c.u)} />
|
||
<span class="tx">
|
||
<span class="l1"><span class="nm">{c.u}</span><span class="src slsk"><Icon name="globe" size={10} />Soulseek</span></span>
|
||
<span class="l2">{c.last?.text ?? ""}</span>
|
||
</span>
|
||
<span class="r"><span>{fmtWhen(c.last?.ts)}</span>{#if c.unread}<span class="unread">{c.unread}</span>{/if}</span>
|
||
</button>
|
||
{/each}
|
||
{/if}
|
||
{/if}
|
||
<div class="list">
|
||
{#each threads as c (c.u)}
|
||
<button class="lrow" class:on={slsk.activeChat === c.u} onclick={() => openChat(c.u)}>
|
||
<Identicon seed={c.u} size={36} presence={presenceOf(c.u)} />
|
||
<span class="tx">
|
||
<span class="l1"><span class="nm">{display(c.u)}</span><span class="src slsk"><Icon name="globe" size={10} />Soulseek</span></span>
|
||
<span class="l2">{c.last ? (c.last.me ? "you: " : "") + c.last.text : ""}</span>
|
||
</span>
|
||
<span class="r"><span>{fmtWhen(c.last?.ts)}</span>{#if c.unread}<span class="unread">{c.unread}</span>{/if}</span>
|
||
</button>
|
||
{/each}
|
||
</div>
|
||
<div class="hint pad">Retained on this device only. Nothing here is a receipt — Soulseek has no delivery or read reports.</div>
|
||
</div>
|
||
|
||
{#if slsk.activeChat && activeChat}
|
||
{@const u = slsk.activeChat}
|
||
<div class="thread">
|
||
<div class="thead-bar">
|
||
<Identicon seed={u} size={44} presence={presenceOf(u)} />
|
||
<div class="tt">
|
||
<div class="nm">
|
||
{display(u)}
|
||
{#if metaOf(u).favorite}<span class="mark fav"><Icon name="star" size={11} /></span>{/if}
|
||
{#if metaOf(u).trusted}<span class="mark tr"><Icon name="shield" size={11} /></span>{/if}
|
||
</div>
|
||
<div class="sub">
|
||
{#if community.contacts[u]?.petname}<span>{u}</span><span>·</span>{/if}
|
||
<span class="src slsk"><Icon name="globe" size={10} />Soulseek</span>
|
||
<span>{presenceOf(u) ? presenceWord[presenceOf(u)] : isContact(u) ? "status unknown" : "not a contact — no status"}</span>
|
||
</div>
|
||
</div>
|
||
<div class="r">
|
||
<button class="ax-ghost sm" onclick={() => (community.drawer = u)}><Icon name="eye" size={13} /> Profile</button>
|
||
<button class="ax-ghost sm" onclick={() => browseUser(u)}><Icon name="folder" size={13} /> Browse shares</button>
|
||
{#if !isContact(u)}
|
||
<button class="ax-ghost sm" onclick={() => addContact(u)}><Icon name="plus" size={13} /> Add to contacts</button>
|
||
{/if}
|
||
</div>
|
||
</div>
|
||
<div class="msgs" bind:this={msgsEl}>
|
||
{#each groupedMessages(activeChat.messages) as m, i (m.divider ? "d" + m.divider + i : i)}
|
||
{#if m.divider}
|
||
<div class="mdate">{m.divider}</div>
|
||
{:else}
|
||
<div class="m" class:me={m.me}>
|
||
{#if !m.me}<Identicon seed={u} size={24} />{/if}
|
||
<div>
|
||
<div class="b">{m.text}</div>
|
||
<div class="mst">{fmtClock(m.ts)}{#if m.me} · submitted{/if}</div>
|
||
</div>
|
||
</div>
|
||
{/if}
|
||
{/each}
|
||
{#if dmState.failed}
|
||
<div class="m me">
|
||
<div>
|
||
<div class="b">{dmState.failed}</div>
|
||
<div class="mst fail"><Icon name="alert" size={10} /> failed · <button class="lnk" onclick={() => { dmText = dmState.failed; dmState = { sending: false, failed: "" }; }}>edit and retry</button></div>
|
||
</div>
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
<form class="compose" onsubmit={(e) => { e.preventDefault(); void sendCurrent(); }}>
|
||
<div class="qbox grow">
|
||
<input placeholder={online ? `Message ${display(u)} on Soulseek` : "Sign in to Soulseek to send"} bind:value={dmText} disabled={!online || dmState.sending} />
|
||
</div>
|
||
<span class="via">{dmState.sending ? "queued locally" : "Soulseek PM · plain text"}</span>
|
||
<button class="send" type="submit" disabled={!online || !dmText.trim() || dmState.sending} title="Send"><Icon name="send" size={15} /></button>
|
||
</form>
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
{/if}
|
||
|
||
<!-- ── ROOMS ──────────────────────────────────────────────────────── -->
|
||
{:else if community.section === "rooms"}
|
||
<div class="split narrow">
|
||
<div class="col">
|
||
{#if joinedNames.length}
|
||
<div class="grp">Joined <span class="ln"></span></div>
|
||
{#each joinedNames as n (n)}
|
||
<button class="lrow slim" class:on={slsk.activeRoom === n} onclick={() => (slsk.activeRoom = n)}>
|
||
<span class="roomic"><Icon name="hash" size={14} /></span>
|
||
<span class="tx">
|
||
<span class="l1"><span class="nm">{n}</span>{#if community.roomFavs.includes(n)}<span class="mark fav"><Icon name="star" size={10} /></span>{/if}</span>
|
||
<span class="l2">{fmtCount(slsk.joined[n].users.length)} here · joined</span>
|
||
</span>
|
||
</button>
|
||
{/each}
|
||
{/if}
|
||
{#if favRooms.length}
|
||
<div class="grp">Favorites <span class="ln"></span></div>
|
||
{#each favRooms as n (n)}
|
||
<button class="lrow slim" onclick={() => joinRoom(n)} disabled={!online}>
|
||
<span class="roomic"><Icon name="hash" size={14} /></span>
|
||
<span class="tx"><span class="l1"><span class="nm">{n}</span></span><span class="l2">{roomCount(n) != null ? `${fmtCount(roomCount(n))} here` : "click to join"}</span></span>
|
||
</button>
|
||
{/each}
|
||
{/if}
|
||
<div class="grp">
|
||
Directory <span class="n muted">· {fmtCount(slsk.rooms.length)} rooms</span><span class="ln"></span>
|
||
<button class="ibtn sm" title="Refresh the list" onclick={() => api.slskRoomList()} disabled={!online}><Icon name="refresh" size={12} /></button>
|
||
</div>
|
||
<div class="qbox slim"><Icon name="search" size={14} /><input placeholder="Find a room" bind:value={roomQuery} /></div>
|
||
<div class="list">
|
||
{#each directory as r (r.name)}
|
||
<div class="drow" class:joined={!!slsk.joined[r.name]}>
|
||
<button class="star" class:on={community.roomFavs.includes(r.name)} title="Favorite" onclick={() => toggleRoomFav(r.name)}><Icon name="star" size={12} /></button>
|
||
<button class="dname" onclick={() => (slsk.joined[r.name] ? (slsk.activeRoom = r.name) : joinRoom(r.name))} disabled={!online && !slsk.joined[r.name]}>{r.name}</button>
|
||
<span class="cnt">{fmtCount(r.users)}</span>
|
||
</div>
|
||
{/each}
|
||
{#if !slsk.rooms.length}
|
||
<div class="hint pad">{online ? "The room list arrives after login — refresh if it stays empty." : "Sign in to Soulseek to see the room directory."}</div>
|
||
{/if}
|
||
</div>
|
||
<div class="hint pad">Counts are the server's. A room streams only after you join it; the global firehose stays off.</div>
|
||
</div>
|
||
|
||
{#if slsk.activeRoom && activeRoom}
|
||
{@const rn = slsk.activeRoom}
|
||
<div class="thread">
|
||
<div class="roomhead">
|
||
<span class="roomic lg"><Icon name="hash" size={22} /></span>
|
||
<div class="tt">
|
||
<div class="nm">#{rn}</div>
|
||
<div class="sub">
|
||
<span class="src slsk"><Icon name="globe" size={10} />Soulseek Room</span>
|
||
<span>{fmtCount(activeRoom.users.length)} here</span>
|
||
<span>·</span>
|
||
<span>history on this device since {fmtClock(roomSince[rn] ?? Date.now())}</span>
|
||
</div>
|
||
</div>
|
||
<div class="r">
|
||
<button class="ibtn" class:fav={community.roomFavs.includes(rn)} title="Favorite" onclick={() => toggleRoomFav(rn)}><Icon name="star" size={15} /></button>
|
||
<button class="ax-ghost sm" onclick={() => leaveRoom(rn)}>Leave</button>
|
||
</div>
|
||
</div>
|
||
<div class="roomwrap">
|
||
<div class="roomchat">
|
||
<div class="chat" bind:this={roomEl}>
|
||
<div class="cm sys"><span class="tm">{fmtClock(roomSince[rn] ?? Date.now())}</span><span class="body">You joined #{rn}. History on this device starts here.</span></div>
|
||
{#each activeRoom.messages as m, i (i)}
|
||
<div class="cm">
|
||
<span class="tm">{fmtClock(m.ts)}</span>
|
||
<span class="body">
|
||
<button class="whoin" class:c={isContact(m.username)} class:mine={m.username === me} onclick={() => (community.drawer = m.username)}>{display(m.username)}</button>{m.message}
|
||
</span>
|
||
</div>
|
||
{/each}
|
||
</div>
|
||
<form class="compose" onsubmit={(e) => { e.preventDefault(); void sayCurrent(); }}>
|
||
<div class="qbox grow"><input placeholder="Say something in #{rn}" bind:value={roomText} disabled={!online} /></div>
|
||
<span class="via">public · everyone in the room</span>
|
||
<button class="send" type="submit" disabled={!online || !roomText.trim()} title="Send"><Icon name="send" size={15} /></button>
|
||
</form>
|
||
</div>
|
||
<div class="roster">
|
||
{#if roster.contacts.length}
|
||
<div class="grp">Contacts here <span class="n">{roster.contacts.length}</span></div>
|
||
{#each roster.contacts as u (u.username)}
|
||
<button class="rrow" onclick={() => (community.drawer = u.username)}><Identicon seed={u.username} size={20} presence={presenceFromStatus(u.status)} />{display(u.username)}</button>
|
||
{/each}
|
||
{/if}
|
||
<div class="grp">Everyone <span class="n">{roster.others.length}</span></div>
|
||
{#each roster.others.slice(0, 80) as u (u.username)}
|
||
<button class="rrow" onclick={() => (community.drawer = u.username)}><Identicon seed={u.username} size={20} presence={presenceFromStatus(u.status)} />{u.username}</button>
|
||
{/each}
|
||
{#if roster.others.length > 80}<div class="hint pad">+{roster.others.length - 80} more</div>{/if}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{:else}
|
||
<div class="empty tight">
|
||
<Icon name="hash" size={30} />
|
||
<div class="big">No room open</div>
|
||
<div class="sub">Pick one from the directory. Joined rooms keep their roster and messages here for the session.</div>
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
|
||
<!-- ── PEOPLE ─────────────────────────────────────────────────────── -->
|
||
{:else if community.section === "people"}
|
||
<div class="ptool">
|
||
<div class="qbox slim"><Icon name="search" size={14} /><input placeholder="Find a contact" bind:value={peopleQuery} /></div>
|
||
<button class="fchip" class:on={peopleGroup === "all"} onclick={() => (peopleGroup = "all")}>All</button>
|
||
{#each groups as g (g)}
|
||
<button class="fchip" class:on={peopleGroup === g} onclick={() => (peopleGroup = g)}>{g}</button>
|
||
{/each}
|
||
<button class="fchip" class:on={peopleGroup === "friends"} onclick={() => (peopleGroup = "friends")}>Friends</button>
|
||
<button class="fchip" class:on={peopleGroup === "favorite"} onclick={() => (peopleGroup = "favorite")}>Favorites</button>
|
||
<button class="fchip" class:on={peopleGroup === "trusted"} onclick={() => (peopleGroup = "trusted")}>Trusted</button>
|
||
<span class="sp"></span>
|
||
<form class="addrow" onsubmit={(e) => { e.preventDefault(); const k = addKey.trim(); if (k) { void friendRequestByKey(k); addKey = ""; } }}>
|
||
<div class="qbox slim"><Icon name="link" size={13} /><input placeholder="Friend by SoulStream ID" bind:value={addKey} disabled={!nodeUp || !key} title="Sends a sealed friend request through their encrypted inbox — they must have published one" /></div>
|
||
<button class="ax-ghost sm" type="submit" disabled={!nodeUp || !key || !addKey.trim() || !!community.busy["friend:" + addKey.trim()]}>Ask</button>
|
||
</form>
|
||
<form class="addrow" onsubmit={(e) => { e.preventDefault(); void addContact(); }}>
|
||
<div class="qbox slim"><Icon name="plus" size={13} /><input placeholder="Add by Soulseek username" bind:value={addName} disabled={!online} /></div>
|
||
<button class="ax-ghost sm" type="submit" disabled={!online || !addName.trim() || adding}>Add</button>
|
||
</form>
|
||
</div>
|
||
{#if !slsk.buddies.length}
|
||
<div class="empty tight">
|
||
<Icon name="users" size={30} />
|
||
<div class="big">No contacts yet</div>
|
||
<div class="sub">Add someone by username, or from a room roster and a conversation. Contacts are watched for status while you are signed in.</div>
|
||
</div>
|
||
{:else}
|
||
<div class="list">
|
||
{#snippet personRow(p, seenCol)}
|
||
<div class="prow">
|
||
<button class="avbtn" onclick={() => (community.drawer = p.u)}><Identicon seed={p.u} size={36} presence={p.presence} /></button>
|
||
<div class="ptx">
|
||
<div class="nm">
|
||
<button class="lnkname" onclick={() => (community.drawer = p.u)}>{display(p.u)}</button>
|
||
{#if p.meta.petname}<span class="real">{p.u}</span>{/if}
|
||
{#if p.meta.favorite}<span class="mark fav" title="Favorite"><Icon name="star" size={11} /></span>{/if}
|
||
{#if p.meta.trusted}<span class="mark tr" title="Trusted — a local permission choice, not a verified identity"><Icon name="shield" size={11} /></span>{/if}
|
||
{#if friendshipFor(p.u)?.state === "friends"}<span class="mark fr" title="Friend — a signed, reciprocal SoulStream relationship"><Icon name="link" size={11} /></span>{/if}
|
||
</div>
|
||
<div class="sub">
|
||
<span class="src slsk"><Icon name="globe" size={10} />Soulseek</span>
|
||
{#if linkFor(p.u)}<span class="src ss" title="Verified SoulStream ID {fpShort(linkFor(p.u).persona_key)}"><Icon name="disc" size={10} />SoulStream</span>{/if}
|
||
{#if p.meta.group}<span>·</span><span>{p.meta.group}</span>{/if}
|
||
{#if p.st.file_count != null}<span>·</span><span>{fmtCount(p.st.file_count)} files</span>{/if}
|
||
{#if p.meta.note}<span>·</span><span class="note">{p.meta.note}</span>{/if}
|
||
</div>
|
||
</div>
|
||
<div class="seen">
|
||
{#if p.presence === "online"}<b>online</b>Soulseek, now
|
||
{:else if p.presence === "away"}<b>away</b>Soulseek, now
|
||
{:else if p.st.exists === false}<b>unknown user</b>Soulseek has no such name
|
||
{:else if fmtSeen(community.lastSeen[p.u])}<b>{fmtSeen(community.lastSeen[p.u])}</b>last seen by this device
|
||
{:else}<b>offline</b>not seen by this device yet{/if}
|
||
</div>
|
||
<span class="acts">
|
||
<button class="ibtn" title="Message" onclick={() => messageUser(p.u)}><Icon name="message" size={14} /></button>
|
||
<button class="ibtn" title="View profile" onclick={() => (community.drawer = p.u)}><Icon name="eye" size={14} /></button>
|
||
<button class="ibtn" title="Browse shared library" onclick={() => browseUser(p.u)}><Icon name="folder" size={14} /></button>
|
||
<button class="ibtn danger" title="Remove from contacts" onclick={() => removeBuddy(p.u)}><Icon name="x" size={14} /></button>
|
||
</span>
|
||
</div>
|
||
{/snippet}
|
||
{#if peopleOnline.length}
|
||
<div class="grp">Online <span class="n">{peopleOnline.length}</span><span class="ln"></span></div>
|
||
{#each peopleOnline as p (p.u)}{@render personRow(p, false)}{/each}
|
||
{/if}
|
||
{#if peopleAway.length}
|
||
<div class="grp">Away <span class="n">{peopleAway.length}</span><span class="ln"></span></div>
|
||
{#each peopleAway as p (p.u)}{@render personRow(p, false)}{/each}
|
||
{/if}
|
||
{#if peopleOff.length}
|
||
<div class="grp">Offline or unknown <span class="n">{peopleOff.length}</span><span class="ln"></span></div>
|
||
{#each peopleOff as p (p.u)}{@render personRow(p, true)}{/each}
|
||
{/if}
|
||
</div>
|
||
<div class="hint wide">
|
||
Contacts are yours and stay on this device. "Trusted" is a permission you granted, not a verified identity. Petnames,
|
||
groups and notes live in the profile drawer — click a name.
|
||
</div>
|
||
{/if}
|
||
|
||
<!-- ── DISCOVER ───────────────────────────────────────────────────── -->
|
||
{:else if community.section === "discover"}
|
||
<div class="shelf">
|
||
<div class="ax-head">
|
||
<h2 class="ax-title">Closest to you</h2>
|
||
<span class="prov"><Icon name="sparkle" size={12} /> the shelves below, scored together · every reason named</span>
|
||
</div>
|
||
{#if ranked.length}
|
||
<div class="track">
|
||
{#each ranked as p (p.username)}
|
||
<button class="pcard" onclick={() => (community.drawer = p.username)}>
|
||
<span class="face" style:background="hsl({hueOf(p.username)} 30% 14%)"><Identicon seed={p.username} size={84} presence={presenceOf(p.username)} /></span>
|
||
<span class="nm">{display(p.username)}</span>
|
||
<span class="why">{#each p.reasons as r, i}{#if i}{" · "}{/if}{#each reasonParts(r) as seg}{#if seg.b}<b>{seg.t}</b>{:else}{seg.t}{/if}{/each}{/each}</span>
|
||
</button>
|
||
{/each}
|
||
</div>
|
||
{:else}
|
||
<div class="emptyshelf"><Icon name="sparkle" size={22} /><div><b>Nothing to rank yet.</b> This shelf is the ones below added up — join a room, browse someone, declare a like — with contacts and friends left off.</div></div>
|
||
{/if}
|
||
</div>
|
||
<div class="shelf">
|
||
<div class="ax-head">
|
||
<h2 class="ax-title">{slsk.activeRoom ? `In #${slsk.activeRoom} right now` : "In a room right now"}</h2>
|
||
<span class="prov"><Icon name="hash" size={12} /> from the room roster{#if activeRoom} · {fmtCount(activeRoom.users.length)} here{/if}</span>
|
||
</div>
|
||
{#if roomPeople.length}
|
||
<div class="track">
|
||
{#each roomPeople as u (u.username)}
|
||
<button class="pcard" onclick={() => (community.drawer = u.username)}>
|
||
<span class="face" style:background="hsl({hueOf(u.username)} 30% 14%)"><Identicon seed={u.username} size={84} presence={presenceFromStatus(u.status)} /></span>
|
||
<span class="nm">{u.username}</span>
|
||
<span class="why">in <b>#{slsk.activeRoom}</b> now · not a contact</span>
|
||
</button>
|
||
{/each}
|
||
</div>
|
||
{:else}
|
||
<div class="emptyshelf"><Icon name="hash" size={22} /><div>{activeRoom ? "Everyone in this room is already a contact." : "Join a room and the people in it show up here, with the room as their provenance."}</div>{#if !activeRoom}<button class="ax-ghost sm" onclick={() => setSection("rooms")}>Open Rooms</button>{/if}</div>
|
||
{/if}
|
||
</div>
|
||
<div class="shelf">
|
||
<div class="ax-head">
|
||
<h2 class="ax-title">Recent peers</h2>
|
||
<span class="prov"><Icon name="download" size={12} /> from your transfers this session</span>
|
||
</div>
|
||
{#if recentPeers.length}
|
||
<div class="track">
|
||
{#each recentPeers as p (p.u)}
|
||
<button class="pcard" onclick={() => (community.drawer = p.u)}>
|
||
<span class="face" style:background="hsl({hueOf(p.u)} 30% 14%)"><Identicon seed={p.u} size={84} /></span>
|
||
<span class="nm">{p.u}</span>
|
||
<span class="why">
|
||
{#if p.from}sent you <b>{p.from}</b> {p.from === 1 ? "file" : "files"}{/if}{#if p.from && p.to} · {/if}{#if p.to}you sent them <b>{p.to}</b>{/if}
|
||
</span>
|
||
</button>
|
||
{/each}
|
||
</div>
|
||
{:else}
|
||
<div class="emptyshelf"><Icon name="download" size={22} /><div>Nobody yet. People who send you files, or take files from you, appear here for the session.</div></div>
|
||
{/if}
|
||
</div>
|
||
<div class="shelf">
|
||
<div class="ax-head">
|
||
<h2 class="ax-title">Similar Soulseek interests</h2>
|
||
<span class="prov"><Icon name="heart" size={12} /> the server's match on declared likes · not follows</span>
|
||
</div>
|
||
{#if online}
|
||
<div class="likes">
|
||
{#each community.interests?.likes ?? [] as thing (thing)}
|
||
<span class="chip acc">{thing}<button class="x" title="Stop declaring {thing}" onclick={() => void setInterest(thing, true, false)} disabled={!!community.busy.interest}>×</button></span>
|
||
{/each}
|
||
<form class="inl" onsubmit={(e) => { e.preventDefault(); const t = likeDraft.trim(); if (t) { void setInterest(t, true, true); likeDraft = ""; } }}>
|
||
<input placeholder="Declare a like — an artist, a label, a genre" maxlength="64" bind:value={likeDraft} />
|
||
<button class="ax-ghost sm" type="submit" disabled={!likeDraft.trim() || !!community.busy.interest}>{community.busy.interest ? "Telling the server…" : "Declare"}</button>
|
||
</form>
|
||
</div>
|
||
{/if}
|
||
{#if community.similar?.users?.length}
|
||
<div class="track">
|
||
{#each community.similar.users.slice(0, 24) as su (su.username)}
|
||
<button class="pcard" onclick={() => (community.drawer = su.username)}>
|
||
<span class="face" style:background="hsl({hueOf(su.username)} 30% 14%)"><Identicon seed={su.username} size={84} presence={presenceOf(su.username)} /></span>
|
||
<span class="nm">{su.username}</span>
|
||
<span class="why">shares <b>{su.rating}</b> of your declared likes{#if isContact(su.username)} · a contact{/if}</span>
|
||
</button>
|
||
{/each}
|
||
</div>
|
||
{:else if community.busy.similar}
|
||
<div class="emptyshelf"><span class="bl"></span><div>Asking the server…</div></div>
|
||
{:else}
|
||
<div class="emptyshelf"><Icon name="heart" size={22} /><div>{community.similar?.error ? "The server did not answer." : online ? "The server matches people on the likes you declare on Soulseek — SoulStream has none set for you yet, so this shelf is empty." : "Sign in to Soulseek to ask the server."}</div>{#if online}<button class="ax-ghost sm" onclick={() => loadSimilar(true)} disabled={!!community.busy.similar}>Ask again</button>{/if}</div>
|
||
{/if}
|
||
</div>
|
||
<div class="shelf">
|
||
<div class="ax-head">
|
||
<h2 class="ax-title">Library overlap</h2>
|
||
<span class="prov"><Icon name="library" size={12} /> computed on this device, after an explicit browse</span>
|
||
</div>
|
||
{#if community.overlap.length}
|
||
<div class="track">
|
||
{#each community.overlap.slice(0, 24) as o (o.username)}
|
||
<button class="pcard" onclick={() => (community.drawer = o.username)}>
|
||
<span class="face" style:background="hsl({hueOf(o.username)} 30% 14%)"><Identicon seed={o.username} size={84} presence={presenceOf(o.username)} /></span>
|
||
<span class="nm">{display(o.username)}</span>
|
||
<span class="why">
|
||
{#if o.albums_in_common_count}shares <b>{o.albums_in_common_count}</b> {o.albums_in_common_count === 1 ? "album" : "albums"} you own {#if o.gaps_count}· <b>{o.gaps_count}</b> you lack{/if}
|
||
{:else if o.artists_in_common_count}<b>{o.artists_in_common_count}</b> {o.artists_in_common_count === 1 ? "artist" : "artists"} in common · <b>{o.gaps_count}</b> albums you lack
|
||
{:else if o.their_albums}nothing in common · {fmtCount(o.their_albums)} albums browsed
|
||
{:else}no album folders found{/if}
|
||
</span>
|
||
</button>
|
||
{/each}
|
||
</div>
|
||
<p class="hint pad">Names compared, not files: a folder counts as yours when your library has that album under that artist. Browse someone again to refresh their line.</p>
|
||
{:else}
|
||
<div class="emptyshelf">
|
||
<Icon name="folder" size={22} />
|
||
<div><b>Nothing computed yet.</b> Browse a person's shares and SoulStream compares them with your library locally — nothing is uploaded to do it.</div>
|
||
<button class="ax-ghost sm" onclick={() => setSection("people")}>Browse someone</button>
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
<div class="hint wide">
|
||
There is no global directory here and no "people you may know". Every shelf says where its people came from. Freenet
|
||
discovery indexes are opt-in, curator-run, and currently <b>off</b>.
|
||
</div>
|
||
|
||
<!-- ── SPACES ─────────────────────────────────────────────────────── -->
|
||
{:else if community.section === "spaces"}
|
||
<div class="split" style="grid-template-columns:260px 1fr">
|
||
<div class="col">
|
||
<div class="grp">Your Spaces <span class="n">{community.spaces.length}</span><span class="ln"></span></div>
|
||
{#each community.spaces as sp0 (sp0.id)}
|
||
<button class="lrow" class:on={sv?.id === sp0.id} onclick={() => openSpace(sp0.owner, sp0.space_id)}>
|
||
<span class="spmini">{sp0.name.slice(0, 2).toUpperCase()}</span>
|
||
<span class="tx"><span class="l1"><span class="nm">{sp0.name}</span></span><span class="l2">{sp0.private ? "private" : "public"} · {sp0.my_role} · epoch {sp0.last_epoch}</span></span>
|
||
</button>
|
||
{/each}
|
||
{#if spaceCreating}
|
||
<form class="panel" style="margin-top:6px" onsubmit={(e) => { e.preventDefault(); void makeSpace(); }}>
|
||
<div class="field" style="margin-top:0"><label for="sp-name">Name</label><input id="sp-name" bind:value={spaceName} maxlength="64" placeholder="Late Night Ambient" /></div>
|
||
<label class="swrow" style="margin-top:10px"><button type="button" class="tog" class:on={spacePrivate} role="switch" aria-label="Private" aria-checked={spacePrivate} onclick={() => (spacePrivate = !spacePrivate)}></button><span>{spacePrivate ? "Private — bodies sealed to a key members receive" : "Public — anyone who finds it can read"}</span></label>
|
||
<div class="btns"><button class="ax-fill sm" type="submit" disabled={!spaceName.trim() || !nodeUp || !key || !!community.busy["space:create"]}>{community.busy["space:create"] ? "Creating…" : "Create"}</button><button class="ax-ghost sm" type="button" onclick={() => (spaceCreating = false)}>Cancel</button></div>
|
||
</form>
|
||
{:else}
|
||
<button class="lrow" style="color:var(--text-secondary)" onclick={() => (spaceCreating = true)} disabled={!nodeUp || !key}>
|
||
<span class="av" style="border:1px dashed rgba(255,255,255,0.25);display:grid;place-items:center;width:40px;height:40px;border-radius:50%">{@html ""}<Icon name="plus" size={16} /></span>
|
||
<span class="tx"><span class="l1"><span class="nm">Create a Space</span></span><span class="l2">{key ? "on Freenet, under your ID" : "needs a SoulStream identity"}</span></span>
|
||
</button>
|
||
{/if}
|
||
<div class="panel" style="margin-top:14px"><h3>What a Space is</h3><p class="hint">A persistent SoulStream community on Freenet. The owner's signed checkpoint is the roster; a removal is a new epoch, and in a private Space every removal rotates the key. Not a Soulseek Room, and never mirrored to one. Invitations travel sealed through the encrypted inbox — the person you invite needs one. A member joins under a key minted for this Space alone, so its roster says nothing about who they are anywhere else; only the owner can put a name to one.</p></div>
|
||
</div>
|
||
{#if sv}
|
||
<div>
|
||
<div class="sphead">
|
||
<div class="spico">{sv.name.slice(0, 2).toUpperCase()}</div>
|
||
<div>
|
||
<div class="nm">{sv.name} <span class="src space"><Icon name="sparkle" size={10} />Space</span></div>
|
||
<div class="sub">
|
||
<span>{#if sv.private}<Icon name="lock" size={12} /> private · sealed{:else}public{/if}</span><span>·</span>
|
||
<span>{sv.members.length} {sv.members.length === 1 ? "member" : "members"}</span><span>·</span>
|
||
<span>epoch {sv.epoch}</span><span>·</span>
|
||
<span>not a Soulseek Room</span>
|
||
{#if !sv.reachable}<span>·</span><span class="chip warn">not on this node yet</span>{:else}<span>·</span><span class="chip ok"><Icon name="check" size={10} />read from the network</span>{/if}
|
||
</div>
|
||
</div>
|
||
<div class="r" style="margin-left:auto;display:flex;gap:6px">
|
||
<button class="ax-ghost sm" onclick={() => openSpace(sv.owner, sv.space_id)} disabled={!!community.busy["space:" + sv.space_id]}><Icon name="refresh" size={12} /></button>
|
||
{#if !spaceOwner}<button class="ax-ghost sm" onclick={() => leaveSpace(sv.owner, sv.space_id)}>Leave</button>{/if}
|
||
</div>
|
||
</div>
|
||
<div class="sptabs">
|
||
<button class="subtab" class:on={spaceTab === "chat"} onclick={() => (spaceTab = "chat")}>Chat<b>{sv.messages.length}</b></button>
|
||
<button class="subtab" class:on={spaceTab === "listening"} onclick={() => (spaceTab = "listening")}>Listening{#if sv.listens.length}<b>{sv.listens.length}</b>{/if}</button>
|
||
<button class="subtab" class:on={spaceTab === "holdings"} onclick={() => (spaceTab = "holdings")}>Holdings{#if hv?.sharing}<b>{hv.sharing}</b>{/if}</button>
|
||
<button class="subtab" class:on={spaceTab === "members"} onclick={() => (spaceTab = "members")}>Members<b>{sv.members.length}</b></button>
|
||
<button class="subtab" class:on={spaceTab === "about"} onclick={() => (spaceTab = "about")}>About</button>
|
||
<button class="subtab" class:on={spaceTab === "proposals"} onclick={() => (spaceTab = "proposals")}>Proposals{#if bv && bv.uuid === sv.pinned_uuid && bv.proposals.length}<b>{bv.proposals.length}</b>{/if}</button>
|
||
</div>
|
||
{#if community.spaceError}<p class="hint warn">{community.spaceError}</p>{/if}
|
||
{#if spaceTab === "chat"}
|
||
{#if sv.pinned_uuid}
|
||
<div class="panel" style="margin-bottom:12px"><h3><Icon name="pin" size={13} /> Pinned playlist {#if pinnedPreview && !pinnedPreview.pending && !pinnedPreview.error}<span class="chip acc"><Icon name="disc" size={10} />revision {pinnedPreview.revision_seq}</span>{/if}</h3>
|
||
<div class="pinned" style="margin-top:8px">
|
||
<span class="mosaic-ph"><Icon name="playlist" size={22} /></span>
|
||
<div>
|
||
<div class="t">{pinnedPreview?.name ?? "Reading…"}</div>
|
||
<div class="s">{#if pinnedPreview?.entries}{pinnedPreview.entries.length} tracks · signed by {fpShort(sv.pinned_owner)}{:else if pinnedPreview?.error}{pinnedPreview.error}{:else if pinnedPreview?.withdrawn}withdrawn by its owner{:else}fetching through your node{/if}</div>
|
||
<div class="btns"><button class="ax-fill sm" onclick={importPinned} disabled={!pinnedPreview?.entries?.length || !!community.busy["import:pin:" + sv.pinned_uuid]}><Icon name="download" size={12} /> Import</button>{#if spaceOwner}<button class="ax-ghost sm" onclick={() => pinToSpace(sv.owner, sv.space_id, null, null)}>Unpin</button>{/if}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{:else if spaceOwner && community.published.some((p) => !p.withdrawn)}
|
||
<div class="noderow" style="margin:0 0 12px"><span class="hint">Pin one of your public playlists:</span>{#each community.published.filter((p) => !p.withdrawn) as p (p.playlist_id)}<button class="fchip" onclick={() => pinToSpace(sv.owner, sv.space_id, key, p.uuid)}>{p.name}</button>{/each}</div>
|
||
{/if}
|
||
<div class="thread" style="height:auto;min-height:360px">
|
||
<div class="chat" bind:this={spaceEl} style="max-height:420px">
|
||
{#if !sv.messages.length}<div class="cm sys"><span class="tm"></span><span class="body">Nothing said yet. The last {sv.message_cap} lines are kept; older ones fall away for everyone.</span></div>{/if}
|
||
{#each sv.messages as m (m.id)}
|
||
<div class="cm"><span class="tm">{fmtClock(m.created_at * 1000)}</span><span class="body"><span class="whoin" class:mine={m.is_me} class:c={!m.is_me && !!m.username}>{whoIs(m)}</span>{#if m.text != null}{m.text}{:else}<span class="muted"><Icon name="lock" size={10} /> sealed to a key this device does not hold (epoch {m.epoch})</span>{/if}</span></div>
|
||
{/each}
|
||
</div>
|
||
<form class="compose" onsubmit={(e) => { e.preventDefault(); void sayInSpace(); }}>
|
||
<div class="qbox grow"><input placeholder={sv.can_post ? `Say something in ${sv.name}` : sv.has_key ? "You are not on the roster" : "No key for this epoch yet"} bind:value={spaceText} disabled={!sv.can_post || !nodeUp} /></div>
|
||
<span class="via">{sv.private ? "sealed · members only" : "public · signed"} · last {sv.message_cap} kept</span>
|
||
<button class="send" type="submit" disabled={!sv.can_post || !nodeUp || !spaceText.trim() || !!community.busy["space:post"]} title="Post"><Icon name="send" size={15} /></button>
|
||
</form>
|
||
</div>
|
||
{:else if spaceTab === "listening"}
|
||
<div class="panel sharebar" style="margin-bottom:12px">
|
||
<button class="toggle" class:on={sv.share_listens} aria-pressed={sv.share_listens} title="Share what I play here"
|
||
onclick={() => setShareListens(sv.owner, sv.space_id, !sv.share_listens)} disabled={!nodeUp || !!community.busy["space:listens"]}><i></i></button>
|
||
<div class="grow">
|
||
<div class="ttl">Share what I play in this Space</div>
|
||
<p class="hint">Per Space, off until you say so. A track is posted once it has actually played, never on a skip{#if sv.private}, and sealed to this room's epoch key — the same key its messages use{/if}. Leaving takes it with you.</p>
|
||
</div>
|
||
{#if sv.share_listens}<span class="chip acc">sharing</span>{:else}<span class="chip off">not sharing</span>{/if}
|
||
</div>
|
||
{#if nowPlaying.length}
|
||
<div class="panel" style="margin-bottom:12px">
|
||
<h3><Icon name="play" size={13} /> Right now{#if nowPlaying.some((n) => n.live)}<span class="chip ok">{nowPlaying.filter((n) => n.live).length} playing</span>{/if}</h3>
|
||
<div class="nowband">
|
||
{#each nowPlaying as n (n.author)}
|
||
<div class="nowcard" class:quiet={!n.live}>
|
||
<Identicon seed={n.author} size={40} presence={n.live ? "online" : null} />
|
||
<div class="body">
|
||
<div class="who">{n.is_me ? "you" : n.username ? display(n.username) : fpShort(n.author)}
|
||
{#if n.live}<span class="bars"><i></i><i></i><i></i></span>{:else}<span class="muted">{ago(n.created_at)}</span>{/if}</div>
|
||
<div class="t">{n.title}</div>
|
||
<div class="a">{n.artist}</div>
|
||
</div>
|
||
</div>
|
||
{/each}
|
||
</div>
|
||
<p class="hint" style="margin-top:10px">A card goes quiet after {LISTEN_LIVE_MINS} minutes with nothing new — nobody is reported as listening because the app forgot to say they stopped.</p>
|
||
</div>
|
||
{/if}
|
||
<div class="panel">
|
||
<h3><Icon name="disc" size={13} /> The trail<span class="chip off">last {sv.message_cap} kept, with the chat</span></h3>
|
||
{#if !sv.listens.length}
|
||
<p class="hint" style="padding:18px 0 8px">Nothing played here yet. Turn the switch above on and the next track you finish appears — for everyone in the room, and nobody outside it.</p>
|
||
{:else}
|
||
<div class="lf">
|
||
{#each trail as l (l.id)}
|
||
{#if l.day}<div class="daysep"><span>{l.day}</span><span class="rule"></span></div>{/if}
|
||
<div class="lr" class:self={l.is_me}>
|
||
<span class="tm">{fmtClock(l.created_at * 1000)}</span>
|
||
<span class="art" style="background:{artWash(l.album || l.artist || l.title)}">{(l.album || l.artist || l.title).slice(0, 2).toUpperCase()}</span>
|
||
<span class="meta"><span class="t">{l.title}</span><span class="a">{l.artist}</span></span>
|
||
<span class="who" class:mine={l.is_me}>
|
||
{#if l.run}<span class="runpad"></span>{:else}<Identicon seed={l.author} size={22} />{/if}
|
||
{#if !l.run}{l.is_me ? "you" : l.username ? display(l.username) : fpShort(l.author)}{/if}
|
||
<span class="acts"><button class="ico" title="Find it on Soulseek" onclick={() => findListen(l)}><Icon name="search" size={12} /></button></span>
|
||
</span>
|
||
</div>
|
||
{/each}
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
{:else if spaceTab === "holdings"}
|
||
<div class="panel sharebar" style="margin-bottom:12px">
|
||
<button class="toggle" class:on={sv.share_holdings} aria-pressed={sv.share_holdings} title="Share what I hold in this Space"
|
||
onclick={() => setShareHoldings(sv.owner, sv.space_id, !sv.share_holdings)} disabled={!nodeUp || !sv.can_post || !!community.busy["holdings:share"]}><i></i></button>
|
||
<div class="grow">
|
||
<div class="ttl">Share what I hold in this Space</div>
|
||
<p class="hint">{#if sv.share_holdings && hv?.mine && !hv.mine.withdrawn}<b>{fmtCount(hv.mine.count)} albums</b> · published{#if sv.private} sealed to this room's epoch key{/if} · updated when your library changes · <b>synced as of {fmtClock(hv.mine.published_at * 1000)}</b>{:else}Album names as keys, one quality word each, and your Soulseek name so members can get it from you — never files. Per Space, off until you say so.{/if}</p>
|
||
</div>
|
||
{#if community.busy["holdings:share"]}<span class="chip off">working…</span>{:else if sv.share_holdings}<span class="chip acc">sharing</span>{:else}<span class="chip off">not sharing</span>{/if}
|
||
</div>
|
||
<div class="disc"><Icon name="alert" size={14} /><span><b>What the room learns:</b> members can tell whether you hold any album they can name, and roughly how many you hold. They never see files, paths, or formats beyond a lossless/lossy word. Turning this off withdraws the list; copies members already read stay with them.</span></div>
|
||
<div class="panel" style="margin-top:12px">
|
||
<h3><Icon name="search" size={13} /> Ask this room{#if hv}<span class="chip up">{hv.sharing} of {sv.members.length} sharing</span>{/if}</h3>
|
||
<form class="inl askinl" style="margin-top:8px" onsubmit={(e) => { e.preventDefault(); if (askArtist.trim() && askAlbum.trim()) asked = { artist: askArtist.trim(), album: askAlbum.trim() }; }}>
|
||
<input placeholder="Artist" bind:value={askArtist} />
|
||
<input placeholder="Album" bind:value={askAlbum} />
|
||
<button class="ax-ghost sm" type="submit" disabled={!askArtist.trim() || !askAlbum.trim()}>Ask</button>
|
||
</form>
|
||
{#if asked}{#key asked.artist + "\u0000" + asked.album}<div style="margin-top:8px"><WhoHas artist={asked.artist} album={asked.album} /></div>{/key}{/if}
|
||
</div>
|
||
<div class="panel" style="margin-top:12px">
|
||
<h3><Icon name="users" size={13} /> Who shares here <button class="ax-ghost sm" style="margin-left:auto" onclick={() => refreshHoldings(sv.owner, sv.space_id, true)} disabled={!nodeUp}><Icon name="refresh" size={12} /> Refresh</button></h3>
|
||
{#if hv}
|
||
<div class="hlist">
|
||
<div class="hrow">
|
||
<Identicon seed={key ?? "you"} size={30} />
|
||
<span class="tx"><span class="l1">you</span>
|
||
<span class="l2">{#if sv.share_holdings && hv.mine && !hv.mine.withdrawn}<b>{fmtCount(hv.mine.count)}</b> albums · list {hv.mine.sequence} · published {fmtClock(hv.mine.published_at * 1000)}{:else}not sharing{/if}</span></span>
|
||
<span class="chip" class:acc={sv.share_holdings && hv.mine && !hv.mine.withdrawn} class:off={!(sv.share_holdings && hv.mine && !hv.mine.withdrawn)}>{sv.share_holdings && hv.mine && !hv.mine.withdrawn ? "shared" : "none"}</span>
|
||
</div>
|
||
{#each hv.members.filter((m) => !m.is_me) as m (m.key)}
|
||
<div class="hrow">
|
||
<Identicon seed={m.username ?? m.key} size={30} />
|
||
<span class="tx"><span class="l1">{m.is_me ? "you" : m.username ? display(m.username) : fpShort(m.key)}</span>
|
||
<span class="l2">{#if m.state === "shared"}<b>{fmtCount(m.count)}</b> albums · list {m.sequence}{#if m.read_at} · read {fmtClock(m.read_at * 1000)}{/if}{:else if m.state === "none"}not sharing{:else if m.state === "withdrawn"}withdrew their list{:else if m.state === "sealed"}sealed to an epoch you have no key for{:else}not read yet{/if}</span></span>
|
||
<span class="chip" class:acc={m.state === "shared"} class:off={m.state !== "shared"}>{m.state}</span>
|
||
</div>
|
||
{/each}
|
||
</div>
|
||
{:else}
|
||
<p class="hint" style="margin-top:8px">Reading the room's lists…</p>
|
||
{/if}
|
||
<p class="hint" style="margin-top:8px">A member's list is read when the Space opens and again after ten minutes; a member who never published costs a slow search, so they are asked again only hourly.</p>
|
||
</div>
|
||
{:else if spaceTab === "members"}
|
||
<div class="panel">
|
||
<div class="members">
|
||
{#each sv.members as m (m.key)}
|
||
<div class="mem">
|
||
<button class="avbtn" onclick={() => m.username && (community.drawer = m.username)}><Identicon seed={m.key} size={56} /></button>
|
||
<span class="nm">{m.is_me ? "you" : m.username ? display(m.username) : fpShort(m.key)}</span>
|
||
<span class="role" class:own={m.role === "owner"}>{m.role}{#if m.since_epoch > 1} · since {m.since_epoch}{/if}</span>
|
||
{#if spaceOwner && !m.is_me}<button class="lnk" onclick={() => removeFromSpace(sv.owner, sv.space_id, m.key)} disabled={!!community.busy["space:remove"]}>remove</button>{/if}
|
||
</div>
|
||
{/each}
|
||
</div>
|
||
{#if spaceOwner}
|
||
<form class="inl" style="margin-top:14px" onsubmit={(e) => { e.preventDefault(); const k = spaceInviteKey.trim(); if (k) { void inviteToSpace(sv.owner, sv.space_id, k); spaceInviteKey = ""; } }}>
|
||
<input placeholder="Invite by SoulStream ID (they need an encrypted inbox)" bind:value={spaceInviteKey} />
|
||
<button class="ax-fill sm" type="submit" disabled={!spaceInviteKey.trim() || !nodeUp || !!community.busy["space:invite"]}>{community.busy["space:invite"] ? "Inviting…" : "Invite"}</button>
|
||
</form>
|
||
<p class="hint" style="margin-top:8px">They appear in Members once they answer with the key they will wear here — an invitation on its own puts nobody on the roster.</p>
|
||
{#if community.friends.some((f) => f.state === "friends")}<p class="hint" style="margin-top:8px">Friends: {#each community.friends.filter((f) => f.state === "friends") as f (f.id)}<button class="lnk" onclick={() => (spaceInviteKey = f.persona_key)}>{f.username || fpShort(f.persona_key)}</button> {/each}</p>{/if}
|
||
{/if}
|
||
<p class="hint" style="margin-top:12px">Members are their SoulStream identities here — a joined Space is a linked identity. Removing someone is a new epoch{#if sv.private}, and in this private Space it also rotates the key: those who stay receive the new one sealed{/if}.</p>
|
||
</div>
|
||
{:else if spaceTab === "proposals"}
|
||
{#if !sv.pinned_uuid}
|
||
<div class="panel">
|
||
<h3><Icon name="list-plus" size={13} /> Proposals</h3>
|
||
<p class="hint">A board lives beside a pinned playlist: members propose adds, removes and moves against the exact revision they see, and the playlist's owner decides. {#if spaceOwner}Pin one of your public playlists from the Chat tab and the board opens here.{:else}The owner has not pinned a playlist yet.{/if}</p>
|
||
</div>
|
||
{:else if !bv && community.boardError}
|
||
<div class="panel">
|
||
<h3><Icon name="list-plus" size={13} /> Proposals</h3>
|
||
<p class="hint warn">{community.boardError}</p>
|
||
<div class="bbtns"><button class="ax-ghost sm" onclick={() => openBoard(sv.owner, sv.space_id)} disabled={boardBusy}><Icon name="refresh" size={12} /> Try again</button></div>
|
||
</div>
|
||
{:else if !bv}
|
||
<div class="panel"><p class="hint"><span class="bl"></span> Reading the board through your node…</p></div>
|
||
{:else}
|
||
<div class="panel">
|
||
<h3><Icon name="playlist" size={13} /> {bv.name || "Pinned playlist"}{#if bv.withdrawn}<span class="chip warn">withdrawn by its owner</span>{:else}<span class="chip acc"><Icon name="disc" size={10} />revision {bv.revision_seq}</span>{/if}</h3>
|
||
<p class="hint">Proposals bind to revision {bv.revision_seq} exactly · {bv.i_am_owner ? "you decide" : "the playlist's owner decides"} · every decision is signed · {bv.contributors.length} {bv.contributors.length === 1 ? "contributor" : "contributors"} on epoch {bv.epoch}{#if !bv.reachable} · <span class="chip warn">the owner has not opened the board yet</span>{/if}</p>
|
||
<div class="bbtns">
|
||
{#if bv.can_propose && !boardDraft}<button class="ax-fill sm" onclick={startDraft} disabled={!nodeUp || boardBusy}><Icon name="list-plus" size={12} /> Propose changes</button>{/if}
|
||
{#if !bv.can_propose && !bv.i_am_owner && bv.reachable && !bv.withdrawn}<span class="hint">You are not on this board's roster yet — the owner refreshes it from the members when they open the board.</span>{/if}
|
||
<button class="ax-ghost sm" onclick={() => openBoard(sv.owner, sv.space_id)} disabled={boardBusy}><Icon name="refresh" size={12} /> {boardBusy ? "Working…" : "Refresh"}</button>
|
||
</div>
|
||
{#if community.boardError}<p class="hint warn">{community.boardError}</p>{/if}
|
||
</div>
|
||
{#if boardDraft}
|
||
<div class="panel">
|
||
<h3><Icon name="list-plus" size={13} /> Your proposal <span class="chip acc">{boardDraft.ops.length} / 32 changes</span></h3>
|
||
<p class="hint">Move rows, take some out, add from your library. The owner receives titles, artists and albums — never a file, never a path. Nothing here plays or downloads.</p>
|
||
<ol class="dlist">
|
||
{#each boardDraft.list as e, i (e.entry_id)}
|
||
<li class:fresh={e.fresh}>
|
||
<span class="n">{i + 1}</span>
|
||
<span class="tx"><span class="t">{e.title}</span><span class="a">{e.artist}{#if e.fresh} · added{/if}</span></span>
|
||
<span class="b">
|
||
<button class="ibtn sm" title="Move up" disabled={i === 0} onclick={() => draftApply({ kind: "move", entry_id: e.entry_id, to_position: i - 1 })}><Icon name="chevron-up" size={12} /></button>
|
||
<button class="ibtn sm" title="Move down" disabled={i === boardDraft.list.length - 1} onclick={() => draftApply({ kind: "move", entry_id: e.entry_id, to_position: i + 1 })}><Icon name="chevron-down" size={12} /></button>
|
||
<button class="ibtn sm" title="Remove" onclick={() => draftApply({ kind: "remove", entry_id: e.entry_id })}><Icon name="x" size={12} /></button>
|
||
</span>
|
||
</li>
|
||
{/each}
|
||
{#if !boardDraft.list.length}<li class="none">Empty — add something below.</li>{/if}
|
||
</ol>
|
||
<div class="qbox slim"><Icon name="search" size={13} /><input placeholder="Add from your library — title or artist" bind:value={boardQuery} oninput={searchLibrary} /></div>
|
||
{#if boardHits.length}
|
||
<div class="hits">{#each boardHits.slice(0, 8) as h (h.path)}<button class="hit" onclick={() => draftAdd(h)}><Icon name="plus" size={11} /><span class="t">{h.title}</span><span class="a">{h.artist}{h.album ? ` · ${h.album}` : ""}</span></button>{/each}</div>
|
||
{/if}
|
||
<div class="field"><input placeholder="A note for the owner — optional" bind:value={boardDraft.note} maxlength="280" /></div>
|
||
<div class="bbtns">
|
||
<button class="ax-fill sm" onclick={sendDraft} disabled={!boardDraft.ops.length || !nodeUp || boardBusy}><Icon name="send" size={12} /> {community.busy["board:propose"] ? "Signing & offering…" : "Send proposal"}</button>
|
||
<button class="ax-ghost sm" onclick={draftUndo} disabled={!boardDraft.ops.length}>Undo</button>
|
||
<button class="ax-ghost sm" onclick={() => (boardDraft = null)}>Discard</button>
|
||
</div>
|
||
</div>
|
||
{/if}
|
||
<div class="panel">
|
||
<h3>Pending <span class="chip off">{bv.proposals.length}</span></h3>
|
||
{#if !bv.proposals.length}<p class="hint">Nothing pending. {#if bv.can_propose}Propose an add, a remove or a move against the current revision — up to 32 changes each, 8 pending per person.{/if}</p>{/if}
|
||
{#each bv.proposals as p (p.id)}
|
||
<div class="prop" class:stale={p.stale}>
|
||
<div class="ph">
|
||
<Identicon seed={p.author} size={30} />
|
||
<div class="who"><span class="nm">{whoProposed(p)}</span><span class="hint">{fmtWhen(p.created_at * 1000)} · {p.ops.length} {p.ops.length === 1 ? "change" : "changes"}</span></div>
|
||
{#if p.stale}<span class="chip warn">stale · an older revision</span>{:else}<span class="chip ok"><Icon name="check" size={10} />current revision</span>{/if}
|
||
</div>
|
||
{#if p.note}<p class="note">{p.note}</p>{/if}
|
||
<ul class="ops">
|
||
{#each p.ops as op, i (i)}
|
||
<li class={op.kind}>
|
||
<b>{op.kind === "add" ? "+" : op.kind === "remove" ? "−" : "↕"}</b>
|
||
<span>
|
||
{#if op.kind === "add"}add <i>{op.title}</i> — {op.artist} at #{op.position + 1}
|
||
{:else if op.kind === "remove"}remove {#if op.known}<i>{op.title}</i> — {op.artist}{:else}an entry from an older revision{/if}
|
||
{:else}move {#if op.known}<i>{op.title}</i>{:else}an entry from an older revision{/if} to #{op.position + 1}{/if}
|
||
</span>
|
||
</li>
|
||
{/each}
|
||
</ul>
|
||
{#if bv.i_am_owner}
|
||
<div class="decide">
|
||
{#if !p.stale}<button class="ax-fill sm" onclick={() => decideOn(p, true)} disabled={!nodeUp || boardBusy}><Icon name="check" size={12} /> {community.busy["board:decide"] ? "Working…" : "Accept & publish"}</button>{/if}
|
||
<input placeholder={p.stale ? "Reason — optional; it is marked stale either way" : "Reason — optional"} bind:value={boardReason[p.id]} maxlength="128" />
|
||
<button class="ax-ghost sm" onclick={() => decideOn(p, false)} disabled={!nodeUp || boardBusy}>{p.stale ? "Reject as stale" : "Reject"}</button>
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
{/each}
|
||
</div>
|
||
<div class="panel">
|
||
<h3>Decisions <span class="chip off">{bv.decisions.length}</span></h3>
|
||
{#if !bv.decisions.length}<p class="hint">None yet. Each decision is owner-signed with a sequence number; the last 256 are kept.</p>{/if}
|
||
{#each bv.decisions as d (d.seq)}
|
||
<div class="dec"><span class="chip {d.accept ? 'ok' : 'off'}">#{d.seq} · {d.accept ? "accepted" : "rejected"}</span><span class="rs">{d.reason}</span>{#if d.result_hash}<span class="mono hint" title={d.result_hash}>→ revision {fpShort(d.result_hash)}</span>{/if}</div>
|
||
{/each}
|
||
</div>
|
||
{/if}
|
||
{:else}
|
||
<div class="panel">
|
||
<h3>About</h3>
|
||
<dl class="kv"><dt>Owner</dt><dd class="mono">{sv.owner}</dd><dt>Space id</dt><dd class="mono">{sv.space_id}</dd><dt>Mode</dt><dd>{sv.private ? "private — message bodies sealed to a per-epoch key" : "public — anyone who resolves the contract can read"}</dd><dt>Window</dt><dd>the last {sv.message_cap} messages</dd><dt>Epoch</dt><dd>{sv.epoch}</dd></dl>
|
||
<p class="hint" style="margin-top:10px">What the network can see: that the Space exists, its owner, its roster keys, and — for a private Space — how many sealed lines it holds. Leaving does not erase copies others hold.</p>
|
||
{#if sv.description}<p class="desc">{sv.description}</p>{/if}
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
{:else}
|
||
<div class="empty tight">
|
||
<span class="spico"><Icon name="sparkle" size={26} /></span>
|
||
<div class="big">{community.spaces.length ? "Pick a Space" : "No Spaces yet"}</div>
|
||
<div class="sub">A Space is a persistent SoulStream community on Freenet with its own roster — invitation only, sealed when private, never a Soulseek Room in disguise.</div>
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
|
||
<!-- ── YOU ────────────────────────────────────────────────────────── -->
|
||
{:else if community.section === "you"}
|
||
<!-- Three panel columns on a wide pane, two on a normal one, one when narrow
|
||
(container queries below): the section USES the width instead of leaving
|
||
it empty (Kyle, full screen, 2026-09-02). -->
|
||
<div class="yougrid" class:setup={!key}>
|
||
<div class="yc a">
|
||
{#if key}
|
||
<div class="panel">
|
||
<h3>Your public profile <span class="src ss"><Icon name="disc" size={10} />SoulStream</span></h3>
|
||
<p class="hint">
|
||
Anyone who has your SoulStream ID can fetch this. It is signed by your persona key and replicated —
|
||
publishing is not something that can be fully undone.
|
||
</p>
|
||
<div class="avset">
|
||
<Identicon seed={key} size={88} photo={avatarUrl} />
|
||
<div class="opts">
|
||
<button class="opt" class:on={!avatarUrl} onclick={() => avatarUrl && clearAvatar()} disabled={!!community.busy.avatar || !nodeUp}>
|
||
<span class="rad"></span><span>Identicon<span class="hint">cut from your key · nothing uploaded</span></span>
|
||
</button>
|
||
<button class="opt" class:on={!!avatarUrl} onclick={() => pickAndSetAvatar()} disabled={!!community.busy.avatar || !nodeUp}>
|
||
<span class="rad"></span><span>A photo<span class="hint">square crop · 256 px · every trace of metadata removed · under 96 KB</span></span>
|
||
</button>
|
||
{#if avatarUrl}<button class="ax-ghost sm" onclick={() => pickAndSetAvatar()} disabled={!!community.busy.avatar || !nodeUp}><Icon name="image" size={12} /> Replace photo</button>{/if}
|
||
</div>
|
||
</div>
|
||
{#if community.busy.avatar}
|
||
<p class="hint"><span class="bl"></span> Preparing your photo and publishing it — a few seconds normally; the first publication can take longer.</p>
|
||
{:else if profile?.avatar_note && profile.avatar_note !== "none published"}<p class="hint warn">{profile.avatar_note}</p>{/if}
|
||
<div class="field">
|
||
<label for="cm-name">Display name <span class:over={nameBytes > 64}>{nameBytes} / 64</span></label>
|
||
<input id="cm-name" bind:value={nameDraft} maxlength="64" placeholder="How you want to be shown" />
|
||
</div>
|
||
<div class="field">
|
||
<label for="cm-bio">Bio <span class:over={bioBytes > 280}>{bioBytes} / 280</span></label>
|
||
<textarea id="cm-bio" rows="3" bind:value={bioDraft} maxlength="280" placeholder="A line or two — optional"></textarea>
|
||
<p class="hint">Display names are not unique. People you meet may give you a private petname; you cannot see it.</p>
|
||
</div>
|
||
<div class="pub">
|
||
<button class="ax-fill" disabled={!canPublish} onclick={() => publishProfile(nameDraft, bioDraft)}>
|
||
<Icon name="export" size={13} /> {community.busy.publish ? "Publishing…" : profile ? "Publish changes" : "Publish profile"}
|
||
</button>
|
||
{#if community.busy.publish}
|
||
<span class="chip acc">pending publication</span>
|
||
{:else if community.busy.profile}
|
||
<span class="chip acc"><span class="bl"></span>reading from the network — up to a minute</span>
|
||
{:else if profile}
|
||
<span class="chip ok"><Icon name="check" size={10} />{profile.from_ledger ? "published from this device" : `synced as of ${fmtClock(community.profileAt)}`} · revision {profile.sequence}</span>
|
||
{:else}
|
||
<span class="chip off">nothing published yet</span>
|
||
{/if}
|
||
{#if community.profileError}<span class="chip err">{community.profileError}</span>{/if}
|
||
<button class="ibtn sm" title="Re-read from the network (slow when nothing is published)" onclick={() => loadProfile(true)} disabled={!nodeUp || !!community.busy.profile}><Icon name="refresh" size={12} /></button>
|
||
</div>
|
||
</div>
|
||
{:else}
|
||
<div class="panel">
|
||
<h3>Your SoulStream identity <span class="src ss"><Icon name="disc" size={10} />SoulStream</span></h3>
|
||
{#if !freenetOn}
|
||
<p class="hint">
|
||
A SoulStream identity is a key pair held by your local Freenet node. Turn the Freenet layer on
|
||
(right) and the node will hold it; nothing is published until you say so.
|
||
</p>
|
||
{:else if !nodeUp}
|
||
<p class="hint">Waiting for the node — <span class="chip {status.tone}">{status.text}</span>. Identity needs a connected node.</p>
|
||
{:else}
|
||
<p class="hint">
|
||
Create a fresh key here, or bring one back from a recovery file. Creating one publishes nothing;
|
||
your profile is a separate button, later.
|
||
</p>
|
||
<div class="btns">
|
||
<button class="ax-fill" onclick={() => createPersona()} disabled={!!community.busy.persona}><Icon name="sparkle" size={13} /> {community.busy.persona ? "Creating…" : "Create identity"}</button>
|
||
</div>
|
||
<div class="field">
|
||
<label for="cm-imp">Or import a recovery file</label>
|
||
<div class="inl">
|
||
<input id="cm-imp" type="password" placeholder="Its passphrase" bind:value={passImport} />
|
||
<button class="ax-ghost sm" onclick={() => doImport()} disabled={passImport.length < 1 || !!community.busy.import}>{community.busy.import ? "Importing…" : "Choose file & import"}</button>
|
||
</div>
|
||
</div>
|
||
{/if}
|
||
{#if community.error}<p class="hint warn">{community.error}</p>{/if}
|
||
</div>
|
||
{/if}
|
||
</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">
|
||
The fingerprint of your persona key. It is the stable you — names change, this does not. Share it as
|
||
text; nothing else identifies you across networks.
|
||
</p>
|
||
<div class="fpblock">
|
||
<div class="kbox mono">{#each fpGroups(key) as g, i (i)}{#if i}<i>·</i>{/if}<span>{g}</span>{/each}</div>
|
||
<button class="ax-ghost sm" onclick={copyKey}><Icon name={copied ? "check" : "copy"} size={12} /> {copied ? "Copied" : "Copy"}</button>
|
||
</div>
|
||
<dl class="kv">
|
||
<dt>Key version</dt><dd>{community.persona?.persona?.key_version ?? 1}{#if (community.persona?.persona?.key_version ?? 1) > 1} · rotated{/if}</dd>
|
||
<dt>Signs</dt><dd>profile today; playlists, friend links, Soulseek link and Space links as those land</dd>
|
||
<dt>Delegate</dt><dd class="mono">{fpShort(community.persona?.delegate_key)}</dd>
|
||
</dl>
|
||
</div>
|
||
<div class="panel">
|
||
<h3><Icon name="shield" size={13} /> Recovery</h3>
|
||
<p class="hint">
|
||
Your identity lives in the node's key custody on this machine. Export a recovery file — encrypted
|
||
with a passphrase you choose — and keep it somewhere that is not this disk.
|
||
</p>
|
||
<div class="field">
|
||
<label for="cm-exp">Export</label>
|
||
<div class="inl">
|
||
<input id="cm-exp" type="password" placeholder="A passphrase for the file (8+ characters)" bind:value={passExport} />
|
||
<button class="ax-fill sm" onclick={() => doExport()} disabled={passExport.length < 8 || !!community.busy.export || !nodeUp}><Icon name="download" size={12} /> {community.busy.export ? "Exporting…" : "Export"}</button>
|
||
</div>
|
||
{#if exportDone}<p class="hint ok">Written: {exportDone.path} ({exportDone.bytes} bytes). Test it on another machine before you need it.</p>{/if}
|
||
</div>
|
||
<div class="field">
|
||
<label for="cm-imp2">Import <span>replaces the identity on this node</span></label>
|
||
<div class="inl">
|
||
<input id="cm-imp2" type="password" placeholder="The file's passphrase" bind:value={passImport} />
|
||
<button class="ax-ghost sm" onclick={() => doImport()} disabled={passImport.length < 1 || !!community.busy.import || !nodeUp}>{community.busy.import ? "Importing…" : "Choose file & import"}</button>
|
||
</div>
|
||
<label class="chk"><input type="checkbox" bind:checked={importForce} /> replace the identity that is already here</label>
|
||
</div>
|
||
<div class="danger">
|
||
{#if confirmRotate}
|
||
<span class="hint warn">Rotating signs a transition with the old key; friends re-verify on their next fetch.</span>
|
||
<button class="ax-fill sm" onclick={() => { confirmRotate = false; void rotatePersona(); }} disabled={!!community.busy.rotate}>Confirm rotate</button>
|
||
<button class="ax-ghost sm" onclick={() => (confirmRotate = false)}>Cancel</button>
|
||
{:else if confirmRemove}
|
||
<span class="hint warn">This deletes the key from this node. Without a recovery file, this identity ends.</span>
|
||
<button class="ax-fill sm danger" onclick={() => { confirmRemove = false; void removePersona(); }} disabled={!!community.busy.remove}>Confirm remove</button>
|
||
<button class="ax-ghost sm" onclick={() => (confirmRemove = false)}>Cancel</button>
|
||
{:else}
|
||
<button class="ax-ghost sm" onclick={() => (confirmRotate = true)} disabled={!nodeUp}><Icon name="refresh" size={12} /> Rotate key…</button>
|
||
<button class="ax-ghost sm dangerb" onclick={() => (confirmRemove = true)} disabled={!nodeUp}>Remove identity…</button>
|
||
{/if}
|
||
</div>
|
||
{#if community.error}<p class="hint warn">{community.error}</p>{/if}
|
||
</div>
|
||
</div>
|
||
{/if}
|
||
<div class="yc c">
|
||
<div class="panel">
|
||
<h3>Networks</h3>
|
||
<div class="netcard">
|
||
<div class="nico slsk"><Icon name="globe" size={18} /></div>
|
||
<div class="ntx">
|
||
<div class="t">Soulseek <span class="chip {online ? 'ok' : 'off'}">{online ? "online" : "offline"}</span></div>
|
||
<div class="s">
|
||
{#if me}Signed in as <b>{me}</b> · {fmtCount(network.shareFiles)} files shared.{:else}Not signed in — see Settings.{/if}
|
||
<br />
|
||
{#if !key}
|
||
Create a SoulStream identity to link this account to it.
|
||
{:else if !me}
|
||
Sign in to Soulseek to link this account to your identity.
|
||
{:else if ownLinkCurrent}
|
||
<b>Linked to this identity</b> — proof verified {new Date(ownLink.verified_at * 1000).toLocaleString()} through a Soulseek message to yourself. Other SoulStream users who ask over Soulseek now get your signed ID; nothing is published.
|
||
{:else}
|
||
Not linked to your identity. Linking sends one message to yourself over Soulseek and verifies the signed answer — nothing is published, and from then on other SoulStream users who ask get your signed ID.
|
||
{/if}
|
||
</div>
|
||
{#if key && me}
|
||
<div class="noderow">
|
||
{#if ownLinkCurrent}
|
||
<button class="ax-ghost sm" onclick={() => unlinkSelf()} disabled={!!community.busy.linkself}>Unlink</button>
|
||
{:else}
|
||
<button class="ax-fill sm" onclick={() => linkSelf()} disabled={!online || !nodeUp || !!community.busy.linkself}><Icon name="link" size={12} /> {community.busy.linkself ? "Linking…" : "Link this account"}</button>
|
||
{#if !nodeUp}<span class="hint">needs the node connected</span>{/if}
|
||
{/if}
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
</div>
|
||
<div class="netcard">
|
||
<div class="nico fn"><Icon name="disc" size={18} /></div>
|
||
<div class="ntx">
|
||
<div class="t">Freenet <span class="chip acc">experimental</span></div>
|
||
<div class="s">
|
||
{#if freenetOn}
|
||
Local node on 127.0.0.1:{community.freenet?.ws_port ?? 7509} · <span class="chip {status.tone}">{status.text}</span>
|
||
<button class="lnk" onclick={() => detectNode()} disabled={!!community.busy.detect}>{community.busy.detect ? "checking…" : "check again"}</button>
|
||
{#if statusKind(community.freenet?.status) === "node-absent"}
|
||
<div class="noderow">
|
||
{#if community.nodeInfo?.binary}
|
||
<button class="ax-fill sm" onclick={() => startNode()} disabled={!!community.busy.node}>
|
||
<Icon name="disc" size={12} /> {community.busy.node ? "Starting…" : "Start node"}
|
||
</button>
|
||
<span class="hint">runs the installed peer{#if community.nodeInfo.version} ({community.nodeInfo.version}){/if} in the background · telemetry pinned off · data in the app folder</span>
|
||
{:else}
|
||
<span class="hint warn">No Freenet peer found on this machine — install it (cargo install freenet) and check again.</span>
|
||
{/if}
|
||
</div>
|
||
{:else if community.nodeInfo?.started_by_app}
|
||
<div class="noderow">
|
||
<button class="ax-ghost sm" onclick={() => stopNode()} disabled={!!community.busy.node}>{community.busy.node ? "Stopping…" : "Stop node"}</button>
|
||
<span class="hint">started by SoulStream · keeps running when the app closes; this button stops it</span>
|
||
</div>
|
||
{/if}
|
||
{:else}
|
||
Off. Carries your identity and profile; later playlists, friend links and Spaces. Turning it on publishes nothing. Turning it off is instant and touches nothing else in the app.
|
||
{/if}
|
||
</div>
|
||
<details class="disc" bind:open={disclosuresOpen}>
|
||
<summary>Before you rely on it — seven things that are true</summary>
|
||
<ul>
|
||
<li>A desktop background peer must run; it uses network, disk, memory and CPU.</li>
|
||
<li>The network is alpha. Things you publish are replicated and cannot be guaranteed erased.</li>
|
||
<li>Freenet is not an anonymity service.</li>
|
||
<li>The node's own diagnostics are a node setting SoulStream pins off; verify after upgrades.</li>
|
||
<li>Nothing is published when you enable this. Publishing is always a button.</li>
|
||
<li>Nothing here ever says "online" — a successful fetch is not presence.</li>
|
||
<li>Phones and TVs cannot run the same peer today.</li>
|
||
</ul>
|
||
</details>
|
||
{#if freenetOn}
|
||
<label class="swrow">
|
||
<button class="tog" class:on={community.freenet?.load_remote_images ?? true} role="switch" aria-label="Load remote profile images" aria-checked={community.freenet?.load_remote_images ?? true} onclick={() => setLoadRemoteImages(!(community.freenet?.load_remote_images ?? true))} disabled={!!community.busy.images}></button>
|
||
<span>Load remote profile images</span>
|
||
<span class="hint r">verified, resized, cached by hash</span>
|
||
</label>
|
||
{#if key}
|
||
<div class="noderow">
|
||
{#if community.inbox?.published}
|
||
<span class="chip ok"><Icon name="lock" size={10} />encrypted inbox · key {community.inbox.box_key_id}</span>
|
||
<button class="ax-ghost sm" onclick={() => checkInbox()} disabled={!nodeUp || !!community.busy.inboxcheck}>{community.busy.inboxcheck ? "Checking…" : "Check now"}</button>
|
||
<span class="hint">{community.inbox.last_check ? `checked ${fmtWhen(community.inbox.last_check * 1000)} · ` : ""}friend requests and shares arrive sealed to a key kept on this device; a persona restored elsewhere publishes a new one</span>
|
||
{:else}
|
||
<button class="ax-fill sm" onclick={() => publishInbox()} disabled={!nodeUp || !!community.busy.inbox}><Icon name="lock" size={12} /> {community.busy.inbox ? "Publishing…" : "Publish an encrypted inbox"}</button>
|
||
<span class="hint">lets other SoulStream users send you friend requests and shares sealed, without Soulseek in between</span>
|
||
{/if}
|
||
</div>
|
||
{/if}
|
||
{/if}
|
||
</div>
|
||
<button class="tog big" class:on={freenetOn} role="switch" aria-checked={freenetOn} title={freenetOn ? "Turn the Freenet layer off" : "Turn the Freenet layer on"} onclick={() => setFreenetEnabled(!freenetOn)} disabled={!!community.busy.freenet}></button>
|
||
</div>
|
||
</div>
|
||
<div class="panel dim">
|
||
<h3><Icon name="devices" size={13} /> Devices <span class="chip off">later</span></h3>
|
||
<p class="hint">A paired device gets its own key and a signed grant — it never receives your root key or Soulseek password. Comes after the profile work settles.</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
|
||
<!-- ── the person drawer: two sources, two sections, one footnote ─────── -->
|
||
{#if drawerUser}
|
||
<div class="overlay" use:portal>
|
||
<div class="scrim" role="presentation" onclick={closeDrawer}></div>
|
||
<aside class="drawer" aria-label="Profile of {drawerUser}">
|
||
<div class="dhead">
|
||
<Identicon seed={drawerUser} size={56} photo={drawerPhoto} presence={presenceOf(drawerUser)} />
|
||
<div class="dtx">
|
||
<div class="nm">
|
||
<input class="pet" placeholder={drawerUser} bind:value={petDraft} onchange={() => saveMeta()} title="Your private petname for this person" />
|
||
{#if drawerMeta?.favorite}<span class="mark fav"><Icon name="star" size={12} /></span>{/if}
|
||
{#if drawerMeta?.trusted}<span class="mark tr"><Icon name="shield" size={12} /></span>{/if}
|
||
{#if drawerFriend?.state === "friends"}<span class="mark fr" title="Friend"><Icon name="link" size={12} /></span>{/if}
|
||
</div>
|
||
<div class="sub">
|
||
{#if petDraft.trim()}<span>{drawerUser}</span><span>·</span>{/if}
|
||
<span class="src slsk"><Icon name="globe" size={10} />Soulseek</span>
|
||
<span>{presenceOf(drawerUser) ? presenceWord[presenceOf(drawerUser)] : isContact(drawerUser) ? "status unknown" : "not a contact"}</span>
|
||
</div>
|
||
</div>
|
||
<button class="ibtn x" onclick={closeDrawer} title="Close"><Icon name="x" size={16} /></button>
|
||
</div>
|
||
<div class="dacts">
|
||
<button class="ax-fill sm" onclick={() => messageUser(drawerUser)}><Icon name="message" size={12} /> Message</button>
|
||
<button class="ax-ghost sm" onclick={() => browseUser(drawerUser)}><Icon name="folder" size={12} /> Browse shares</button>
|
||
{#if isContact(drawerUser)}
|
||
<button class="ax-ghost sm" onclick={() => removeBuddy(drawerUser)}>Remove contact</button>
|
||
{:else}
|
||
<button class="ax-ghost sm" onclick={() => addContact(drawerUser)} disabled={!online}><Icon name="plus" size={12} /> Add to contacts</button>
|
||
{/if}
|
||
</div>
|
||
<div class="marks">
|
||
<button class="markbtn" class:on={drawerMeta?.favorite} onclick={() => saveMeta({ favorite: !drawerMeta?.favorite })}><Icon name="star" size={13} /> Favorite</button>
|
||
<button class="markbtn tr" class:on={drawerMeta?.trusted} onclick={() => saveMeta({ trusted: !drawerMeta?.trusted })}><Icon name="shield" size={13} /> Trusted</button>
|
||
<input class="grp-in" placeholder="Group" bind:value={groupDraft} onchange={() => saveMeta()} list="cm-groups" />
|
||
<datalist id="cm-groups">{#each groups as g (g)}<option value={g}></option>{/each}</datalist>
|
||
</div>
|
||
<textarea class="note-in" rows="2" placeholder="A private note — only you see it" bind:value={noteDraft} onchange={() => saveMeta()}></textarea>
|
||
|
||
<div class="dsec">
|
||
<h4>Library overlap <span class="src slsk"><Icon name="library" size={10} />this device</span></h4>
|
||
{#if drawerOverlap}
|
||
<p class="hint">
|
||
{#if drawerOverlap.their_albums}<b>{drawerOverlap.albums_in_common_count}</b> of their {fmtCount(drawerOverlap.their_albums)} albums are in your library · <b>{drawerOverlap.artists_in_common_count}</b> {drawerOverlap.artists_in_common_count === 1 ? "artist" : "artists"} in common · <b>{drawerOverlap.gaps_count}</b> you lack{:else}No album folders among {fmtCount(drawerOverlap.audio_files)} audio files.{/if}
|
||
· compared {fmtWhen(drawerOverlap.computed_at * 1000)}
|
||
</p>
|
||
{#if drawerOverlap.albums_in_common.length}
|
||
<div class="ovl"><span class="lbl">In common</span>{#each drawerOverlap.albums_in_common.slice(0, 8) as a, i (i)}<span class="ov"><b>{a.album}</b> · {a.artist}</span>{/each}{#if drawerOverlap.albums_in_common_count > 8}<span class="ov more">+{drawerOverlap.albums_in_common_count - 8} more</span>{/if}</div>
|
||
{/if}
|
||
{#if drawerOverlap.gaps.length}
|
||
<div class="ovl"><span class="lbl">You lack</span>{#each drawerOverlap.gaps.slice(0, 8) as a, i (i)}<span class="ov gap"><b>{a.album}</b> · {a.artist}</span>{/each}{#if drawerOverlap.gaps_count > 8}<span class="ov more">+{drawerOverlap.gaps_count - 8} more</span>{/if}</div>
|
||
{/if}
|
||
<div class="bbtns">
|
||
<button class="ax-ghost sm" onclick={() => computeOverlap(drawerUser)} disabled={!online || !!community.busy["overlap:" + drawerUser]}><Icon name="refresh" size={12} /> {community.busy["overlap:" + drawerUser] ? "Browsing…" : "Compare again"}</button>
|
||
<button class="lnk" onclick={() => forgetOverlap(drawerUser)} disabled={!!community.busy["overlap:" + drawerUser]}>forget</button>
|
||
</div>
|
||
{:else}
|
||
<p class="hint">Not compared yet. Their share folders against your albums, on this device — nothing is uploaded to do it.</p>
|
||
<div class="bbtns"><button class="ax-ghost sm" onclick={() => computeOverlap(drawerUser)} disabled={!online || !!community.busy["overlap:" + drawerUser]}><Icon name="library" size={12} /> {community.busy["overlap:" + drawerUser] ? "Browsing their shares…" : "Compare with my library"}</button></div>
|
||
{/if}
|
||
</div>
|
||
<div class="dsec">
|
||
<h4>Native Soulseek profile <span class="src slsk"><Icon name="globe" size={10} />Soulseek</span></h4>
|
||
<dl class="kv">
|
||
<dt>Username</dt>
|
||
<dd><b>{drawerUser}</b>{#if presenceOf(drawerUser)} · {presenceWord[presenceOf(drawerUser)]}{/if}{#if presenceOf(drawerUser) === "off" && fmtSeen(community.lastSeen[drawerUser])} · last seen by this device {fmtSeen(community.lastSeen[drawerUser])}{/if}</dd>
|
||
{#if drawerState?.exists === false}
|
||
<dt>Status</dt><dd>Soulseek has no user by this name.</dd>
|
||
{:else if drawerState}
|
||
<dt>Shares</dt><dd>{fmtCount(drawerState.file_count)} files in {fmtCount(drawerState.dir_count)} folders</dd>
|
||
{#if drawerState.avg_speed}<dt>Upload speed</dt><dd>{Math.round(drawerState.avg_speed / 1024)} KB/s average</dd>{/if}
|
||
{:else}
|
||
<dt>Shares</dt><dd class="muted">not watched — add to contacts and Soulseek reports status and share counts</dd>
|
||
{/if}
|
||
{#if drawerInfo && !drawerInfo.pending && !drawerInfo.error}
|
||
<dt>Uploads</dt><dd>{drawerInfo.has_free_slot ? "a slot is free" : "no free slot"} · queue {fmtCount(drawerInfo.queue_size)} · {fmtCount(drawerInfo.total_uploads)} served</dd>
|
||
{/if}
|
||
</dl>
|
||
{#if !online}
|
||
<p class="hint">Sign in to Soulseek to ask this person's client for their card.</p>
|
||
{:else if !drawerInfo || drawerInfo.pending}
|
||
<p class="hint"><span class="bl"></span> Asking their client for the card — direct, peer to peer.</p>
|
||
{:else if drawerInfo.error}
|
||
<p class="hint">Their client did not answer ({drawerInfo.error.replace(/^.*?: /, "")}) — offline, firewalled, or not sharing a card.</p>
|
||
{:else}
|
||
<p class="desc">{drawerInfo.description.trim() || "No description."}</p>
|
||
{#if drawerInfo.picture_note}<p class="hint warn">{drawerInfo.picture_note}</p>{/if}
|
||
{/if}
|
||
<div class="hint" style="margin-top:10px">Soulseek interests <span class="muted">— raw likes and hates, not follows</span></div>
|
||
{#if drawerInterests && !drawerInterests.pending && !drawerInterests.error}
|
||
{#if drawerInterests.likes.length || drawerInterests.hates.length}
|
||
<div class="tagrow">
|
||
{#each drawerInterests.likes as l (l)}<span class="tag">{l}</span>{/each}
|
||
{#each drawerInterests.hates as h (h)}<span class="tag hate">{h}</span>{/each}
|
||
</div>
|
||
{:else}
|
||
<p class="hint">None declared.</p>
|
||
{/if}
|
||
{:else if drawerInterests?.error}
|
||
<p class="hint">The server did not answer.</p>
|
||
{:else if online}
|
||
<p class="hint"><span class="bl"></span> asking the server</p>
|
||
{/if}
|
||
</div>
|
||
<div class="dsec">
|
||
{#if drawerLink}
|
||
<h4>Signed SoulStream profile <span class="chip ok"><Icon name="check" size={10} />verified {fmtWhen(drawerLink.verified_at * 1000)}</span></h4>
|
||
<div class="rp">
|
||
<Identicon seed={drawerLink.persona_key} size={44} photo={drawerRemote?.avatar_png_base64 ? `data:image/png;base64,${drawerRemote.avatar_png_base64}` : null} />
|
||
<div class="rptx">
|
||
{#if !drawerRemote || drawerRemote.pending}
|
||
<div class="nm">SoulStream ID {fpShort(drawerLink.persona_key)}</div>
|
||
<p class="hint"><span class="bl"></span> reading their profile from the network</p>
|
||
{:else if drawerRemote.error}
|
||
<div class="nm">SoulStream ID {fpShort(drawerLink.persona_key)}</div>
|
||
<p class="hint warn">Could not read their profile: {drawerRemote.error}</p>
|
||
{:else if drawerRemote.none}
|
||
<div class="nm">SoulStream ID {fpShort(drawerLink.persona_key)}</div>
|
||
<p class="hint">They proved their ID but have not published a profile yet.</p>
|
||
{:else}
|
||
<div class="nm">{drawerRemote.display_name || "(no name)"} <span class="src ss"><Icon name="disc" size={10} />SoulStream</span></div>
|
||
<div class="mono muted" style="font-size:11px">{fpShort(drawerLink.persona_key)} · revision {drawerRemote.sequence}</div>
|
||
{#if drawerRemote.bio}<p class="desc">{drawerRemote.bio}</p>{/if}
|
||
{#if drawerRemote.avatar_note && drawerRemote.avatar_note !== "none published"}<p class="hint">{drawerRemote.avatar_note}</p>{/if}
|
||
{/if}
|
||
</div>
|
||
</div>
|
||
<p class="hint" style="margin-top:8px">Their client answered a one-time question over Soulseek with a claim signed by this key — the server vouched for the sender, the signature for the key. <button class="lnk" onclick={() => checkLink(drawerUser)} disabled={!online || !!community.busy["link:" + drawerUser]}>ask again</button></p>
|
||
{#if !isMe(drawerUser)}
|
||
<div class="friendrow">
|
||
{#if !drawerFriend || drawerFriend.state === "expired" || drawerFriend.state === "revoked" && drawerFriend.direction === "in"}
|
||
<button class="ax-fill sm" onclick={() => friendRequest(drawerUser)} disabled={!online || !nodeUp || !key || !!community.busy["friend:" + drawerUser]}><Icon name="link" size={12} /> {community.busy["friend:" + drawerUser] ? "Sending…" : "Add as friend"}</button>
|
||
<span class="hint">{drawerFriend?.state === "revoked" ? "the earlier friendship was ended — a new request starts over" : "a signed request goes to them over Soulseek; friends are two reciprocal signed answers"}</span>
|
||
{:else if drawerFriend.state === "friends"}
|
||
<span class="chip ok"><Icon name="link" size={10} />friends since {fmtWhen(drawerFriend.since * 1000)}</span>
|
||
<button class="ax-ghost sm" onclick={() => friendRemove(drawerFriend.id)} disabled={!!community.busy["friend:" + drawerFriend.id]}>Unfriend</button>
|
||
{:else if drawerFriend.state === "pending-in"}
|
||
<span class="chip acc">they asked to be friends</span>
|
||
<button class="ax-fill sm" onclick={() => friendAccept(drawerFriend.id)} disabled={!online || !nodeUp || !!community.busy["friend:" + drawerFriend.id]}>Accept</button>
|
||
<button class="ax-ghost sm" onclick={() => friendDecline(drawerFriend.id)} disabled={!!community.busy["friend:" + drawerFriend.id]}>Decline</button>
|
||
{:else if drawerFriend.state === "pending-out"}
|
||
<span class="chip acc"><span class="bl"></span>request sent · waiting for their answer</span>
|
||
<button class="lnk" onclick={() => friendForget(drawerFriend.id)}>withdraw</button>
|
||
{:else if drawerFriend.state === "accepted-waiting"}
|
||
<span class="chip acc"><span class="bl"></span>you accepted · waiting for their signed answer</span>
|
||
{:else if drawerFriend.state === "declined"}
|
||
<span class="chip off">{drawerFriend.direction === "out" ? "they declined" : "you declined"}</span>
|
||
<button class="lnk" onclick={() => friendForget(drawerFriend.id)}>forget</button>
|
||
{:else if drawerFriend.state === "revoked"}
|
||
<span class="chip warn">friendship ended</span>
|
||
<button class="ax-ghost sm" onclick={() => friendRequest(drawerUser)} disabled={!online || !nodeUp}>Ask again</button>
|
||
{:else if drawerFriend.state === "reacceptance-required"}
|
||
<span class="chip warn">their key changed — must re-accept</span>
|
||
<button class="ax-ghost sm" onclick={() => friendRequest(drawerUser)} disabled={!online || !nodeUp}>Ask again</button>
|
||
{/if}
|
||
</div>
|
||
{/if}
|
||
{:else}
|
||
<h4>Signed SoulStream profile <span class="chip off">not asked</span></h4>
|
||
<p class="hint">Ask their client, over Soulseek, for a signed SoulStream ID. One message goes out; a client that is not SoulStream sees a line saying it is safe to ignore.</p>
|
||
<div class="noderow">
|
||
<button class="ax-ghost sm" onclick={() => checkLink(drawerUser)} disabled={!online || !!community.busy["link:" + drawerUser]}><Icon name="link" size={12} /> {community.busy["link:" + drawerUser] ? "Asking… up to 30 s" : "Ask for their SoulStream ID"}</button>
|
||
</div>
|
||
{#if community.linkErrors[drawerUser]}<p class="hint" style="margin-top:6px">{community.linkErrors[drawerUser]}</p>{/if}
|
||
{/if}
|
||
</div>
|
||
<div class="dfoot">
|
||
A SoulStream badge means a compatible, signed profile was presented and verified against its key. It is not proof of who
|
||
this person is in real life — that is what your petname, your note and "trusted" are for.
|
||
</div>
|
||
</aside>
|
||
</div>
|
||
{/if}
|
||
|
||
<style>
|
||
/* A "start BELOW the bar" page, the hub's rule exactly. Compact density
|
||
throughout (the approved knob): 46px rows, 12px gaps and pads. */
|
||
.community {
|
||
position: relative;
|
||
/* The whole pane, edge to edge (Kyle, 2026-09-08: "not using the full
|
||
window"). The 1520px cap from 2026-09-02 answered a page that
|
||
huddled top-left at 2560; now every section stretches instead —
|
||
the shelves, the split grids and the You grid all size to the pane,
|
||
and only a chat line keeps a readable cap (.split .thread). */
|
||
max-width: none;
|
||
margin: 0;
|
||
padding: calc(var(--bar-h) + 18px) 24px 40px;
|
||
--row: 46px;
|
||
--gap: 12px;
|
||
--pad: 12px;
|
||
--ok: #3ddc84;
|
||
--warn: #e8a33d;
|
||
--err: #f15e6c;
|
||
--space: #c77dff;
|
||
--surf: var(--bg-elevated);
|
||
--surf-hi: var(--bg-highlight);
|
||
--hair: rgba(255, 255, 255, 0.07);
|
||
}
|
||
/* Dark glass: surfaces on the frost add white, never black (the ladder
|
||
in global.css). */
|
||
:global(:root.glass) .community {
|
||
--surf: rgba(255, 255, 255, 0.03);
|
||
--surf-hi: rgba(255, 255, 255, 0.08);
|
||
--hair: rgba(255, 255, 255, 0.1);
|
||
}
|
||
.community > :not(.amb) {
|
||
position: relative;
|
||
z-index: 1;
|
||
}
|
||
/* The persona ambience: the page top washed in the identicon's own hue,
|
||
the artist page's wash idea with the key as the palette. */
|
||
.amb {
|
||
position: absolute;
|
||
left: -28px;
|
||
right: -28px;
|
||
top: 0;
|
||
height: 380px;
|
||
z-index: 0;
|
||
pointer-events: none;
|
||
background:
|
||
radial-gradient(900px 340px at 18% 0%, hsl(var(--hue) 60% 40% / 0.28), transparent 70%),
|
||
radial-gradient(600px 300px at 70% 0%, hsl(calc(var(--hue) + 150) 50% 40% / 0.16), transparent 70%);
|
||
-webkit-mask-image: linear-gradient(180deg, #000 30%, transparent);
|
||
mask-image: linear-gradient(180deg, #000 30%, transparent);
|
||
}
|
||
|
||
/* ── shared vocabulary (hub + artist page) ── */
|
||
.ax-head { display: flex; align-items: center; justify-content: space-between; margin: 26px 0 8px; }
|
||
.ax-title { margin: 0; font-size: 20px; font-weight: 800; }
|
||
.ax-ghost { display: inline-flex; align-items: center; gap: 6px; height: 34px; padding: 0 15px; border-radius: 99px; font-size: 12px; font-weight: 800; border: 1px solid rgba(255, 255, 255, 0.28); transition: 0.18s; white-space: nowrap; }
|
||
.ax-ghost:hover:not(:disabled) { border-color: #fff; background: rgba(255, 255, 255, 0.08); }
|
||
.ax-ghost.sm { height: 28px; padding: 0 11px; font-size: 11px; }
|
||
.ax-ghost:disabled { opacity: 0.4; cursor: default; }
|
||
.ax-ghost.dangerb { border-color: color-mix(in srgb, var(--err) 45%, transparent); color: var(--err); }
|
||
.ax-fill { display: inline-flex; align-items: center; gap: 6px; height: 34px; padding: 0 17px; border-radius: 99px; font-size: 12px; font-weight: 800; background: var(--accent); color: #000; white-space: nowrap; }
|
||
.ax-fill:hover:not(:disabled) { background: var(--accent-hover); }
|
||
.ax-fill.sm { height: 28px; padding: 0 12px; font-size: 11px; }
|
||
.ax-fill:disabled { opacity: 0.4; cursor: default; }
|
||
.ax-fill.danger { background: var(--err); }
|
||
.qtabs { display: flex; gap: 4px; margin: 12px 0 16px; flex-wrap: wrap; align-items: center; }
|
||
.qtab { display: inline-flex; align-items: center; gap: 8px; height: 34px; padding: 0 15px; border-radius: 99px; font-size: 12.5px; font-weight: 800; color: var(--text-secondary); background: var(--surf); }
|
||
.qtab:hover { color: var(--text-primary); background: var(--surf-hi); }
|
||
.qtab.on { background: var(--accent); color: #000; }
|
||
.qtab .n { font-size: 10.5px; font-weight: 900; padding: 1px 7px; border-radius: 99px; background: rgba(255, 255, 255, 0.14); }
|
||
.qtab.on .n { background: rgba(0, 0, 0, 0.18); }
|
||
.qtab.you { padding-left: 6px; gap: 9px; }
|
||
.pillsep { width: 1px; height: 18px; background: rgba(255, 255, 255, 0.14); margin: 0 6px; flex: none; }
|
||
.chip { font-size: 10px; font-weight: 900; letter-spacing: 0.05em; padding: 2px 8px; border-radius: 99px; white-space: nowrap; display: inline-flex; align-items: center; gap: 5px; line-height: 1.5; vertical-align: middle; }
|
||
.chip.ok { background: color-mix(in srgb, var(--ok) 14%, transparent); color: #5ee89a; }
|
||
.chip.warn { background: color-mix(in srgb, var(--warn) 13%, transparent); color: var(--warn); }
|
||
.chip.acc { background: var(--accent-soft); color: var(--accent-hover); }
|
||
.chip.up { background: color-mix(in srgb, var(--space) 14%, transparent); color: #d9a6ff; }
|
||
.chip.off { background: var(--surf-hi); color: var(--text-muted); }
|
||
.chip.err { background: color-mix(in srgb, var(--err) 14%, transparent); color: var(--err); }
|
||
.qbox { display: flex; align-items: center; gap: 9px; height: 36px; padding: 0 15px; border-radius: 18px; background: var(--surf-hi); color: var(--text-muted); }
|
||
.qbox input { flex: 1; background: none; border: none; outline: none; color: var(--text-primary); font-size: 13px; min-width: 0; }
|
||
.qbox input::placeholder { color: var(--text-muted); }
|
||
.qbox.slim { height: 32px; }
|
||
.qbox.grow { flex: 1; }
|
||
.fchip { padding: 5px 11px; border-radius: 99px; font-size: 11px; font-weight: 800; background: var(--surf); color: var(--text-secondary); }
|
||
.fchip:hover { color: var(--text-primary); }
|
||
.fchip.on { background: rgba(255, 255, 255, 0.14); color: #fff; }
|
||
.panel { background: var(--surf); border: 1px solid var(--hair); border-radius: 12px; padding: var(--pad) 14px; }
|
||
.panel + .panel { margin-top: 12px; }
|
||
.panel.dim { opacity: 0.7; }
|
||
.panel h3 { margin: 0 0 4px; font-size: 13.5px; font-weight: 800; display: flex; align-items: center; gap: 8px; }
|
||
.panel h3 .chip, .panel h3 .src { margin-left: auto; }
|
||
.ibtn { width: 28px; height: 28px; border-radius: 50%; display: grid; place-items: center; color: #fff; }
|
||
.ibtn:hover:not(:disabled) { background: rgba(255, 255, 255, 0.12); }
|
||
.ibtn:disabled { opacity: 0.4; cursor: default; }
|
||
.ibtn.sm { width: 24px; height: 24px; }
|
||
.ibtn.fav { color: var(--warn); }
|
||
.muted { color: var(--text-muted); }
|
||
.mono { font-family: ui-monospace, "Cascadia Mono", Consolas, monospace; font-size: 11.5px; letter-spacing: 0.02em; }
|
||
.hint { font-size: 11.5px; color: var(--text-muted); line-height: 1.5; }
|
||
.hint.pad { padding: 8px 10px 0; }
|
||
.hint.wide { margin-top: 14px; max-width: 90ch; }
|
||
.hint.warn { color: var(--warn); }
|
||
.hint.ok { color: #5ee89a; }
|
||
.hint b { color: var(--text-secondary); }
|
||
.lnk { color: var(--text-secondary); font-weight: 700; text-decoration: underline; text-underline-offset: 2px; }
|
||
.lnk:hover:not(:disabled) { color: #fff; }
|
||
.lnk:disabled { opacity: 0.5; }
|
||
|
||
/* ── source badges: the two networks, always named ── */
|
||
.src { display: inline-flex; align-items: center; gap: 5px; height: 18px; padding: 0 7px 0 5px; border-radius: 99px; font-size: 10px; font-weight: 900; letter-spacing: 0.04em; white-space: nowrap; vertical-align: middle; }
|
||
.src.slsk { background: rgba(255, 255, 255, 0.09); color: var(--text-secondary); }
|
||
.src.ss { background: var(--accent-soft); color: var(--accent-hover); }
|
||
.mark { display: inline-flex; align-items: center; color: var(--text-muted); }
|
||
.mark.fav { color: var(--warn); }
|
||
.mark.tr { color: #5ee89a; }
|
||
.stack { display: flex; }
|
||
.stack > :global(.av) { margin-left: -8px; box-shadow: 0 0 0 2px var(--bg-base); border-radius: 50%; }
|
||
.stack > :global(.av:first-child) { margin-left: 0; }
|
||
|
||
/* ── the identity strip ── */
|
||
.who { display: flex; align-items: center; gap: 14px; padding: 2px 0; }
|
||
.noid { width: 48px; height: 48px; border-radius: 50%; display: grid; place-items: center; background: var(--surf-hi); color: var(--text-muted); flex: none; }
|
||
.whotx { min-width: 0; }
|
||
.who .name { font-size: 22px; font-weight: 800; letter-spacing: -0.01em; line-height: 1.1; display: flex; align-items: center; gap: 10px; }
|
||
.who .id { display: flex; align-items: center; gap: 9px; margin-top: 5px; font-size: 12px; color: var(--text-secondary); flex-wrap: wrap; }
|
||
.who .fp { white-space: nowrap; display: inline-flex; align-items: center; gap: 7px; padding: 2px 6px 2px 8px; border-radius: 6px; background: var(--surf-hi); color: var(--text-primary); }
|
||
.who .fp:hover { background: rgba(255, 255, 255, 0.14); }
|
||
.who .fp .k { color: var(--text-muted); font-weight: 700; font-size: 10.5px; letter-spacing: 0.06em; text-transform: uppercase; }
|
||
.who .fp .cp { color: var(--text-muted); display: grid; }
|
||
.who .sl { display: inline-flex; align-items: center; gap: 5px; }
|
||
.who .sl b { color: var(--text-primary); }
|
||
.who .right { margin-left: auto; display: flex; align-items: center; gap: 8px; flex: none; }
|
||
.who .st { display: flex; flex-direction: column; align-items: flex-end; gap: 5px; margin-right: 6px; }
|
||
|
||
/* ── lists ── */
|
||
.split { display: grid; grid-template-columns: 300px 1fr; gap: var(--gap); align-items: start; }
|
||
.split.narrow { grid-template-columns: 270px 1fr; }
|
||
.split > * { min-width: 0; }
|
||
.col { display: flex; flex-direction: column; gap: 6px; }
|
||
.list { display: flex; flex-direction: column; gap: 2px; }
|
||
.lrow { display: flex; align-items: center; gap: 10px; min-height: var(--row); padding: 5px 10px; border-radius: 8px; text-align: left; width: 100%; }
|
||
.lrow.slim { min-height: 40px; }
|
||
.lrow:hover:not(:disabled) { background: var(--surf-hi); }
|
||
.lrow.on { background: var(--surf-hi); box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.06); }
|
||
.lrow:disabled { opacity: 0.5; cursor: default; }
|
||
.lrow .tx { min-width: 0; flex: 1; display: flex; flex-direction: column; gap: 2px; }
|
||
.lrow .l1 { display: flex; align-items: center; gap: 7px; font-size: 13px; font-weight: 800; }
|
||
.lrow .l1 .nm { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||
.lrow .l2 { font-size: 11.5px; color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||
.lrow .r { display: flex; flex-direction: column; align-items: flex-end; gap: 4px; flex: none; font-size: 10.5px; color: var(--text-muted); font-variant-numeric: tabular-nums; }
|
||
.unread { min-width: 18px; height: 18px; padding: 0 5px; border-radius: 9px; background: var(--accent); color: #000; font-size: 10px; font-weight: 900; display: grid; place-items: center; }
|
||
.grp { display: flex; align-items: center; gap: 8px; height: 28px; padding: 0 10px; font-size: 10.5px; font-weight: 900; letter-spacing: 0.1em; text-transform: uppercase; color: var(--text-muted); }
|
||
.grp .n { font-weight: 700; letter-spacing: 0; text-transform: none; }
|
||
.grp .ln { flex: 1; height: 1px; background: var(--hair); }
|
||
.roomic { width: 28px; height: 28px; border-radius: 50%; display: grid; place-items: center; background: var(--surf-hi); color: var(--text-secondary); flex: none; }
|
||
.roomic.lg { width: 44px; height: 44px; border-radius: 12px; }
|
||
.empty { display: flex; flex-direction: column; align-items: center; gap: 8px; padding-top: 12vh; text-align: center; color: var(--text-secondary); }
|
||
.empty.tight { padding-top: 7vh; }
|
||
.big { font-size: 20px; font-weight: 700; color: var(--text-primary); }
|
||
.sub { color: var(--text-secondary); font-size: 12.5px; max-width: 460px; line-height: 1.6; }
|
||
.spico { width: 56px; height: 56px; border-radius: 16px; display: grid; place-items: center; background: color-mix(in srgb, var(--space) 16%, transparent); color: #d9a6ff; }
|
||
|
||
/* friend requests — the invitation card, parsed and signed, never pasted text */
|
||
.freqs { display: flex; flex-direction: column; gap: 10px; margin-bottom: 14px; }
|
||
.card { border-radius: 12px; background: var(--surf); border: 1px solid var(--hair); overflow: hidden; max-width: 560px; }
|
||
.card .top { display: flex; gap: 12px; padding: 12px; align-items: center; }
|
||
.card .t { font-size: 14px; font-weight: 800; }
|
||
.card .s { font-size: 11.5px; color: var(--text-secondary); margin-top: 2px; }
|
||
.card .prov { font-size: 10.5px; color: var(--text-muted); margin-top: 6px; display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
|
||
.card .bot { display: flex; gap: 8px; padding: 0 12px 12px; align-items: center; flex-wrap: wrap; }
|
||
.card .bot .hint { margin-left: auto; font-size: 10.5px; }
|
||
/* ── thread / room ── */
|
||
.thread { display: flex; flex-direction: column; height: calc(100vh - 330px); min-height: 480px; }
|
||
.thead-bar, .roomhead { display: flex; align-items: center; gap: 12px; padding: 4px 4px 10px; border-bottom: 1px solid var(--hair); }
|
||
.tt { min-width: 0; }
|
||
.thead-bar .nm, .roomhead .nm { font-size: 15px; font-weight: 800; display: flex; align-items: center; gap: 7px; }
|
||
.thead-bar .sub, .roomhead .sub { font-size: 11.5px; color: var(--text-muted); display: flex; align-items: center; gap: 7px; margin-top: 2px; flex-wrap: wrap; max-width: none; }
|
||
.thead-bar .r, .roomhead .r { margin-left: auto; display: flex; gap: 4px; align-items: center; flex: none; }
|
||
.msgs { flex: 1; overflow-y: auto; padding: 12px 4px 8px; display: flex; flex-direction: column; gap: 8px; }
|
||
.mdate { align-self: center; font-size: 10.5px; font-weight: 800; letter-spacing: 0.08em; text-transform: uppercase; color: var(--text-muted); margin: 6px 0; }
|
||
.m { display: flex; gap: 8px; max-width: 78%; align-items: flex-end; }
|
||
.m .b { padding: 7px 11px; border-radius: 14px; background: var(--surf-hi); font-size: 13px; line-height: 1.45; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||
.m.me { align-self: flex-end; flex-direction: row-reverse; }
|
||
.m.me .b { background: color-mix(in srgb, var(--accent) 26%, transparent); border-bottom-right-radius: 4px; }
|
||
.m:not(.me) .b { border-bottom-left-radius: 4px; }
|
||
.mst { font-size: 10px; color: var(--text-muted); display: flex; align-items: center; gap: 4px; margin-top: 3px; font-variant-numeric: tabular-nums; }
|
||
.m.me .mst { justify-content: flex-end; }
|
||
.mst.fail { color: var(--err); }
|
||
.compose { display: flex; align-items: center; gap: 8px; padding-top: 10px; border-top: 1px solid var(--hair); }
|
||
.compose .qbox { height: 38px; border-radius: 19px; }
|
||
.compose .via { font-size: 10.5px; color: var(--text-muted); white-space: nowrap; }
|
||
.send { width: 32px; height: 32px; border-radius: 50%; background: var(--accent); color: #000; display: grid; place-items: center; flex: none; }
|
||
.send:hover:not(:disabled) { background: var(--accent-hover); }
|
||
.send:disabled { opacity: 0.4; cursor: default; }
|
||
.roomwrap { display: grid; grid-template-columns: 1fr 170px; gap: 16px; flex: 1; min-height: 0; }
|
||
.roomchat { display: flex; flex-direction: column; min-height: 0; }
|
||
.chat { flex: 1; display: flex; flex-direction: column; gap: 1px; overflow-y: auto; padding: 10px 2px; font-size: 13px; }
|
||
.cm { display: grid; grid-template-columns: 42px 1fr; gap: 0 8px; padding: 3px 6px; border-radius: 6px; align-items: baseline; }
|
||
.cm:hover { background: var(--surf); }
|
||
.cm .tm { font-size: 10.5px; color: var(--text-muted); font-variant-numeric: tabular-nums; }
|
||
.cm .body { line-height: 1.5; overflow-wrap: anywhere; }
|
||
.whoin { font-weight: 800; margin-right: 7px; color: var(--text-secondary); }
|
||
.whoin:hover { color: #fff; }
|
||
.whoin.c { color: var(--accent-hover); }
|
||
.whoin.mine { color: #fff; }
|
||
.cm.sys { color: var(--text-muted); font-size: 11.5px; }
|
||
.roster { border-left: 1px solid var(--hair); padding-left: 12px; overflow-y: auto; min-height: 0; }
|
||
.roster .grp { padding: 0 2px; height: 24px; }
|
||
.rrow { display: flex; align-items: center; gap: 8px; height: 28px; font-size: 12px; font-weight: 700; color: var(--text-secondary); padding: 0 4px; border-radius: 6px; width: 100%; text-align: left; white-space: nowrap; overflow: hidden; }
|
||
.rrow:hover { background: var(--surf-hi); color: #fff; }
|
||
.drow { display: flex; align-items: center; gap: 6px; height: 32px; padding: 0 6px 0 4px; border-radius: 8px; font-size: 12.5px; font-weight: 700; }
|
||
.drow:hover { background: var(--surf-hi); }
|
||
.drow.joined .dname { color: var(--accent-hover); }
|
||
.drow .dname { flex: 1; text-align: left; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; padding: 0 4px; }
|
||
.drow .dname:disabled { opacity: 0.5; cursor: default; }
|
||
.drow .cnt { font-size: 11px; color: var(--text-muted); font-variant-numeric: tabular-nums; }
|
||
.drow .star { width: 22px; height: 22px; border-radius: 50%; display: grid; place-items: center; color: var(--text-muted); }
|
||
.drow .star.on { color: var(--warn); }
|
||
|
||
/* ── people ── */
|
||
.ptool { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 8px; }
|
||
.ptool .qbox { width: 260px; }
|
||
.ptool .sp { flex: 1; }
|
||
.addrow { display: flex; gap: 6px; align-items: center; }
|
||
.addrow .qbox { width: 230px; }
|
||
.prow { display: grid; grid-template-columns: 36px 1fr auto auto; gap: 0 12px; align-items: center; min-height: var(--row); padding: 5px 10px; border-radius: 8px; }
|
||
.prow:hover { background: var(--surf-hi); }
|
||
.avbtn { display: grid; }
|
||
.ptx { min-width: 0; }
|
||
.prow .nm { font-size: 13.5px; font-weight: 800; display: flex; align-items: center; gap: 7px; flex-wrap: wrap; }
|
||
.lnkname { font: inherit; font-weight: 800; }
|
||
.lnkname:hover { text-decoration: underline; text-underline-offset: 2px; }
|
||
.prow .nm .real { font-weight: 600; color: var(--text-muted); font-size: 12px; }
|
||
.prow .sub { font-size: 11.5px; color: var(--text-muted); margin-top: 2px; display: flex; gap: 8px; align-items: center; flex-wrap: wrap; max-width: none; line-height: 1.4; }
|
||
.prow .sub .note { font-style: italic; color: var(--text-secondary); }
|
||
.prow .seen { font-size: 10.5px; color: var(--text-muted); text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||
.prow .seen b { display: block; color: var(--text-secondary); font-weight: 700; }
|
||
.acts { display: flex; gap: 2px; opacity: 0; transition: opacity 0.12s; justify-self: end; }
|
||
.prow:hover .acts { opacity: 1; }
|
||
.acts .ibtn { width: 26px; height: 26px; color: var(--text-secondary); }
|
||
.acts .ibtn:hover { color: #fff; }
|
||
.acts .ibtn.danger:hover { color: var(--err); }
|
||
|
||
/* ── discover ── */
|
||
.shelf .prov { font-size: 11.5px; color: var(--text-muted); display: flex; align-items: center; gap: 6px; }
|
||
.track { display: flex; gap: 10px; overflow-x: auto; padding: 2px 2px 8px; scrollbar-width: none; }
|
||
.track::-webkit-scrollbar { display: none; }
|
||
.pcard { flex: 0 0 150px; padding: 9px; border-radius: 8px; text-align: left; }
|
||
.pcard:hover { background: var(--surf-hi); }
|
||
.pcard .face { width: 132px; height: 132px; border-radius: 6px; display: grid; place-items: center; }
|
||
.pcard .nm { display: block; margin-top: 9px; font-size: 13px; font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||
.pcard .why { display: block; font-size: 11.5px; color: var(--text-muted); line-height: 1.35; margin-top: 2px; }
|
||
.pcard .why b { color: var(--text-secondary); font-weight: 700; }
|
||
.emptyshelf { border: 1px dashed rgba(255, 255, 255, 0.16); border-radius: 10px; padding: 16px 18px; display: flex; align-items: center; gap: 14px; color: var(--text-muted); font-size: 12.5px; line-height: 1.5; }
|
||
.emptyshelf b { color: var(--text-secondary); }
|
||
.emptyshelf .ax-ghost { margin-left: auto; }
|
||
|
||
/* ── spaces ── */
|
||
.spmini { width: 40px; height: 40px; border-radius: 10px; flex: none; display: grid; place-items: center; background: color-mix(in srgb, var(--space) 16%, transparent); color: #d9a6ff; font-size: 13px; font-weight: 900; }
|
||
.sphead { display: flex; align-items: center; gap: 16px; padding-bottom: 14px; border-bottom: 1px solid var(--hair); }
|
||
.spico { width: 56px; height: 56px; border-radius: 16px; display: grid; place-items: center; flex: none; background: color-mix(in srgb, var(--space) 16%, transparent); color: #d9a6ff; font-size: 20px; font-weight: 900; }
|
||
.sphead .nm { font-size: 20px; font-weight: 800; display: flex; align-items: center; gap: 10px; }
|
||
.sphead .sub { font-size: 12px; color: var(--text-muted); margin-top: 4px; display: flex; gap: 8px; flex-wrap: wrap; align-items: center; max-width: none; }
|
||
.src.space { background: color-mix(in srgb, var(--space) 14%, transparent); color: #d9a6ff; }
|
||
/* library overlap in the drawer */
|
||
.ovl { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 8px; align-items: center; }
|
||
.ovl .lbl { font-size: 10px; font-weight: 900; letter-spacing: 0.08em; text-transform: uppercase; color: var(--text-muted); margin-right: 4px; }
|
||
.ovl .ov { font-size: 11px; padding: 3px 8px; border-radius: 99px; background: var(--surf-hi); color: var(--text-secondary); max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.ovl .ov b { color: var(--text-primary); font-weight: 700; }
|
||
.ovl .ov.gap { background: color-mix(in srgb, var(--warn) 10%, transparent); }
|
||
.ovl .ov.more { background: none; color: var(--text-muted); }
|
||
/* the proposal board */
|
||
.bbtns { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin-top: 10px; }
|
||
.dlist { list-style: none; margin: 10px 0; padding: 0; display: flex; flex-direction: column; gap: 2px; max-height: 320px; overflow: auto; }
|
||
.dlist li { display: grid; grid-template-columns: 26px 1fr auto; gap: 8px; align-items: center; padding: 5px 8px; border-radius: 8px; font-size: 12.5px; }
|
||
.dlist li:hover { background: var(--surf-hi); }
|
||
.dlist li.fresh { background: var(--accent-soft); }
|
||
.dlist li.none { display: block; color: var(--text-muted); font-size: 11.5px; }
|
||
.dlist .n { font-size: 11px; color: var(--text-muted); font-variant-numeric: tabular-nums; }
|
||
.dlist .tx { display: flex; flex-direction: column; min-width: 0; }
|
||
.dlist .t { font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.dlist .a { font-size: 11px; color: var(--text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.dlist .b { display: flex; gap: 2px; }
|
||
.hits { display: flex; flex-direction: column; gap: 2px; margin-top: 6px; }
|
||
.hit { display: flex; align-items: center; gap: 8px; padding: 6px 10px; border-radius: 8px; text-align: left; font-size: 12.5px; color: var(--text-secondary); min-width: 0; }
|
||
.hit:hover { background: var(--surf-hi); color: #fff; }
|
||
.hit .t { font-weight: 700; white-space: nowrap; }
|
||
.hit .a { font-size: 11px; color: var(--text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.prop { padding: 12px 12px 10px; border: 1px solid var(--hair); border-radius: 10px; margin-top: 10px; }
|
||
.prop.stale { opacity: 0.8; border-style: dashed; }
|
||
.prop .ph { display: flex; align-items: center; gap: 10px; }
|
||
.prop .who { display: flex; flex-direction: column; min-width: 0; }
|
||
.prop .who .nm { font-weight: 800; font-size: 13px; }
|
||
.prop .ph .chip { margin-left: auto; }
|
||
.prop .note { margin: 8px 0 0; font-size: 12.5px; color: var(--text-secondary); font-style: italic; }
|
||
.ops { list-style: none; margin: 8px 0 0; padding: 0; display: flex; flex-direction: column; gap: 3px; font-size: 12.5px; }
|
||
.ops li { display: flex; gap: 8px; align-items: baseline; }
|
||
.ops li b { width: 14px; text-align: center; font-weight: 900; flex: none; }
|
||
.ops li.add b { color: #5ee89a; }
|
||
.ops li.remove b { color: var(--err); }
|
||
.ops li.move b { color: var(--accent-hover); }
|
||
.ops i { font-style: normal; font-weight: 700; }
|
||
.decide { display: flex; gap: 8px; align-items: center; margin-top: 10px; flex-wrap: wrap; }
|
||
.decide input { flex: 1; min-width: 160px; height: 30px; padding: 0 10px; border-radius: 8px; background: var(--surf-hi); border: none; color: var(--text-primary); font-size: 12px; outline: none; }
|
||
.dec { display: flex; gap: 10px; align-items: center; padding: 7px 0; border-top: 1px solid var(--hair); font-size: 12.5px; }
|
||
.dec:first-of-type { border-top: none; }
|
||
.dec .rs { flex: 1; }
|
||
/* ── the room's listening trail ── */
|
||
.sharebar { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
|
||
.sharebar .grow { flex: 1; min-width: 240px; }
|
||
.sharebar .ttl { font-size: 12.5px; font-weight: 800; }
|
||
.toggle { width: 38px; height: 21px; border-radius: 99px; background: var(--surf-hi); position: relative; flex: none; transition: 0.18s; }
|
||
.toggle i { position: absolute; top: 3px; left: 3px; width: 15px; height: 15px; border-radius: 50%; background: var(--text-muted); transition: 0.18s; }
|
||
.toggle.on { background: var(--accent); }
|
||
.toggle.on i { left: 20px; background: #000; }
|
||
.toggle:disabled { opacity: 0.45; cursor: default; }
|
||
.nowband { display: grid; grid-template-columns: repeat(auto-fill, minmax(232px, 1fr)); gap: 10px; margin-top: 10px; }
|
||
.nowcard { display: flex; gap: 11px; align-items: center; padding: 10px 12px; border-radius: 10px; background: var(--surf-hi); border: 1px solid var(--hair); min-width: 0; }
|
||
.nowcard .body { min-width: 0; flex: 1; }
|
||
.nowcard .who { font-size: 11px; font-weight: 800; color: var(--text-secondary); display: flex; align-items: center; gap: 6px; }
|
||
.nowcard .t { font-size: 13px; font-weight: 800; margin-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||
.nowcard .a { font-size: 11.5px; color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||
.nowcard.quiet { opacity: 0.55; }
|
||
.bars { display: inline-flex; align-items: flex-end; gap: 1.5px; height: 9px; }
|
||
.bars i { width: 2px; background: var(--ok); border-radius: 1px; animation: eq 900ms ease-in-out infinite alternate; }
|
||
.bars i:nth-child(2) { animation-delay: 160ms; }
|
||
.bars i:nth-child(3) { animation-delay: 320ms; }
|
||
@keyframes eq { from { height: 2px; } to { height: 9px; } }
|
||
@media (prefers-reduced-motion: reduce) { .bars i { animation: none; height: 6px; } }
|
||
.lf { display: flex; flex-direction: column; gap: 1px; margin-top: 6px; }
|
||
/* Wider than the chat's 42px time column: these rows are centred rather
|
||
than baseline-aligned, so a locale clock that wraps ("11:49 / AM")
|
||
shows as two lines instead of pushing the row. */
|
||
.lr { display: grid; grid-template-columns: 58px auto 1fr auto; gap: 0 10px; align-items: center; padding: 0 8px; height: 44px; border-radius: 8px; }
|
||
.lr:hover { background: var(--surf-hi); }
|
||
.lr.self { background: color-mix(in srgb, var(--accent) 6%, transparent); }
|
||
.lr .tm { font-size: 10.5px; color: var(--text-muted); font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||
.lr .art { width: 32px; height: 32px; border-radius: 5px; flex: none; display: grid; place-items: center; font-size: 9px; font-weight: 900; color: rgba(255, 255, 255, 0.5); overflow: hidden; }
|
||
.lr .meta { min-width: 0; display: flex; align-items: baseline; gap: 8px; }
|
||
.lr .t { font-size: 13px; font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||
.lr .a { font-size: 12px; color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||
.lr .who { display: inline-flex; align-items: center; gap: 7px; font-size: 12px; font-weight: 800; color: var(--text-secondary); }
|
||
.lr .who.mine { color: #fff; }
|
||
/* A run keeps the name's column without repeating it, so the rows stay
|
||
aligned and a person still reads as one block. */
|
||
.lr .runpad { width: 22px; flex: none; }
|
||
.lr .acts { display: flex; gap: 5px; opacity: 0; }
|
||
.lr:hover .acts, .lr:focus-within .acts { opacity: 1; }
|
||
.lr .ico { width: 26px; height: 26px; border-radius: 99px; display: grid; place-items: center; background: rgba(255, 255, 255, 0.1); }
|
||
.lr .ico:hover { background: rgba(255, 255, 255, 0.2); }
|
||
.daysep { display: flex; align-items: center; gap: 10px; padding: 12px 8px 6px; }
|
||
.daysep span { font-size: 10px; font-weight: 900; letter-spacing: 0.08em; text-transform: uppercase; color: var(--text-muted); }
|
||
.daysep .rule { flex: 1; height: 1px; background: var(--hair); }
|
||
.sptabs { display: flex; gap: 2px; margin: 12px 0 14px; align-items: center; }
|
||
.subtab { padding: 6px 13px; border-radius: 8px; font-size: 12px; font-weight: 800; color: var(--text-secondary); display: inline-flex; gap: 6px; align-items: center; }
|
||
.subtab.on { background: var(--surf-hi); color: #fff; }
|
||
.subtab b { font-size: 10px; }
|
||
.pinned { display: flex; gap: 14px; align-items: center; }
|
||
.pinned .t { font-size: 15px; font-weight: 800; }
|
||
.pinned .s { font-size: 12px; color: var(--text-secondary); margin-top: 2px; }
|
||
.members { display: grid; grid-template-columns: repeat(auto-fill, minmax(96px, 1fr)); gap: 12px 8px; }
|
||
.mem { display: flex; flex-direction: column; align-items: center; gap: 5px; text-align: center; }
|
||
.mem .nm { font-size: 11.5px; font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 96px; }
|
||
.mem .role { font-size: 9.5px; font-weight: 900; letter-spacing: 0.08em; text-transform: uppercase; color: var(--text-muted); }
|
||
.mem .role.own { color: #d9a6ff; }
|
||
/* holdings: the disclosure and the who-shares list */
|
||
.disc { display: flex; gap: 8px; align-items: flex-start; padding: 9px 11px; border-radius: 9px; background: color-mix(in srgb, var(--warn) 7%, transparent); border: 1px solid color-mix(in srgb, var(--warn) 25%, transparent); font-size: 11.5px; color: var(--text-secondary); line-height: 1.5; }
|
||
.disc b { color: #f0c070; }
|
||
.askinl input { height: 30px; padding: 0 10px; border-radius: 8px; background: var(--surf-hi); border: none; color: var(--text-primary); font-size: 12px; outline: none; }
|
||
.askinl input::placeholder { color: var(--text-muted); }
|
||
.hlist { display: flex; flex-direction: column; gap: 2px; margin-top: 8px; }
|
||
.hrow { display: flex; align-items: center; gap: 10px; padding: 5px 8px; border-radius: 8px; min-height: 40px; }
|
||
.hrow:hover { background: var(--surf-hi); }
|
||
.hrow .tx { min-width: 0; flex: 1; display: flex; flex-direction: column; gap: 2px; }
|
||
.hrow .l1 { font-size: 13px; font-weight: 800; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||
.hrow .l2 { font-size: 11.5px; color: var(--text-muted); }
|
||
.hrow .l2 b { color: var(--text-secondary); }
|
||
/* ── you ── */
|
||
/* The You section sized by the PANE (container query), never the window:
|
||
three columns from ~1240px of pane, two down to ~820, then one. */
|
||
.community { container-type: inline-size; }
|
||
.yougrid { display: grid; gap: 18px; align-items: start; grid-template-columns: minmax(380px, 1.15fr) minmax(340px, 1fr) minmax(340px, 1fr); }
|
||
.yougrid.setup { grid-template-columns: minmax(380px, 1fr) minmax(360px, 1fr); }
|
||
.yc { display: flex; flex-direction: column; gap: 12px; min-width: 0; }
|
||
.yc .panel + .panel { margin-top: 0; }
|
||
@container (max-width: 1240px) {
|
||
.yougrid { grid-template-columns: minmax(360px, 1fr) minmax(340px, 1fr); }
|
||
.yc.a { grid-column: 1; }
|
||
.yc.b { grid-column: 1; }
|
||
.yc.c { grid-column: 2; grid-row: 1 / span 2; }
|
||
}
|
||
@container (max-width: 820px) {
|
||
.yougrid, .yougrid.setup { grid-template-columns: 1fr; }
|
||
.yc.c { grid-column: 1; grid-row: auto; }
|
||
}
|
||
/* A chat wider than a comfortable line reads badly; the pane may be 1900px. */
|
||
.split .thread { max-width: 1200px; }
|
||
.noderow { display: flex; align-items: center; gap: 10px; margin-top: 10px; flex-wrap: wrap; }
|
||
.bl { display: inline-block; width: 6px; height: 6px; border-radius: 50%; background: var(--accent-hover); animation: cm-blink 1.4s infinite; margin-right: 2px; vertical-align: middle; }
|
||
@keyframes cm-blink { 50% { opacity: 0.25; } }
|
||
.field { display: flex; flex-direction: column; gap: 6px; margin-top: 12px; }
|
||
.field label { font-size: 10.5px; font-weight: 900; letter-spacing: 0.1em; text-transform: uppercase; color: var(--text-muted); display: flex; justify-content: space-between; }
|
||
.field label span { letter-spacing: 0; text-transform: none; font-weight: 700; }
|
||
.field label span.over { color: var(--err); }
|
||
.field input, .field textarea { background: var(--surf-hi); border: 1px solid transparent; border-radius: 8px; color: #fff; padding: 8px 12px; font-size: 13px; outline: none; resize: none; font-family: inherit; }
|
||
.field input:focus, .field textarea:focus { border-color: rgba(255, 255, 255, 0.28); }
|
||
.likes { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin: 2px 0 12px; }
|
||
.likes .inl { flex: 1; min-width: 240px; }
|
||
.likes .chip .x {
|
||
all: unset;
|
||
cursor: pointer;
|
||
margin-left: 6px;
|
||
opacity: 0.65;
|
||
line-height: 1;
|
||
}
|
||
.likes .chip .x:hover { opacity: 1; }
|
||
.inl { display: flex; gap: 8px; align-items: center; }
|
||
.inl input { flex: 1; min-width: 0; }
|
||
.chk { display: flex; align-items: center; gap: 7px; font-size: 11.5px; color: var(--text-muted); }
|
||
.btns { display: flex; gap: 8px; margin-top: 10px; flex-wrap: wrap; }
|
||
.avset { display: flex; gap: 16px; align-items: center; margin-top: 14px; }
|
||
.avset .opts { display: flex; flex-direction: column; gap: 4px; align-items: flex-start; }
|
||
.opt { display: flex; align-items: center; gap: 9px; padding: 5px 10px; border-radius: 8px; font-size: 12.5px; font-weight: 700; color: var(--text-secondary); text-align: left; }
|
||
.opt:hover:not(:disabled) { background: var(--surf-hi); color: #fff; }
|
||
.opt:disabled { opacity: 0.5; cursor: default; }
|
||
.opt.on { color: #fff; }
|
||
.opt .hint { display: block; font-weight: 600; }
|
||
.opt .rad { width: 14px; height: 14px; border-radius: 50%; border: 1.5px solid rgba(255, 255, 255, 0.35); display: grid; place-items: center; flex: none; }
|
||
.opt.on .rad::after { content: ""; width: 7px; height: 7px; border-radius: 50%; background: var(--accent); }
|
||
.pub { display: flex; align-items: center; gap: 10px; margin-top: 16px; padding-top: 12px; border-top: 1px solid var(--hair); flex-wrap: wrap; }
|
||
.fpblock { display: flex; align-items: center; gap: 10px; margin-top: 8px; }
|
||
.kbox { flex: 1; background: var(--surf-hi); border-radius: 8px; padding: 8px 12px; color: #fff; display: flex; align-items: center; gap: 4px; flex-wrap: wrap; user-select: all; }
|
||
.kbox i { font-style: normal; color: var(--text-muted); }
|
||
.kv { display: grid; grid-template-columns: auto 1fr; gap: 4px 14px; font-size: 11.5px; margin: 8px 0 0; }
|
||
.kv dt { color: var(--text-muted); font-weight: 700; }
|
||
.kv dd { margin: 0; color: var(--text-secondary); overflow-wrap: anywhere; }
|
||
.kv dd b { color: var(--text-primary); }
|
||
.netcard { display: flex; gap: 12px; align-items: flex-start; margin-top: 12px; }
|
||
.nico { width: 36px; height: 36px; border-radius: 10px; display: grid; place-items: center; flex: none; }
|
||
.nico.slsk { background: rgba(255, 255, 255, 0.08); color: #fff; }
|
||
.nico.fn { background: var(--accent-soft); color: var(--accent-hover); }
|
||
.ntx { min-width: 0; flex: 1; }
|
||
.netcard .t { font-size: 13.5px; font-weight: 800; display: flex; align-items: center; gap: 8px; }
|
||
.netcard .s { font-size: 11.5px; color: var(--text-muted); margin-top: 3px; line-height: 1.5; }
|
||
.netcard .s b { color: var(--text-secondary); font-weight: 700; }
|
||
.tog { display: inline-block; width: 36px; height: 20px; border-radius: 10px; background: rgba(255, 255, 255, 0.16); position: relative; flex: none; transition: background 0.15s; }
|
||
.tog::after { content: ""; position: absolute; top: 2px; left: 2px; width: 16px; height: 16px; border-radius: 50%; background: #fff; transition: left 0.15s; }
|
||
.tog.on { background: var(--accent); }
|
||
.tog.on::after { left: 18px; }
|
||
.tog:disabled { opacity: 0.5; cursor: default; }
|
||
.tog.big { margin-top: 6px; }
|
||
.swrow { display: flex; align-items: center; gap: 10px; margin-top: 12px; font-size: 12px; color: var(--text-secondary); }
|
||
.swrow .hint.r { margin-left: auto; }
|
||
.disc { margin-top: 10px; }
|
||
.disc summary { font-size: 11.5px; font-weight: 800; color: var(--text-secondary); cursor: pointer; }
|
||
.disc ul { margin: 8px 0 0; padding-left: 18px; font-size: 11.5px; color: var(--text-muted); line-height: 1.6; }
|
||
.danger { display: flex; gap: 8px; align-items: center; margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--hair); flex-wrap: wrap; }
|
||
|
||
/* ── the person drawer (portaled to body — see portal()) ── */
|
||
.overlay { --hair: rgba(255, 255, 255, 0.07); --surf: var(--bg-elevated); --surf-hi: var(--bg-highlight); --ok: #3ddc84; --warn: #e8a33d; --err: #f15e6c; font-family: var(--app-font); font-size: 13px; color: var(--text-primary); }
|
||
.scrim { position: fixed; inset: 0; z-index: 60; background: rgba(0, 0, 0, 0.45); }
|
||
.drawer { position: fixed; top: 0; right: 0; bottom: 0; width: 440px; z-index: 61; background: var(--bg-elevated); border-left: 1px solid rgba(255, 255, 255, 0.08); box-shadow: -24px 0 60px rgba(0, 0, 0, 0.5); overflow-y: auto; padding: 18px 20px 30px; animation: drawer-in 0.24s cubic-bezier(0.22, 1, 0.36, 1); }
|
||
@keyframes drawer-in { from { transform: translateX(100%); } to { transform: none; } }
|
||
.dhead { display: flex; gap: 14px; align-items: center; }
|
||
.dtx { min-width: 0; flex: 1; }
|
||
.dhead .nm { font-size: 20px; font-weight: 800; display: flex; align-items: center; gap: 8px; }
|
||
.pet { background: none; border: 0; border-bottom: 1px dashed rgba(255, 255, 255, 0.3); color: #fff; font: inherit; font-size: 20px; font-weight: 800; width: 180px; outline: none; padding: 0 0 2px; }
|
||
.pet::placeholder { color: #fff; opacity: 0.85; }
|
||
.dhead .sub { font-size: 11.5px; color: var(--text-muted); margin-top: 3px; display: flex; gap: 7px; align-items: center; flex-wrap: wrap; max-width: none; }
|
||
.dhead .x { align-self: flex-start; }
|
||
.dacts { display: flex; gap: 6px; margin: 14px 0 10px; flex-wrap: wrap; }
|
||
.marks { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
|
||
.markbtn { display: inline-flex; align-items: center; gap: 5px; height: 28px; padding: 0 11px; border-radius: 99px; font-size: 11px; font-weight: 800; background: var(--surf); color: var(--text-secondary); }
|
||
.markbtn.on { background: color-mix(in srgb, var(--warn) 16%, transparent); color: var(--warn); }
|
||
.markbtn.tr.on { background: color-mix(in srgb, var(--ok) 14%, transparent); color: #5ee89a; }
|
||
.grp-in { background: var(--surf-hi); border: 1px solid transparent; border-radius: 99px; color: #fff; padding: 5px 12px; font: inherit; font-size: 11.5px; width: 130px; outline: none; }
|
||
.grp-in:focus { border-color: rgba(255, 255, 255, 0.28); }
|
||
.note-in { width: 100%; margin-top: 8px; background: var(--surf-hi); border: 1px solid transparent; border-radius: 8px; color: #fff; padding: 8px 12px; font: inherit; font-size: 12.5px; outline: none; resize: none; }
|
||
.note-in:focus { border-color: rgba(255, 255, 255, 0.28); }
|
||
.tagrow { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 6px; }
|
||
.tag { font-size: 11px; font-weight: 700; padding: 3px 9px; border-radius: 99px; background: var(--surf-hi); color: var(--text-secondary); }
|
||
.tag.hate { text-decoration: line-through; opacity: 0.7; }
|
||
.desc { margin: 8px 0 0; font-size: 12.5px; line-height: 1.5; color: var(--text-secondary); white-space: pre-wrap; overflow-wrap: anywhere; max-height: 180px; overflow-y: auto; }
|
||
.rp { display: flex; gap: 12px; align-items: flex-start; }
|
||
.rptx { min-width: 0; flex: 1; }
|
||
.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; }
|
||
.dsec .kv { margin-top: 0; }
|
||
.dsec p.hint { margin: 8px 0 0; }
|
||
.dfoot { margin-top: 20px; padding: 12px 14px; border-radius: 10px; border: 1px dashed rgba(255, 255, 255, 0.16); font-size: 11.5px; color: var(--text-muted); line-height: 1.5; }
|
||
|
||
@media (max-width: 1180px) {
|
||
.split, .split.narrow { grid-template-columns: 1fr; }
|
||
.roomwrap { grid-template-columns: 1fr; }
|
||
.roster { display: none; }
|
||
}
|
||
</style>
|