rspace-online/modules/rpubs/components/folk-pubs-editor.ts

1305 lines
41 KiB
TypeScript

/**
* <folk-pubs-editor> — Collaborative markdown editor with 3-step wizard flow.
*
* Step 1: Write — full-width markdown editor with format dropdown
* Step 2: Preview — full-width flipbook with download/edit actions
* Step 3: Publish — centered publish panel (Share/DIY/Order)
*
* Supports file drag-and-drop, sample content, PDF preview/download,
* and real-time collaborative editing via Automerge CRDT.
*/
import { pubsDraftSchema, pubsDocId } from '../schemas';
import type { PubsDoc } from '../schemas';
import type { DocumentId } from '../../../shared/local-first/document';
import { TourEngine } from '../../../shared/tour-engine';
import { startPresenceHeartbeat } from '../../../shared/collab-presence';
interface BookFormat {
id: string;
name: string;
widthMm: number;
heightMm: number;
description: string;
minPages: number;
maxPages: number;
}
interface DraftEntry {
docId: DocumentId;
draftId: string;
title: string;
updatedAt: number;
}
const SAMPLE_CONTENT = `# The Commons
## What Are Commons?
Commons are shared resources managed by communities. They can be natural resources like forests, fisheries, and water systems, or digital resources like open-source software, Wikipedia, and creative works.
The concept of the commons has deep historical roots. In medieval England, common land was shared among villagers for grazing animals, gathering firewood, and growing food. These weren't unmanaged free-for-alls — they operated under sophisticated rules developed over generations.
## Elinor Ostrom's Design Principles
Elinor Ostrom, who won the Nobel Prize in Economics in 2009, identified eight design principles for successful commons governance:
1. Clearly defined boundaries
2. Rules adapted to local conditions
3. Collective-choice arrangements
4. Monitoring by community members
5. Graduated sanctions for rule violators
6. Accessible conflict-resolution mechanisms
7. Recognition of the right to organize
8. Nested enterprises for larger-scale resources
> The tragedy of the commons is not inevitable. Communities around the world have managed shared resources sustainably for centuries when given the tools and authority to do so.
## The Digital Commons
The internet has created entirely new forms of commons. Open-source software, Creative Commons licensing, and collaborative platforms demonstrate that the commons model scales beyond physical resources.
---
These ideas matter because they challenge the assumption that only private ownership or government control can manage resources effectively. The commons represent a third way — community governance of shared wealth.`;
const SYNC_DEBOUNCE_MS = 800;
// ── SVG Icons ──
const SVG_CHECK = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>`;
const SVG_ARROW_RIGHT = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg>`;
const SVG_ARROW_LEFT = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>`;
const SVG_EXPAND = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/></svg>`;
const SVG_DOWNLOAD = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>`;
const SVG_SPINNER = `<span class="btn-spinner"></span>`;
export class FolkPubsEditor extends HTMLElement {
private _formats: BookFormat[] = [];
private _spaceSlug = "personal";
private _selectedFormat = "digest";
private _loading = false;
private _error: string | null = null;
private _pdfUrl: string | null = null;
private _pdfInfo: string | null = null;
private _pdfPageCount = 0;
// ── Cached content (for publish panel access when textarea isn't in DOM) ──
private _cachedContent = "";
private _cachedTitle = "";
private _cachedAuthor = "";
// ── Wizard view state ──
private _view: "write" | "preview" | "publish" = "write";
// ── Dropdown state ──
private _formatDropdownOpen = false;
private _draftsDropdownOpen = false;
// ── Automerge collaborative state ──
private _runtime: any = null;
private _activeDocId: DocumentId | null = null;
private _activeDraftId: string | null = null;
private _drafts: DraftEntry[] = [];
private _unsubChange: (() => void) | null = null;
private _isRemoteUpdate = false;
private _syncTimer: ReturnType<typeof setTimeout> | null = null;
private _syncConnected = false;
private _stopPresence: (() => void) | null = null;
private _tour!: TourEngine;
private static readonly TOUR_STEPS = [
{ target: '.content-area', title: "Editor", message: "Write or paste markdown content here. Drag-and-drop text files also works.", advanceOnClick: false },
{ target: '.format-dropdown-btn', title: "Format", message: "Choose a pocket-book format — digest, half-letter, A6, and more.", advanceOnClick: false },
{ target: '.btn-generate', title: "Press It", message: "Generate a print-ready PDF and advance to the press step.", advanceOnClick: false },
{ target: '.drafts-dropdown-btn', title: "Drafts", message: "Save multiple drafts with real-time collaborative sync.", advanceOnClick: false },
{ target: '.btn-zine-gen', title: "Zine Generator", message: "Generate an AI-illustrated 8-page zine — pick a topic, style, and tone, then edit any section before printing.", advanceOnClick: false },
];
// ── Close dropdowns on outside click ──
private _outsideClickHandler = (e: MouseEvent) => {
if (!this.shadowRoot) return;
const path = e.composedPath();
if (this._formatDropdownOpen) {
const dd = this.shadowRoot.querySelector('.format-dropdown');
if (dd && !path.includes(dd)) {
this._formatDropdownOpen = false;
this.renderDropdowns();
}
}
if (this._draftsDropdownOpen) {
const dd = this.shadowRoot.querySelector('.drafts-dropdown');
if (dd && !path.includes(dd)) {
this._draftsDropdownOpen = false;
this.renderDropdowns();
}
}
};
/** Expose cached content for the publish panel (which can't access the textarea when it's not rendered) */
get cachedContent() { return { content: this._cachedContent, title: this._cachedTitle, author: this._cachedAuthor }; }
set formats(val: BookFormat[]) {
this._formats = val;
if (this.shadowRoot) this.render();
}
set spaceSlug(val: string) {
this._spaceSlug = val;
}
async connectedCallback() {
if (!this.shadowRoot) this.attachShadow({ mode: "open" });
this._tour = new TourEngine(
this.shadowRoot!,
FolkPubsEditor.TOUR_STEPS,
"rpubs_tour_done",
() => this.shadowRoot!.host as HTMLElement,
);
this.render();
const space = this.getAttribute("space") || "";
this._spaceSlug = space || this._spaceSlug;
// Connect to the local-first runtime
await this.initRuntime();
if (space === "demo" && !this._activeDocId) {
this.loadDemoContent();
}
if (!localStorage.getItem("rpubs_tour_done")) {
setTimeout(() => this._tour.start(), 1200);
}
document.addEventListener("click", this._outsideClickHandler);
this._stopPresence = startPresenceHeartbeat(() => {
const activeDraft = this._drafts.find(d => d.draftId === this._activeDraftId);
return { module: 'rpubs', context: activeDraft?.title || 'Editor' };
});
}
private async initRuntime() {
const runtime = (window as any).__rspaceOfflineRuntime;
if (!runtime?.isInitialized) return;
this._runtime = runtime;
try {
const docs = await runtime.subscribeModule('pubs', 'drafts', pubsDraftSchema);
this._syncConnected = runtime.isOnline;
this._drafts = [];
for (const [docId, doc] of docs) {
const parts = (docId as string).split(':');
const draftId = parts[3] || '';
this._drafts.push({
docId: docId as DocumentId,
draftId,
title: (doc as any).draft?.title || 'Untitled',
updatedAt: (doc as any).draft?.updatedAt || (doc as any).meta?.createdAt || 0,
});
}
this._drafts.sort((a, b) => b.updatedAt - a.updatedAt);
if (this._drafts.length > 0) {
await this.switchDraft(this._drafts[0].docId, this._drafts[0].draftId);
} else {
await this.createNewDraft();
}
} catch (e) {
console.error('[folk-pubs-editor] Runtime init failed:', e);
}
}
private async createNewDraft() {
if (!this._runtime) return;
const draftId = crypto.randomUUID();
const dataSpace = this._runtime.resolveDocSpace?.('pubs') || this._spaceSlug;
const docId = pubsDocId(dataSpace, draftId) as DocumentId;
const doc = await this._runtime.subscribe(docId, pubsDraftSchema);
this._runtime.change(docId, 'Initialize draft', (d: PubsDoc) => {
d.meta.spaceSlug = this._spaceSlug;
d.meta.createdAt = Date.now();
d.draft.id = draftId;
d.draft.createdAt = Date.now();
d.draft.updatedAt = Date.now();
});
this._drafts.unshift({
docId,
draftId,
title: 'Untitled',
updatedAt: Date.now(),
});
await this.switchDraft(docId, draftId);
}
private async switchDraft(docId: DocumentId, draftId: string) {
if (this._unsubChange) {
this._unsubChange();
this._unsubChange = null;
}
this._activeDocId = docId;
this._activeDraftId = draftId;
// Reset to write view on draft switch
this._view = "write";
if (this._pdfUrl) {
URL.revokeObjectURL(this._pdfUrl);
this._pdfUrl = null;
this._pdfInfo = null;
}
if (!this._runtime) return;
const doc = await this._runtime.subscribe(docId, pubsDraftSchema);
this.populateFromDoc(doc);
this._unsubChange = this._runtime.onChange(docId, (updated: any) => {
this._isRemoteUpdate = true;
this.applyRemoteUpdate(updated);
this.updateDraftListEntry(docId, updated);
this._isRemoteUpdate = false;
});
this.render();
}
private populateFromDoc(doc: any) {
if (!this.shadowRoot) return;
requestAnimationFrame(() => {
if (!this.shadowRoot) return;
const textarea = this.shadowRoot.querySelector(".content-area") as HTMLTextAreaElement;
const titleInput = this.shadowRoot.querySelector(".title-input") as HTMLInputElement;
const authorInput = this.shadowRoot.querySelector(".author-input") as HTMLInputElement;
if (textarea && doc.content != null) textarea.value = doc.content;
if (titleInput && doc.draft?.title != null) titleInput.value = doc.draft.title;
if (authorInput && doc.draft?.author != null) authorInput.value = doc.draft.author;
if (doc.draft?.format) {
this._selectedFormat = doc.draft.format;
}
});
}
private applyRemoteUpdate(doc: any) {
if (!this.shadowRoot) return;
const textarea = this.shadowRoot.querySelector(".content-area") as HTMLTextAreaElement;
const titleInput = this.shadowRoot.querySelector(".title-input") as HTMLInputElement;
const authorInput = this.shadowRoot.querySelector(".author-input") as HTMLInputElement;
if (textarea && doc.content != null && textarea.value !== doc.content) {
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
textarea.value = doc.content;
textarea.selectionStart = Math.min(start, textarea.value.length);
textarea.selectionEnd = Math.min(end, textarea.value.length);
}
if (titleInput && doc.draft?.title != null && titleInput.value !== doc.draft.title) {
titleInput.value = doc.draft.title;
}
if (authorInput && doc.draft?.author != null && authorInput.value !== doc.draft.author) {
authorInput.value = doc.draft.author;
}
if (doc.draft?.format && doc.draft.format !== this._selectedFormat) {
this._selectedFormat = doc.draft.format;
}
}
private updateDraftListEntry(docId: DocumentId, doc: any) {
const entry = this._drafts.find((d) => d.docId === docId);
if (entry) {
entry.title = doc.draft?.title || 'Untitled';
entry.updatedAt = doc.draft?.updatedAt || Date.now();
}
}
private syncContentToDoc(content: string) {
if (!this._runtime || !this._activeDocId || this._isRemoteUpdate) return;
if (this._syncTimer) clearTimeout(this._syncTimer);
this._syncTimer = setTimeout(() => {
this._runtime.change(this._activeDocId!, 'Update content', (d: PubsDoc) => {
d.content = content;
d.draft.updatedAt = Date.now();
});
}, SYNC_DEBOUNCE_MS);
}
private syncMetaToDoc(field: 'title' | 'author' | 'format', value: string) {
if (!this._runtime || !this._activeDocId || this._isRemoteUpdate) return;
this._runtime.change(this._activeDocId!, `Update ${field}`, (d: PubsDoc) => {
(d.draft as any)[field] = value;
d.draft.updatedAt = Date.now();
});
if (field === 'title') {
const entry = this._drafts.find((d) => d.docId === this._activeDocId);
if (entry) {
entry.title = value || 'Untitled';
entry.updatedAt = Date.now();
}
}
}
private loadDemoContent() {
requestAnimationFrame(() => {
if (!this.shadowRoot) return;
const textarea = this.shadowRoot.querySelector(".content-area") as HTMLTextAreaElement;
const titleInput = this.shadowRoot.querySelector(".title-input") as HTMLInputElement;
const authorInput = this.shadowRoot.querySelector(".author-input") as HTMLInputElement;
if (textarea) textarea.value = SAMPLE_CONTENT;
if (titleInput) titleInput.value = "The Commons";
if (authorInput) authorInput.value = "rSpace Community";
});
}
disconnectedCallback() {
if (this._pdfUrl) URL.revokeObjectURL(this._pdfUrl);
if (this._unsubChange) this._unsubChange();
if (this._syncTimer) clearTimeout(this._syncTimer);
document.removeEventListener("click", this._outsideClickHandler);
this._stopPresence?.();
}
private renderDropdowns() {
if (!this.shadowRoot) return;
// Format dropdown panel
const formatPanel = this.shadowRoot.querySelector('.format-dropdown-panel') as HTMLElement;
if (formatPanel) formatPanel.hidden = !this._formatDropdownOpen;
// Drafts dropdown panel
const draftsPanel = this.shadowRoot.querySelector('.drafts-dropdown-panel') as HTMLElement;
if (draftsPanel) draftsPanel.hidden = !this._draftsDropdownOpen;
}
private getWordCount(): number {
if (!this.shadowRoot) return 0;
const textarea = this.shadowRoot.querySelector(".content-area") as HTMLTextAreaElement;
if (!textarea || !textarea.value.trim()) return 0;
return textarea.value.trim().split(/\s+/).length;
}
private getContent(): string {
if (!this.shadowRoot) return '';
const textarea = this.shadowRoot.querySelector(".content-area") as HTMLTextAreaElement;
return textarea?.value || '';
}
private formatTime(ts: number): string {
if (!ts) return '';
const d = new Date(ts);
const now = new Date();
if (d.toDateString() === now.toDateString()) {
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
return d.toLocaleDateString([], { month: 'short', day: 'numeric' });
}
private render() {
if (!this.shadowRoot) return;
this.shadowRoot.innerHTML = `
${this.getStyles()}
<div class="wizard-layout">
<!-- Toolbar -->
<div class="editor-toolbar">
<div class="toolbar-left">
<input type="text" class="title-input" placeholder="Title (optional)" />
<input type="text" class="author-input" placeholder="Author (optional)" />
</div>
<div class="toolbar-right">
${this._runtime ? `
<!-- Drafts dropdown -->
<div class="drafts-dropdown">
<button class="drafts-dropdown-btn toolbar-btn" title="Drafts">
Drafts${this._drafts.length > 0 ? ` <span class="draft-count">${this._drafts.length}</span>` : ''}
</button>
<div class="drafts-dropdown-panel" hidden>
${this._drafts.map(d => `
<button class="draft-option ${d.docId === this._activeDocId ? 'active' : ''}" data-doc-id="${d.docId}" data-draft-id="${d.draftId}">
<span class="draft-option-title">${this.escapeHtml(d.title || 'Untitled')}</span>
<span class="draft-option-time">${this.formatTime(d.updatedAt)}</span>
</button>
`).join('')}
<button class="draft-option new-draft-option">+ New Draft</button>
</div>
</div>
` : ''}
${this._syncConnected ? '<span class="sync-badge" title="Syncing with collaborators">Synced</span>' : ''}
<button class="toolbar-btn btn-sample" title="Load sample content">Sample</button>
<label class="toolbar-btn btn-upload" title="Open a text file">
<input type="file" accept=".md,.txt,.markdown" style="display:none" />
Open File
</label>
<a class="toolbar-btn btn-zine-gen" href="/${this._spaceSlug}/rpubs/zine" title="AI Zine Generator">
&#128240; Zine
</a>
<button class="toolbar-btn btn-tour-trigger" title="Take a tour" style="opacity:0.7">Tour</button>
</div>
</div>
<!-- Step indicator -->
<div class="step-indicator">
<div class="steps">
<button class="step ${this._view === 'write' ? 'active' : ''} ${this._pdfUrl && this._view !== 'write' ? 'done' : ''}" data-step="write">
<span class="step-circle">${this._pdfUrl && this._view !== 'write' ? SVG_CHECK : '1'}</span>
<span class="step-label">Write</span>
</button>
<div class="step-line ${this._view !== 'write' ? 'filled' : ''}"></div>
<button class="step ${this._view === 'preview' ? 'active' : ''} ${this._view === 'publish' ? 'done' : ''}" data-step="preview" ${!this._pdfUrl ? 'disabled' : ''}>
<span class="step-circle">${this._view === 'publish' ? SVG_CHECK : '2'}</span>
<span class="step-label">Press</span>
</button>
<div class="step-line ${this._view === 'publish' ? 'filled' : ''}"></div>
<button class="step ${this._view === 'publish' ? 'active' : ''}" data-step="publish" ${!this._pdfUrl ? 'disabled' : ''}>
<span class="step-circle">3</span>
<span class="step-label">Print</span>
</button>
</div>
${this._pdfInfo ? `<span class="step-info">${this._pdfInfo}</span>` : ''}
</div>
<!-- Step content -->
<div class="step-content">
${this._view === 'write' ? this.renderWriteStep() : ''}
${this._view === 'preview' ? this.renderPreviewStep() : ''}
${this._view === 'publish' ? this.renderPublishStep() : ''}
</div>
</div>
`;
this.bindEvents();
this._tour.renderOverlay();
// Populate fields from current doc after render
if (this._runtime && this._activeDocId) {
const doc = this._runtime.get(this._activeDocId);
if (doc) this.populateFromDoc(doc);
}
}
private renderWriteStep(): string {
return `
<div class="write-step">
<textarea class="content-area" placeholder="Drop in your markdown or plain text here..."></textarea>
<div class="write-bottom-bar">
<div class="write-meta">
<span class="word-count"></span>
<div class="format-dropdown">
<button class="format-badge format-dropdown-btn" title="Change book format">
${this.escapeHtml(this._formats.find(f => f.id === this._selectedFormat)?.name || this._selectedFormat)}
<span class="dropdown-arrow">&#9662;</span>
</button>
<div class="format-dropdown-panel" hidden>
${this._formats.map(f => `
<button class="format-option ${f.id === this._selectedFormat ? 'active' : ''}" data-format="${f.id}">
<span class="format-option-name">${f.name}</span>
<span class="format-option-desc">${f.widthMm}\u00D7${f.heightMm}mm \u00B7 ${f.minPages}\u2013${f.maxPages}pp</span>
</button>
`).join('')}
</div>
</div>
</div>
${this._error ? `<div class="inline-error">${this.escapeHtml(this._error)}</div>` : ''}
<button class="btn-generate" ${this._loading ? "disabled" : ""}>
${this._loading
? `${SVG_SPINNER} Pressing...`
: `Press It ${SVG_ARROW_RIGHT}`}
</button>
</div>
</div>
`;
}
private renderPreviewStep(): string {
const currentFormat = this._formats.find(f => f.id === this._selectedFormat);
const dims = currentFormat ? `${currentFormat.widthMm}\u00D7${currentFormat.heightMm}mm` : '';
return `
<div class="preview-step">
<div class="preview-actions">
<button class="action-btn" data-action="back-to-write">${SVG_ARROW_LEFT} Edit</button>
<button class="action-btn" data-action="fullscreen-toggle">${SVG_EXPAND} Fullscreen</button>
<a class="action-btn" href="${this._pdfUrl}" download>${SVG_DOWNLOAD} Download</a>
${currentFormat ? `<span class="format-chip">${this.escapeHtml(currentFormat.name)} \u00B7 ${dims}</span>` : ''}
</div>
<div class="preview-flipbook">
<folk-pubs-flipbook pdf-url="${this._pdfUrl}"></folk-pubs-flipbook>
</div>
<div class="preview-bottom-bar">
<span class="preview-info">${this._pdfInfo || ''}</span>
<button class="btn-publish-next">Print Locally ${SVG_ARROW_RIGHT}</button>
</div>
</div>
`;
}
private renderPublishStep(): string {
return `
<div class="publish-step">
<button class="back-link" data-action="back-to-preview">\u2190 Back to press</button>
<folk-pubs-publish-panel
pdf-url="${this._pdfUrl}"
format-id="${this._selectedFormat}"
format-name="${this.escapeHtml(this._formats.find(f => f.id === this._selectedFormat)?.name || this._selectedFormat)}"
page-count="${this._pdfPageCount || 0}"
space-slug="${this._spaceSlug}"
></folk-pubs-publish-panel>
</div>
`;
}
startTour() {
this._tour.start();
}
private bindEvents() {
if (!this.shadowRoot) return;
// Tour
this.shadowRoot.querySelector(".btn-tour-trigger")?.addEventListener("click", () => this.startTour());
// Step navigation
this.shadowRoot.querySelectorAll(".step[data-step]").forEach(btn => {
btn.addEventListener("click", () => {
const step = (btn as HTMLElement).dataset.step as "write" | "preview" | "publish";
if (step === "write") {
this._view = "write";
this.render();
} else if (step === "preview" && this._pdfUrl) {
this._view = "preview";
this.render();
} else if (step === "publish" && this._pdfUrl) {
this._view = "publish";
this.render();
}
});
});
// Back navigation buttons
this.shadowRoot.querySelector('[data-action="back-to-write"]')?.addEventListener("click", () => {
this._view = "write";
this.render();
});
this.shadowRoot.querySelector('[data-action="back-to-preview"]')?.addEventListener("click", () => {
this._view = "preview";
this.render();
});
this.shadowRoot.querySelector('.btn-publish-next')?.addEventListener("click", () => {
this._view = "publish";
this.render();
});
// Fullscreen toggle
this.shadowRoot.querySelector('[data-action="fullscreen-toggle"]')?.addEventListener("click", () => {
const flipbook = this.shadowRoot!.querySelector("folk-pubs-flipbook");
if (!flipbook) return;
if (document.fullscreenElement) {
document.exitFullscreen();
} else {
flipbook.requestFullscreen().catch(() => {});
}
});
// Format dropdown
this.shadowRoot.querySelector('.format-dropdown-btn')?.addEventListener("click", (e) => {
e.stopPropagation();
this._formatDropdownOpen = !this._formatDropdownOpen;
this._draftsDropdownOpen = false;
this.renderDropdowns();
});
this.shadowRoot.querySelectorAll('.format-option').forEach(btn => {
btn.addEventListener("click", () => {
this._selectedFormat = (btn as HTMLElement).dataset.format!;
this._formatDropdownOpen = false;
this.syncMetaToDoc('format', this._selectedFormat);
// Clear previous PDF on format change
if (this._pdfUrl) {
URL.revokeObjectURL(this._pdfUrl);
this._pdfUrl = null;
this._pdfInfo = null;
this._view = "write";
}
this._error = null;
this.render();
});
});
// Drafts dropdown
this.shadowRoot.querySelector('.drafts-dropdown-btn')?.addEventListener("click", (e) => {
e.stopPropagation();
this._draftsDropdownOpen = !this._draftsDropdownOpen;
this._formatDropdownOpen = false;
this.renderDropdowns();
});
this.shadowRoot.querySelectorAll('.draft-option:not(.new-draft-option)').forEach(btn => {
btn.addEventListener("click", () => {
const el = btn as HTMLElement;
const docId = el.dataset.docId as DocumentId;
const draftId = el.dataset.draftId!;
this._draftsDropdownOpen = false;
if (docId !== this._activeDocId) {
this.switchDraft(docId, draftId);
} else {
this.renderDropdowns();
}
});
});
this.shadowRoot.querySelector('.new-draft-option')?.addEventListener("click", () => {
this._draftsDropdownOpen = false;
this.createNewDraft();
});
const textarea = this.shadowRoot.querySelector(".content-area") as HTMLTextAreaElement;
const titleInput = this.shadowRoot.querySelector(".title-input") as HTMLInputElement;
const authorInput = this.shadowRoot.querySelector(".author-input") as HTMLInputElement;
const generateBtn = this.shadowRoot.querySelector(".btn-generate") as HTMLButtonElement;
const sampleBtn = this.shadowRoot.querySelector(".btn-sample");
const fileInput = this.shadowRoot.querySelector('input[type="file"]') as HTMLInputElement;
// Content sync + word count update
textarea?.addEventListener("input", () => {
this.syncContentToDoc(textarea.value);
this.updateWordCount();
});
// Update word count on initial render
if (textarea) {
requestAnimationFrame(() => this.updateWordCount());
}
// Title/Author sync
titleInput?.addEventListener("input", () => {
this.syncMetaToDoc('title', titleInput.value);
});
authorInput?.addEventListener("input", () => {
this.syncMetaToDoc('author', authorInput.value);
});
// Sample content
sampleBtn?.addEventListener("click", () => {
if (!textarea) return;
textarea.value = SAMPLE_CONTENT;
if (titleInput) titleInput.value = "";
if (authorInput) authorInput.value = "";
this.syncContentToDoc(SAMPLE_CONTENT);
this.syncMetaToDoc('title', '');
this.syncMetaToDoc('author', '');
this.updateWordCount();
});
// File upload
fileInput?.addEventListener("change", () => {
const file = fileInput.files?.[0];
if (file && textarea) {
const reader = new FileReader();
reader.onload = () => {
const text = reader.result as string;
textarea.value = text;
this.syncContentToDoc(text);
this.updateWordCount();
};
reader.readAsText(file);
}
});
// Drag-and-drop
textarea?.addEventListener("dragover", (e) => {
e.preventDefault();
textarea.classList.add("dragover");
});
textarea?.addEventListener("dragleave", () => {
textarea.classList.remove("dragover");
});
textarea?.addEventListener("drop", (e) => {
e.preventDefault();
textarea.classList.remove("dragover");
const file = (e as DragEvent).dataTransfer?.files[0];
if (file && (file.type.startsWith("text/") || file.name.match(/\.(md|txt|markdown)$/))) {
const reader = new FileReader();
reader.onload = () => {
const text = reader.result as string;
textarea.value = text;
this.syncContentToDoc(text);
this.updateWordCount();
};
reader.readAsText(file);
}
});
// Generate PDF
generateBtn?.addEventListener("click", async () => {
const content = textarea?.value?.trim();
if (!content) {
this._error = "Please enter some content first.";
this.render();
return;
}
// Cache content before switching views (publish panel can't access textarea)
this._cachedContent = content;
this._cachedTitle = titleInput?.value?.trim() || "";
this._cachedAuthor = authorInput?.value?.trim() || "";
this._loading = true;
this._error = null;
if (this._pdfUrl) URL.revokeObjectURL(this._pdfUrl);
this._pdfUrl = null;
this.render();
try {
const authHeaders: Record<string, string> = {};
try {
const s = JSON.parse(localStorage.getItem("encryptid_session") || "{}");
if (s?.accessToken) authHeaders["Authorization"] = `Bearer ${s.accessToken}`;
} catch {}
const res = await fetch(`/${this._spaceSlug}/rpubs/api/generate`, {
method: "POST",
headers: { "Content-Type": "application/json", ...authHeaders },
body: JSON.stringify({
content,
title: this._cachedTitle || undefined,
author: this._cachedAuthor || undefined,
format: this._selectedFormat,
}),
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || "Generation failed");
}
const blob = await res.blob();
const pageCount = res.headers.get("X-Page-Count") || "?";
const format = this._formats.find((f) => f.id === this._selectedFormat);
this._pdfUrl = URL.createObjectURL(blob);
this._pdfPageCount = parseInt(pageCount) || 0;
this._pdfInfo = `${pageCount} pages \u00B7 ${format?.name || this._selectedFormat}`;
this._loading = false;
// Auto-advance to press (preview) step
this._view = "preview";
this.render();
} catch (e: any) {
this._loading = false;
this._error = e.message;
this.render();
}
});
}
private updateWordCount() {
if (!this.shadowRoot) return;
const el = this.shadowRoot.querySelector('.word-count');
if (!el) return;
const count = this.getWordCount();
el.textContent = count > 0 ? `${count} words` : '';
}
private getStyles(): string {
return `<style>
:host {
display: block;
height: 100%;
background: var(--rs-bg-page);
color: var(--rs-text-primary);
overflow: hidden;
}
.wizard-layout {
display: flex;
flex-direction: column;
height: 100%;
}
/* ── Toolbar ── */
.editor-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.75rem;
padding: 0.5rem 0.75rem;
border-bottom: 1px solid var(--rs-border-subtle);
flex-wrap: wrap;
}
.toolbar-left {
display: flex;
gap: 0.5rem;
flex: 1;
}
.toolbar-right {
display: flex;
gap: 0.5rem;
align-items: center;
flex-wrap: wrap;
}
.toolbar-btn {
padding: 0.375rem 0.75rem;
border: 1px solid var(--rs-border);
border-radius: 0.375rem;
background: var(--rs-bg-surface);
color: var(--rs-text-secondary);
font-size: 0.8rem;
cursor: pointer;
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 0.25rem;
}
.toolbar-btn:hover { border-color: var(--rs-primary); color: var(--rs-text-primary); }
.btn-zine-gen {
background: linear-gradient(135deg, #f59e0b, #ef4444);
color: #fff;
border-color: transparent;
font-weight: 600;
}
.btn-zine-gen:hover { opacity: 0.85; color: #fff; }
.sync-badge {
font-size: 0.65rem;
padding: 0.2rem 0.5rem;
border-radius: 1rem;
background: rgba(34, 197, 94, 0.15);
color: var(--rs-success, #22c55e);
font-weight: 500;
}
.title-input, .author-input {
padding: 0.375rem 0.625rem;
border: 1px solid var(--rs-input-border);
border-radius: 0.375rem;
background: var(--rs-input-bg);
color: var(--rs-input-text);
font-size: 0.8rem;
min-width: 120px;
}
.title-input { flex: 1; max-width: 240px; }
.author-input { flex: 0.7; max-width: 180px; }
.title-input::placeholder, .author-input::placeholder { color: var(--rs-text-muted); }
.title-input:focus, .author-input:focus { outline: none; border-color: var(--rs-primary); }
/* ── Dropdowns ── */
.format-dropdown, .drafts-dropdown {
position: relative;
}
.dropdown-arrow { font-size: 0.6rem; }
.format-dropdown-panel, .drafts-dropdown-panel {
position: absolute;
right: 0;
min-width: 220px;
background: var(--rs-bg-surface);
border: 1px solid var(--rs-border);
border-radius: 0.5rem;
box-shadow: 0 8px 24px rgba(0,0,0,0.3);
z-index: 100;
display: flex;
flex-direction: column;
padding: 0.25rem;
max-height: 320px;
overflow-y: auto;
}
.format-dropdown-panel[hidden], .drafts-dropdown-panel[hidden] {
display: none;
}
.format-dropdown-panel {
bottom: calc(100% + 4px);
}
.drafts-dropdown-panel {
top: calc(100% + 4px);
}
.format-option, .draft-option {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 0.625rem;
border: none;
border-radius: 0.375rem;
background: transparent;
color: var(--rs-text-primary);
font-size: 0.8rem;
cursor: pointer;
text-align: left;
gap: 0.5rem;
}
.format-option:hover, .draft-option:hover { background: rgba(255,255,255,0.05); }
.format-option.active { background: rgba(59, 130, 246, 0.15); color: var(--rs-primary); }
.draft-option.active { background: rgba(59, 130, 246, 0.15); }
.format-option-name { font-weight: 500; }
.format-option-desc { font-size: 0.65rem; color: var(--rs-text-muted); white-space: nowrap; }
.draft-option-title {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.draft-option-time { font-size: 0.65rem; color: var(--rs-text-muted); flex-shrink: 0; }
.new-draft-option {
border-top: 1px solid var(--rs-border-subtle);
margin-top: 0.25rem;
padding-top: 0.625rem;
color: var(--rs-text-secondary);
font-style: italic;
}
.draft-count {
font-size: 0.6rem;
background: var(--rs-primary);
color: #fff;
border-radius: 1rem;
padding: 0.05rem 0.4rem;
font-weight: 600;
margin-left: 0.125rem;
}
/* ── Step indicator ── */
.step-indicator {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.625rem 1rem;
border-bottom: 1px solid var(--rs-border-subtle);
background: var(--rs-bg-surface);
}
.steps {
display: flex;
align-items: center;
gap: 0;
}
.step {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.25rem 0.5rem;
border: none;
border-radius: 0;
background: transparent;
color: var(--rs-text-muted);
font-size: 0.78rem;
cursor: pointer;
transition: all 0.15s;
}
.step:disabled { cursor: not-allowed; opacity: 0.35; }
.step:not(:disabled):hover { color: var(--rs-text-primary); }
.step:not(:disabled):hover .step-circle { border-color: var(--rs-accent, #14b8a6); }
.step.active .step-circle {
background: var(--rs-accent, #14b8a6);
border-color: var(--rs-accent, #14b8a6);
color: #fff;
}
.step.active { color: var(--rs-text-primary); font-weight: 600; }
.step.done .step-circle {
background: var(--rs-accent, #14b8a6);
border-color: var(--rs-accent, #14b8a6);
color: #fff;
}
.step.done { color: var(--rs-accent, #14b8a6); }
.step-circle {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 50%;
border: 2px solid var(--rs-border, #444);
background: transparent;
color: var(--rs-text-muted);
font-size: 0.75rem;
font-weight: 700;
transition: all 0.15s;
flex-shrink: 0;
}
.step-line {
width: 32px;
height: 2px;
background: var(--rs-border);
margin: 0 0.125rem;
transition: background 0.15s;
}
.step-line.filled { background: var(--rs-accent, #14b8a6); }
.step-info {
font-size: 0.75rem;
color: var(--rs-text-secondary);
}
/* ── Step content ── */
.step-content {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
/* ── Write step ── */
.write-step {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.content-area {
flex: 1;
padding: 1rem 1.5rem;
border: none;
background: var(--rs-bg-page);
color: var(--rs-text-primary);
font-family: "JetBrains Mono", "Fira Code", monospace;
font-size: 0.85rem;
line-height: 1.6;
resize: none;
}
.content-area::placeholder { color: var(--rs-text-secondary); }
.content-area:focus { outline: none; }
.content-area.dragover {
background: var(--rs-bg-surface);
outline: 2px dashed var(--rs-primary);
outline-offset: -4px;
}
.write-bottom-bar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 1rem;
border-top: 1px solid var(--rs-border-subtle);
gap: 1rem;
}
.write-meta {
display: flex;
align-items: center;
gap: 0.75rem;
font-size: 0.75rem;
color: var(--rs-text-muted);
}
.format-badge {
padding: 0.15rem 0.5rem;
border-radius: 1rem;
background: rgba(20, 184, 166, 0.1);
color: var(--rs-accent, #14b8a6);
font-size: 0.65rem;
font-weight: 500;
border: 1px solid transparent;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 0.25rem;
}
.format-badge:hover {
border-color: var(--rs-accent, #14b8a6);
}
.inline-error {
color: #f87171;
font-size: 0.75rem;
}
.btn-generate {
padding: 0.5rem 1.25rem;
border: none;
border-radius: 0.5rem;
background: var(--rs-accent, #14b8a6);
color: #fff;
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
transition: background 0.15s;
white-space: nowrap;
display: inline-flex;
align-items: center;
gap: 0.375rem;
}
.btn-generate:hover { background: var(--rs-accent-hover, #0d9488); }
.btn-generate:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-spinner {
display: inline-block;
width: 14px; height: 14px;
border: 2px solid rgba(255,255,255,0.3);
border-top-color: #fff;
border-radius: 50%;
animation: btn-spin 0.6s linear infinite;
}
@keyframes btn-spin { to { transform: rotate(360deg); } }
/* ── Preview step ── */
.preview-step {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.preview-actions {
display: flex;
gap: 0.5rem;
padding: 0.5rem 1rem;
border-bottom: 1px solid var(--rs-border-subtle);
}
.action-btn {
padding: 0.375rem 0.75rem;
border: 1px solid var(--rs-border);
border-radius: 0.375rem;
background: var(--rs-bg-surface);
color: var(--rs-text-secondary);
font-size: 0.8rem;
cursor: pointer;
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 0.375rem;
}
.action-btn:hover { border-color: var(--rs-accent, #14b8a6); color: var(--rs-text-primary); }
.action-btn:hover svg { color: var(--rs-accent, #14b8a6); }
.format-chip {
margin-left: auto;
padding: 0.25rem 0.625rem;
border-radius: 1rem;
background: rgba(20, 184, 166, 0.1);
color: var(--rs-accent, #14b8a6);
font-size: 0.7rem;
font-weight: 500;
white-space: nowrap;
}
.preview-flipbook {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
min-height: 300px;
overflow: auto;
}
.preview-flipbook folk-pubs-flipbook {
max-width: 100%;
max-height: 100%;
}
.preview-bottom-bar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 1rem;
border-top: 1px solid var(--rs-border-subtle);
}
.preview-info {
font-size: 0.75rem;
color: var(--rs-text-secondary);
}
.btn-publish-next {
padding: 0.5rem 1.25rem;
border: none;
border-radius: 0.5rem;
background: var(--rs-accent, #14b8a6);
color: #fff;
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
transition: background 0.15s;
display: inline-flex;
align-items: center;
gap: 0.375rem;
}
.btn-publish-next:hover { background: var(--rs-accent-hover, #0d9488); }
/* ── Publish step ── */
.publish-step {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
padding: 1.5rem 1rem;
overflow-y: auto;
}
.publish-step folk-pubs-publish-panel {
width: 100%;
max-width: 32rem;
}
.back-link {
align-self: flex-start;
max-width: 32rem;
width: 100%;
margin: 0 auto 1rem;
background: none;
border: none;
color: var(--rs-text-secondary);
font-size: 0.8rem;
cursor: pointer;
padding: 0;
text-align: left;
}
.back-link:hover { color: var(--rs-text-primary); }
/* ── Responsive ── */
@media (max-width: 640px) {
:host { height: auto; min-height: 100%; }
.editor-toolbar { gap: 0.5rem; }
.toolbar-left { flex-direction: column; gap: 0.375rem; }
.title-input, .author-input { max-width: 100%; flex: 1; }
.step-label { display: none; }
.step { padding: 0.3rem 0.5rem; }
.content-area { min-height: 45vh; padding: 0.75rem; }
.write-bottom-bar { flex-wrap: wrap; }
.preview-flipbook { padding: 0.5rem; }
}
@media (max-width: 480px) {
.toolbar-right { width: 100%; flex-wrap: wrap; }
.toolbar-btn { flex: 1; text-align: center; justify-content: center; font-size: 0.75rem; padding: 0.3rem 0.5rem; }
.format-dropdown-panel, .drafts-dropdown-panel { right: auto; left: 0; min-width: 200px; }
}
</style>`;
}
private escapeHtml(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
}
customElements.define("folk-pubs-editor", FolkPubsEditor);