486 lines
13 KiB
TypeScript
486 lines
13 KiB
TypeScript
/**
|
||
* <folk-pubs-editor> — 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, and PDF preview/download.
|
||
*/
|
||
|
||
interface BookFormat {
|
||
id: string;
|
||
name: string;
|
||
widthMm: number;
|
||
heightMm: number;
|
||
description: string;
|
||
minPages: number;
|
||
maxPages: 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.`;
|
||
|
||
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;
|
||
|
||
set formats(val: BookFormat[]) {
|
||
this._formats = val;
|
||
if (this.shadowRoot) this.render();
|
||
}
|
||
|
||
set spaceSlug(val: string) {
|
||
this._spaceSlug = val;
|
||
}
|
||
|
||
connectedCallback() {
|
||
this.attachShadow({ mode: "open" });
|
||
this.render();
|
||
}
|
||
|
||
disconnectedCallback() {
|
||
if (this._pdfUrl) URL.revokeObjectURL(this._pdfUrl);
|
||
}
|
||
|
||
private render() {
|
||
if (!this.shadowRoot) return;
|
||
|
||
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">
|
||
<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>
|
||
</div>
|
||
</div>
|
||
<textarea class="content-area" placeholder="Drop in your markdown or plain text here..."></textarea>
|
||
</div>
|
||
<div class="sidebar">
|
||
<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();
|
||
}
|
||
|
||
private bindEvents() {
|
||
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;
|
||
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;
|
||
|
||
// Format buttons
|
||
this.shadowRoot.querySelectorAll(".format-btn").forEach((btn) => {
|
||
btn.addEventListener("click", () => {
|
||
this._selectedFormat = (btn as HTMLElement).dataset.format!;
|
||
// 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 = "";
|
||
});
|
||
|
||
// File upload
|
||
fileInput?.addEventListener("change", () => {
|
||
const file = fileInput.files?.[0];
|
||
if (file) {
|
||
const reader = new FileReader();
|
||
reader.onload = () => {
|
||
textarea.value = reader.result as string;
|
||
};
|
||
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 = () => {
|
||
textarea.value = reader.result as string;
|
||
};
|
||
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: #0f172a;
|
||
color: #f1f5f9;
|
||
}
|
||
|
||
.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 #1e293b;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.toolbar-left {
|
||
display: flex;
|
||
gap: 0.5rem;
|
||
flex: 1;
|
||
}
|
||
|
||
.toolbar-right {
|
||
display: flex;
|
||
gap: 0.5rem;
|
||
}
|
||
|
||
.title-input, .author-input {
|
||
padding: 0.375rem 0.625rem;
|
||
border: 1px solid #334155;
|
||
border-radius: 0.375rem;
|
||
background: #1e293b;
|
||
color: #f1f5f9;
|
||
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: #64748b; }
|
||
.title-input:focus, .author-input:focus { outline: none; border-color: #60a5fa; }
|
||
|
||
.btn-sample, .btn-upload {
|
||
padding: 0.375rem 0.75rem;
|
||
border: 1px solid #334155;
|
||
border-radius: 0.375rem;
|
||
background: #1e293b;
|
||
color: #94a3b8;
|
||
font-size: 0.8rem;
|
||
cursor: pointer;
|
||
}
|
||
.btn-sample:hover, .btn-upload:hover { border-color: #60a5fa; color: #f1f5f9; }
|
||
|
||
.content-area {
|
||
flex: 1;
|
||
padding: 1rem;
|
||
border: none;
|
||
background: #0f172a;
|
||
color: #e2e8f0;
|
||
font-family: "JetBrains Mono", "Fira Code", monospace;
|
||
font-size: 0.85rem;
|
||
line-height: 1.6;
|
||
resize: none;
|
||
}
|
||
.content-area::placeholder { color: #475569; }
|
||
.content-area:focus { outline: none; }
|
||
.content-area.dragover {
|
||
background: #1e293b;
|
||
outline: 2px dashed #60a5fa;
|
||
outline-offset: -4px;
|
||
}
|
||
|
||
.sidebar {
|
||
width: 280px;
|
||
border-left: 1px solid #1e293b;
|
||
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: #94a3b8;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.05em;
|
||
}
|
||
|
||
.format-grid {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 0.5rem;
|
||
}
|
||
|
||
.format-btn {
|
||
padding: 0.5rem;
|
||
border: 1px solid #334155;
|
||
border-radius: 0.5rem;
|
||
background: #1e293b;
|
||
color: #f1f5f9;
|
||
cursor: pointer;
|
||
text-align: left;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.125rem;
|
||
transition: all 0.15s;
|
||
}
|
||
.format-btn:hover { border-color: #60a5fa; }
|
||
.format-btn.active {
|
||
border-color: #60a5fa;
|
||
background: #1e3a5f;
|
||
}
|
||
|
||
.format-name {
|
||
font-size: 0.8rem;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.format-desc {
|
||
font-size: 0.65rem;
|
||
color: #64748b;
|
||
}
|
||
|
||
.btn-generate {
|
||
padding: 0.625rem 1rem;
|
||
border: none;
|
||
border-radius: 0.5rem;
|
||
background: #2563eb;
|
||
color: #fff;
|
||
font-size: 0.9rem;
|
||
font-weight: 600;
|
||
cursor: pointer;
|
||
transition: background 0.15s;
|
||
}
|
||
.btn-generate:hover { background: #1d4ed8; }
|
||
.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: #94a3b8;
|
||
text-align: center;
|
||
}
|
||
|
||
.pdf-preview {
|
||
width: 100%;
|
||
height: 300px;
|
||
border: 1px solid #334155;
|
||
border-radius: 0.375rem;
|
||
background: #fff;
|
||
}
|
||
|
||
.btn-download {
|
||
display: block;
|
||
text-align: center;
|
||
padding: 0.5rem;
|
||
border: 1px solid #22c55e;
|
||
border-radius: 0.375rem;
|
||
color: #22c55e;
|
||
text-decoration: none;
|
||
font-size: 0.85rem;
|
||
font-weight: 500;
|
||
}
|
||
.btn-download:hover { background: rgba(34, 197, 94, 0.1); }
|
||
|
||
.placeholder {
|
||
color: #64748b;
|
||
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: #e2e8f0; }
|
||
.format-detail .pages { color: #60a5fa; }
|
||
|
||
@media (max-width: 768px) {
|
||
.editor-layout { flex-direction: column; }
|
||
.sidebar {
|
||
width: 100%;
|
||
border-left: none;
|
||
border-top: 1px solid #1e293b;
|
||
max-height: 50vh;
|
||
}
|
||
.content-area { min-height: 40vh; }
|
||
}
|
||
</style>`;
|
||
}
|
||
|
||
private escapeHtml(s: string): string {
|
||
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||
}
|
||
}
|
||
|
||
customElements.define("folk-pubs-editor", FolkPubsEditor);
|