888 lines
24 KiB
TypeScript
888 lines
24 KiB
TypeScript
/**
|
|
* <folk-feed> — Canvas shape that renders a live feed from another layer.
|
|
*
|
|
* Bridges layers by pulling data from a source layer's module API endpoint
|
|
* and rendering it as a live, updating feed within the current canvas.
|
|
*
|
|
* Attributes:
|
|
* source-layer — source layer ID
|
|
* source-module — source module ID (e.g. "notes", "funds", "vote")
|
|
* feed-id — which feed to pull (e.g. "recent-notes", "proposals")
|
|
* flow-kind — flow type for visual styling ("economic", "trust", "data", etc.)
|
|
* feed-filter — optional JSON filter string
|
|
* max-items — max items to display (default 10)
|
|
* refresh-interval — auto-refresh ms (default 30000, 0 = manual only)
|
|
*
|
|
* The shape auto-fetches from /{space}/{source-module}/api/{feed-endpoint}
|
|
* and renders results as a scrollable card list.
|
|
*/
|
|
|
|
import { FolkShape } from "./folk-shape";
|
|
import { FLOW_COLORS, FLOW_LABELS } from "./layer-types";
|
|
import type { FlowKind } from "./layer-types";
|
|
|
|
export class FolkFeed extends FolkShape {
|
|
static tagName = "folk-feed";
|
|
|
|
#feedData: any[] = [];
|
|
#loading = false;
|
|
#error: string | null = null;
|
|
#refreshTimer: ReturnType<typeof setInterval> | null = null;
|
|
#inner: HTMLElement | null = null;
|
|
#editingIndex: number | null = null;
|
|
|
|
static get observedAttributes() {
|
|
return [
|
|
...FolkShape.observedAttributes,
|
|
"source-layer", "source-module", "feed-id", "flow-kind",
|
|
"feed-filter", "max-items", "refresh-interval",
|
|
];
|
|
}
|
|
|
|
get sourceLayer(): string { return this.getAttribute("source-layer") || ""; }
|
|
set sourceLayer(v: string) { this.setAttribute("source-layer", v); }
|
|
|
|
get sourceModule(): string { return this.getAttribute("source-module") || ""; }
|
|
set sourceModule(v: string) { this.setAttribute("source-module", v); }
|
|
|
|
get feedId(): string { return this.getAttribute("feed-id") || ""; }
|
|
set feedId(v: string) { this.setAttribute("feed-id", v); }
|
|
|
|
get flowKind(): FlowKind { return (this.getAttribute("flow-kind") as FlowKind) || "data"; }
|
|
set flowKind(v: FlowKind) { this.setAttribute("flow-kind", v); }
|
|
|
|
get feedFilter(): string { return this.getAttribute("feed-filter") || ""; }
|
|
set feedFilter(v: string) { this.setAttribute("feed-filter", v); }
|
|
|
|
get maxItems(): number { return parseInt(this.getAttribute("max-items") || "10", 10); }
|
|
set maxItems(v: number) { this.setAttribute("max-items", String(v)); }
|
|
|
|
get refreshInterval(): number { return parseInt(this.getAttribute("refresh-interval") || "30000", 10); }
|
|
set refreshInterval(v: number) { this.setAttribute("refresh-interval", String(v)); }
|
|
|
|
connectedCallback() {
|
|
super.connectedCallback();
|
|
this.#buildUI();
|
|
this.#fetchFeed();
|
|
this.#startAutoRefresh();
|
|
}
|
|
|
|
disconnectedCallback() {
|
|
super.disconnectedCallback();
|
|
this.#stopAutoRefresh();
|
|
}
|
|
|
|
attributeChangedCallback(name: string, oldVal: string | null, newVal: string | null) {
|
|
super.attributeChangedCallback(name, oldVal, newVal);
|
|
if (["source-module", "feed-id", "feed-filter", "max-items"].includes(name)) {
|
|
this.#fetchFeed();
|
|
}
|
|
if (name === "refresh-interval") {
|
|
this.#stopAutoRefresh();
|
|
this.#startAutoRefresh();
|
|
}
|
|
if (name === "flow-kind") {
|
|
this.#updateHeader();
|
|
}
|
|
}
|
|
|
|
// ── Build the inner UI ──
|
|
|
|
#buildUI() {
|
|
if (this.#inner) return;
|
|
|
|
this.#inner = document.createElement("div");
|
|
this.#inner.className = "folk-feed-inner";
|
|
this.#inner.innerHTML = `
|
|
<div class="feed-header">
|
|
<div class="feed-kind-dot"></div>
|
|
<div class="feed-title"></div>
|
|
<button class="feed-navigate" title="Go to source layer">↗</button>
|
|
<button class="feed-refresh" title="Refresh">↻</button>
|
|
</div>
|
|
<div class="feed-items"></div>
|
|
<div class="feed-edit-overlay"></div>
|
|
<div class="feed-status"></div>
|
|
`;
|
|
|
|
const style = document.createElement("style");
|
|
style.textContent = FEED_STYLES;
|
|
this.#inner.prepend(style);
|
|
|
|
this.appendChild(this.#inner);
|
|
|
|
// Refresh button
|
|
this.#inner.querySelector(".feed-refresh")?.addEventListener("click", (e) => {
|
|
e.stopPropagation();
|
|
this.#fetchFeed();
|
|
});
|
|
|
|
// Navigate to source layer
|
|
this.#inner.querySelector(".feed-navigate")?.addEventListener("click", (e) => {
|
|
e.stopPropagation();
|
|
if (this.sourceModule) {
|
|
const space = this.#getSpaceSlug();
|
|
window.location.href = `/${space}/${this.sourceModule}`;
|
|
}
|
|
});
|
|
|
|
this.#updateHeader();
|
|
}
|
|
|
|
#updateHeader() {
|
|
if (!this.#inner) return;
|
|
const color = FLOW_COLORS[this.flowKind] || "#94a3b8";
|
|
const label = FLOW_LABELS[this.flowKind] || "Feed";
|
|
const dot = this.#inner.querySelector<HTMLElement>(".feed-kind-dot");
|
|
const title = this.#inner.querySelector<HTMLElement>(".feed-title");
|
|
if (dot) dot.style.background = color;
|
|
if (title) title.textContent = `${this.sourceModule} / ${this.feedId || label}`;
|
|
}
|
|
|
|
// ── Fetch feed data ──
|
|
|
|
async #fetchFeed() {
|
|
if (!this.sourceModule) return;
|
|
if (this.#loading) return;
|
|
|
|
this.#loading = true;
|
|
this.#updateStatus("loading");
|
|
|
|
try {
|
|
// Construct feed URL based on feed ID
|
|
const space = this.#getSpaceSlug();
|
|
const feedEndpoint = this.#getFeedEndpoint();
|
|
const url = `/${space}/${this.sourceModule}/api/${feedEndpoint}`;
|
|
|
|
const res = await fetch(url);
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
|
|
const data = await res.json();
|
|
|
|
// Normalize: extract the array from common response shapes
|
|
if (Array.isArray(data)) {
|
|
this.#feedData = data.slice(0, this.maxItems);
|
|
} else if (data.notes) {
|
|
this.#feedData = data.notes.slice(0, this.maxItems);
|
|
} else if (data.notebooks) {
|
|
this.#feedData = data.notebooks.slice(0, this.maxItems);
|
|
} else if (data.proposals) {
|
|
this.#feedData = data.proposals.slice(0, this.maxItems);
|
|
} else if (data.tasks) {
|
|
this.#feedData = data.tasks.slice(0, this.maxItems);
|
|
} else if (data.nodes) {
|
|
this.#feedData = data.nodes.slice(0, this.maxItems);
|
|
} else if (data.flows) {
|
|
this.#feedData = data.flows.slice(0, this.maxItems);
|
|
} else {
|
|
// Try to use the data as-is if it has array-like fields
|
|
const firstArray = Object.values(data).find(v => Array.isArray(v));
|
|
this.#feedData = firstArray ? (firstArray as any[]).slice(0, this.maxItems) : [data];
|
|
}
|
|
|
|
this.#error = null;
|
|
this.#renderItems();
|
|
this.#updateStatus("ok");
|
|
} catch (err: any) {
|
|
this.#error = err.message || "Failed to fetch";
|
|
this.#updateStatus("error");
|
|
} finally {
|
|
this.#loading = false;
|
|
}
|
|
}
|
|
|
|
#getFeedEndpoint(): string {
|
|
// Map feed IDs to actual API endpoints
|
|
const FEED_ENDPOINTS: Record<string, Record<string, string>> = {
|
|
notes: {
|
|
"notes-by-tag": "notes",
|
|
"recent-notes": "notes",
|
|
default: "notes",
|
|
},
|
|
funds: {
|
|
"treasury-flows": "flows",
|
|
"transactions": "flows",
|
|
default: "flows",
|
|
},
|
|
vote: {
|
|
proposals: "proposals",
|
|
decisions: "proposals?status=PASSED,FAILED",
|
|
default: "proposals",
|
|
},
|
|
choices: {
|
|
"poll-results": "choices",
|
|
default: "choices",
|
|
},
|
|
wallet: {
|
|
balances: "safe/detect",
|
|
transfers: "safe/detect",
|
|
default: "safe/detect",
|
|
},
|
|
data: {
|
|
analytics: "stats",
|
|
"active-users": "active",
|
|
default: "stats",
|
|
},
|
|
work: {
|
|
"task-activity": "spaces",
|
|
"board-summary": "spaces",
|
|
default: "spaces",
|
|
},
|
|
network: {
|
|
"trust-graph": "graph",
|
|
connections: "people",
|
|
default: "graph",
|
|
},
|
|
trips: {
|
|
"trip-expenses": "trips",
|
|
itinerary: "trips",
|
|
default: "trips",
|
|
},
|
|
};
|
|
|
|
const moduleEndpoints = FEED_ENDPOINTS[this.sourceModule];
|
|
if (!moduleEndpoints) return this.feedId || "info";
|
|
return moduleEndpoints[this.feedId] || moduleEndpoints.default || this.feedId;
|
|
}
|
|
|
|
#getSpaceSlug(): string {
|
|
// Try to get from URL
|
|
const parts = window.location.pathname.split("/").filter(Boolean);
|
|
return parts[0] || "demo";
|
|
}
|
|
|
|
// ── Render feed items ──
|
|
|
|
#renderItems() {
|
|
const container = this.#inner?.querySelector(".feed-items");
|
|
if (!container) return;
|
|
|
|
const color = FLOW_COLORS[this.flowKind] || "#94a3b8";
|
|
|
|
if (this.#feedData.length === 0) {
|
|
container.innerHTML = `<div class="feed-empty">No data</div>`;
|
|
return;
|
|
}
|
|
|
|
container.innerHTML = this.#feedData.map((item, i) => {
|
|
const title = item.title || item.name || item.label || item.id || `Item ${i + 1}`;
|
|
const subtitle = item.description || item.content_plain?.slice(0, 80) || item.status || item.type || "";
|
|
const badge = item.status || item.kind || item.type || "";
|
|
const editable = this.#isEditable(item);
|
|
|
|
return `
|
|
<div class="feed-item ${editable ? "feed-item--editable" : ""}" data-index="${i}" data-item-id="${item.id || ""}">
|
|
<div class="feed-item-line" style="background:${color}"></div>
|
|
<div class="feed-item-content">
|
|
<div class="feed-item-title">${this.#escapeHtml(String(title))}</div>
|
|
${subtitle ? `<div class="feed-item-subtitle">${this.#escapeHtml(String(subtitle).slice(0, 100))}</div>` : ""}
|
|
</div>
|
|
<div class="feed-item-actions">
|
|
${editable ? `<button class="feed-item-edit" data-edit="${i}" title="Edit">✎</button>` : ""}
|
|
${badge ? `<div class="feed-item-badge" style="color:${color}">${this.#escapeHtml(String(badge))}</div>` : ""}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}).join("");
|
|
|
|
// Attach item click and edit events
|
|
container.querySelectorAll<HTMLElement>(".feed-item").forEach(el => {
|
|
// Double-click to navigate to source
|
|
el.addEventListener("dblclick", (e) => {
|
|
e.stopPropagation();
|
|
const idx = parseInt(el.dataset.index || "0", 10);
|
|
const item = this.#feedData[idx];
|
|
if (item) this.#navigateToItem(item);
|
|
});
|
|
});
|
|
|
|
container.querySelectorAll<HTMLElement>(".feed-item-edit").forEach(btn => {
|
|
btn.addEventListener("click", (e) => {
|
|
e.stopPropagation();
|
|
const idx = parseInt(btn.dataset.edit || "0", 10);
|
|
this.#openEditOverlay(idx);
|
|
});
|
|
});
|
|
}
|
|
|
|
/** Check if an item supports write-back */
|
|
#isEditable(item: any): boolean {
|
|
// Items with an ID from modules that support PUT/PATCH are editable
|
|
if (!item.id) return false;
|
|
const editableModules = ["notes", "work", "vote", "trips"];
|
|
return editableModules.includes(this.sourceModule);
|
|
}
|
|
|
|
/** Navigate to the source item in its module */
|
|
#navigateToItem(item: any) {
|
|
const space = this.#getSpaceSlug();
|
|
const mod = this.sourceModule;
|
|
|
|
// Build a deep link to the item in its source module
|
|
// Emit an event so the canvas/shell can handle it
|
|
this.dispatchEvent(new CustomEvent("feed-navigate", {
|
|
bubbles: true,
|
|
detail: {
|
|
sourceModule: mod,
|
|
itemId: item.id,
|
|
item,
|
|
url: `/${space}/${mod}`,
|
|
}
|
|
}));
|
|
}
|
|
|
|
// ── Edit overlay (bidirectional write-back) ──
|
|
|
|
#openEditOverlay(index: number) {
|
|
const overlay = this.#inner?.querySelector<HTMLElement>(".feed-edit-overlay");
|
|
if (!overlay) return;
|
|
|
|
const item = this.#feedData[index];
|
|
if (!item) return;
|
|
|
|
this.#editingIndex = index;
|
|
const color = FLOW_COLORS[this.flowKind] || "#94a3b8";
|
|
|
|
// Build edit fields based on item properties
|
|
const editableFields = this.#getEditableFields(item);
|
|
|
|
overlay.innerHTML = `
|
|
<div class="edit-panel">
|
|
<div class="edit-header">
|
|
<span style="color:${color}">Edit: ${this.#escapeHtml(item.title || item.name || "Item")}</span>
|
|
<button class="edit-close">×</button>
|
|
</div>
|
|
<div class="edit-fields">
|
|
${editableFields.map(f => `
|
|
<label class="edit-field">
|
|
<span class="edit-label">${f.label}</span>
|
|
${f.type === "textarea"
|
|
? `<textarea class="edit-input" data-field="${f.key}" rows="3">${this.#escapeHtml(String(f.value))}</textarea>`
|
|
: f.type === "select"
|
|
? `<select class="edit-input" data-field="${f.key}">
|
|
${f.options!.map(o => `<option value="${o}" ${o === f.value ? "selected" : ""}>${o}</option>`).join("")}
|
|
</select>`
|
|
: `<input class="edit-input" type="text" data-field="${f.key}" value="${this.#escapeHtml(String(f.value))}" />`
|
|
}
|
|
</label>
|
|
`).join("")}
|
|
</div>
|
|
<div class="edit-actions">
|
|
<button class="edit-cancel">Cancel</button>
|
|
<button class="edit-save" style="background:${color}20; color:${color}; border-color:${color}40">Save & Push</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
overlay.classList.add("open");
|
|
|
|
// Events
|
|
overlay.querySelector(".edit-close")?.addEventListener("click", () => this.#closeEditOverlay());
|
|
overlay.querySelector(".edit-cancel")?.addEventListener("click", () => this.#closeEditOverlay());
|
|
overlay.querySelector(".edit-save")?.addEventListener("click", () => this.#saveEdit());
|
|
}
|
|
|
|
#closeEditOverlay() {
|
|
const overlay = this.#inner?.querySelector<HTMLElement>(".feed-edit-overlay");
|
|
if (overlay) {
|
|
overlay.classList.remove("open");
|
|
overlay.innerHTML = "";
|
|
}
|
|
this.#editingIndex = null;
|
|
}
|
|
|
|
async #saveEdit() {
|
|
if (this.#editingIndex === null) return;
|
|
const item = this.#feedData[this.#editingIndex];
|
|
if (!item?.id) return;
|
|
|
|
const overlay = this.#inner?.querySelector<HTMLElement>(".feed-edit-overlay");
|
|
if (!overlay) return;
|
|
|
|
// Collect edited values
|
|
const updates: Record<string, string> = {};
|
|
overlay.querySelectorAll<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>(".edit-input").forEach(el => {
|
|
const field = el.dataset.field;
|
|
if (field && el.value !== String(item[field] ?? "")) {
|
|
updates[field] = el.value;
|
|
}
|
|
});
|
|
|
|
if (Object.keys(updates).length === 0) {
|
|
this.#closeEditOverlay();
|
|
return;
|
|
}
|
|
|
|
// Write back to source module API
|
|
try {
|
|
const space = this.#getSpaceSlug();
|
|
const endpoint = this.#getWriteBackEndpoint(item);
|
|
const url = `/${space}/${this.sourceModule}/api/${endpoint}`;
|
|
|
|
const method = this.#getWriteBackMethod();
|
|
const token = this.#getAuthToken();
|
|
|
|
const res = await fetch(url, {
|
|
method,
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
},
|
|
body: JSON.stringify(updates),
|
|
});
|
|
|
|
if (!res.ok) {
|
|
const err = await res.json().catch(() => ({ error: "Failed" }));
|
|
throw new Error(err.error || `HTTP ${res.status}`);
|
|
}
|
|
|
|
// Update local data
|
|
Object.assign(item, updates);
|
|
this.#renderItems();
|
|
this.#closeEditOverlay();
|
|
|
|
// Emit event for flow tracking
|
|
this.dispatchEvent(new CustomEvent("feed-writeback", {
|
|
bubbles: true,
|
|
detail: {
|
|
sourceModule: this.sourceModule,
|
|
itemId: item.id,
|
|
updates,
|
|
flowKind: this.flowKind,
|
|
}
|
|
}));
|
|
} catch (err: any) {
|
|
// Show error in overlay
|
|
const actions = overlay.querySelector(".edit-actions");
|
|
if (actions) {
|
|
const existing = actions.querySelector(".edit-error");
|
|
if (existing) existing.remove();
|
|
const errorEl = document.createElement("div");
|
|
errorEl.className = "edit-error";
|
|
errorEl.textContent = err.message;
|
|
actions.prepend(errorEl);
|
|
}
|
|
}
|
|
}
|
|
|
|
#getEditableFields(item: any): { key: string; label: string; value: string; type: string; options?: string[] }[] {
|
|
const fields: { key: string; label: string; value: string; type: string; options?: string[] }[] = [];
|
|
|
|
// Module-specific editable fields
|
|
switch (this.sourceModule) {
|
|
case "notes":
|
|
if (item.title !== undefined) fields.push({ key: "title", label: "Title", value: item.title || "", type: "text" });
|
|
if (item.content !== undefined) fields.push({ key: "content", label: "Content", value: item.content || "", type: "textarea" });
|
|
break;
|
|
case "work":
|
|
if (item.title !== undefined) fields.push({ key: "title", label: "Title", value: item.title || "", type: "text" });
|
|
if (item.status !== undefined) fields.push({
|
|
key: "status", label: "Status", value: item.status,
|
|
type: "select", options: ["TODO", "IN_PROGRESS", "REVIEW", "DONE"],
|
|
});
|
|
if (item.priority !== undefined) fields.push({
|
|
key: "priority", label: "Priority", value: item.priority || "MEDIUM",
|
|
type: "select", options: ["LOW", "MEDIUM", "HIGH", "URGENT"],
|
|
});
|
|
break;
|
|
case "vote":
|
|
if (item.title !== undefined) fields.push({ key: "title", label: "Title", value: item.title || "", type: "text" });
|
|
if (item.description !== undefined) fields.push({ key: "description", label: "Description", value: item.description || "", type: "textarea" });
|
|
break;
|
|
case "trips":
|
|
if (item.title !== undefined) fields.push({ key: "title", label: "Title", value: item.title || "", type: "text" });
|
|
if (item.description !== undefined) fields.push({ key: "description", label: "Description", value: item.description || "", type: "textarea" });
|
|
break;
|
|
default:
|
|
// Generic: expose title and description if present
|
|
if (item.title !== undefined) fields.push({ key: "title", label: "Title", value: item.title || "", type: "text" });
|
|
if (item.description !== undefined) fields.push({ key: "description", label: "Description", value: item.description || "", type: "textarea" });
|
|
}
|
|
|
|
return fields;
|
|
}
|
|
|
|
#getWriteBackEndpoint(item: any): string {
|
|
switch (this.sourceModule) {
|
|
case "notes": return `notes/${item.id}`;
|
|
case "work": return `tasks/${item.id}`;
|
|
case "vote": return `proposals/${item.id}`;
|
|
case "trips": return `trips/${item.id}`;
|
|
default: return `${item.id}`;
|
|
}
|
|
}
|
|
|
|
#getWriteBackMethod(): string {
|
|
switch (this.sourceModule) {
|
|
case "work": return "PATCH";
|
|
default: return "PUT";
|
|
}
|
|
}
|
|
|
|
#getAuthToken(): string | null {
|
|
// Try to get token from EncryptID (stored in localStorage by rstack-identity)
|
|
try {
|
|
return localStorage.getItem("encryptid-token") || null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
#updateStatus(state: "loading" | "ok" | "error") {
|
|
const el = this.#inner?.querySelector<HTMLElement>(".feed-status");
|
|
if (!el) return;
|
|
|
|
if (state === "loading") {
|
|
el.textContent = "Loading...";
|
|
el.style.color = "#94a3b8";
|
|
} else if (state === "error") {
|
|
el.textContent = this.#error || "Error";
|
|
el.style.color = "#ef4444";
|
|
} else {
|
|
el.textContent = `${this.#feedData.length} items`;
|
|
el.style.color = FLOW_COLORS[this.flowKind] || "#94a3b8";
|
|
}
|
|
}
|
|
|
|
// ── Auto-refresh ──
|
|
|
|
#startAutoRefresh() {
|
|
if (this.refreshInterval > 0) {
|
|
this.#refreshTimer = setInterval(() => this.#fetchFeed(), this.refreshInterval);
|
|
}
|
|
}
|
|
|
|
#stopAutoRefresh() {
|
|
if (this.#refreshTimer) {
|
|
clearInterval(this.#refreshTimer);
|
|
this.#refreshTimer = null;
|
|
}
|
|
}
|
|
|
|
// ── Serialization ──
|
|
|
|
toJSON() {
|
|
return {
|
|
...super.toJSON(),
|
|
type: "folk-feed",
|
|
sourceLayer: this.sourceLayer,
|
|
sourceModule: this.sourceModule,
|
|
feedId: this.feedId,
|
|
flowKind: this.flowKind,
|
|
feedFilter: this.feedFilter,
|
|
maxItems: this.maxItems,
|
|
refreshInterval: this.refreshInterval,
|
|
};
|
|
}
|
|
|
|
#escapeHtml(s: string): string {
|
|
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
}
|
|
|
|
static define(tag = "folk-feed") {
|
|
if (!customElements.get(tag)) customElements.define(tag, FolkFeed);
|
|
}
|
|
}
|
|
|
|
// ── Styles ──
|
|
|
|
const FEED_STYLES = `
|
|
.folk-feed-inner {
|
|
display: flex;
|
|
flex-direction: column;
|
|
height: 100%;
|
|
border-radius: 8px;
|
|
overflow: hidden;
|
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
background: rgba(15, 23, 42, 0.9);
|
|
border: 1px solid rgba(255,255,255,0.08);
|
|
}
|
|
|
|
.feed-header {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
padding: 8px 12px;
|
|
border-bottom: 1px solid rgba(255,255,255,0.06);
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.feed-kind-dot {
|
|
width: 8px;
|
|
height: 8px;
|
|
border-radius: 50%;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.feed-title {
|
|
flex: 1;
|
|
font-size: 0.75rem;
|
|
font-weight: 600;
|
|
color: #e2e8f0;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
|
|
.feed-refresh {
|
|
width: 22px;
|
|
height: 22px;
|
|
border: none;
|
|
border-radius: 4px;
|
|
background: transparent;
|
|
color: #64748b;
|
|
font-size: 0.85rem;
|
|
cursor: pointer;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
transition: color 0.15s, background 0.15s;
|
|
}
|
|
.feed-refresh:hover {
|
|
color: #e2e8f0;
|
|
background: rgba(255,255,255,0.06);
|
|
}
|
|
|
|
.feed-items {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
padding: 4px 0;
|
|
scrollbar-width: thin;
|
|
scrollbar-color: rgba(148,163,184,0.2) transparent;
|
|
}
|
|
|
|
.feed-item {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
gap: 8px;
|
|
padding: 6px 12px;
|
|
transition: background 0.12s;
|
|
cursor: default;
|
|
}
|
|
.feed-item:hover {
|
|
background: rgba(255,255,255,0.03);
|
|
}
|
|
|
|
.feed-item-line {
|
|
width: 3px;
|
|
min-height: 24px;
|
|
border-radius: 2px;
|
|
flex-shrink: 0;
|
|
margin-top: 2px;
|
|
opacity: 0.6;
|
|
}
|
|
|
|
.feed-item-content {
|
|
flex: 1;
|
|
min-width: 0;
|
|
}
|
|
|
|
.feed-item-title {
|
|
font-size: 0.75rem;
|
|
font-weight: 500;
|
|
color: #e2e8f0;
|
|
line-height: 1.3;
|
|
}
|
|
|
|
.feed-item-subtitle {
|
|
font-size: 0.65rem;
|
|
color: #64748b;
|
|
margin-top: 2px;
|
|
line-height: 1.3;
|
|
display: -webkit-box;
|
|
-webkit-line-clamp: 2;
|
|
-webkit-box-orient: vertical;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.feed-item-badge {
|
|
font-size: 0.55rem;
|
|
font-weight: 700;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.05em;
|
|
white-space: nowrap;
|
|
padding: 2px 4px;
|
|
border-radius: 3px;
|
|
background: rgba(255,255,255,0.04);
|
|
flex-shrink: 0;
|
|
margin-top: 2px;
|
|
}
|
|
|
|
.feed-navigate {
|
|
width: 22px;
|
|
height: 22px;
|
|
border: none;
|
|
border-radius: 4px;
|
|
background: transparent;
|
|
color: #64748b;
|
|
font-size: 0.75rem;
|
|
cursor: pointer;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
transition: color 0.15s, background 0.15s;
|
|
}
|
|
.feed-navigate:hover {
|
|
color: #22d3ee;
|
|
background: rgba(34,211,238,0.1);
|
|
}
|
|
|
|
.feed-item--editable { cursor: pointer; }
|
|
.feed-item--editable:hover { background: rgba(255,255,255,0.05); }
|
|
|
|
.feed-item-actions {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.feed-item-edit {
|
|
width: 20px;
|
|
height: 20px;
|
|
border: none;
|
|
border-radius: 3px;
|
|
background: transparent;
|
|
color: #475569;
|
|
font-size: 0.7rem;
|
|
cursor: pointer;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
opacity: 0;
|
|
transition: opacity 0.15s, color 0.15s, background 0.15s;
|
|
}
|
|
.feed-item:hover .feed-item-edit { opacity: 0.7; }
|
|
.feed-item-edit:hover { opacity: 1 !important; color: #22d3ee; background: rgba(34,211,238,0.1); }
|
|
|
|
.feed-empty {
|
|
padding: 20px;
|
|
text-align: center;
|
|
font-size: 0.75rem;
|
|
color: #475569;
|
|
}
|
|
|
|
.feed-status {
|
|
padding: 4px 12px;
|
|
font-size: 0.6rem;
|
|
text-align: right;
|
|
border-top: 1px solid rgba(255,255,255,0.04);
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
/* ── Edit overlay ── */
|
|
|
|
.feed-edit-overlay {
|
|
display: none;
|
|
position: absolute;
|
|
inset: 0;
|
|
background: rgba(15, 23, 42, 0.95);
|
|
border-radius: 8px;
|
|
z-index: 10;
|
|
overflow: auto;
|
|
}
|
|
.feed-edit-overlay.open { display: flex; }
|
|
|
|
.edit-panel {
|
|
display: flex;
|
|
flex-direction: column;
|
|
width: 100%;
|
|
padding: 12px;
|
|
gap: 10px;
|
|
}
|
|
|
|
.edit-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
font-size: 0.75rem;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.edit-close {
|
|
width: 22px;
|
|
height: 22px;
|
|
border: none;
|
|
border-radius: 4px;
|
|
background: transparent;
|
|
color: #94a3b8;
|
|
font-size: 1rem;
|
|
cursor: pointer;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
.edit-close:hover { color: #ef4444; background: rgba(239,68,68,0.1); }
|
|
|
|
.edit-fields {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
flex: 1;
|
|
}
|
|
|
|
.edit-field {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 3px;
|
|
}
|
|
|
|
.edit-label {
|
|
font-size: 0.6rem;
|
|
font-weight: 600;
|
|
color: #64748b;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.05em;
|
|
}
|
|
|
|
.edit-input {
|
|
padding: 6px 8px;
|
|
border: 1px solid rgba(255,255,255,0.1);
|
|
border-radius: 5px;
|
|
background: rgba(255,255,255,0.04);
|
|
color: #e2e8f0;
|
|
font-size: 0.75rem;
|
|
font-family: inherit;
|
|
outline: none;
|
|
transition: border-color 0.15s;
|
|
resize: vertical;
|
|
}
|
|
.edit-input:focus { border-color: rgba(34,211,238,0.4); }
|
|
|
|
select.edit-input { cursor: pointer; }
|
|
|
|
.edit-actions {
|
|
display: flex;
|
|
gap: 6px;
|
|
align-items: center;
|
|
justify-content: flex-end;
|
|
}
|
|
|
|
.edit-cancel, .edit-save {
|
|
padding: 5px 12px;
|
|
border-radius: 5px;
|
|
font-size: 0.7rem;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
transition: opacity 0.15s;
|
|
}
|
|
|
|
.edit-cancel {
|
|
border: 1px solid rgba(255,255,255,0.1);
|
|
background: transparent;
|
|
color: #94a3b8;
|
|
}
|
|
.edit-cancel:hover { color: #e2e8f0; }
|
|
|
|
.edit-save {
|
|
border: 1px solid;
|
|
}
|
|
.edit-save:hover { opacity: 0.8; }
|
|
|
|
.edit-error {
|
|
font-size: 0.65rem;
|
|
color: #ef4444;
|
|
flex: 1;
|
|
}
|
|
`;
|