Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e943870d77 | ||
|
|
08fc661077 | ||
|
|
92430296c2 | ||
|
|
bc8e2f46fd | ||
|
|
bd713d01f9 | ||
|
|
7ce913997a | ||
|
|
63f5097a6d | ||
|
|
89fed0c818 | ||
|
|
0c9e8bebcb | ||
|
|
22334ad432 | ||
|
|
3ff4bc532b |
201
plans/document-page.md
Normal file
201
plans/document-page.md
Normal file
@@ -0,0 +1,201 @@
|
||||
# Document Page (`www/document.html`)
|
||||
|
||||
## Overview
|
||||
|
||||
A new page, derived from [`www/ai.html`](../www/ai.html:1), that lets the user co-author a long-form Nostr note with an LLM assistant. The page starts as an encrypted draft (**kind 30024**) and can be promoted to a published article (**kind 30023**).
|
||||
|
||||
Layout is a 3-column page:
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[Documents + Skills<br/>left pane] --> B[Chat thread<br/>center pane]
|
||||
B --> C[Document column<br/>title + markdown editor<br/>markdown/plaintext toggle]
|
||||
C -.autosave 30024.-> R[(Nostr relays)]
|
||||
B -.chat turn JSON.-> LLM[LLM]
|
||||
LLM -.document + chat.-> B
|
||||
LLM -.document.-> C
|
||||
B -.autosave 30078 doc-chat:dtag.-> R
|
||||
```
|
||||
|
||||
**There is no "Conversations" list.** The chat log is bound 1:1 to the document. Selecting a document loads its entire past chat; creating a new document creates a new empty chat.
|
||||
|
||||
## Approach summary (from Q&A)
|
||||
|
||||
| Topic | Decision |
|
||||
|------|----------|
|
||||
| LLM response format | **Option A** — `response_format: {type: 'json_object'}` returning `{ "chat": "...", "document": "..." }`. LLM always returns the full document. |
|
||||
| Skill strategy | **Option C (hybrid)** — a hardcoded page-level wrapper enforces the JSON contract; user-selected kind 31123 skills are concatenated *above* it as style/tone layers. |
|
||||
| Document column behavior | **Editable** textarea (same feel as [`www/note.html`](../www/note.html:1)); both the user and the LLM can write to it. |
|
||||
| View toggle | Reuse [`dot-menu`](../www/js/dot-menu.mjs:1): "View as markdown", "View as plaintext", "Copy document". |
|
||||
|
||||
## 1. HTML structure changes
|
||||
|
||||
Starting from the current copy of [`www/ai.html`](../www/ai.html:1) at [`www/document.html`](../www/document.html:1):
|
||||
|
||||
- Change `<title>` and header text from "AI" to "Document".
|
||||
- Rename `#divAiLayout` to a 3-column flex row:
|
||||
- Left: `#divDocumentsPane` (renamed from `#divAiConversationsPane`) — **Documents list on top, Skills on bottom**. The "Conversations" list is removed entirely.
|
||||
- Center: `#divAiChatPane` (unchanged internals; just displays the chat log for the currently-selected document).
|
||||
- **New right pane:** `#divAiDocumentPane` containing:
|
||||
- Header row with title input, publish-as-30023 button, and a `.doc-menu-host` for the dot-menu.
|
||||
- Summary input + hashtags input (single line each).
|
||||
- Image URL input (optional).
|
||||
- `#taDocument` markdown textarea (main editor).
|
||||
- `#divDocumentPreview` hidden div for rendered-markdown view mode.
|
||||
- Small status line ("Saved / UNSAVED", current kind, current `d` tag).
|
||||
- Empty state: when no document is selected, the chat input is disabled and the document pane shows a placeholder "Select or create a document".
|
||||
- CSS: `#divAiDocumentPane` is `flex: 1.2; min-width: 420px;` similar styling to the chat pane.
|
||||
|
||||
## 2. Document model (ported from note.html)
|
||||
|
||||
Port the following from [`www/note.html`](../www/note.html:552) but render the list in the **left column** (not the sidenav):
|
||||
|
||||
- `OBJ_NOTES` map keyed by `d` tag.
|
||||
- `renderDocumentsList()` — renders `OBJ_NOTES` as a vertical list in `#divDocumentsList` (replaces `#divAiConversationsList`). Each row: title (or "Untitled"), small meta (`kind`, last-edited), delete button.
|
||||
- `LoadNote(d)` — populates `#taDocument`, title/summary/hashtags/image inputs, sets `CURRENT_NOTE = d`, re-renders preview via `marked`, AND triggers `loadDocChat(d)` (see §5.3).
|
||||
- `newDocument()` — assigns `CURRENT_NOTE = now()`, clears inputs and chat thread. No publish yet; publish happens on first real save.
|
||||
- `DeleteNote(d)` — kind 5 delete event for the document. Also deletes the paired `doc-chat:<d>` kind-30078 event.
|
||||
- `Publish30024Note()` — autosave path (unchanged logic).
|
||||
- `Publish30023Note(d)` — promote draft → published (unchanged logic).
|
||||
|
||||
The sidenav drawer keeps only relay / blossom / AI-config sections — no notes table duplicated there.
|
||||
|
||||
## 3. Autosave
|
||||
|
||||
Mirror note.html's autosave:
|
||||
|
||||
- `LastText` baseline vs. current textarea value.
|
||||
- Footer shows "UNSAVED" vs. "Saved".
|
||||
- Debounced save on textarea input (e.g. 1500 ms idle). Immediate save when the LLM writes a new document. Immediate save on blur if dirty.
|
||||
- On save, `Publish30024Note()` keeps the same `d` tag.
|
||||
|
||||
## 4. Markdown / plaintext / copy dot-menu
|
||||
|
||||
A tiny helper inside `document.html` (no new module needed) uses [`mountDotMenu`](../www/js/dot-menu.mjs:1) on `.doc-menu-host`:
|
||||
|
||||
```text
|
||||
┌───────── Document ⋯ ─────────┐
|
||||
│ View as markdown │
|
||||
│ View as plaintext │
|
||||
│ ────────────────────────────│
|
||||
│ Copy document │
|
||||
└──────────────────────────────┘
|
||||
```
|
||||
|
||||
- **Plaintext** (default): show `#taDocument`, hide `#divDocumentPreview`.
|
||||
- **Markdown**: hide textarea, show `#divDocumentPreview` with `marked.parse(taDocument.value)` (purified via `DOMPurify` already loaded by the page).
|
||||
- **Copy document**: clipboard copy of the raw textarea value.
|
||||
|
||||
The current view mode lives in a `documentViewMode` variable (not per-message like messaging-ui — it's a single document). The pattern is copied from `setMessageViewMode` / `renderBubbleContent` in [`www/js/messaging-ui.mjs`](../www/js/messaging-ui.mjs:36).
|
||||
|
||||
## 5. LLM ↔ document wiring
|
||||
|
||||
### 5.1 Hardcoded skill wrapper
|
||||
|
||||
Add a constant inside `document.html`:
|
||||
|
||||
```js
|
||||
const DOCUMENT_EDITOR_WRAPPER = `
|
||||
system:
|
||||
You are collaborating with the user on a single long-form markdown document.
|
||||
Every reply MUST be valid JSON with exactly these fields:
|
||||
|
||||
{
|
||||
"chat": "<short conversational reply, plain text>",
|
||||
"document": "<the FULL updated markdown document, or null if unchanged>"
|
||||
}
|
||||
|
||||
Rules:
|
||||
- Never include commentary outside the JSON.
|
||||
- Always return the ENTIRE document in "document" when you make ANY edit, even small ones.
|
||||
- Set "document" to null (or omit it) when the user only asked a question and no edit is needed.
|
||||
- Preserve the user's existing structure unless they ask you to restructure.
|
||||
- Keep markdown formatting (headings, lists, code fences) intact.
|
||||
|
||||
user:
|
||||
The current document is below, inside <document></document>. The user's new message follows.
|
||||
|
||||
<document>
|
||||
{{document}}
|
||||
</document>
|
||||
|
||||
User message:
|
||||
{{message}}
|
||||
`;
|
||||
```
|
||||
|
||||
This wrapper is concatenated *after* any user-selected kind 31123 skills (so user skills set style/tone, wrapper enforces JSON contract last and therefore wins on conflicting instructions — per the existing "last skill wins" rule in [`plans/ai-skills-integration.md`](ai-skills-integration.md:5)).
|
||||
|
||||
### 5.2 Send flow changes
|
||||
|
||||
In the existing `sendPrompt()` path (the one already ported from ai.html):
|
||||
|
||||
1. Build `combinedSystem` from selected skills as today, then append `DOCUMENT_EDITOR_WRAPPER`'s system portion.
|
||||
2. Resolve `{{document}}` placeholder with `taDocument.value` and `{{message}}` with the user's input.
|
||||
3. Call the chat endpoint using [`sendAiChatJson`](../www/js/ai-chat.mjs:72) (already supports `response_format: { type: 'json_object' }`) — replaces the current plain `callOpenAICompatibleChat` path only on this page.
|
||||
4. On success, `result.json` is `{ chat, document }`:
|
||||
- Append `chat` as the assistant message in the thread.
|
||||
- If `document` is a non-empty string and differs from `taDocument.value`, replace the textarea, re-render preview if in markdown view, trigger immediate autosave.
|
||||
- If `document` is `null`/omitted, skip the document update (chat-only turn).
|
||||
5. On malformed JSON, fall back to displaying `result.content` as the assistant message and do NOT touch the document. Show an inline warning chip under the message.
|
||||
|
||||
### 5.3 Chat storage — bound 1:1 to the document
|
||||
|
||||
There is **no separate Conversations concept**. The chat log for a document is persisted as its own addressable event:
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| `kind` | `30078` |
|
||||
| `d` tag | `doc-chat:<docDTag>` — e.g. `doc-chat:1714310000` |
|
||||
| `t` tag | `client-document-chat-v1` (distinct from ai.html's `client-ai-chat-v1`) |
|
||||
| `content` | NIP-44-encrypted JSON `{ schema: 1, messages: [...] }` |
|
||||
|
||||
Each message object shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "uuid-ish",
|
||||
"role": "user" | "assistant",
|
||||
"content": "text shown in bubble",
|
||||
"documentSnapshot": "optional full markdown at time of assistant turn",
|
||||
"created_at": 1714310015
|
||||
}
|
||||
```
|
||||
|
||||
Storing the `documentSnapshot` with the assistant message is optional but cheap and gives us a natural undo/rollback surface later.
|
||||
|
||||
**Load flow** (`loadDocChat(d)`):
|
||||
1. Query cache for `{ kinds: [30078], authors: [self], '#d': ['doc-chat:' + d], '#t': ['client-document-chat-v1'] }`.
|
||||
2. Decrypt `content` with NIP-44.
|
||||
3. Parse `messages` array and render via the existing ai.html message thread renderer.
|
||||
4. If no event exists, render empty thread.
|
||||
|
||||
**Save flow** (`saveDocChat(d, messages)`):
|
||||
1. Encrypt `{ schema: 1, messages }` with NIP-44 to self.
|
||||
2. Publish kind 30078 with tags `[['d', 'doc-chat:' + d], ['t', 'client-document-chat-v1']]`.
|
||||
3. Because addressable, this replaces the prior version on relays.
|
||||
|
||||
**When messages are published**: after each completed assistant turn (chat appended + optional document replaced). User-only drafts in the input box are NOT published.
|
||||
|
||||
**Empty-to-first-message transition**: if `CURRENT_NOTE` is a freshly-created doc that has never been saved, sending the first user message runs `Publish30024Note()` first (so the document `d` exists on relays) and then `saveDocChat()`, preventing orphan chats.
|
||||
|
||||
**Deletion**: `DeleteNote(d)` publishes two kind-5 events — one for the document event id, one for the `doc-chat:<d>` event id.
|
||||
|
||||
## 6. Publish-as-30023 action
|
||||
|
||||
A "Publish as article" button in the document column header calls `Publish30023Note(CURRENT_NOTE)` (ported unchanged from note.html), with the same confirmation prompt. After publishing, the page reloads the sidenav list; the draft `d` tag is kept or cleaned up per note.html's existing logic.
|
||||
|
||||
## 7. Files touched
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| [`www/document.html`](../www/document.html:1) | Major modification — new column, note model port, LLM JSON round-trip, dot-menu view toggle. |
|
||||
| [`plans/document-page.md`](document-page.md:1) | This plan (new). |
|
||||
|
||||
No new JS modules are required. Everything reuses existing modules: [`dot-menu.mjs`](../www/js/dot-menu.mjs:1), [`ai-chat.mjs`](../www/js/ai-chat.mjs:1), [`init-ndk.mjs`](../www/js/init-ndk.mjs:1), `marked`, `DOMPurify`.
|
||||
|
||||
## 8. Open questions (non-blocking)
|
||||
|
||||
- Should we show a visible diff when the LLM rewrites the document (red/green lines) before accepting? — Deferred; v1 just overwrites and relies on Nostr history for rollback.
|
||||
- Should very large documents be chunked into the prompt? — Deferred; rely on `max_tokens` and surface a truncation warning (`sendAiChatJson` already returns `truncated`).
|
||||
- Should we debounce-autosave every keystroke or only on blur? — Start with 1500 ms idle + blur; revisit if relay spam shows up.
|
||||
@@ -799,7 +799,7 @@
|
||||
</div>
|
||||
|
||||
<script src="./nostr.bundle.js"></script>
|
||||
<script src="./nostr-lite.js"></script>
|
||||
<script src="/nostr-login-lite/nostr-lite.js"></script>
|
||||
|
||||
<script type="module">
|
||||
import {
|
||||
|
||||
@@ -743,7 +743,7 @@
|
||||
</div>
|
||||
|
||||
<script src="./nostr.bundle.js"></script>
|
||||
<script src="./nostr-lite.js"></script>
|
||||
<script src="/nostr-login-lite/nostr-lite.js"></script>
|
||||
|
||||
<script type="module">
|
||||
import {
|
||||
|
||||
@@ -684,7 +684,7 @@
|
||||
</div>
|
||||
|
||||
<script src="./nostr.bundle.js"></script>
|
||||
<script src="./nostr-lite.js"></script>
|
||||
<script src="/nostr-login-lite/nostr-lite.js"></script>
|
||||
|
||||
<script type="module">
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
|
||||
@@ -301,7 +301,7 @@
|
||||
</div>
|
||||
|
||||
<script src="./nostr.bundle.js"></script>
|
||||
<script src="./nostr-lite.js"></script>
|
||||
<script src="/nostr-login-lite/nostr-lite.js"></script>
|
||||
|
||||
<script type="module">
|
||||
import {
|
||||
|
||||
@@ -402,7 +402,7 @@
|
||||
</div>
|
||||
|
||||
<script src="./nostr.bundle.js"></script>
|
||||
<script src="./nostr-lite.js"></script>
|
||||
<script src="/nostr-login-lite/nostr-lite.js"></script>
|
||||
|
||||
<script type="module">
|
||||
import {
|
||||
|
||||
@@ -466,7 +466,7 @@
|
||||
</div>
|
||||
|
||||
<script src="./nostr.bundle.js"></script>
|
||||
<script src="./nostr-lite.js"></script>
|
||||
<script src="/nostr-login-lite/nostr-lite.js"></script>
|
||||
|
||||
<script type="module">
|
||||
import { initNDKPage, getPubkey, injectHeaderAvatar, subscribe, publishEvent, disconnect, encryptContent, decryptContent } from './js/init-ndk.mjs';
|
||||
|
||||
@@ -718,7 +718,7 @@
|
||||
|
||||
<script src="./nostr.bundle.js"></script>
|
||||
<script src="./ndk-core.bundle.js?v=wallet-hotfix-3"></script>
|
||||
<script src="./nostr-lite.js"></script>
|
||||
<script src="/nostr-login-lite/nostr-lite.js"></script>
|
||||
<script type="text/javascript" src="./js/qrcode-generator.min.js"></script>
|
||||
|
||||
<script type="module">
|
||||
|
||||
@@ -210,7 +210,7 @@
|
||||
2. nostr-lite.js - Authentication modal (nostr-login-lite)
|
||||
================================================================ -->
|
||||
<script src="./nostr.bundle.js"></script>
|
||||
<script src="./nostr-lite.js"></script>
|
||||
<script src="/nostr-login-lite/nostr-lite.js"></script>
|
||||
|
||||
<script type="module">
|
||||
/* ================================================================
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
.msg-thread-pane {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
border: 2px solid var(--primary-color);
|
||||
/* border: 2px solid var(--primary-color); */
|
||||
border-radius: 10px;
|
||||
background: var(--secondary-color);
|
||||
display: flex;
|
||||
|
||||
@@ -378,7 +378,7 @@
|
||||
REQUIRED SCRIPTS
|
||||
================================================================ -->
|
||||
<script src="./nostr.bundle.js"></script>
|
||||
<script src="./nostr-lite.js"></script>
|
||||
<script src="/nostr-login-lite/nostr-lite.js"></script>
|
||||
|
||||
<script type="module">
|
||||
/* ================================================================
|
||||
|
||||
@@ -513,7 +513,7 @@
|
||||
REQUIRED SCRIPTS
|
||||
================================================================ -->
|
||||
<script src="./nostr.bundle.js"></script>
|
||||
<script src="./nostr-lite.js"></script>
|
||||
<script src="/nostr-login-lite/nostr-lite.js"></script>
|
||||
|
||||
<script type="module">
|
||||
import { initNDKPage, getPubkey, injectHeaderAvatar, disconnect, getVersion, updateVersionDisplay } from './js/init-ndk.mjs';
|
||||
|
||||
4454
www/document.html
Normal file
4454
www/document.html
Normal file
File diff suppressed because it is too large
Load Diff
@@ -432,7 +432,7 @@
|
||||
</div>
|
||||
|
||||
<script src="./nostr.bundle.js"></script>
|
||||
<script src="./nostr-lite.js"></script>
|
||||
<script src="/nostr-login-lite/nostr-lite.js"></script>
|
||||
|
||||
<script type="module">
|
||||
import {
|
||||
|
||||
@@ -139,7 +139,7 @@
|
||||
</div>
|
||||
|
||||
<script src="./nostr.bundle.js"></script>
|
||||
<script src="./nostr-lite.js"></script>
|
||||
<script src="/nostr-login-lite/nostr-lite.js"></script>
|
||||
|
||||
<script type="module">
|
||||
import {
|
||||
|
||||
@@ -876,7 +876,7 @@
|
||||
</div>
|
||||
|
||||
<script src="./nostr.bundle.js"></script>
|
||||
<script src="./nostr-lite.js"></script>
|
||||
<script src="/nostr-login-lite/nostr-lite.js"></script>
|
||||
|
||||
<script type="module">
|
||||
import {
|
||||
|
||||
@@ -363,7 +363,7 @@
|
||||
================================================================ -->
|
||||
<script type="text/javascript" src="./js/qrcode-svg.min.js"></script>
|
||||
<script src="./nostr.bundle.js"></script>
|
||||
<script src="./nostr-lite.js"></script>
|
||||
<script src="/nostr-login-lite/nostr-lite.js"></script>
|
||||
|
||||
<script type="module">
|
||||
/* ================================================================
|
||||
|
||||
@@ -184,7 +184,7 @@ export async function initNDKPage(options = {}) {
|
||||
|
||||
// All pages in www/ directory, so worker path is always the same.
|
||||
// Include explicit worker revision token so updated SharedWorker code is guaranteed to restart.
|
||||
const WORKER_REVISION = 'relay-events-db-5';
|
||||
const WORKER_REVISION = 'relay-events-db-6';
|
||||
const workerPath = `./ndk-worker.js?v=${encodeURIComponent(VERSION)}&wr=${encodeURIComponent(WORKER_REVISION)}`;
|
||||
|
||||
// Initialize NDK SharedWorker (shared across all tabs/pages)
|
||||
@@ -217,6 +217,7 @@ export async function initNDKPage(options = {}) {
|
||||
const readyHandler = () => {
|
||||
cleanup();
|
||||
console.log('[init-ndk] Worker signaled ready, initialization complete');
|
||||
tryRegisterActiveSigningPort();
|
||||
resolve();
|
||||
};
|
||||
|
||||
@@ -273,7 +274,8 @@ async function ensureAuthenticated() {
|
||||
extension: true,
|
||||
local: true,
|
||||
nip46: true,
|
||||
seedphrase: true
|
||||
seedphrase: true,
|
||||
nsigner: true
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -319,6 +321,7 @@ async function ensureAuthenticated() {
|
||||
try {
|
||||
const pubkey = await getPublicKeyWithTimeout(4000);
|
||||
console.log('[init-ndk] Authentication successful, pubkey:', pubkey);
|
||||
tryRegisterActiveSigningPort();
|
||||
finish(true);
|
||||
} catch (error) {
|
||||
console.log('[init-ndk] window.nostr not ready yet:', error.message);
|
||||
@@ -335,6 +338,7 @@ async function ensureAuthenticated() {
|
||||
window.addEventListener('nlAuth', authHandler);
|
||||
window.addEventListener('nlAuthRestored', authHandler);
|
||||
window.addEventListener('nlMethodSelected', authHandler);
|
||||
window.addEventListener('nlAuthStateChanged', authHandler);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -484,6 +488,18 @@ function isExpectedSignerCapabilityError(method, error) {
|
||||
/**
|
||||
* Handle sign request from worker
|
||||
*/
|
||||
function tryRegisterActiveSigningPort() {
|
||||
if (!ndkWorker?.port) return;
|
||||
try {
|
||||
const auth = window?.nostr?.authState || window?.NOSTR_LOGIN_LITE?.getAuthState?.() || null;
|
||||
if (auth?.method === 'nsigner' && auth?.signer?.driver) {
|
||||
ndkWorker.port.postMessage({ type: 'setActiveSigningPort' });
|
||||
}
|
||||
} catch (_) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSignRequest(requestId, method, params) {
|
||||
try {
|
||||
let result;
|
||||
@@ -526,6 +542,9 @@ async function handleSignRequest(requestId, method, params) {
|
||||
requestId: requestId,
|
||||
result: result
|
||||
});
|
||||
|
||||
// If this tab successfully signs using hardware signer, pin worker routing to this tab.
|
||||
tryRegisterActiveSigningPort();
|
||||
|
||||
} catch (error) {
|
||||
if (isExpectedSignerCapabilityError(method, error)) {
|
||||
@@ -996,7 +1015,8 @@ export async function injectHeaderLoginButton() {
|
||||
extension: true,
|
||||
local: true,
|
||||
nip46: true,
|
||||
seedphrase: true
|
||||
seedphrase: true,
|
||||
nsigner: true
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"VERSION": "v0.7.4",
|
||||
"VERSION_NUMBER": "0.7.4",
|
||||
"BUILD_DATE": "2026-04-19T18:00:32.861Z"
|
||||
"VERSION": "v0.7.15",
|
||||
"VERSION_NUMBER": "0.7.15",
|
||||
"BUILD_DATE": "2026-05-28T18:44:00.780Z"
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ const STATS_POLL_INTERVAL = 10000;
|
||||
const DEFAULT_STREAMING_SITE = Object.freeze({
|
||||
name: 'laantungir.net',
|
||||
streamBaseUrl: 'https://laantungir.net',
|
||||
streamUrlTemplate: '{base}/stream/{slug}/stream.m3u8',
|
||||
rtmpServer: 'rtmp://laantungir.net:1935/publish',
|
||||
obsKeyTemplate: '{slug}/src/{SECRET_KEY}',
|
||||
});
|
||||
@@ -18,11 +19,13 @@ function normalizeStreamingSiteConfig(site) {
|
||||
const input = site && typeof site === 'object' ? site : {};
|
||||
const name = String(input.name || '').trim() || DEFAULT_STREAMING_SITE.name;
|
||||
const streamBaseUrl = String(input.streamBaseUrl || '').trim() || DEFAULT_STREAMING_SITE.streamBaseUrl;
|
||||
const streamUrlTemplate = String(input.streamUrlTemplate || '').trim() || DEFAULT_STREAMING_SITE.streamUrlTemplate;
|
||||
const rtmpServer = String(input.rtmpServer || '').trim() || DEFAULT_STREAMING_SITE.rtmpServer;
|
||||
const obsKeyTemplate = String(input.obsKeyTemplate || '').trim() || DEFAULT_STREAMING_SITE.obsKeyTemplate;
|
||||
return {
|
||||
name,
|
||||
streamBaseUrl: streamBaseUrl.replace(/\/+$/, ''),
|
||||
streamUrlTemplate,
|
||||
rtmpServer,
|
||||
obsKeyTemplate,
|
||||
};
|
||||
@@ -85,8 +88,12 @@ function deriveShowUrls(slug, siteConfig = DEFAULT_STREAMING_SITE) {
|
||||
}
|
||||
|
||||
const base = site.streamBaseUrl;
|
||||
const masterPlaylist = String(site.streamUrlTemplate || '').trim()
|
||||
.replace(/\{base\}/gi, base)
|
||||
.replace(/\{slug\}/gi, safeSlug)
|
||||
|| `${base}/stream/${safeSlug}/stream.m3u8`;
|
||||
return {
|
||||
masterPlaylist: `${base}/stream/${safeSlug}/stream.m3u8`,
|
||||
masterPlaylist,
|
||||
viewerPage: `${base}/stream/${safeSlug}`,
|
||||
stats: `${base}/api/stream/stats?show=${encodeURIComponent(safeSlug)}`,
|
||||
obsServer: site.rtmpServer,
|
||||
@@ -220,10 +227,11 @@ function parseStreamTargetFromUrl() {
|
||||
}
|
||||
|
||||
function buildStreamTags({ dTag, title, summary, image, streamingUrl, status, episodeId, episodeDescription, webUrl }) {
|
||||
const safeStatus = String(status || 'planned').trim() || 'planned';
|
||||
const tags = [
|
||||
['d', dTag],
|
||||
['title', String(title || '').trim() || 'Untitled stream'],
|
||||
['status', String(status || 'planned').trim() || 'planned']
|
||||
['status', safeStatus]
|
||||
];
|
||||
|
||||
const safeSummary = String(summary || '').trim();
|
||||
@@ -232,6 +240,7 @@ function buildStreamTags({ dTag, title, summary, image, streamingUrl, status, ep
|
||||
const safeEpisodeId = String(episodeId || '').trim();
|
||||
const safeEpisodeDescription = String(episodeDescription || '').trim();
|
||||
const safeWeb = String(webUrl || '').trim();
|
||||
const nowUnix = String(Math.floor(Date.now() / 1000));
|
||||
|
||||
if (safeSummary) tags.push(['summary', safeSummary]);
|
||||
if (safeImage) tags.push(['image', safeImage]);
|
||||
@@ -240,6 +249,14 @@ function buildStreamTags({ dTag, title, summary, image, streamingUrl, status, ep
|
||||
if (safeEpisodeDescription) tags.push(['episode_description', safeEpisodeDescription]);
|
||||
if (safeWeb) tags.push(['web', safeWeb]);
|
||||
|
||||
// NIP-53 compatibility: include timestamps so clients don't default to Unix epoch.
|
||||
// Prefer episodeId timestamp when available because it is set when the episode starts.
|
||||
const startsAt = /^\d+$/.test(safeEpisodeId) ? safeEpisodeId : nowUnix;
|
||||
tags.push(['starts', startsAt]);
|
||||
if (safeStatus === 'ended') {
|
||||
tags.push(['ends', nowUnix]);
|
||||
}
|
||||
|
||||
return tags;
|
||||
}
|
||||
|
||||
@@ -279,6 +296,7 @@ export function initVjStreamPanel({
|
||||
streamingSiteFormPanel: document.getElementById('streamingSiteFormPanel'),
|
||||
inputSiteName: document.getElementById('inputSiteName'),
|
||||
inputSiteStreamBaseUrl: document.getElementById('inputSiteStreamBaseUrl'),
|
||||
inputSiteStreamUrlTemplate: document.getElementById('inputSiteStreamUrlTemplate'),
|
||||
inputSiteRtmpServer: document.getElementById('inputSiteRtmpServer'),
|
||||
inputSiteObsKeyTemplate: document.getElementById('inputSiteObsKeyTemplate'),
|
||||
btnSaveStreamingSite: document.getElementById('btnSaveStreamingSite'),
|
||||
@@ -435,6 +453,9 @@ export function initVjStreamPanel({
|
||||
if (els.inputSiteStreamBaseUrl) {
|
||||
els.inputSiteStreamBaseUrl.value = String(seedSite?.streamBaseUrl || '').trim();
|
||||
}
|
||||
if (els.inputSiteStreamUrlTemplate) {
|
||||
els.inputSiteStreamUrlTemplate.value = String(seedSite?.streamUrlTemplate || '').trim();
|
||||
}
|
||||
if (els.inputSiteRtmpServer) {
|
||||
els.inputSiteRtmpServer.value = String(seedSite?.rtmpServer || '').trim();
|
||||
}
|
||||
@@ -450,7 +471,7 @@ export function initVjStreamPanel({
|
||||
|
||||
function upsertStreamingSite(draft, { mode = 'add', editingName = '' } = {}) {
|
||||
const normalizedDraft = normalizeStreamingSiteConfig(draft);
|
||||
if (!normalizedDraft.name || !normalizedDraft.streamBaseUrl || !normalizedDraft.rtmpServer || !normalizedDraft.obsKeyTemplate) {
|
||||
if (!normalizedDraft.name || !normalizedDraft.streamBaseUrl || !normalizedDraft.streamUrlTemplate || !normalizedDraft.rtmpServer || !normalizedDraft.obsKeyTemplate) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1780,6 +1801,7 @@ export function initVjStreamPanel({
|
||||
showStreamingSiteForm('add', {
|
||||
name: '',
|
||||
streamBaseUrl: 'https://',
|
||||
streamUrlTemplate: '{base}/stream/{slug}/stream.m3u8',
|
||||
rtmpServer: 'rtmp://',
|
||||
obsKeyTemplate: '{slug}/src/{SECRET_KEY}',
|
||||
});
|
||||
@@ -1807,6 +1829,7 @@ export function initVjStreamPanel({
|
||||
const draft = normalizeStreamingSiteConfig({
|
||||
name: String(els.inputSiteName?.value || '').trim(),
|
||||
streamBaseUrl: String(els.inputSiteStreamBaseUrl?.value || '').trim(),
|
||||
streamUrlTemplate: String(els.inputSiteStreamUrlTemplate?.value || '').trim(),
|
||||
rtmpServer: String(els.inputSiteRtmpServer?.value || '').trim(),
|
||||
obsKeyTemplate: String(els.inputSiteObsKeyTemplate?.value || '').trim(),
|
||||
});
|
||||
|
||||
@@ -241,7 +241,7 @@
|
||||
</div>
|
||||
|
||||
<script src="./nostr.bundle.js"></script>
|
||||
<script src="./nostr-lite.js"></script>
|
||||
<script src="/nostr-login-lite/nostr-lite.js"></script>
|
||||
|
||||
<script type="module">
|
||||
import {
|
||||
|
||||
@@ -457,7 +457,7 @@
|
||||
</div>
|
||||
|
||||
<script src="./nostr.bundle.js"></script>
|
||||
<script src="./nostr-lite.js"></script>
|
||||
<script src="/nostr-login-lite/nostr-lite.js"></script>
|
||||
|
||||
<script type="module">
|
||||
import {
|
||||
|
||||
223
www/music.html
223
www/music.html
@@ -932,15 +932,6 @@
|
||||
min-width: 52px;
|
||||
}
|
||||
|
||||
#musicSaveToggleBtn {
|
||||
min-width: 52px;
|
||||
}
|
||||
|
||||
#musicSaveToggleBtn[data-enabled='true'] {
|
||||
border-color: var(--accent-color);
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
#musicProgressWrap {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
@@ -1226,7 +1217,6 @@
|
||||
|
||||
<div id="musicPlayerControls">
|
||||
<button id="musicPlaybackModeBtn" class="btn musicControlBtn" type="button" title="Playback mode">—</button>
|
||||
<button id="musicSaveToggleBtn" class="btn musicControlBtn" type="button" title="Auto-save now playing track to Blossom" aria-pressed="false">💾</button>
|
||||
<button id="musicPrevBtn" class="btn musicControlBtn" type="button">◀</button>
|
||||
<button id="musicPlayPauseBtn" class="btn musicControlBtn" type="button">▶</button>
|
||||
<button id="musicNextBtn" class="btn musicControlBtn" type="button">▶▶</button>
|
||||
@@ -1343,7 +1333,7 @@
|
||||
2. nostr-lite.js - Authentication modal (nostr-login-lite)
|
||||
================================================================ -->
|
||||
<script src="./nostr.bundle.js"></script>
|
||||
<script src="./nostr-lite.js"></script>
|
||||
<script src="/nostr-login-lite/nostr-lite.js"></script>
|
||||
|
||||
<script type="module">
|
||||
/* ================================================================
|
||||
@@ -1377,8 +1367,6 @@
|
||||
import { HamburgerMorphing } from "./hamburger_morphing/hamburger.mjs";
|
||||
import { initFooterRelayStatus, updateFooterRelayStatus, initSidenavRelaySection, updateSidenavRelaySection, setRelayActivityState } from './js/relay-ui.mjs';
|
||||
import { initBlossomSection, updateBlossomSection } from './js/blossom-ui.mjs';
|
||||
import { uploadToServer, getDefaultBlossomServer } from './js/blossom-api.mjs';
|
||||
|
||||
import { initAiSectionWithLocalConfig } from './js/ai-ui.mjs';
|
||||
import { GreyscaleAPI } from './js/greyscale-api.mjs';
|
||||
import { SimplePlayer } from './js/greyscale-player.mjs';
|
||||
@@ -1486,7 +1474,6 @@ import { GreyscaleAPI } from './js/greyscale-api.mjs';
|
||||
queueClearBtn: document.getElementById('musicQueueClearBtn'),
|
||||
queueList: document.getElementById('musicQueueList'),
|
||||
playbackModeBtn: document.getElementById('musicPlaybackModeBtn'),
|
||||
saveToggleBtn: document.getElementById('musicSaveToggleBtn'),
|
||||
shareModal: document.getElementById('musicShareModal'),
|
||||
shareTarget: document.getElementById('musicShareTarget'),
|
||||
shareRendered: document.getElementById('musicShareRendered'),
|
||||
@@ -1543,7 +1530,6 @@ import { GreyscaleAPI } from './js/greyscale-api.mjs';
|
||||
};
|
||||
let isHandlingRoute = false;
|
||||
let playbackMode = 'normal';
|
||||
let autoSaveToBlossom = false;
|
||||
let currentPlayingTrackId = '';
|
||||
let pendingShareUrl = '';
|
||||
let pendingShareArtUrl = '';
|
||||
@@ -1850,14 +1836,6 @@ import { GreyscaleAPI } from './js/greyscale-api.mjs';
|
||||
return `music-queue:${currentPubkey || 'anon'}`;
|
||||
}
|
||||
|
||||
function getAutoSaveBlossomStorageKey() {
|
||||
return `music-auto-save-blossom:${currentPubkey || 'anon'}`;
|
||||
}
|
||||
|
||||
function getSavedTrackBlossomMapStorageKey() {
|
||||
return `music-saved-track-blossom-map:${currentPubkey || 'anon'}`;
|
||||
}
|
||||
|
||||
function normalizeQueueTrack(track) {
|
||||
if (!track || typeof track !== 'object') return null;
|
||||
return {
|
||||
@@ -1985,64 +1963,6 @@ import { GreyscaleAPI } from './js/greyscale-api.mjs';
|
||||
}
|
||||
}
|
||||
|
||||
const savedTrackBlossomUrlById = new Map();
|
||||
const pendingTrackBlossomSaves = new Set();
|
||||
|
||||
function updateSaveToggleButton() {
|
||||
if (!musicEls.saveToggleBtn) return;
|
||||
musicEls.saveToggleBtn.dataset.enabled = autoSaveToBlossom ? 'true' : 'false';
|
||||
musicEls.saveToggleBtn.setAttribute('aria-pressed', autoSaveToBlossom ? 'true' : 'false');
|
||||
musicEls.saveToggleBtn.textContent = autoSaveToBlossom ? '💾✓' : '💾';
|
||||
musicEls.saveToggleBtn.title = autoSaveToBlossom
|
||||
? 'Auto-save now playing track to Blossom: ON'
|
||||
: 'Auto-save now playing track to Blossom: OFF';
|
||||
}
|
||||
|
||||
function loadAutoSaveBlossomSetting() {
|
||||
try {
|
||||
autoSaveToBlossom = localStorage.getItem(getAutoSaveBlossomStorageKey()) === 'true';
|
||||
} catch {
|
||||
autoSaveToBlossom = false;
|
||||
}
|
||||
updateSaveToggleButton();
|
||||
}
|
||||
|
||||
function persistAutoSaveBlossomSetting() {
|
||||
try {
|
||||
localStorage.setItem(getAutoSaveBlossomStorageKey(), autoSaveToBlossom ? 'true' : 'false');
|
||||
} catch {
|
||||
// Ignore storage write failures.
|
||||
}
|
||||
}
|
||||
|
||||
function loadSavedTrackBlossomMap() {
|
||||
savedTrackBlossomUrlById.clear();
|
||||
try {
|
||||
const raw = localStorage.getItem(getSavedTrackBlossomMapStorageKey());
|
||||
if (!raw) return;
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!parsed || typeof parsed !== 'object') return;
|
||||
Object.entries(parsed).forEach(([trackId, url]) => {
|
||||
if (!trackId || typeof url !== 'string' || !url.trim()) return;
|
||||
savedTrackBlossomUrlById.set(String(trackId), url.trim());
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn('[music] Could not load saved Blossom track map:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function persistSavedTrackBlossomMap() {
|
||||
try {
|
||||
const payload = {};
|
||||
savedTrackBlossomUrlById.forEach((url, trackId) => {
|
||||
payload[trackId] = url;
|
||||
});
|
||||
localStorage.setItem(getSavedTrackBlossomMapStorageKey(), JSON.stringify(payload));
|
||||
} catch {
|
||||
// Ignore storage write failures.
|
||||
}
|
||||
}
|
||||
|
||||
const mountedMusicMenus = {
|
||||
myPlaylists: [],
|
||||
friendPlaylists: [],
|
||||
@@ -3502,7 +3422,6 @@ import { GreyscaleAPI } from './js/greyscale-api.mjs';
|
||||
updatePlayingResultHighlight();
|
||||
saveQueueLocal();
|
||||
setMusicStatus(`Playing queue: ${track.title}`, 'ok');
|
||||
void maybeAutoSaveTrackToBlossom(track);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
setMusicStatus(`Queue playback failed: ${error.message || 'Unknown error'}`, 'error');
|
||||
@@ -4422,14 +4341,70 @@ import { GreyscaleAPI } from './js/greyscale-api.mjs';
|
||||
|
||||
async function fetchDownloadBlobForTrack(track) {
|
||||
if (!track?.id) throw new Error('Track not available for download.');
|
||||
const stream = await musicApi.getTrackStream(track.id, 'LOSSLESS');
|
||||
const streamUrl = stream?.streamUrl;
|
||||
if (!streamUrl) throw new Error('No stream URL for download');
|
||||
const res = await fetch(streamUrl);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const blob = await res.blob();
|
||||
const ext = extensionFromMime(blob.type);
|
||||
return { blob, ext };
|
||||
|
||||
console.debug('[music][download] start', {
|
||||
trackId: track.id,
|
||||
title: track.title || '',
|
||||
});
|
||||
|
||||
const qualityCandidates = ['HIGH', 'MP3_320', 'LOSSLESS'];
|
||||
let lastError = null;
|
||||
|
||||
for (const quality of qualityCandidates) {
|
||||
try {
|
||||
console.debug('[music][download] requesting stream', { trackId: track.id, quality });
|
||||
|
||||
const stream = await musicApi.getTrackStream(track.id, quality);
|
||||
const streamUrl = stream?.streamUrl;
|
||||
if (!streamUrl) {
|
||||
lastError = new Error(`No stream URL for quality ${quality}`);
|
||||
console.warn('[music][download] missing stream URL', { trackId: track.id, quality });
|
||||
continue;
|
||||
}
|
||||
|
||||
console.debug('[music][download] fetching stream URL', {
|
||||
trackId: track.id,
|
||||
quality,
|
||||
streamUrl,
|
||||
});
|
||||
|
||||
const res = await fetch(streamUrl);
|
||||
if (!res.ok) {
|
||||
lastError = new Error(`HTTP ${res.status} for quality ${quality}`);
|
||||
console.warn('[music][download] fetch failed', {
|
||||
trackId: track.id,
|
||||
quality,
|
||||
status: res.status,
|
||||
statusText: res.statusText,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const blob = await res.blob();
|
||||
const ext = extensionFromMime(blob.type);
|
||||
console.debug('[music][download] success', {
|
||||
trackId: track.id,
|
||||
quality,
|
||||
mimeType: blob.type,
|
||||
size: blob.size,
|
||||
ext,
|
||||
});
|
||||
return { blob, ext };
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error : new Error(String(error || 'Unknown error'));
|
||||
console.warn('[music][download] quality attempt threw', {
|
||||
trackId: track.id,
|
||||
quality,
|
||||
error: lastError.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
console.error('[music][download] all quality attempts failed', {
|
||||
trackId: track.id,
|
||||
error: lastError?.message || 'No stream URL for download',
|
||||
});
|
||||
throw (lastError || new Error('No stream URL for download'));
|
||||
}
|
||||
|
||||
async function downloadTrackFile(track, { suppressStatus = false } = {}) {
|
||||
@@ -4458,65 +4433,6 @@ import { GreyscaleAPI } from './js/greyscale-api.mjs';
|
||||
}
|
||||
}
|
||||
|
||||
async function saveTrackToBlossom(track, { suppressStatus = true } = {}) {
|
||||
const trackId = String(track?.id || '').trim();
|
||||
if (!trackId) return '';
|
||||
if (savedTrackBlossomUrlById.has(trackId)) {
|
||||
return savedTrackBlossomUrlById.get(trackId) || '';
|
||||
}
|
||||
if (pendingTrackBlossomSaves.has(trackId)) return '';
|
||||
|
||||
const targetServer = getDefaultBlossomServer();
|
||||
if (!targetServer) {
|
||||
if (!suppressStatus) setMusicStatus('No Blossom server configured.', 'error');
|
||||
return '';
|
||||
}
|
||||
|
||||
pendingTrackBlossomSaves.add(trackId);
|
||||
try {
|
||||
if (!suppressStatus) {
|
||||
setMusicStatus(`Saving to Blossom: ${track.title || 'Track'}...`, 'neutral');
|
||||
}
|
||||
|
||||
const { blob, ext } = await fetchDownloadBlobForTrack(track);
|
||||
const title = sanitizeFilenamePart(track.title || 'Unknown title') || 'Unknown title';
|
||||
const artist = sanitizeFilenamePart(track.artist || 'Unknown artist') || 'Unknown artist';
|
||||
const fileName = `${artist} - ${title}.${ext}`;
|
||||
const file = new File([blob], fileName, {
|
||||
type: blob.type || 'application/octet-stream',
|
||||
});
|
||||
|
||||
const result = await uploadToServer(file, targetServer);
|
||||
const sha256 = String(result?.sha256 || '').trim();
|
||||
if (!sha256) throw new Error('Upload returned no SHA-256 hash.');
|
||||
|
||||
const suffix = ext ? `.${ext.replace(/^\./, '')}` : '';
|
||||
const blossomUrl = `${String(result?.serverUrl || targetServer).replace(/\/$/, '')}/${sha256}${suffix}`;
|
||||
|
||||
savedTrackBlossomUrlById.set(trackId, blossomUrl);
|
||||
persistSavedTrackBlossomMap();
|
||||
|
||||
if (!suppressStatus) {
|
||||
setMusicStatus(`Saved to Blossom: ${track.title || 'Track'}`, 'ok');
|
||||
}
|
||||
|
||||
return blossomUrl;
|
||||
} catch (error) {
|
||||
console.error('[music] saveTrackToBlossom failed:', error);
|
||||
if (!suppressStatus) {
|
||||
setMusicStatus(`Save to Blossom failed: ${error.message || 'Unknown error'}`, 'error');
|
||||
}
|
||||
return '';
|
||||
} finally {
|
||||
pendingTrackBlossomSaves.delete(trackId);
|
||||
}
|
||||
}
|
||||
|
||||
async function maybeAutoSaveTrackToBlossom(track) {
|
||||
if (!autoSaveToBlossom) return;
|
||||
await saveTrackToBlossom(track, { suppressStatus: true });
|
||||
}
|
||||
|
||||
async function downloadMusicByIndex(index) {
|
||||
if (!Number.isInteger(index) || index < 0 || index >= musicTracks.length) return;
|
||||
const track = musicTracks[index];
|
||||
@@ -5105,16 +5021,9 @@ import { GreyscaleAPI } from './js/greyscale-api.mjs';
|
||||
updatePlayingResultHighlight();
|
||||
});
|
||||
musicEls.playbackModeBtn?.addEventListener('click', cyclePlaybackMode);
|
||||
musicEls.saveToggleBtn?.addEventListener('click', () => {
|
||||
autoSaveToBlossom = !autoSaveToBlossom;
|
||||
persistAutoSaveBlossomSetting();
|
||||
updateSaveToggleButton();
|
||||
setMusicStatus(`Auto-save to Blossom ${autoSaveToBlossom ? 'enabled' : 'disabled'}.`, 'ok');
|
||||
});
|
||||
setMusicPlayButtonState();
|
||||
updateMusicPlayerMeta(null);
|
||||
updatePlaybackModeButton();
|
||||
updateSaveToggleButton();
|
||||
|
||||
musicEls.queueClearBtn?.addEventListener('click', clearQueue);
|
||||
|
||||
@@ -5215,8 +5124,6 @@ import { GreyscaleAPI } from './js/greyscale-api.mjs';
|
||||
myPlaylists.clear();
|
||||
}
|
||||
loadQueueLocal();
|
||||
loadAutoSaveBlossomSetting();
|
||||
loadSavedTrackBlossomMap();
|
||||
updateViewerControls();
|
||||
renderMyPlaylists();
|
||||
renderFriendPlaylists();
|
||||
|
||||
@@ -6103,10 +6103,8 @@ self.onconnect = (event) => {
|
||||
console.log('[Worker] New port connected');
|
||||
|
||||
connectedPorts.push(port);
|
||||
activeSigningPort = port;
|
||||
|
||||
port.onmessage = async (e) => {
|
||||
activeSigningPort = port;
|
||||
const {
|
||||
type,
|
||||
pubkey,
|
||||
@@ -6180,6 +6178,12 @@ self.onconnect = (event) => {
|
||||
case 'ndkFetchEvents':
|
||||
await handleNdkFetchEvents(requestId, filters, port);
|
||||
break;
|
||||
|
||||
case 'setActiveSigningPort':
|
||||
activeSigningPort = port;
|
||||
console.log('[Worker] Active signing port set explicitly by page');
|
||||
port.postMessage({ type: 'activeSigningPortSet' });
|
||||
break;
|
||||
|
||||
case 'signResponse':
|
||||
handleSignResponse(requestId, result, error);
|
||||
|
||||
4401
www/nostr-lite.js
4401
www/nostr-lite.js
File diff suppressed because it is too large
Load Diff
297
www/note.html
297
www/note.html
@@ -253,6 +253,24 @@
|
||||
#divHeaderText {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
#txtDecryptTrace {
|
||||
font-family: var(--font-mono);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
#divDiagnosticBadge {
|
||||
display: inline-block;
|
||||
margin-left: 10px;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid #b45309;
|
||||
border-radius: 4px;
|
||||
color: #b45309;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
</style>
|
||||
<script src="./js/marked.min.js"></script>
|
||||
</head>
|
||||
@@ -330,10 +348,15 @@
|
||||
<td class="tdTitle">Autosave</td>
|
||||
<td id="txtAutosave" class="tdEditable" contenteditable="true">10</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="tdTitle">Decrypt Trace</td>
|
||||
<td id="txtDecryptTrace">None</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="tdTitle"></td>
|
||||
<td>
|
||||
<button id="btnSaveNote" class="btn">Save Note</button>
|
||||
<span id="divDiagnosticBadge" class="clsHidden">DIAGNOSTIC VIEW — do not save</span>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
@@ -413,7 +436,7 @@
|
||||
REQUIRED SCRIPTS
|
||||
================================================================ -->
|
||||
<script src="./nostr.bundle.js"></script>
|
||||
<script src="./nostr-lite.js"></script>
|
||||
<script src="/nostr-login-lite/nostr-lite.js"></script>
|
||||
|
||||
<script type="module">
|
||||
/* ================================================================
|
||||
@@ -449,10 +472,13 @@ const versionInfo = await getVersion();
|
||||
let OBJ_NOTES = {};
|
||||
let CURRENT_NOTE = "";
|
||||
let ENCRYPTED_TAG_TYPES = ["title", "image", "summary", "t"];
|
||||
const MAX_DECRYPT_PASSES = 8;
|
||||
let EDIT_MODE = false;
|
||||
let LastText = "";
|
||||
let NUM_LAST_EDIT_TIME = Math.floor(Date.now() / 1000);
|
||||
let numAutoSaveSec = 30;
|
||||
let CURRENT_DECRYPT_TRACE = null;
|
||||
let DIAGNOSTIC_ACTIVE = false;
|
||||
|
||||
/* ================================================================
|
||||
DOM VARIABLES
|
||||
@@ -478,6 +504,8 @@ const versionInfo = await getVersion();
|
||||
const divHeaderText = document.getElementById('divHeaderText');
|
||||
const tblTags = document.getElementById('tblTags');
|
||||
const divFiles = document.getElementById('divFiles');
|
||||
const txtDecryptTrace = document.getElementById('txtDecryptTrace');
|
||||
const divDiagnosticBadge = document.getElementById('divDiagnosticBadge');
|
||||
|
||||
/* ================================================================
|
||||
HAMBURGER MENU
|
||||
@@ -549,6 +577,195 @@ const versionInfo = await getVersion();
|
||||
/* ================================================================
|
||||
NOTE FUNCTIONS
|
||||
================================================================ */
|
||||
async function nip44DecryptFromSelf(ciphertext) {
|
||||
const selfPubkey = String(currentPubkey || await getPubkey() || '').trim();
|
||||
if (!selfPubkey) throw new Error('Missing pubkey for NIP-44 decryption');
|
||||
if (!window.nostr?.nip44?.decrypt) throw new Error('NIP-44 decrypt unavailable');
|
||||
return await window.nostr.nip44.decrypt(selfPubkey, String(ciphertext || ''));
|
||||
}
|
||||
|
||||
async function nip04DecryptFromSelf(ciphertext) {
|
||||
const selfPubkey = String(currentPubkey || await getPubkey() || '').trim();
|
||||
if (!selfPubkey) throw new Error('Missing pubkey for NIP-04 decryption');
|
||||
if (!window.nostr?.nip04?.decrypt) throw new Error('NIP-04 decrypt unavailable');
|
||||
return await window.nostr.nip04.decrypt(selfPubkey, String(ciphertext || ''));
|
||||
}
|
||||
|
||||
function looksLikeNip04Ciphertext(input) {
|
||||
const text = String(input || '').trim();
|
||||
return /^[A-Za-z0-9+/_-]+={0,2}\?iv=[A-Za-z0-9+/_-]+={0,2}$/.test(text);
|
||||
}
|
||||
|
||||
function looksLikeBase64(input) {
|
||||
const text = String(input || '').trim();
|
||||
if (!text || text.length < 24 || text.length % 4 !== 0) return false;
|
||||
return /^[A-Za-z0-9+/]+={0,2}$/.test(text);
|
||||
}
|
||||
|
||||
function looksLikeNip44Ciphertext(input) {
|
||||
const text = String(input || '').trim();
|
||||
if (!looksLikeBase64(text)) return false;
|
||||
try {
|
||||
const decoded = atob(text);
|
||||
if (!decoded || decoded.length < 2) return false;
|
||||
const versionByte = decoded.charCodeAt(0);
|
||||
return versionByte === 1 || versionByte === 2;
|
||||
} catch (_error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function classifyCiphertext(input) {
|
||||
const text = String(input || '').trim();
|
||||
if (!text) return 'plaintext';
|
||||
if (looksLikeNip04Ciphertext(text)) return 'nip04';
|
||||
if (looksLikeNip44Ciphertext(text)) return 'nip44';
|
||||
|
||||
if (/[\n\r\t]/.test(text) || /[#*_`\[\]{}()]/.test(text)) {
|
||||
return 'plaintext';
|
||||
}
|
||||
|
||||
if (looksLikeBase64(text)) return 'base64-unknown';
|
||||
return 'plaintext';
|
||||
}
|
||||
|
||||
async function decryptOnePass(input) {
|
||||
const text = String(input || '').trim();
|
||||
const kind = classifyCiphertext(text);
|
||||
const methods = kind === 'nip04'
|
||||
? ['nip04', 'nip44']
|
||||
: ['nip44', 'nip04'];
|
||||
|
||||
for (const method of methods) {
|
||||
try {
|
||||
const output = method === 'nip44'
|
||||
? await nip44DecryptFromSelf(text)
|
||||
: await nip04DecryptFromSelf(text);
|
||||
return {
|
||||
ok: true,
|
||||
method,
|
||||
text: String(output || '')
|
||||
};
|
||||
} catch (_error) {
|
||||
// try next method
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
method: null,
|
||||
text
|
||||
};
|
||||
}
|
||||
|
||||
async function peelDecrypt(input, maxPasses = MAX_DECRYPT_PASSES) {
|
||||
let current = String(input || '').trim();
|
||||
const trace = [];
|
||||
let stoppedReason = 'max-passes';
|
||||
|
||||
for (let pass = 1; pass <= maxPasses; pass++) {
|
||||
const beforeClass = classifyCiphertext(current);
|
||||
if (beforeClass === 'plaintext') {
|
||||
stoppedReason = 'plaintext';
|
||||
break;
|
||||
}
|
||||
|
||||
const one = await decryptOnePass(current);
|
||||
if (!one.ok) {
|
||||
stoppedReason = 'decrypt-failed';
|
||||
break;
|
||||
}
|
||||
|
||||
trace.push({
|
||||
pass,
|
||||
method: one.method,
|
||||
inLength: current.length,
|
||||
outLength: one.text.length
|
||||
});
|
||||
|
||||
current = String(one.text || '');
|
||||
}
|
||||
|
||||
return {
|
||||
finalText: current,
|
||||
trace,
|
||||
stoppedReason,
|
||||
finalClass: classifyCiphertext(current)
|
||||
};
|
||||
}
|
||||
|
||||
function formatDecryptTrace(diag) {
|
||||
if (!diag) return 'None';
|
||||
const passes = Number(diag.passes || 0);
|
||||
const methods = Array.isArray(diag.methods) && diag.methods.length > 0
|
||||
? diag.methods.join(' → ')
|
||||
: 'none';
|
||||
const reason = String(diag.stoppedReason || 'unknown');
|
||||
const finalClass = String(diag.finalClass || 'unknown');
|
||||
const finalLength = Number(diag.finalLength || 0);
|
||||
return `passes=${passes} | methods=${methods} | stop=${reason} | final=${finalClass} | len=${finalLength}`;
|
||||
}
|
||||
|
||||
async function peelTagsMaybeEncrypted(objTags) {
|
||||
const safe = objTags && typeof objTags === 'object' ? { ...objTags } : {};
|
||||
|
||||
for (const key of ['title', 'image', 'summary']) {
|
||||
const value = String(safe[key] || '').trim();
|
||||
if (!value) continue;
|
||||
const result = await peelDecrypt(value, 3);
|
||||
safe[key] = result.trace.length > 0 ? result.finalText : value;
|
||||
}
|
||||
|
||||
if (Array.isArray(safe.t)) {
|
||||
const decryptedTags = [];
|
||||
for (const tagValue of safe.t) {
|
||||
const value = String(tagValue || '').trim();
|
||||
if (!value) continue;
|
||||
const result = await peelDecrypt(value, 3);
|
||||
decryptedTags.push(result.trace.length > 0 ? result.finalText : value);
|
||||
}
|
||||
safe.t = decryptedTags;
|
||||
}
|
||||
|
||||
return safe;
|
||||
}
|
||||
|
||||
async function ensureNoteDiagnostics(note) {
|
||||
if (!note || Number(note.kind) !== 30024) return note;
|
||||
if (note.decryptDiagnostics) return note;
|
||||
|
||||
const peel = await peelDecrypt(String(note.content || ''));
|
||||
const decryptedTags = await peelTagsMaybeEncrypted(note.objTags || {});
|
||||
|
||||
note.decryptedContent = peel.finalText;
|
||||
note.decryptedObjTags = decryptedTags;
|
||||
note.decryptDiagnostics = {
|
||||
passes: peel.trace.length,
|
||||
methods: peel.trace.map((step) => String(step.method || '').toUpperCase()),
|
||||
stoppedReason: peel.stoppedReason,
|
||||
finalClass: peel.finalClass,
|
||||
finalLength: String(peel.finalText || '').length
|
||||
};
|
||||
|
||||
return note;
|
||||
}
|
||||
|
||||
function setDiagnosticUI(note) {
|
||||
CURRENT_DECRYPT_TRACE = note?.decryptDiagnostics || null;
|
||||
txtDecryptTrace.textContent = formatDecryptTrace(CURRENT_DECRYPT_TRACE);
|
||||
|
||||
DIAGNOSTIC_ACTIVE = Boolean(
|
||||
Number(note?.kind) === 30024 &&
|
||||
(Number(CURRENT_DECRYPT_TRACE?.passes || 0) > 0 || CURRENT_DECRYPT_TRACE?.stoppedReason === 'decrypt-failed')
|
||||
);
|
||||
|
||||
if (DIAGNOSTIC_ACTIVE) {
|
||||
divDiagnosticBadge.className = '';
|
||||
} else {
|
||||
divDiagnosticBadge.className = 'clsHidden';
|
||||
}
|
||||
}
|
||||
|
||||
const LoadSidenav = async () => {
|
||||
// Subscribe to long-form notes (kind 30023 and 30024)
|
||||
const notesSub = subscribe(
|
||||
@@ -561,8 +778,8 @@ const versionInfo = await getVersion();
|
||||
|
||||
// Get notes from cache via events
|
||||
let htmlOut = `<table id="tblNotes">`;
|
||||
htmlOut += `<tr><th colspan="8"> NOTES </th></tr>`;
|
||||
htmlOut += `<tr><th colspan="8">   </th></tr>`;
|
||||
htmlOut += `<tr><th colspan="10"> NOTES </th></tr>`;
|
||||
htmlOut += `<tr><th colspan="10">   </th></tr>`;
|
||||
htmlOut += `<tr>
|
||||
<th>Evt</th>
|
||||
<th>Del</th>
|
||||
@@ -573,21 +790,26 @@ const versionInfo = await getVersion();
|
||||
<th>Edited</th>
|
||||
<th>Type</th>
|
||||
<th>Encrypted</th>
|
||||
<th>Layers</th>
|
||||
</tr>`;
|
||||
|
||||
// Build table from OBJ_NOTES
|
||||
for (let Each of Object.keys(OBJ_NOTES).sort((a, b) => b - a)) {
|
||||
const note = OBJ_NOTES[Each];
|
||||
if (Number(note?.kind) === 30024) {
|
||||
await ensureNoteDiagnostics(note);
|
||||
}
|
||||
|
||||
let d = new Date(Number(Each) * 1000);
|
||||
let dCreated = d.toLocaleString();
|
||||
d = new Date(Number(OBJ_NOTES[Each].created_at) * 1000);
|
||||
d = new Date(Number(note?.created_at) * 1000);
|
||||
let dEdited = d.toLocaleString();
|
||||
|
||||
const dTagValue = OBJ_NOTES[Each].objTags?.d ?? `${OBJ_NOTES[Each].created_at} (from created_at)`;
|
||||
const dTagValue = note?.objTags?.d ?? `${note?.created_at} (from created_at)`;
|
||||
const rowTitle = note?.decryptedObjTags?.title || note?.objTags?.title || 'Untitled';
|
||||
|
||||
let Status = "Published";
|
||||
let svgPub = `<div class="divTableButtons"></div>`;
|
||||
if (OBJ_NOTES[Each].kind == 30024) {
|
||||
Status = "Encrypted";
|
||||
if (note?.kind == 30024) {
|
||||
svgPub = `<div id="divSVGPublish-${Each}" class="divTableButtons">➜</div>`;
|
||||
}
|
||||
|
||||
@@ -595,12 +817,13 @@ const versionInfo = await getVersion();
|
||||
<td class="col5"> <div id="divSVG-${Each}" class="divTableButtons">📋</div> </td>
|
||||
<td class="col5"> <div id="divSVGDelete-${Each}" class="divTableButtons">✕</div> </td>
|
||||
<td class="col5"> ${svgPub} </td>
|
||||
<td class="col0" id="divFileID-${Each}">${OBJ_NOTES[Each].objTags?.title || 'Untitled'}</td>
|
||||
<td class="col0" id="divFileID-${Each}">${rowTitle}</td>
|
||||
<td class="col1">${dTagValue}</td>
|
||||
<td class="col1">${dCreated}</td>
|
||||
<td class="col1">${dEdited}</td>
|
||||
<td class="col3">${OBJ_NOTES[Each].kind} </td>
|
||||
<td class="col3">${OBJ_NOTES[Each].encrypted ? 'Yes' : 'No'} </td>
|
||||
<td class="col3">${note?.kind} </td>
|
||||
<td class="col3">${note?.encrypted ? 'Yes' : 'No'} </td>
|
||||
<td class="col3">${note?.kind == 30024 ? Number(note?.decryptDiagnostics?.passes || 0) : '-'}</td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
@@ -656,9 +879,14 @@ const versionInfo = await getVersion();
|
||||
|
||||
// Save when switching from edit to view mode if there are unsaved changes
|
||||
if (taNote.value.trim() !== LastText) {
|
||||
console.log('[autosave] Switching to view mode with unsaved changes - triggering save');
|
||||
await Publish30024Note();
|
||||
LastText = taNote.value.trim();
|
||||
if (DIAGNOSTIC_ACTIVE) {
|
||||
console.log('[autosave] Diagnostic view active - skipping implicit save');
|
||||
LastText = taNote.value.trim();
|
||||
} else {
|
||||
console.log('[autosave] Switching to view mode with unsaved changes - triggering save');
|
||||
await Publish30024Note();
|
||||
LastText = taNote.value.trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -672,15 +900,24 @@ const versionInfo = await getVersion();
|
||||
return;
|
||||
}
|
||||
|
||||
taNote.value = OBJ_NOTES[id].content || '';
|
||||
LastText = OBJ_NOTES[id].content || '';
|
||||
txtTitle.textContent = OBJ_NOTES[id].objTags?.title || '';
|
||||
divTitle.textContent = OBJ_NOTES[id].objTags?.title || '';
|
||||
txtSummary.textContent = OBJ_NOTES[id].objTags?.summary || '';
|
||||
txtTags.textContent = OBJ_NOTES[id].objTags?.t?.join(' ') || '';
|
||||
const note = OBJ_NOTES[id];
|
||||
await ensureNoteDiagnostics(note);
|
||||
|
||||
const displayTags = note.decryptedObjTags || note.objTags || {};
|
||||
const displayContent = Number(note.kind) === 30024
|
||||
? String(note.decryptedContent || note.content || '')
|
||||
: String(note.content || '');
|
||||
|
||||
taNote.value = displayContent;
|
||||
LastText = taNote.value.trim();
|
||||
txtTitle.textContent = displayTags?.title || '';
|
||||
divTitle.textContent = displayTags?.title || '';
|
||||
txtSummary.textContent = displayTags?.summary || '';
|
||||
txtTags.textContent = displayTags?.t?.join(' ') || '';
|
||||
|
||||
CURRENT_NOTE = id;
|
||||
txtID.textContent = CURRENT_NOTE;
|
||||
setDiagnosticUI(note);
|
||||
|
||||
// Parse markdown for preview
|
||||
console.log('[note.html] LoadNote - window.marked exists:', !!window.marked);
|
||||
@@ -699,11 +936,11 @@ const versionInfo = await getVersion();
|
||||
console.log('[note.html] No content to display - reason:', reason);
|
||||
}
|
||||
|
||||
if (OBJ_NOTES[id].objTags?.image === "" || !OBJ_NOTES[id].objTags?.image) {
|
||||
if (displayTags?.image === "" || !displayTags?.image) {
|
||||
txtImg.textContent = "";
|
||||
imgMain.className = "clsHidden";
|
||||
} else {
|
||||
txtImg.textContent = OBJ_NOTES[id].objTags.image;
|
||||
txtImg.textContent = displayTags.image;
|
||||
imgMain.className = "clsVisible";
|
||||
}
|
||||
|
||||
@@ -932,7 +1169,7 @@ const versionInfo = await getVersion();
|
||||
);
|
||||
|
||||
// Listen for incoming note events
|
||||
window.addEventListener('ndkEvent', (event) => {
|
||||
window.addEventListener('ndkEvent', async (event) => {
|
||||
const evt = event.detail;
|
||||
if (evt.kind === 30023 || evt.kind === 30024) {
|
||||
console.log("[note.html] Received note:", evt);
|
||||
@@ -957,6 +1194,14 @@ const versionInfo = await getVersion();
|
||||
objTags: objTags,
|
||||
encrypted: evt.kind === 30024
|
||||
};
|
||||
|
||||
if (evt.kind === 30024) {
|
||||
await ensureNoteDiagnostics(OBJ_NOTES[dTag]);
|
||||
}
|
||||
|
||||
if (isNavOpen) {
|
||||
LoadSidenav();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -982,6 +1227,7 @@ const versionInfo = await getVersion();
|
||||
txtSummary.textContent = "";
|
||||
txtTags.textContent = "";
|
||||
divHeaderText.textContent = "TITLE";
|
||||
setDiagnosticUI(null);
|
||||
SetEditMode(true);
|
||||
closeNav();
|
||||
});
|
||||
@@ -1042,6 +1288,11 @@ const versionInfo = await getVersion();
|
||||
return;
|
||||
}
|
||||
|
||||
if (DIAGNOSTIC_ACTIVE) {
|
||||
console.log('[autosave] Skipping: diagnostic mode active');
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip if not enough time passed since last edit
|
||||
const secondsSinceEdit = Math.floor(Date.now() / 1000) - NUM_LAST_EDIT_TIME;
|
||||
// console.log('[autosave] Seconds since edit:', secondsSinceEdit, '| required:', autoSaveSetting);
|
||||
|
||||
@@ -356,7 +356,7 @@
|
||||
</div>
|
||||
|
||||
<script src="./nostr.bundle.js"></script>
|
||||
<script src="./nostr-lite.js"></script>
|
||||
<script src="/nostr-login-lite/nostr-lite.js"></script>
|
||||
|
||||
<script type="module">
|
||||
import {
|
||||
|
||||
@@ -161,7 +161,7 @@
|
||||
REQUIRED SCRIPTS
|
||||
================================================================ -->
|
||||
<script src="./nostr.bundle.js"></script>
|
||||
<script src="./nostr-lite.js"></script>
|
||||
<script src="/nostr-login-lite/nostr-lite.js"></script>
|
||||
<script type="text/javascript" src="./js/qrcode-svg.min.js"></script>
|
||||
|
||||
<script type="module">
|
||||
|
||||
@@ -145,7 +145,7 @@
|
||||
================================================================ -->
|
||||
<script src="./nostr.bundle.js"></script>
|
||||
<script src="./ndk-core.bundle.js"></script>
|
||||
<script src="./nostr-lite.js"></script>
|
||||
<script src="/nostr-login-lite/nostr-lite.js"></script>
|
||||
<script type="text/javascript" src="./js/qrcode-generator.min.js"></script>
|
||||
|
||||
<script type="module">
|
||||
|
||||
@@ -378,7 +378,7 @@
|
||||
REQUIRED SCRIPTS
|
||||
================================================================ -->
|
||||
<script src="./nostr.bundle.js"></script>
|
||||
<script src="./nostr-lite.js"></script>
|
||||
<script src="/nostr-login-lite/nostr-lite.js"></script>
|
||||
|
||||
<script type="module">
|
||||
/* ================================================================
|
||||
|
||||
@@ -301,7 +301,7 @@
|
||||
</div>
|
||||
|
||||
<script src="./nostr.bundle.js"></script>
|
||||
<script src="./nostr-lite.js"></script>
|
||||
<script src="/nostr-login-lite/nostr-lite.js"></script>
|
||||
|
||||
<script type="module">
|
||||
import { initNDKPage, getPubkey, injectHeaderAvatar, subscribe, publishEvent, disconnect, getRelayData, fetchEventsFromAllRelays, ndkFetchEvents, queryCache } from './js/init-ndk.mjs';
|
||||
|
||||
@@ -169,7 +169,7 @@
|
||||
2. nostr-lite.js - Authentication modal (nostr-login-lite)
|
||||
================================================================ -->
|
||||
<script src="./nostr.bundle.js"></script>
|
||||
<script src="./nostr-lite.js"></script>
|
||||
<script src="/nostr-login-lite/nostr-lite.js"></script>
|
||||
|
||||
<script type="module">
|
||||
/* ================================================================
|
||||
|
||||
@@ -157,7 +157,7 @@
|
||||
2. nostr-lite.js - Authentication modal (nostr-login-lite)
|
||||
================================================================ -->
|
||||
<script src="./nostr.bundle.js"></script>
|
||||
<script src="./nostr-lite.js"></script>
|
||||
<script src="/nostr-login-lite/nostr-lite.js"></script>
|
||||
|
||||
<script type="module">
|
||||
/* ================================================================
|
||||
|
||||
@@ -1015,7 +1015,7 @@
|
||||
</div>
|
||||
|
||||
<script src="./nostr.bundle.js"></script>
|
||||
<script src="./nostr-lite.js"></script>
|
||||
<script src="/nostr-login-lite/nostr-lite.js"></script>
|
||||
|
||||
<script type="module">
|
||||
import {
|
||||
|
||||
@@ -325,7 +325,7 @@
|
||||
2. nostr-lite.js - Authentication modal (nostr-login-lite)
|
||||
================================================================ -->
|
||||
<script src="./nostr.bundle.js"></script>
|
||||
<script src="./nostr-lite.js"></script>
|
||||
<script src="/nostr-login-lite/nostr-lite.js"></script>
|
||||
|
||||
<script type="module">
|
||||
/* ================================================================
|
||||
|
||||
@@ -323,7 +323,7 @@
|
||||
2. nostr-lite.js - Authentication modal (nostr-login-lite)
|
||||
================================================================ -->
|
||||
<script src="./nostr.bundle.js"></script>
|
||||
<script src="./nostr-lite.js"></script>
|
||||
<script src="/nostr-login-lite/nostr-lite.js"></script>
|
||||
|
||||
<script type="module">
|
||||
/* ================================================================
|
||||
|
||||
@@ -253,7 +253,7 @@
|
||||
</div>
|
||||
|
||||
<script src="./nostr.bundle.js"></script>
|
||||
<script src="./nostr-lite.js"></script>
|
||||
<script src="/nostr-login-lite/nostr-lite.js"></script>
|
||||
|
||||
<script type="module">
|
||||
import {
|
||||
|
||||
@@ -202,7 +202,7 @@
|
||||
</div>
|
||||
|
||||
<script src="./nostr.bundle.js"></script>
|
||||
<script src="./nostr-lite.js"></script>
|
||||
<script src="/nostr-login-lite/nostr-lite.js"></script>
|
||||
|
||||
<script type="module">
|
||||
import {
|
||||
|
||||
@@ -268,7 +268,7 @@
|
||||
</div>
|
||||
|
||||
<script src="./nostr.bundle.js"></script>
|
||||
<script src="./nostr-lite.js"></script>
|
||||
<script src="/nostr-login-lite/nostr-lite.js"></script>
|
||||
|
||||
<script type="module">
|
||||
import { initNDKPage, getPubkey, injectHeaderAvatar, subscribe, publishEvent, disconnect, getVersion, updateVersionDisplay } from './js/init-ndk.mjs';
|
||||
|
||||
1394
www/projects.html
Normal file
1394
www/projects.html
Normal file
File diff suppressed because it is too large
Load Diff
@@ -514,7 +514,7 @@
|
||||
</div>
|
||||
|
||||
<script src="./nostr.bundle.js"></script>
|
||||
<script src="./nostr-lite.js"></script>
|
||||
<script src="/nostr-login-lite/nostr-lite.js"></script>
|
||||
|
||||
<script type="module">
|
||||
import {
|
||||
|
||||
@@ -326,7 +326,7 @@
|
||||
REQUIRED SCRIPTS
|
||||
================================================================ -->
|
||||
<script src="./nostr.bundle.js"></script>
|
||||
<script src="./nostr-lite.js"></script>
|
||||
<script src="/nostr-login-lite/nostr-lite.js"></script>
|
||||
|
||||
<script type="module">
|
||||
/* ================================================================
|
||||
|
||||
@@ -639,7 +639,7 @@
|
||||
</div>
|
||||
|
||||
<script src="./nostr.bundle.js"></script>
|
||||
<script src="./nostr-lite.js"></script>
|
||||
<script src="/nostr-login-lite/nostr-lite.js"></script>
|
||||
|
||||
<script type="module">
|
||||
import {
|
||||
|
||||
@@ -331,7 +331,7 @@
|
||||
</div>
|
||||
|
||||
<script src="./nostr.bundle.js"></script>
|
||||
<script src="./nostr-lite.js"></script>
|
||||
<script src="/nostr-login-lite/nostr-lite.js"></script>
|
||||
|
||||
<script type="module">
|
||||
import {
|
||||
|
||||
@@ -255,7 +255,7 @@ note("<c a f e>(3,8)")
|
||||
2. nostr-lite.js - Authentication modal (nostr-login-lite)
|
||||
================================================================ -->
|
||||
<script src="./nostr.bundle.js"></script>
|
||||
<script src="./nostr-lite.js"></script>
|
||||
<script src="/nostr-login-lite/nostr-lite.js"></script>
|
||||
|
||||
<script type="module">
|
||||
/* ================================================================
|
||||
|
||||
@@ -144,7 +144,7 @@
|
||||
2. nostr-lite.js - Authentication modal (nostr-login-lite)
|
||||
================================================================ -->
|
||||
<script src="./nostr.bundle.js"></script>
|
||||
<script src="./nostr-lite.js"></script>
|
||||
<script src="/nostr-login-lite/nostr-lite.js"></script>
|
||||
|
||||
<script type="module">
|
||||
/* ================================================================
|
||||
|
||||
@@ -305,7 +305,7 @@
|
||||
REQUIRED SCRIPTS
|
||||
================================================================ -->
|
||||
<script src="./nostr.bundle.js"></script>
|
||||
<script src="./nostr-lite.js"></script>
|
||||
<script src="/nostr-login-lite/nostr-lite.js"></script>
|
||||
|
||||
<script type="module">
|
||||
/* ================================================================
|
||||
|
||||
@@ -508,7 +508,7 @@
|
||||
REQUIRED SCRIPTS
|
||||
================================================================ -->
|
||||
<script src="./nostr.bundle.js"></script>
|
||||
<script src="./nostr-lite.js"></script>
|
||||
<script src="/nostr-login-lite/nostr-lite.js"></script>
|
||||
|
||||
<script type="module">
|
||||
/* ================================================================
|
||||
|
||||
@@ -307,7 +307,7 @@
|
||||
2. nostr-lite.js - Authentication modal (nostr-login-lite)
|
||||
================================================================ -->
|
||||
<script src="./nostr.bundle.js"></script>
|
||||
<script src="./nostr-lite.js"></script>
|
||||
<script src="/nostr-login-lite/nostr-lite.js"></script>
|
||||
|
||||
<script type="module">
|
||||
/* ================================================================
|
||||
|
||||
@@ -1535,6 +1535,7 @@
|
||||
<div id="streamingSiteFormPanel" class="hidden" style="margin-top:6px;">
|
||||
<input id="inputSiteName" class="streamInput" placeholder="Site name" />
|
||||
<input id="inputSiteStreamBaseUrl" class="streamInput" placeholder="Stream base URL (https://example.com)" />
|
||||
<input id="inputSiteStreamUrlTemplate" class="streamInput" placeholder="Stream URL template ({base}/stream/{slug}/stream.m3u8)" />
|
||||
<input id="inputSiteRtmpServer" class="streamInput" placeholder="RTMP server (rtmp://example.com:1935/publish)" />
|
||||
<input id="inputSiteObsKeyTemplate" class="streamInput" placeholder="OBS key template ({slug}/src/{SECRET_KEY})" />
|
||||
<div style="display:flex; gap:4px; flex-wrap:wrap; margin-top:4px;">
|
||||
@@ -1905,7 +1906,7 @@
|
||||
2. nostr-lite.js - Authentication modal (nostr-login-lite)
|
||||
================================================================ -->
|
||||
<script src="./nostr.bundle.js"></script>
|
||||
<script src="./nostr-lite.js"></script>
|
||||
<script src="/nostr-login-lite/nostr-lite.js"></script>
|
||||
|
||||
<script type="module">
|
||||
/* ================================================================
|
||||
|
||||
Reference in New Issue
Block a user