390 lines
13 KiB
TypeScript
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)} · ${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);
|