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

939 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* <folk-pubs-editor> — Collaborative markdown editor with format selector and PDF generation.
*
* Drop in markdown text, pick a pocket-book format, generate a print-ready PDF.
* 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';
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;
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;
// ── 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 _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-btn', title: "Format", message: "Choose a pocket-book format — digest, half-letter, A6, and more.", advanceOnClick: false },
{ target: '.btn-generate', title: "Generate PDF", message: "Generate a print-ready PDF in the selected format.", advanceOnClick: false },
{ target: '.btn-new-draft', title: "Drafts", message: "Save multiple drafts with real-time collaborative sync.", advanceOnClick: false },
];
set formats(val: BookFormat[]) {
this._formats = val;
if (this.shadowRoot) this.render();
}
set spaceSlug(val: string) {
this._spaceSlug = val;
}
async connectedCallback() {
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);
}
}
private async initRuntime() {
const runtime = (window as any).__rspaceOfflineRuntime;
if (!runtime?.isInitialized) return;
this._runtime = runtime;
try {
// Discover all existing drafts for this space
const docs = await runtime.subscribeModule('pubs', 'drafts', pubsDraftSchema);
this._syncConnected = runtime.isOnline;
// Build draft list from discovered docs
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,
});
}
// Sort by most recently updated
this._drafts.sort((a, b) => b.updatedAt - a.updatedAt);
// Open the most recent draft, or create one if none exist
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);
// Initialize the draft metadata
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();
});
// Add to local list
this._drafts.unshift({
docId,
draftId,
title: 'Untitled',
updatedAt: Date.now(),
});
await this.switchDraft(docId, draftId);
}
private async switchDraft(docId: DocumentId, draftId: string) {
// Unsubscribe from previous doc
if (this._unsubChange) {
this._unsubChange();
this._unsubChange = null;
}
this._activeDocId = docId;
this._activeDraftId = draftId;
if (!this._runtime) return;
// Subscribe to this doc (may already be subscribed from subscribeModule)
const doc = await this._runtime.subscribe(docId, pubsDraftSchema);
// Populate UI from doc
this.populateFromDoc(doc);
// Listen for remote changes
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;
this.updateFormatButtons();
}
});
}
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;
// Preserve cursor position when applying remote content changes
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;
this.updateFormatButtons();
}
}
private updateFormatButtons() {
if (!this.shadowRoot) return;
this.shadowRoot.querySelectorAll(".format-btn").forEach((btn) => {
const el = btn as HTMLElement;
if (el.dataset.format === this._selectedFormat) {
el.classList.add("active");
} else {
el.classList.remove("active");
}
});
// Update format details visibility
this.shadowRoot.querySelectorAll(".format-detail").forEach((el) => {
const htmlEl = el as HTMLElement;
htmlEl.hidden = htmlEl.dataset.for !== this._selectedFormat;
});
}
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();
this.renderDraftList();
}
}
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();
});
// Update local draft list entry
if (field === 'title') {
const entry = this._drafts.find((d) => d.docId === this._activeDocId);
if (entry) {
entry.title = value || 'Untitled';
entry.updatedAt = Date.now();
this.renderDraftList();
}
}
}
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);
}
private renderDraftList() {
if (!this.shadowRoot) return;
const container = this.shadowRoot.querySelector(".draft-list");
if (!container) return;
container.innerHTML = this._drafts.map((d) => `
<button class="draft-item ${d.docId === this._activeDocId ? 'active' : ''}" data-doc-id="${d.docId}" data-draft-id="${d.draftId}">
<span class="draft-title">${this.escapeHtml(d.title || 'Untitled')}</span>
<span class="draft-time">${this.formatTime(d.updatedAt)}</span>
</button>
`).join("");
// Bind click events on draft items
container.querySelectorAll(".draft-item").forEach((btn) => {
btn.addEventListener("click", () => {
const el = btn as HTMLElement;
const docId = el.dataset.docId as DocumentId;
const draftId = el.dataset.draftId!;
if (docId !== this._activeDocId) {
this.switchDraft(docId, draftId);
}
});
});
}
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;
const hasDrafts = this._drafts.length > 0;
this.shadowRoot.innerHTML = `
${this.getStyles()}
<div class="editor-layout">
<div class="editor-main">
<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._syncConnected ? '<span class="sync-badge" title="Syncing with collaborators">Synced</span>' : ''}
<button class="btn-sample" title="Load sample content">Sample</button>
<label class="btn-upload" title="Open a text file">
<input type="file" accept=".md,.txt,.markdown" style="display:none" />
Open File
</label>
<button class="btn-sample" id="btn-tour" style="font-size:0.78rem;padding:2px 8px;opacity:0.7">Tour</button>
</div>
</div>
<textarea class="content-area" placeholder="Drop in your markdown or plain text here..."></textarea>
</div>
<div class="sidebar">
${this._runtime ? `
<h3>Drafts</h3>
<button class="btn-new-draft">+ New Draft</button>
<div class="draft-list">
${this._drafts.map((d) => `
<button class="draft-item ${d.docId === this._activeDocId ? 'active' : ''}" data-doc-id="${d.docId}" data-draft-id="${d.draftId}">
<span class="draft-title">${this.escapeHtml(d.title || 'Untitled')}</span>
<span class="draft-time">${this.formatTime(d.updatedAt)}</span>
</button>
`).join("")}
</div>
` : ''}
<h3>Format</h3>
<div class="format-grid">
${this._formats.map((f) => `
<button class="format-btn ${f.id === this._selectedFormat ? "active" : ""}" data-format="${f.id}">
<span class="format-name">${f.name}</span>
<span class="format-desc">${f.widthMm}×${f.heightMm}mm</span>
</button>
`).join("")}
</div>
<button class="btn-generate" ${this._loading ? "disabled" : ""}>
${this._loading ? "Generating..." : "Generate PDF"}
</button>
${this._error ? `<div class="error">${this.escapeHtml(this._error)}</div>` : ""}
${this._pdfUrl ? `
<div class="result">
<div class="result-info">${this._pdfInfo || ""}</div>
<iframe class="pdf-preview" src="${this._pdfUrl}"></iframe>
<a class="btn-download" href="${this._pdfUrl}" download>Download PDF</a>
</div>
` : `
<div class="placeholder">
<p>Choose a format and click Generate to create your pocket book.</p>
<div class="format-details">
${this._formats.map((f) => `
<div class="format-detail" data-for="${f.id}" ${f.id !== this._selectedFormat ? "hidden" : ""}>
<strong>${f.name}</strong>
<span>${f.description}</span>
<span class="pages">${f.minPages}${f.maxPages} pages</span>
</div>
`).join("")}
</div>
</div>
`}
</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);
}
}
startTour() {
this._tour.start();
}
private bindEvents() {
if (!this.shadowRoot) return;
this.shadowRoot.querySelector("#btn-tour")?.addEventListener("click", () => this.startTour());
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;
const newDraftBtn = this.shadowRoot.querySelector(".btn-new-draft");
// ── Content sync → Automerge (debounced) ──
textarea?.addEventListener("input", () => {
this.syncContentToDoc(textarea.value);
});
// ── Title/Author sync → Automerge (immediate) ──
titleInput?.addEventListener("input", () => {
this.syncMetaToDoc('title', titleInput.value);
});
authorInput?.addEventListener("input", () => {
this.syncMetaToDoc('author', authorInput.value);
});
// ── New draft button ──
newDraftBtn?.addEventListener("click", () => {
this.createNewDraft();
});
// ── Draft list items ──
this.shadowRoot.querySelectorAll(".draft-item").forEach((btn) => {
btn.addEventListener("click", () => {
const el = btn as HTMLElement;
const docId = el.dataset.docId as DocumentId;
const draftId = el.dataset.draftId!;
if (docId !== this._activeDocId) {
this.switchDraft(docId, draftId);
}
});
});
// Format buttons
this.shadowRoot.querySelectorAll(".format-btn").forEach((btn) => {
btn.addEventListener("click", () => {
this._selectedFormat = (btn as HTMLElement).dataset.format!;
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._error = null;
this.render();
});
});
// Sample content
sampleBtn?.addEventListener("click", () => {
textarea.value = SAMPLE_CONTENT;
titleInput.value = "";
authorInput.value = "";
this.syncContentToDoc(SAMPLE_CONTENT);
this.syncMetaToDoc('title', '');
this.syncMetaToDoc('author', '');
});
// File upload
fileInput?.addEventListener("change", () => {
const file = fileInput.files?.[0];
if (file) {
const reader = new FileReader();
reader.onload = () => {
const text = reader.result as string;
textarea.value = text;
this.syncContentToDoc(text);
};
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);
};
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;
}
this._loading = true;
this._error = null;
if (this._pdfUrl) URL.revokeObjectURL(this._pdfUrl);
this._pdfUrl = null;
this.render();
try {
const res = await fetch(`/${this._spaceSlug}/pubs/api/generate`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
content,
title: titleInput.value.trim() || undefined,
author: authorInput.value.trim() || 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._pdfInfo = `${pageCount} pages · ${format?.name || this._selectedFormat}`;
this._loading = false;
this.render();
} catch (e: any) {
this._loading = false;
this._error = e.message;
this.render();
}
});
}
private getStyles(): string {
return `<style>
:host {
display: block;
height: calc(100vh - 52px);
background: var(--rs-bg-page);
color: var(--rs-text-primary);
}
.editor-layout {
display: flex;
height: 100%;
}
.editor-main {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
}
.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;
}
.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); }
.btn-sample, .btn-upload {
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;
}
.btn-sample:hover, .btn-upload:hover { border-color: var(--rs-primary); color: var(--rs-text-primary); }
.content-area {
flex: 1;
padding: 1rem;
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;
}
.sidebar {
width: 280px;
border-left: 1px solid var(--rs-border-subtle);
padding: 1rem;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.sidebar h3 {
margin: 0;
font-size: 0.85rem;
font-weight: 600;
color: var(--rs-text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
}
/* ── Draft list ── */
.btn-new-draft {
padding: 0.375rem 0.625rem;
border: 1px dashed var(--rs-border);
border-radius: 0.375rem;
background: transparent;
color: var(--rs-text-secondary);
font-size: 0.8rem;
cursor: pointer;
width: 100%;
text-align: left;
}
.btn-new-draft:hover { border-color: var(--rs-primary); color: var(--rs-text-primary); }
.draft-list {
display: flex;
flex-direction: column;
gap: 0.25rem;
max-height: 180px;
overflow-y: auto;
}
.draft-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.375rem 0.5rem;
border: 1px solid transparent;
border-radius: 0.375rem;
background: transparent;
color: var(--rs-text-primary);
font-size: 0.8rem;
cursor: pointer;
text-align: left;
gap: 0.5rem;
}
.draft-item:hover { background: var(--rs-bg-surface); }
.draft-item.active {
border-color: var(--rs-primary);
background: rgba(59, 130, 246, 0.1);
}
.draft-title {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.draft-time {
font-size: 0.65rem;
color: var(--rs-text-muted);
flex-shrink: 0;
}
/* ── Format grid ── */
.format-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.5rem;
}
.format-btn {
padding: 0.5rem;
border: 1px solid var(--rs-border);
border-radius: 0.5rem;
background: var(--rs-bg-surface);
color: var(--rs-text-primary);
cursor: pointer;
text-align: left;
display: flex;
flex-direction: column;
gap: 0.125rem;
transition: all 0.15s;
}
.format-btn:hover { border-color: var(--rs-primary); }
.format-btn.active {
border-color: var(--rs-primary);
background: #1e3a5f;
}
.format-name {
font-size: 0.8rem;
font-weight: 600;
}
.format-desc {
font-size: 0.65rem;
color: var(--rs-text-muted);
}
.btn-generate {
padding: 0.625rem 1rem;
border: none;
border-radius: 0.5rem;
background: var(--rs-primary);
color: #fff;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
transition: background 0.15s;
}
.btn-generate:hover { background: var(--rs-primary-hover); }
.btn-generate:disabled { opacity: 0.5; cursor: not-allowed; }
.error {
color: #f87171;
font-size: 0.8rem;
padding: 0.5rem;
background: rgba(248, 113, 113, 0.1);
border-radius: 0.375rem;
}
.result {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.result-info {
font-size: 0.8rem;
color: var(--rs-text-secondary);
text-align: center;
}
.pdf-preview {
width: 100%;
height: 300px;
border: 1px solid var(--rs-border);
border-radius: 0.375rem;
background: #fff;
}
.btn-download {
display: block;
text-align: center;
padding: 0.5rem;
border: 1px solid var(--rs-success);
border-radius: 0.375rem;
color: var(--rs-success);
text-decoration: none;
font-size: 0.85rem;
font-weight: 500;
}
.btn-download:hover { background: rgba(34, 197, 94, 0.1); }
.placeholder {
color: var(--rs-text-muted);
font-size: 0.8rem;
line-height: 1.5;
}
.placeholder p { margin: 0 0 0.75rem; }
.format-details { margin-top: 0.5rem; }
.format-detail {
display: flex;
flex-direction: column;
gap: 0.125rem;
font-size: 0.75rem;
}
.format-detail strong { color: var(--rs-text-primary); }
.format-detail .pages { color: var(--rs-primary); }
@media (max-width: 768px) {
:host { height: auto; min-height: calc(100vh - 92px); }
.editor-layout { flex-direction: column; height: auto; }
.editor-main { min-height: 0; }
.sidebar {
width: 100%;
border-left: none;
border-top: 1px solid var(--rs-border-subtle);
max-height: none;
padding: 0.75rem;
}
.content-area { min-height: 45vh; }
.toolbar-left { flex-direction: column; gap: 0.375rem; }
.title-input, .author-input { max-width: 100%; flex: 1; }
.editor-toolbar { gap: 0.5rem; }
.format-grid { grid-template-columns: repeat(3, 1fr); }
.btn-generate { font-size: 0.8rem; padding: 0.5rem; }
.draft-list { max-height: 120px; }
}
@media (max-width: 480px) {
.format-grid { grid-template-columns: 1fr 1fr; }
.toolbar-right { width: 100%; }
.btn-sample, .btn-upload { flex: 1; text-align: center; }
}
</style>`;
}
private escapeHtml(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
}
customElements.define("folk-pubs-editor", FolkPubsEditor);