rspace-online/modules/files/components/folk-file-browser.ts

390 lines
13 KiB
TypeScript

/**
* <folk-file-browser> — file browsing, upload, share links, and memory cards.
*
* Attributes:
* space="slug" — shared space to browse (default: "default")
*/
class FolkFileBrowser extends HTMLElement {
private shadow: ShadowRoot;
private space = "default";
private files: any[] = [];
private cards: any[] = [];
private tab: "files" | "cards" = "files";
private loading = false;
constructor() {
super();
this.shadow = this.attachShadow({ mode: "open" });
}
connectedCallback() {
this.space = this.getAttribute("space") || "default";
this.render();
this.loadFiles();
this.loadCards();
}
private async loadFiles() {
this.loading = true;
this.render();
try {
const base = this.getApiBase();
const res = await fetch(`${base}/api/files?space=${encodeURIComponent(this.space)}`);
const data = await res.json();
this.files = data.files || [];
} catch (e) {
console.error("[FileBrowser] Error loading files:", e);
}
this.loading = false;
this.render();
}
private async loadCards() {
try {
const base = this.getApiBase();
const res = await fetch(`${base}/api/cards?space=${encodeURIComponent(this.space)}`);
const data = await res.json();
this.cards = data.cards || [];
} catch {
this.cards = [];
}
}
private getApiBase(): string {
const path = window.location.pathname;
const match = path.match(/^\/([^/]+)\/files/);
return match ? `/${match[1]}/files` : "";
}
private formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
}
private formatDate(d: string): string {
return new Date(d).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
}
private mimeIcon(mime: string): string {
if (mime?.startsWith("image/")) return "\uD83D\uDDBC\uFE0F";
if (mime?.startsWith("video/")) return "\uD83C\uDFA5";
if (mime?.startsWith("audio/")) return "\uD83C\uDFB5";
if (mime?.includes("pdf")) return "\uD83D\uDCC4";
if (mime?.includes("zip") || mime?.includes("tar") || mime?.includes("gz")) return "\uD83D\uDCE6";
if (mime?.includes("text") || mime?.includes("json") || mime?.includes("xml")) return "\uD83D\uDCDD";
return "\uD83D\uDCC1";
}
private cardTypeIcon(type: string): string {
const icons: Record<string, string> = {
note: "\uD83D\uDCDD",
idea: "\uD83D\uDCA1",
task: "\u2705",
reference: "\uD83D\uDD17",
quote: "\uD83D\uDCAC",
};
return icons[type] || "\uD83D\uDCDD";
}
private async handleUpload(e: Event) {
e.preventDefault();
const form = this.shadow.querySelector("#upload-form") as HTMLFormElement;
if (!form) return;
const fileInput = form.querySelector('input[type="file"]') as HTMLInputElement;
if (!fileInput?.files?.length) return;
const formData = new FormData();
formData.append("file", fileInput.files[0]);
formData.append("space", this.space);
const titleInput = form.querySelector('input[name="title"]') as HTMLInputElement;
if (titleInput?.value) formData.append("title", titleInput.value);
try {
const base = this.getApiBase();
const res = await fetch(`${base}/api/files`, { method: "POST", body: formData });
if (res.ok) {
form.reset();
this.loadFiles();
} else {
const err = await res.json();
alert(`Upload failed: ${err.error || "Unknown error"}`);
}
} catch (e) {
alert("Upload failed — network error");
}
}
private async handleDelete(fileId: string) {
if (!confirm("Delete this file?")) return;
try {
const base = this.getApiBase();
await fetch(`${base}/api/files/${fileId}`, { method: "DELETE" });
this.loadFiles();
} catch {}
}
private async handleShare(fileId: string) {
try {
const base = this.getApiBase();
const res = await fetch(`${base}/api/files/${fileId}/share`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ expires_in_hours: 72 }),
});
const data = await res.json();
if (data.share?.url) {
const fullUrl = `${window.location.origin}${this.getApiBase()}${data.share.url}`;
await navigator.clipboard.writeText(fullUrl).catch(() => {});
alert(`Share link copied!\n${fullUrl}\nExpires in 72 hours.`);
}
} catch {
alert("Failed to create share link");
}
}
private async handleCreateCard(e: Event) {
e.preventDefault();
const form = this.shadow.querySelector("#card-form") as HTMLFormElement;
if (!form) return;
const title = (form.querySelector('input[name="card-title"]') as HTMLInputElement)?.value;
const body = (form.querySelector('textarea[name="card-body"]') as HTMLTextAreaElement)?.value;
const cardType = (form.querySelector('select[name="card-type"]') as HTMLSelectElement)?.value;
if (!title) return;
try {
const base = this.getApiBase();
const res = await fetch(`${base}/api/cards`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title, body, card_type: cardType, shared_space: this.space }),
});
if (res.ok) {
form.reset();
this.loadCards();
}
} catch {}
}
private async handleDeleteCard(cardId: string) {
if (!confirm("Delete this card?")) return;
try {
const base = this.getApiBase();
await fetch(`${base}/api/cards/${cardId}`, { method: "DELETE" });
this.loadCards();
} catch {}
}
private render() {
const filesActive = this.tab === "files";
this.shadow.innerHTML = `
<style>
:host { display: block; font-family: system-ui, -apple-system, sans-serif; color: #e0e0e0; }
* { box-sizing: border-box; }
.tabs { display: flex; gap: 4px; margin-bottom: 20px; }
.tab-btn {
padding: 8px 20px; border: 1px solid #444; border-radius: 6px 6px 0 0;
background: ${filesActive ? "#1a1a2e" : "#2a2a3e"}; color: #e0e0e0;
cursor: pointer; font-size: 14px; border-bottom: ${filesActive ? "2px solid #64b5f6" : "none"};
}
.tab-btn:last-child {
background: ${!filesActive ? "#1a1a2e" : "#2a2a3e"};
border-bottom: ${!filesActive ? "2px solid #64b5f6" : "none"};
}
.upload-section {
background: #1e1e2e; border: 1px dashed #555; border-radius: 8px;
padding: 16px; margin-bottom: 20px;
}
.upload-section h3 { margin: 0 0 12px; font-size: 14px; color: #aaa; }
.upload-row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
input[type="file"] { color: #ccc; font-size: 13px; }
input[type="text"], select, textarea {
background: #2a2a3e; border: 1px solid #444; color: #e0e0e0;
padding: 6px 10px; border-radius: 4px; font-size: 13px;
}
textarea { width: 100%; min-height: 60px; resize: vertical; }
button {
padding: 6px 14px; border-radius: 4px; border: 1px solid #555;
background: #2a4a7a; color: #e0e0e0; cursor: pointer; font-size: 13px;
}
button:hover { background: #3a5a9a; }
button.danger { background: #7a2a2a; }
button.danger:hover { background: #9a3a3a; }
.file-grid {
display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 12px;
}
.file-card {
background: #1e1e2e; border: 1px solid #333; border-radius: 8px;
padding: 14px; transition: border-color 0.2s;
}
.file-card:hover { border-color: #64b5f6; }
.file-icon { font-size: 28px; margin-bottom: 8px; }
.file-name {
font-size: 14px; font-weight: 500; margin-bottom: 4px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.file-meta { font-size: 12px; color: #888; margin-bottom: 8px; }
.file-actions { display: flex; gap: 6px; flex-wrap: wrap; }
.file-actions button { padding: 3px 8px; font-size: 11px; }
.card-grid {
display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 12px;
}
.memory-card {
background: #1e1e2e; border: 1px solid #333; border-radius: 8px;
padding: 14px;
}
.memory-card:hover { border-color: #81c784; }
.card-header { display: flex; justify-content: space-between; align-items: start; margin-bottom: 8px; }
.card-title { font-size: 14px; font-weight: 500; }
.card-type { font-size: 11px; background: #2a2a3e; padding: 2px 6px; border-radius: 3px; color: #aaa; }
.card-body { font-size: 13px; color: #aaa; white-space: pre-wrap; word-break: break-word; }
.empty { text-align: center; color: #666; padding: 40px 20px; font-size: 14px; }
.loading { text-align: center; color: #888; padding: 40px; }
.card-form { margin-bottom: 20px; display: flex; flex-direction: column; gap: 8px; }
.card-form-row { display: flex; gap: 8px; }
</style>
<div class="tabs">
<div class="tab-btn" data-tab="files">\uD83D\uDCC1 Files</div>
<div class="tab-btn" data-tab="cards">\uD83C\uDFB4 Memory Cards</div>
</div>
${filesActive ? this.renderFilesTab() : this.renderCardsTab()}
`;
this.shadow.querySelectorAll(".tab-btn").forEach((btn) => {
btn.addEventListener("click", () => {
this.tab = (btn as HTMLElement).dataset.tab as "files" | "cards";
this.render();
});
});
const uploadForm = this.shadow.querySelector("#upload-form");
if (uploadForm) uploadForm.addEventListener("submit", (e) => this.handleUpload(e));
const cardForm = this.shadow.querySelector("#card-form");
if (cardForm) cardForm.addEventListener("submit", (e) => this.handleCreateCard(e));
this.shadow.querySelectorAll("[data-action]").forEach((btn) => {
const action = (btn as HTMLElement).dataset.action!;
const id = (btn as HTMLElement).dataset.id!;
btn.addEventListener("click", () => {
if (action === "delete") this.handleDelete(id);
else if (action === "share") this.handleShare(id);
else if (action === "delete-card") this.handleDeleteCard(id);
else if (action === "download") {
const base = this.getApiBase();
window.open(`${base}/api/files/${id}/download`, "_blank");
}
});
});
}
private renderFilesTab(): string {
return `
<div class="upload-section">
<h3>Upload File</h3>
<form id="upload-form">
<div class="upload-row">
<input type="file" name="file" required>
<input type="text" name="title" placeholder="Title (optional)" style="flex:1;min-width:120px">
<button type="submit">Upload</button>
</div>
</form>
</div>
${this.loading ? '<div class="loading">Loading files...</div>' : ""}
${!this.loading && this.files.length === 0 ? '<div class="empty">No files yet. Upload one above.</div>' : ""}
${
!this.loading && this.files.length > 0
? `<div class="file-grid">
${this.files
.map(
(f) => `
<div class="file-card">
<div class="file-icon">${this.mimeIcon(f.mime_type)}</div>
<div class="file-name" title="${this.esc(f.original_filename)}">${this.esc(f.title || f.original_filename)}</div>
<div class="file-meta">${this.formatSize(f.file_size)} &middot; ${this.formatDate(f.created_at)}</div>
<div class="file-actions">
<button data-action="download" data-id="${f.id}">Download</button>
<button data-action="share" data-id="${f.id}">Share</button>
<button class="danger" data-action="delete" data-id="${f.id}">Delete</button>
</div>
</div>
`,
)
.join("")}
</div>`
: ""
}
`;
}
private renderCardsTab(): string {
return `
<div class="upload-section">
<h3>New Memory Card</h3>
<form id="card-form" class="card-form">
<div class="card-form-row">
<input type="text" name="card-title" placeholder="Title" required style="flex:1">
<select name="card-type">
<option value="note">Note</option>
<option value="idea">Idea</option>
<option value="task">Task</option>
<option value="reference">Reference</option>
<option value="quote">Quote</option>
</select>
<button type="submit">Add</button>
</div>
<textarea name="card-body" placeholder="Body (optional)"></textarea>
</form>
</div>
${this.cards.length === 0 ? '<div class="empty">No memory cards yet.</div>' : ""}
${
this.cards.length > 0
? `<div class="card-grid">
${this.cards
.map(
(c) => `
<div class="memory-card">
<div class="card-header">
<span class="card-title">${this.cardTypeIcon(c.card_type)} ${this.esc(c.title)}</span>
<span class="card-type">${c.card_type}</span>
</div>
${c.body ? `<div class="card-body">${this.esc(c.body)}</div>` : ""}
<div class="file-actions" style="margin-top:8px">
<button class="danger" data-action="delete-card" data-id="${c.id}">Delete</button>
</div>
</div>
`,
)
.join("")}
</div>`
: ""
}
`;
}
private esc(s: string): string {
const d = document.createElement("div");
d.textContent = s || "";
return d.innerHTML;
}
}
customElements.define("folk-file-browser", FolkFileBrowser);