Initial commit: Goostr static site
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
21093eb800
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
node_modules/
|
||||
.DS_Store
|
||||
6
.stignore
Normal file
6
.stignore
Normal file
@ -0,0 +1,6 @@
|
||||
/node_modules
|
||||
node_modules
|
||||
/.yarn
|
||||
.yarn
|
||||
/.pnp.*
|
||||
.pnp.*
|
||||
94
README.md
Normal file
94
README.md
Normal file
@ -0,0 +1,94 @@
|
||||
# Goostr (static)
|
||||
|
||||
Your Google account is your nostr identity. No server, no key management.
|
||||
|
||||
**Pure static site.** No build step. No backend. No install required to run.
|
||||
|
||||
## How it works
|
||||
|
||||
1. User clicks **Sign in with Google**. We request three OAuth scopes: `profile`, `email`, and `drive.appdata`.
|
||||
2. We look in the user's **Google Drive App Data folder** for a file `goostr-key-v1.json`. (App Data is a per-OAuth-client hidden folder in the user's Drive that only this app's client ID can read. WhatsApp uses it for backups; Enpass uses it for vault sync.)
|
||||
- **If found** → download it. The 32 bytes inside are the nostr private key. User is now signed into their existing nostr identity.
|
||||
- **If missing** → generate 32 fresh random bytes (`crypto.getRandomValues`), upload to App Data. User now has a brand-new nostr identity.
|
||||
3. We fetch the user's Google name + picture. For a brand-new identity we publish a kind-0 metadata event with them so it has a profile. For an existing one, if no relay returns a kind-0 within ~6s, we publish it then.
|
||||
4. SessionStorage caches the key inside the tab (max 12h) so reloads don't require re-auth. Closing the tab logs out.
|
||||
|
||||
Note: the key is **stored**, not derived. It's random bytes that live in the Drive file. Delete that file (or revoke/delete the OAuth client, which makes App Data unreadable) and the identity is gone for good.
|
||||
|
||||
### The demo app
|
||||
|
||||
Once signed in, [`app.js`](public/app.js) shows your name, avatar and npub, a dot per relay (connecting / open / closed / timeout), your last 50 notes (kind 1), and a box to publish a new note.
|
||||
|
||||
### Relays
|
||||
|
||||
[`relay.js`](public/relay.js) opens every relay in [`config.js`](public/config.js) in parallel (4s timeout each) and lets the UI continue as soon as **one** is open. Relays that connect later are sent every active subscription plus anything published in the last 60s, so nothing is lost to a slow relay.
|
||||
|
||||
### Why this is good
|
||||
|
||||
- Works across devices: sign in on phone, laptop, anything — same Google account → same nostr identity.
|
||||
- No nsec to lose, no seed phrase to back up. Recovery == "remember your Google password."
|
||||
- The seed key never touches *this* device's disk. It lives in Google Drive (encrypted at rest by Google) and in tab-only sessionStorage.
|
||||
|
||||
### What this is *not*
|
||||
|
||||
- Not as secure as `keystr` (passkey-based). Anyone with your Google password + 2FA can read your nostr key. With keystr/passkeys, the user must physically be at the authenticator device.
|
||||
- Not for situations where you want to be invisible to a single big platform — Google obviously knows you own this Drive App Data file.
|
||||
- Not safe against script injection. While signed in, the raw key sits in `sessionStorage`, readable by any script on the page. That includes `nostr-tools`, loaded from `esm.sh` via the import map in `index.html` with no integrity check. Pin/self-host it before trusting this with a real identity.
|
||||
- Every sign-in shows Google's consent screen (`prompt: "consent"`) unless `signIn({ silent: true })` is used.
|
||||
|
||||
## One-time Google setup (~2 minutes)
|
||||
|
||||
1. Open <https://console.cloud.google.com/apis/credentials>.
|
||||
2. Create or select a project.
|
||||
3. Enable the **Google Drive API** under "Library."
|
||||
4. Under "OAuth consent screen", configure a minimal app (External + Testing is fine for prototyping; add yourself as a Test User).
|
||||
5. Back in "Credentials" → **Create Credentials** → **OAuth client ID** → **Web application**.
|
||||
6. Under **Authorized JavaScript origins** add:
|
||||
- `http://localhost:5174` (for local dev)
|
||||
- your deploy URL (e.g. `https://you.github.io`)
|
||||
7. Copy the **Client ID** — looks like `1234567890-abcdef.apps.googleusercontent.com`.
|
||||
8. Paste it into [`public/config.js`](public/config.js) as the value of `CLIENT_ID`.
|
||||
|
||||
## Run it
|
||||
|
||||
```bash
|
||||
python3 -m http.server -d public 5174
|
||||
# or
|
||||
npx serve public -l 5174
|
||||
```
|
||||
|
||||
Then open <http://localhost:5174>. Google requires HTTPS for OAuth in production, but `localhost` is exempt for dev.
|
||||
|
||||
## Deploy it
|
||||
|
||||
Static folder. Drop `public/` into any host. GitHub Pages, Netlify, Vercel, Cloudflare Pages, S3 — all fine. Make sure HTTPS is on, and add your deployed URL to the Google Cloud OAuth client's authorized origins.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
public/
|
||||
index.html — entry point + setup banner
|
||||
app.js — UI glue
|
||||
goostr.js — library: signIn(), restoreSession(), clearSession()
|
||||
drive.js — Google Drive App Data helpers
|
||||
relay.js — minimal nostr WebSocket pool with replay-on-late-connect
|
||||
style.css — dark UI
|
||||
config.js — CLIENT_ID + relay list (edit this)
|
||||
```
|
||||
|
||||
## Library use
|
||||
|
||||
```js
|
||||
import { signIn, restoreSession, clearSession } from "./goostr.js";
|
||||
|
||||
// On boot, try restoring first (sessionStorage):
|
||||
let session = restoreSession();
|
||||
if (!session) {
|
||||
session = await signIn({
|
||||
clientId: "1234.apps.googleusercontent.com",
|
||||
onStatus: (msg) => console.log(msg),
|
||||
});
|
||||
}
|
||||
const { signer, profile } = session;
|
||||
// signer.sign({ kind: 1, content: "hello nostr", tags: [] })
|
||||
```
|
||||
19
package.json
Normal file
19
package.json
Normal file
@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "goostr-static",
|
||||
"version": "0.1.0",
|
||||
"description": "Sign in with Google → derive a nostr identity from a key stored in your Drive App Data. Pure static site, no server.",
|
||||
"type": "module",
|
||||
"main": "public/goostr.js",
|
||||
"exports": {
|
||||
".": "./public/goostr.js"
|
||||
},
|
||||
"files": ["public/goostr.js", "public/drive.js"],
|
||||
"peerDependencies": {
|
||||
"nostr-tools": "^2.7.0"
|
||||
},
|
||||
"scripts": {
|
||||
"serve": "npx --yes serve public -l 5174"
|
||||
},
|
||||
"keywords": ["nostr", "google", "oauth", "drive-appdata", "static"],
|
||||
"license": "MIT"
|
||||
}
|
||||
216
public/app.js
Normal file
216
public/app.js
Normal file
@ -0,0 +1,216 @@
|
||||
import { CLIENT_ID } from "./config.js";
|
||||
import {
|
||||
signIn,
|
||||
restoreSession,
|
||||
clearSession,
|
||||
isConfigured,
|
||||
} from "./goostr.js";
|
||||
import { pool } from "./relay.js";
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const views = ["loading", "signin", "home"];
|
||||
function show(view) {
|
||||
for (const v of views) $(`view-${v}`).classList.toggle("hidden", v !== view);
|
||||
}
|
||||
function setStatus(id, msg, kind = "") {
|
||||
const el = $(id);
|
||||
el.textContent = msg;
|
||||
el.className = "status" + (kind ? " " + kind : "");
|
||||
}
|
||||
|
||||
let signer = null;
|
||||
let currentProfile = null;
|
||||
let publishedProfileTs = 0; // most-recent kind-0 we've published or rendered
|
||||
|
||||
const DEFAULT_AVATAR = `data:image/svg+xml;utf8,${encodeURIComponent(
|
||||
`<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'>
|
||||
<rect width='64' height='64' rx='32' fill='#1e2128'/>
|
||||
<text x='32' y='42' font-size='32' text-anchor='middle' font-family='sans-serif'>👤</text>
|
||||
</svg>`
|
||||
)}`;
|
||||
|
||||
// ---------- sign-in ----------
|
||||
|
||||
$("signin-go").addEventListener("click", async () => {
|
||||
$("signin-go").disabled = true;
|
||||
try {
|
||||
const { signer: s, profile, isNewIdentity } = await signIn({
|
||||
clientId: CLIENT_ID,
|
||||
onStatus: (msg) => setStatus("signin-status", msg),
|
||||
});
|
||||
signer = s;
|
||||
currentProfile = profile;
|
||||
|
||||
setStatus("signin-status", "Connecting to relays…");
|
||||
const unsubStatus = pool.onStatus((snap) => {
|
||||
const open = Object.values(snap).filter((s) => s === "open").length;
|
||||
setStatus("signin-status", `Connecting to relays… ${open}/${Object.keys(snap).length}`);
|
||||
});
|
||||
await pool.connectAll({ minRelays: 1 });
|
||||
unsubStatus();
|
||||
|
||||
if (isNewIdentity) {
|
||||
// First time: publish a kind-0 with the Google name + picture so this
|
||||
// user shows up properly on nostr.
|
||||
publishProfileEvent(profile);
|
||||
}
|
||||
|
||||
enterHome(profile);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setStatus("signin-status", err.message || String(err), "error");
|
||||
$("signin-go").disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
$("sign-out").addEventListener("click", () => {
|
||||
clearSession();
|
||||
signer = null;
|
||||
location.reload();
|
||||
});
|
||||
|
||||
// ---------- home / live profile / notes ----------
|
||||
|
||||
function publishProfileEvent(profile) {
|
||||
const event = signer.sign({
|
||||
kind: 0,
|
||||
content: JSON.stringify({
|
||||
name: profile.name,
|
||||
picture: profile.picture,
|
||||
// `nip05` would be nice here but we don't host one
|
||||
}),
|
||||
tags: [],
|
||||
});
|
||||
pool.publish(event);
|
||||
publishedProfileTs = event.created_at;
|
||||
}
|
||||
|
||||
function enterHome(profile) {
|
||||
$("me-name").textContent = profile.name || "anonymous";
|
||||
$("me-pic").src = profile.picture || DEFAULT_AVATAR;
|
||||
$("me-npub").textContent = signer.npub;
|
||||
show("home");
|
||||
pool.onStatus(renderRelayDots);
|
||||
startLiveProfile(signer.pubkey, profile);
|
||||
loadNotes();
|
||||
}
|
||||
|
||||
function renderRelayDots(snap) {
|
||||
const host = $("relay-dots");
|
||||
host.innerHTML = "";
|
||||
for (const [url, state] of Object.entries(snap)) {
|
||||
const dot = document.createElement("span");
|
||||
dot.className = `relay-dot ${state}`;
|
||||
dot.title = `${url} — ${state}`;
|
||||
host.appendChild(dot);
|
||||
}
|
||||
}
|
||||
|
||||
function safeJSON(s) { try { return JSON.parse(s); } catch { return {}; } }
|
||||
|
||||
function startLiveProfile(pubkey, googleProfile) {
|
||||
// Long-lived sub for the latest kind-0. If a relay has a newer event than
|
||||
// what's on screen, upgrade. If no relay returns anything within a few
|
||||
// seconds, our google-side profile may not have been published anywhere —
|
||||
// republish to make sure it lands.
|
||||
let sawOne = false;
|
||||
pool.subscribe(
|
||||
[{ kinds: [0], authors: [pubkey] }],
|
||||
(ev) => {
|
||||
sawOne = true;
|
||||
if (ev.created_at <= publishedProfileTs) return;
|
||||
const p = safeJSON(ev.content);
|
||||
publishedProfileTs = ev.created_at;
|
||||
if (p.name) $("me-name").textContent = p.name;
|
||||
if (p.picture) $("me-pic").src = p.picture;
|
||||
},
|
||||
null
|
||||
);
|
||||
|
||||
setTimeout(() => {
|
||||
if (!sawOne && googleProfile?.name) {
|
||||
// No relay had our profile. Publish it now.
|
||||
publishProfileEvent(googleProfile);
|
||||
}
|
||||
}, 6000);
|
||||
}
|
||||
|
||||
let notesById = new Map();
|
||||
|
||||
function loadNotes() {
|
||||
const tbody = $("notes-table").querySelector("tbody");
|
||||
tbody.innerHTML = "";
|
||||
setStatus("notes-status", "Loading…");
|
||||
notesById = new Map();
|
||||
|
||||
pool.subscribe(
|
||||
[{ kinds: [1], authors: [signer.pubkey], limit: 50 }],
|
||||
(ev) => {
|
||||
if (notesById.has(ev.id)) return;
|
||||
notesById.set(ev.id, ev);
|
||||
renderRows([...notesById.values()], tbody);
|
||||
setStatus("notes-status", "");
|
||||
},
|
||||
null
|
||||
);
|
||||
|
||||
setTimeout(() => {
|
||||
if (notesById.size === 0) {
|
||||
setStatus("notes-status", "No notes yet. Publish one above.");
|
||||
}
|
||||
}, 8000);
|
||||
}
|
||||
|
||||
function renderRows(rows, tbody) {
|
||||
const sorted = [...rows].sort((a, b) => b.created_at - a.created_at);
|
||||
tbody.innerHTML = "";
|
||||
for (const ev of sorted) {
|
||||
const tr = document.createElement("tr");
|
||||
const t = document.createElement("td");
|
||||
t.textContent = new Date(ev.created_at * 1000).toLocaleString();
|
||||
const c = document.createElement("td");
|
||||
c.textContent = ev.content;
|
||||
tr.appendChild(t);
|
||||
tr.appendChild(c);
|
||||
tbody.appendChild(tr);
|
||||
}
|
||||
}
|
||||
|
||||
$("note-send").addEventListener("click", () => {
|
||||
const text = $("note-text").value.trim();
|
||||
if (!text) return;
|
||||
$("note-send").disabled = true;
|
||||
const event = signer.sign({ kind: 1, content: text, tags: [] });
|
||||
pool.publish(event);
|
||||
$("note-text").value = "";
|
||||
$("note-send").disabled = false;
|
||||
notesById.set(event.id, event);
|
||||
renderRows([...notesById.values()], $("notes-table").querySelector("tbody"));
|
||||
setStatus("notes-status", "");
|
||||
});
|
||||
|
||||
// ---------- boot ----------
|
||||
|
||||
(async function boot() {
|
||||
if (!isConfigured(CLIENT_ID)) {
|
||||
$("setup-banner").classList.remove("hidden");
|
||||
show("signin");
|
||||
setStatus("signin-status", "Configure CLIENT_ID in public/config.js first.", "error");
|
||||
$("signin-go").disabled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const restored = restoreSession();
|
||||
if (restored) {
|
||||
signer = restored.signer;
|
||||
currentProfile = restored.profile;
|
||||
show("loading");
|
||||
$("loading-text").textContent = "Welcome back. Connecting to relays…";
|
||||
pool.onStatus(renderRelayDots);
|
||||
await pool.connectAll({ minRelays: 1 });
|
||||
enterHome(restored.profile);
|
||||
return;
|
||||
}
|
||||
|
||||
show("signin");
|
||||
})();
|
||||
17
public/config.js
Normal file
17
public/config.js
Normal file
@ -0,0 +1,17 @@
|
||||
// Goostr config. Edit this file with your Google OAuth client ID before
|
||||
// running the app. See README.md for how to get one.
|
||||
//
|
||||
// Format: "1234567890-abcdef.apps.googleusercontent.com"
|
||||
export const CLIENT_ID = "114166903886-pd1lb3k10o66qacn10smdnd63k64vont.apps.googleusercontent.com";
|
||||
|
||||
// Public nostr relays to publish to and read from.
|
||||
export const RELAYS = [
|
||||
"wss://relay.damus.io",
|
||||
"wss://nos.lol",
|
||||
"wss://relay.primal.net",
|
||||
"wss://relay.snort.social",
|
||||
"wss://nostr.wine",
|
||||
"wss://offchain.pub",
|
||||
"wss://relay.nostr.band",
|
||||
"wss://nostr.mom",
|
||||
];
|
||||
77
public/drive.js
Normal file
77
public/drive.js
Normal file
@ -0,0 +1,77 @@
|
||||
// Google Drive AppData helpers. Files written to the appDataFolder space are
|
||||
// invisible to the user in the regular Drive UI and only readable by the
|
||||
// OAuth client (i.e. *this* app's client_id) that created them.
|
||||
//
|
||||
// Docs: https://developers.google.com/drive/api/guides/appdata
|
||||
|
||||
const DRIVE = "https://www.googleapis.com/drive/v3";
|
||||
const UPLOAD = "https://www.googleapis.com/upload/drive/v3";
|
||||
|
||||
async function driveFetch(token, url, init = {}) {
|
||||
const res = await fetch(url, {
|
||||
...init,
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
...(init.headers || {}),
|
||||
},
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`Drive API ${res.status}: ${text.slice(0, 300)}`);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
// Find a file by exact name inside the appDataFolder. Returns the file id, or
|
||||
// null if not found.
|
||||
export async function findAppDataFile(token, name) {
|
||||
const url =
|
||||
`${DRIVE}/files?spaces=appDataFolder` +
|
||||
`&q=${encodeURIComponent(`name = '${name.replace(/'/g, "\\'")}'`)}` +
|
||||
`&fields=files(id,name,modifiedTime)`;
|
||||
const res = await driveFetch(token, url);
|
||||
const { files } = await res.json();
|
||||
if (!files || files.length === 0) return null;
|
||||
// If somehow multiple, prefer the most recent.
|
||||
files.sort((a, b) => (b.modifiedTime || "").localeCompare(a.modifiedTime || ""));
|
||||
return files[0].id;
|
||||
}
|
||||
|
||||
export async function downloadFile(token, fileId) {
|
||||
const res = await driveFetch(token, `${DRIVE}/files/${fileId}?alt=media`);
|
||||
return res.text();
|
||||
}
|
||||
|
||||
// Multipart upload of a small JSON blob to appDataFolder. If fileId is given,
|
||||
// updates that file (preserving parents); otherwise creates a new file.
|
||||
export async function uploadAppDataJson(token, name, content, fileId = null) {
|
||||
const boundary = "goostr-" + Math.random().toString(36).slice(2);
|
||||
const metadata = fileId
|
||||
? { name }
|
||||
: { name, parents: ["appDataFolder"] };
|
||||
|
||||
const body =
|
||||
`--${boundary}\r\n` +
|
||||
`Content-Type: application/json; charset=UTF-8\r\n\r\n` +
|
||||
`${JSON.stringify(metadata)}\r\n` +
|
||||
`--${boundary}\r\n` +
|
||||
`Content-Type: application/json\r\n\r\n` +
|
||||
`${content}\r\n` +
|
||||
`--${boundary}--`;
|
||||
|
||||
const url = fileId
|
||||
? `${UPLOAD}/files/${fileId}?uploadType=multipart`
|
||||
: `${UPLOAD}/files?uploadType=multipart`;
|
||||
|
||||
const res = await driveFetch(token, url, {
|
||||
method: fileId ? "PATCH" : "POST",
|
||||
headers: { "Content-Type": `multipart/related; boundary=${boundary}` },
|
||||
body,
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function fetchUserInfo(token) {
|
||||
const res = await driveFetch(token, "https://www.googleapis.com/oauth2/v3/userinfo");
|
||||
return res.json(); // { sub, name, picture, email, ... }
|
||||
}
|
||||
194
public/goostr.js
Normal file
194
public/goostr.js
Normal file
@ -0,0 +1,194 @@
|
||||
// Goostr — Google-account-to-nostr login library.
|
||||
//
|
||||
// Flow:
|
||||
// signIn({ clientId })
|
||||
// → opens Google sign-in popup
|
||||
// → requests OAuth scopes: profile, email, drive.appdata
|
||||
// → looks in Drive App Data for goostr-key-v1.json
|
||||
// · if found, downloads the 32-byte seed
|
||||
// · if missing, generates 32 fresh bytes and uploads them
|
||||
// → returns { signer, profile } where profile is the Google name/picture/email
|
||||
//
|
||||
// The 32-byte seed is the nostr private key. It lives in:
|
||||
// 1. The user's Drive App Data — survives device loss, syncs across devices.
|
||||
// 2. sessionStorage for the current tab — survives reload, dies on tab close.
|
||||
// It is never written to localStorage, IndexedDB, or any other disk-persistent
|
||||
// store on the local device.
|
||||
|
||||
import { getPublicKey, finalizeEvent, nip19 } from "nostr-tools";
|
||||
import {
|
||||
findAppDataFile,
|
||||
downloadFile,
|
||||
uploadAppDataJson,
|
||||
fetchUserInfo,
|
||||
} from "./drive.js";
|
||||
|
||||
const KEY_FILE = "goostr-key-v1.json";
|
||||
const SCOPES = [
|
||||
"https://www.googleapis.com/auth/drive.appdata",
|
||||
"https://www.googleapis.com/auth/userinfo.profile",
|
||||
"https://www.googleapis.com/auth/userinfo.email",
|
||||
"openid",
|
||||
].join(" ");
|
||||
|
||||
// ---------- Google Identity Services token client ----------
|
||||
|
||||
let gisReady = null;
|
||||
function ensureGisLoaded() {
|
||||
if (gisReady) return gisReady;
|
||||
gisReady = new Promise((resolve, reject) => {
|
||||
if (window.google?.accounts?.oauth2) return resolve();
|
||||
const s = document.createElement("script");
|
||||
s.src = "https://accounts.google.com/gsi/client";
|
||||
s.async = true;
|
||||
s.defer = true;
|
||||
s.onload = () => resolve();
|
||||
s.onerror = () => reject(new Error("Failed to load Google Identity Services script."));
|
||||
document.head.appendChild(s);
|
||||
});
|
||||
return gisReady;
|
||||
}
|
||||
|
||||
async function requestAccessToken(clientId, { silent = false } = {}) {
|
||||
await ensureGisLoaded();
|
||||
return new Promise((resolve, reject) => {
|
||||
const client = google.accounts.oauth2.initTokenClient({
|
||||
client_id: clientId,
|
||||
scope: SCOPES,
|
||||
prompt: silent ? "" : "consent",
|
||||
callback: (resp) => {
|
||||
if (resp.error) return reject(new Error(`Google auth: ${resp.error}`));
|
||||
if (!resp.access_token) return reject(new Error("Google auth: no access token returned."));
|
||||
resolve(resp.access_token);
|
||||
},
|
||||
error_callback: (err) => reject(new Error(`Google auth: ${err.type || "error"}`)),
|
||||
});
|
||||
client.requestAccessToken();
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- key bytes / signer ----------
|
||||
|
||||
function randomBytes(len) {
|
||||
const buf = new Uint8Array(len);
|
||||
crypto.getRandomValues(buf);
|
||||
return buf;
|
||||
}
|
||||
|
||||
function b64encBytes(bytes) {
|
||||
let s = "";
|
||||
for (const b of bytes) s += String.fromCharCode(b);
|
||||
return btoa(s);
|
||||
}
|
||||
function b64decBytes(b64) {
|
||||
const bin = atob(b64);
|
||||
const out = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
||||
return out;
|
||||
}
|
||||
|
||||
function makeSigner(privKeyBytes) {
|
||||
const pubkey = getPublicKey(privKeyBytes);
|
||||
return {
|
||||
pubkey,
|
||||
npub: nip19.npubEncode(pubkey),
|
||||
sign(unsignedEvent) {
|
||||
return finalizeEvent(
|
||||
{
|
||||
...unsignedEvent,
|
||||
pubkey,
|
||||
created_at: unsignedEvent.created_at ?? Math.floor(Date.now() / 1000),
|
||||
},
|
||||
privKeyBytes
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---------- session persistence (tab-scoped) ----------
|
||||
|
||||
const SESSION_KEY = "goostr.session.v1";
|
||||
const SESSION_MAX_AGE_MS = 12 * 60 * 60 * 1000; // 12h ceiling
|
||||
|
||||
function saveSession(privKeyBytes, profile) {
|
||||
try {
|
||||
sessionStorage.setItem(
|
||||
SESSION_KEY,
|
||||
JSON.stringify({ k: b64encBytes(privKeyBytes), p: profile, t: Date.now() })
|
||||
);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export function restoreSession() {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(SESSION_KEY);
|
||||
if (!raw) return null;
|
||||
const { k, p, t } = JSON.parse(raw);
|
||||
if (!k || !t || Date.now() - t > SESSION_MAX_AGE_MS) {
|
||||
sessionStorage.removeItem(SESSION_KEY);
|
||||
return null;
|
||||
}
|
||||
return { signer: makeSigner(b64decBytes(k)), profile: p || {} };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function clearSession() {
|
||||
try { sessionStorage.removeItem(SESSION_KEY); } catch {}
|
||||
}
|
||||
|
||||
// ---------- key file on Drive ----------
|
||||
|
||||
async function loadOrCreateSeed(accessToken, onStatus) {
|
||||
onStatus?.("Looking for your key in Google Drive…");
|
||||
const fileId = await findAppDataFile(accessToken, KEY_FILE);
|
||||
|
||||
if (fileId) {
|
||||
onStatus?.("Found existing key. Unlocking your nostr identity…");
|
||||
const text = await downloadFile(accessToken, fileId);
|
||||
let parsed;
|
||||
try { parsed = JSON.parse(text); } catch { parsed = null; }
|
||||
if (!parsed?.k) throw new Error("Stored key file is malformed.");
|
||||
return { bytes: b64decBytes(parsed.k), created: false };
|
||||
}
|
||||
|
||||
onStatus?.("First time on this Google account — generating a new identity…");
|
||||
const bytes = randomBytes(32);
|
||||
const payload = JSON.stringify({
|
||||
v: 1,
|
||||
k: b64encBytes(bytes),
|
||||
created_at: new Date().toISOString(),
|
||||
});
|
||||
await uploadAppDataJson(accessToken, KEY_FILE, payload, null);
|
||||
return { bytes, created: true };
|
||||
}
|
||||
|
||||
// ---------- public API ----------
|
||||
|
||||
export async function signIn({ clientId, silent = false, onStatus } = {}) {
|
||||
if (!clientId || clientId.startsWith("PASTE-")) {
|
||||
throw new Error("CLIENT_ID is not configured. Edit public/config.js with your Google OAuth client ID. See README.md.");
|
||||
}
|
||||
|
||||
onStatus?.("Signing in with Google…");
|
||||
const accessToken = await requestAccessToken(clientId, { silent });
|
||||
|
||||
onStatus?.("Reading your Google profile…");
|
||||
const userInfo = await fetchUserInfo(accessToken);
|
||||
const profile = {
|
||||
sub: userInfo.sub,
|
||||
name: userInfo.name || userInfo.email || "Anonymous",
|
||||
picture: userInfo.picture || null,
|
||||
email: userInfo.email || null,
|
||||
};
|
||||
|
||||
const { bytes, created } = await loadOrCreateSeed(accessToken, onStatus);
|
||||
|
||||
saveSession(bytes, profile);
|
||||
return { signer: makeSigner(bytes), profile, isNewIdentity: created };
|
||||
}
|
||||
|
||||
export function isConfigured(clientId) {
|
||||
return !!clientId && !clientId.startsWith("PASTE-") && clientId.endsWith(".apps.googleusercontent.com");
|
||||
}
|
||||
85
public/index.html
Normal file
85
public/index.html
Normal file
@ -0,0 +1,85 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Goostr — Google to nostr</title>
|
||||
<link rel="stylesheet" href="./style.css" />
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"nostr-tools": "https://esm.sh/nostr-tools@2.7.2"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<header>
|
||||
<h1>Goostr</h1>
|
||||
<p class="tag">Your Google account is your nostr identity.</p>
|
||||
</header>
|
||||
|
||||
<div id="setup-banner" class="banner-warn hidden">
|
||||
<strong>Setup required.</strong> Edit <code>public/config.js</code> and
|
||||
paste your Google OAuth client ID into <code>CLIENT_ID</code>. See
|
||||
<a href="./README.md">README</a> for the 2-minute setup.
|
||||
</div>
|
||||
|
||||
<section id="view-loading" class="view">
|
||||
<p id="loading-text">Checking session…</p>
|
||||
</section>
|
||||
|
||||
<!-- Sign in -->
|
||||
<section id="view-signin" class="view hidden">
|
||||
<h2>Create or unlock your nostr identity</h2>
|
||||
<p style="color: var(--muted); font-size: 14px; margin-top: -8px;">
|
||||
We'll save a secret key in your Google Drive's app data — a private
|
||||
folder only this app can read. The key is the seed for your nostr
|
||||
identity. Sign in on any device to use the same identity.
|
||||
</p>
|
||||
<button id="signin-go" class="google">
|
||||
<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M17.64 9.2c0-.637-.057-1.251-.164-1.84H9v3.481h4.844a4.14 4.14 0 0 1-1.796 2.716v2.259h2.908c1.702-1.567 2.684-3.875 2.684-6.615z" fill="#4285F4"/>
|
||||
<path d="M9 18c2.43 0 4.467-.806 5.956-2.184l-2.908-2.259c-.806.54-1.837.86-3.048.86-2.344 0-4.328-1.584-5.036-3.711H.957v2.332A8.997 8.997 0 0 0 9 18z" fill="#34A853"/>
|
||||
<path d="M3.964 10.706A5.41 5.41 0 0 1 3.682 9c0-.593.102-1.17.282-1.706V4.962H.957A8.997 8.997 0 0 0 0 9c0 1.452.348 2.827.957 4.038l3.007-2.332z" fill="#FBBC05"/>
|
||||
<path d="M9 3.58c1.321 0 2.508.454 3.44 1.345l2.582-2.58C13.463.891 11.426 0 9 0A8.997 8.997 0 0 0 .957 4.962L3.964 7.294C4.672 5.167 6.656 3.58 9 3.58z" fill="#EA4335"/>
|
||||
</svg>
|
||||
Sign in with Google
|
||||
</button>
|
||||
<p id="signin-status" class="status"></p>
|
||||
</section>
|
||||
|
||||
<!-- Logged in -->
|
||||
<section id="view-home" class="view hidden">
|
||||
<div class="profile">
|
||||
<img id="me-pic" alt="" />
|
||||
<div>
|
||||
<h2 id="me-name"></h2>
|
||||
<p class="npub" id="me-npub"></p>
|
||||
</div>
|
||||
<button id="sign-out" class="ghost">Sign out</button>
|
||||
</div>
|
||||
|
||||
<div class="composer">
|
||||
<textarea id="note-text" placeholder="Write something. It will be signed by your nostr key and published." maxlength="500"></textarea>
|
||||
<button id="note-send" class="primary">Publish</button>
|
||||
</div>
|
||||
|
||||
<div class="relays-bar">
|
||||
<span class="label">Relays</span>
|
||||
<span id="relay-dots"></span>
|
||||
</div>
|
||||
|
||||
<h3>Your logs</h3>
|
||||
<table id="notes-table">
|
||||
<thead><tr><th>When</th><th>Note</th></tr></thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
<p id="notes-status" class="status">Loading…</p>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script type="module" src="./app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
210
public/relay.js
Normal file
210
public/relay.js
Normal file
@ -0,0 +1,210 @@
|
||||
// Minimal nostr relay pool — fast-start, fault-tolerant.
|
||||
//
|
||||
// Design:
|
||||
// * Open all relays in parallel, each with a 4s connect timeout.
|
||||
// * connectAll() resolves as soon as the FIRST relay opens (or after the
|
||||
// overall timeout). The rest keep opening in the background.
|
||||
// * Late-arriving relays automatically replay anything we've already sent
|
||||
// (active subscriptions + recent publishes), so the user never waits.
|
||||
// * Per-relay status is exposed via onStatus() so the UI can show progress.
|
||||
|
||||
import { RELAYS } from "./config.js";
|
||||
|
||||
const PER_RELAY_CONNECT_TIMEOUT_MS = 4000;
|
||||
const OVERALL_CONNECT_TIMEOUT_MS = 4500; // hard ceiling for connectAll()
|
||||
const PUBLISH_REPLAY_WINDOW_MS = 60_000; // re-send recent publishes to late relays
|
||||
|
||||
class RelayPool {
|
||||
constructor(urls = RELAYS) {
|
||||
this.urls = urls;
|
||||
this.sockets = new Map(); // url -> WebSocket (only OPEN ones)
|
||||
this.status = new Map(); // url -> "connecting"|"open"|"closed"|"timeout"
|
||||
this.subs = new Map(); // subId -> { req, handler }
|
||||
this.recentPublishes = []; // { msg, t }
|
||||
this.subId = 0;
|
||||
this._listeners = new Set(); // status change listeners
|
||||
}
|
||||
|
||||
onStatus(fn) {
|
||||
this._listeners.add(fn);
|
||||
fn(this.statusSnapshot());
|
||||
return () => this._listeners.delete(fn);
|
||||
}
|
||||
|
||||
statusSnapshot() {
|
||||
const out = {};
|
||||
for (const u of this.urls) out[u] = this.status.get(u) || "idle";
|
||||
return out;
|
||||
}
|
||||
|
||||
_notify() {
|
||||
const snap = this.statusSnapshot();
|
||||
for (const fn of this._listeners) fn(snap);
|
||||
}
|
||||
|
||||
_open(url) {
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
const finish = (ok) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolve(ok);
|
||||
};
|
||||
|
||||
let ws;
|
||||
try {
|
||||
ws = new WebSocket(url);
|
||||
} catch {
|
||||
this.status.set(url, "closed");
|
||||
this._notify();
|
||||
return finish(false);
|
||||
}
|
||||
|
||||
this.status.set(url, "connecting");
|
||||
this._notify();
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
if (ws.readyState === WebSocket.CONNECTING) {
|
||||
this.status.set(url, "timeout");
|
||||
this._notify();
|
||||
try { ws.close(); } catch {}
|
||||
finish(false);
|
||||
}
|
||||
}, PER_RELAY_CONNECT_TIMEOUT_MS);
|
||||
|
||||
ws.addEventListener("open", () => {
|
||||
clearTimeout(timer);
|
||||
const t0 = performance.now();
|
||||
this.sockets.set(url, ws);
|
||||
this.status.set(url, "open");
|
||||
this._notify();
|
||||
console.log(`[relay] open ${url} (${Math.round(t0)}ms since page)`);
|
||||
|
||||
// Replay active subscriptions and recent publishes to this new relay.
|
||||
for (const { req } of this.subs.values()) {
|
||||
try { ws.send(req); } catch {}
|
||||
}
|
||||
const cutoff = Date.now() - PUBLISH_REPLAY_WINDOW_MS;
|
||||
for (const { msg, t } of this.recentPublishes) {
|
||||
if (t >= cutoff) {
|
||||
try { ws.send(msg); } catch {}
|
||||
}
|
||||
}
|
||||
finish(true);
|
||||
});
|
||||
|
||||
ws.addEventListener("error", () => {
|
||||
clearTimeout(timer);
|
||||
if (this.status.get(url) !== "open") this.status.set(url, "closed");
|
||||
this._notify();
|
||||
finish(false);
|
||||
});
|
||||
|
||||
ws.addEventListener("close", () => {
|
||||
clearTimeout(timer);
|
||||
this.sockets.delete(url);
|
||||
if (this.status.get(url) !== "timeout") this.status.set(url, "closed");
|
||||
this._notify();
|
||||
finish(false);
|
||||
});
|
||||
|
||||
// Wire messages to active subscription handlers.
|
||||
ws.addEventListener("message", (ev) => {
|
||||
let msg;
|
||||
try { msg = JSON.parse(ev.data); } catch { return; }
|
||||
const subId = msg[1];
|
||||
const sub = this.subs.get(subId);
|
||||
if (sub) sub.handler(msg, url);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Resolves as soon as we have at least `minRelays` open, or after the
|
||||
// overall timeout. Always returns the number of relays that opened.
|
||||
async connectAll({ minRelays = 1 } = {}) {
|
||||
const attempts = this.urls.map((u) => this._open(u));
|
||||
let opened = 0;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let done = false;
|
||||
const finish = () => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
resolve(this.sockets.size);
|
||||
};
|
||||
|
||||
const ceiling = setTimeout(finish, OVERALL_CONNECT_TIMEOUT_MS);
|
||||
|
||||
attempts.forEach((p) =>
|
||||
p.then((ok) => {
|
||||
if (ok) opened++;
|
||||
if (opened >= minRelays) {
|
||||
clearTimeout(ceiling);
|
||||
// Resolve quickly once we hit the threshold; other relays keep
|
||||
// opening in the background and self-register via _open().
|
||||
finish();
|
||||
}
|
||||
if (opened === 0 && attempts.every((_) => false)) {
|
||||
/* unreachable; here for clarity */
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// If every attempt fully settled with zero opens, resolve too.
|
||||
Promise.allSettled(attempts).then(() => finish());
|
||||
});
|
||||
}
|
||||
|
||||
publish(event) {
|
||||
const msg = JSON.stringify(["EVENT", event]);
|
||||
this.recentPublishes.push({ msg, t: Date.now() });
|
||||
// trim
|
||||
if (this.recentPublishes.length > 200) this.recentPublishes.shift();
|
||||
for (const ws of this.sockets.values()) {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
try { ws.send(msg); } catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// filters: array of REQ filters. onEvent(event) per match. onEose() once
|
||||
// every currently-open relay has signaled EOSE.
|
||||
subscribe(filters, onEvent, onEose) {
|
||||
const id = `s${++this.subId}`;
|
||||
const req = JSON.stringify(["REQ", id, ...filters]);
|
||||
const seen = new Set();
|
||||
const eoseSet = new Set();
|
||||
|
||||
const handler = (msg, url) => {
|
||||
if (msg[0] === "EVENT" && msg[1] === id) {
|
||||
const e = msg[2];
|
||||
if (seen.has(e.id)) return;
|
||||
seen.add(e.id);
|
||||
onEvent(e);
|
||||
} else if (msg[0] === "EOSE" && msg[1] === id) {
|
||||
eoseSet.add(url);
|
||||
if (eoseSet.size >= this.sockets.size && onEose) onEose();
|
||||
}
|
||||
};
|
||||
|
||||
this.subs.set(id, { req, handler });
|
||||
|
||||
for (const ws of this.sockets.values()) {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
try { ws.send(req); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
const close = JSON.stringify(["CLOSE", id]);
|
||||
for (const ws of this.sockets.values()) {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
try { ws.send(close); } catch {}
|
||||
}
|
||||
}
|
||||
this.subs.delete(id);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const pool = new RelayPool();
|
||||
258
public/style.css
Normal file
258
public/style.css
Normal file
@ -0,0 +1,258 @@
|
||||
:root {
|
||||
--bg: #0e0f12;
|
||||
--panel: #16181d;
|
||||
--panel-2: #1e2128;
|
||||
--text: #e8e8ea;
|
||||
--muted: #8b8f99;
|
||||
--accent: #8b5cf6;
|
||||
--accent-2: #6d28d9;
|
||||
--border: #262932;
|
||||
--danger: #ef4444;
|
||||
--ok: #10b981;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, system-ui, sans-serif;
|
||||
font-size: 15px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
main {
|
||||
max-width: 620px;
|
||||
margin: 0 auto;
|
||||
padding: 48px 20px 80px;
|
||||
}
|
||||
|
||||
header { margin-bottom: 32px; }
|
||||
h1 {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
margin: 0 0 4px;
|
||||
letter-spacing: -0.02em;
|
||||
background: linear-gradient(90deg, #4285f4, #34a853, #fbbc04, #ea4335);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
.tag { color: var(--muted); margin: 0; }
|
||||
|
||||
.view {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
padding: 28px;
|
||||
}
|
||||
.view + .view { margin-top: 16px; }
|
||||
.hidden { display: none !important; }
|
||||
|
||||
h2 { margin: 0 0 16px; font-size: 20px; font-weight: 600; }
|
||||
h3 { margin: 24px 0 8px; font-size: 15px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
|
||||
.field { display: block; margin-bottom: 20px; }
|
||||
.field > span { display: block; font-size: 13px; color: var(--muted); margin-bottom: 6px; }
|
||||
|
||||
input[type="text"], textarea {
|
||||
width: 100%;
|
||||
background: var(--panel-2);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 12px 14px;
|
||||
font-size: 15px;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
input[type="text"]:focus, textarea:focus { border-color: var(--accent); }
|
||||
textarea { resize: vertical; min-height: 84px; }
|
||||
|
||||
.picker {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(8, 1fr);
|
||||
gap: 6px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.picker button {
|
||||
background: var(--panel-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 8px 0;
|
||||
font-size: 22px;
|
||||
cursor: pointer;
|
||||
transition: transform 0.1s, border-color 0.1s;
|
||||
}
|
||||
.picker button:hover { transform: scale(1.08); }
|
||||
.picker button.selected { border-color: var(--accent); background: rgba(139, 92, 246, 0.12); }
|
||||
|
||||
.upload {
|
||||
display: inline-block;
|
||||
font-size: 13px;
|
||||
color: var(--accent);
|
||||
cursor: pointer;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.upload:hover { text-decoration: underline; }
|
||||
|
||||
.preview img {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 50%;
|
||||
background: var(--panel-2);
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
.preview img:empty, .preview img[src=""] { display: none; }
|
||||
|
||||
button {
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
button.primary {
|
||||
background: linear-gradient(180deg, var(--accent), var(--accent-2));
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
padding: 12px 18px;
|
||||
font-weight: 600;
|
||||
width: 100%;
|
||||
transition: filter 0.15s, transform 0.05s;
|
||||
}
|
||||
button.primary:hover:not(:disabled) { filter: brightness(1.08); }
|
||||
button.primary:active:not(:disabled) { transform: translateY(1px); }
|
||||
button.primary:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
button.google {
|
||||
background: white;
|
||||
color: #1f1f1f;
|
||||
border: 1px solid #dadce0;
|
||||
border-radius: 10px;
|
||||
padding: 12px 18px;
|
||||
font-weight: 500;
|
||||
font-size: 15px;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
button.google:hover:not(:disabled) { background: #f6f9ff; box-shadow: 0 1px 3px rgba(0,0,0,0.15); }
|
||||
button.google:active:not(:disabled) { background: #ebf2ff; }
|
||||
button.google:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
button.google svg { width: 18px; height: 18px; flex-shrink: 0; }
|
||||
|
||||
.banner-warn {
|
||||
background: rgba(234, 67, 53, 0.1);
|
||||
border: 1px solid rgba(234, 67, 53, 0.4);
|
||||
color: #ffd2cd;
|
||||
border-radius: 10px;
|
||||
padding: 14px 16px;
|
||||
font-size: 13px;
|
||||
margin-bottom: 16px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.banner-warn code {
|
||||
background: rgba(0,0,0,0.35);
|
||||
border-radius: 4px;
|
||||
padding: 1px 6px;
|
||||
font-family: ui-monospace, "SF Mono", Menlo, monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
.banner-warn a { color: #ffe; }
|
||||
|
||||
button.ghost {
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 6px 12px;
|
||||
}
|
||||
button.ghost:hover { color: var(--text); border-color: var(--muted); }
|
||||
|
||||
.status {
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
margin: 12px 0 0;
|
||||
min-height: 18px;
|
||||
}
|
||||
.status.error { color: var(--danger); }
|
||||
.status.ok { color: var(--ok); }
|
||||
|
||||
.secondary { font-size: 13px; color: var(--muted); margin-top: 18px; }
|
||||
.secondary a { color: var(--accent); text-decoration: none; }
|
||||
.secondary a:hover { text-decoration: underline; }
|
||||
|
||||
.profile {
|
||||
display: grid;
|
||||
grid-template-columns: 64px 1fr auto;
|
||||
gap: 14px;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.profile img {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 50%;
|
||||
background: var(--panel-2);
|
||||
object-fit: cover;
|
||||
}
|
||||
.profile h2 { margin: 0; }
|
||||
.profile .npub {
|
||||
font-family: ui-monospace, "SF Mono", Menlo, monospace;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
margin: 2px 0 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
.composer { display: grid; gap: 10px; margin-bottom: 8px; }
|
||||
|
||||
.relays-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 18px;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.relays-bar .label { text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
.relay-dot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
margin-right: 4px;
|
||||
background: var(--border);
|
||||
cursor: help;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.relay-dot.open { background: var(--ok); }
|
||||
.relay-dot.connecting { background: #facc15; animation: pulse 1s infinite; }
|
||||
.relay-dot.timeout, .relay-dot.closed { background: var(--danger); }
|
||||
@keyframes pulse { 50% { opacity: 0.4; } }
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 14px;
|
||||
}
|
||||
th, td {
|
||||
text-align: left;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
vertical-align: top;
|
||||
}
|
||||
th { color: var(--muted); font-weight: 500; font-size: 12px; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
td:first-child { color: var(--muted); white-space: nowrap; width: 140px; font-variant-numeric: tabular-nums; }
|
||||
tbody tr:last-child td { border-bottom: none; }
|
||||
Loading…
x
Reference in New Issue
Block a user