rspace-online/lib/folk-social-thread.ts

395 lines
10 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.

import { FolkShape } from "./folk-shape";
import { css, html } from "./tags";
import type { SocialPlatform } from "./folk-social-post";
const PLATFORM_COLORS: Record<string, string> = {
x: "#000000",
linkedin: "#0A66C2",
instagram: "#E4405F",
youtube: "#FF0000",
threads: "#000000",
bluesky: "#0085FF",
tiktok: "#010101",
facebook: "#1877F2",
};
const styles = css`
:host {
background: var(--rs-bg-surface, #fff);
color: var(--rs-text-primary, #1e293b);
border-radius: 12px;
box-shadow: var(--rs-shadow-sm);
min-width: 260px;
min-height: 120px;
overflow: hidden;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
:host(:hover) {
box-shadow: var(--rs-shadow-md);
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
cursor: move;
color: white;
border-radius: 12px 12px 0 0;
}
.header-left {
display: flex;
align-items: center;
gap: 8px;
}
.header-icon {
font-size: 18px;
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.2);
border-radius: 6px;
}
.header-title {
font-size: 13px;
font-weight: 600;
letter-spacing: 0.3px;
max-width: 180px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.header-actions {
display: flex;
gap: 4px;
}
.header-actions button {
background: rgba(255, 255, 255, 0.2);
border: none;
color: white;
width: 24px;
height: 24px;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
display: flex;
align-items: center;
justify-content: center;
}
.header-actions button:hover {
background: rgba(255, 255, 255, 0.3);
}
.body {
padding: 12px 14px;
}
.tweet-count {
display: inline-block;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
padding: 2px 8px;
border-radius: 10px;
background: var(--rs-bg-surface-raised);
color: var(--rs-text-muted);
margin-bottom: 8px;
}
.tweet-preview {
font-size: 13px;
line-height: 1.5;
color: var(--rs-text-primary);
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
margin-bottom: 8px;
}
.cover-image {
width: 100%;
height: 80px;
border-radius: 8px;
overflow: hidden;
margin-bottom: 8px;
border: 1px solid var(--rs-border);
}
.cover-image img {
width: 100%;
height: 100%;
object-fit: cover;
}
.footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 14px;
background: var(--rs-bg-surface-raised);
border-top: 1px solid var(--rs-border);
border-radius: 0 0 12px 12px;
}
.open-link {
font-size: 11px;
color: var(--rs-primary);
cursor: pointer;
text-decoration: none;
font-weight: 500;
}
.open-link:hover {
text-decoration: underline;
}
.status-badge {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
padding: 3px 8px;
border-radius: 10px;
}
.status-badge.draft {
background: var(--rs-bg-surface-raised);
color: var(--rs-text-muted);
}
.status-badge.ready {
background: rgba(59, 130, 246, 0.15);
color: #2563eb;
}
.status-badge.published {
background: rgba(34, 197, 94, 0.15);
color: var(--rs-success);
}
`;
declare global {
interface HTMLElementTagNameMap {
"folk-social-thread": FolkSocialThread;
}
}
export class FolkSocialThread extends FolkShape {
static override tagName = "folk-social-thread";
static {
const sheet = new CSSStyleSheet();
const parentRules = Array.from(FolkShape.styles.cssRules)
.map((r) => r.cssText)
.join("\n");
const childRules = Array.from(styles.cssRules)
.map((r) => r.cssText)
.join("\n");
sheet.replaceSync(`${parentRules}\n${childRules}`);
this.styles = sheet;
}
#threadId = "";
#title = "Untitled Thread";
#tweets: string[] = [];
#platform: SocialPlatform = "x";
#status: "draft" | "ready" | "published" = "draft";
#imageUrl = "";
#spaceSlug = "";
#tweetCountEl: HTMLElement | null = null;
#tweetPreviewEl: HTMLElement | null = null;
#statusBadgeEl: HTMLElement | null = null;
#coverImageEl: HTMLElement | null = null;
get threadId() { return this.#threadId; }
set threadId(v: string) { this.#threadId = v; this.#dispatchChange(); }
get title() { return this.#title; }
set title(v: string) {
this.#title = v;
this.requestUpdate("title");
this.#dispatchChange();
}
get tweets(): string[] { return this.#tweets; }
set tweets(v: string[]) {
this.#tweets = v;
if (this.#tweetCountEl) this.#tweetCountEl.textContent = `${v.length} tweet${v.length !== 1 ? "s" : ""}`;
if (this.#tweetPreviewEl) this.#tweetPreviewEl.textContent = v[0] || "No tweets yet...";
this.requestUpdate("tweets");
this.#dispatchChange();
}
get platform(): SocialPlatform { return this.#platform; }
set platform(v: SocialPlatform) {
this.#platform = v;
this.requestUpdate("platform");
this.#dispatchChange();
}
get status() { return this.#status; }
set status(v: "draft" | "ready" | "published") {
this.#status = v;
if (this.#statusBadgeEl) {
this.#statusBadgeEl.className = `status-badge ${v}`;
this.#statusBadgeEl.textContent = v;
}
this.requestUpdate("status");
this.#dispatchChange();
}
get imageUrl() { return this.#imageUrl; }
set imageUrl(v: string) {
this.#imageUrl = v;
this.#renderCoverImage();
this.requestUpdate("imageUrl");
this.#dispatchChange();
}
get spaceSlug() { return this.#spaceSlug; }
set spaceSlug(v: string) { this.#spaceSlug = v; this.#dispatchChange(); }
#dispatchChange() {
this.dispatchEvent(new CustomEvent("content-change", { detail: this.toJSON() }));
}
override createRenderRoot() {
const root = super.createRenderRoot();
const threadIdAttr = this.getAttribute("thread-id");
if (threadIdAttr) this.#threadId = threadIdAttr;
const titleAttr = this.getAttribute("title");
if (titleAttr) this.#title = titleAttr;
const platformAttr = this.getAttribute("platform") as SocialPlatform;
if (platformAttr && platformAttr in PLATFORM_COLORS) this.#platform = platformAttr;
const statusAttr = this.getAttribute("status") as any;
if (statusAttr) this.#status = statusAttr;
const color = PLATFORM_COLORS[this.#platform] || "#000";
const wrapper = document.createElement("div");
wrapper.style.position = "relative";
wrapper.style.height = "100%";
const escTitle = this.#escapeHtml(this.#title);
const tweetCount = this.#tweets.length;
const firstTweet = this.#escapeHtml(this.#tweets[0] || "No tweets yet...");
wrapper.innerHTML = html`
<div class="header" style="background: ${color}">
<div class="header-left">
<span class="header-icon">🧵</span>
<span class="header-title">${escTitle}</span>
</div>
<div class="header-actions">
<button class="close-btn" title="Remove">×</button>
</div>
</div>
<div class="body">
<span class="tweet-count">${tweetCount} tweet${tweetCount !== 1 ? "s" : ""}</span>
<div class="tweet-preview">${firstTweet}</div>
<div class="cover-image" style="display: ${this.#imageUrl ? "block" : "none"}">
${this.#imageUrl ? `<img src="${this.#escapeHtml(this.#imageUrl)}" alt="Thread cover" />` : ""}
</div>
</div>
<div class="footer">
<span class="status-badge ${this.#status}">${this.#status}</span>
<span class="open-link">Open Editor →</span>
</div>
`;
const slot = root.querySelector("slot");
const containerDiv = slot?.parentElement as HTMLElement;
if (containerDiv) containerDiv.replaceWith(wrapper);
this.#tweetCountEl = wrapper.querySelector(".tweet-count");
this.#tweetPreviewEl = wrapper.querySelector(".tweet-preview");
this.#statusBadgeEl = wrapper.querySelector(".status-badge");
this.#coverImageEl = wrapper.querySelector(".cover-image");
const closeBtn = wrapper.querySelector(".close-btn") as HTMLButtonElement;
closeBtn.addEventListener("click", (e) => {
e.stopPropagation();
this.dispatchEvent(new CustomEvent("close"));
});
const openLink = wrapper.querySelector(".open-link") as HTMLElement;
openLink.addEventListener("click", (e) => {
e.stopPropagation();
this.dispatchEvent(new CustomEvent("navigate-to-module", {
bubbles: true,
composed: true,
detail: { path: `/${this.#spaceSlug}/rsocials/thread-editor?id=${this.#threadId}` },
}));
});
return root;
}
#renderCoverImage() {
if (!this.#coverImageEl) return;
if (this.#imageUrl) {
this.#coverImageEl.style.display = "block";
this.#coverImageEl.innerHTML = `<img src="${this.#escapeHtml(this.#imageUrl)}" alt="Thread cover" />`;
} else {
this.#coverImageEl.style.display = "none";
this.#coverImageEl.innerHTML = "";
}
}
#escapeHtml(text: string): string {
const div = document.createElement("div");
div.textContent = text;
return div.innerHTML;
}
static override fromData(data: Record<string, any>): FolkSocialThread {
const shape = FolkShape.fromData(data) as FolkSocialThread;
if (data.threadId) shape.threadId = data.threadId;
if (data.title) shape.title = data.title;
if (Array.isArray(data.tweets)) shape.tweets = data.tweets;
if (data.platform) shape.platform = data.platform;
if (data.status) shape.status = data.status;
if (data.imageUrl !== undefined) shape.imageUrl = data.imageUrl;
if (data.spaceSlug) shape.spaceSlug = data.spaceSlug;
return shape;
}
override applyData(data: Record<string, any>): void {
super.applyData(data);
if (data.threadId !== undefined && data.threadId !== this.threadId) this.threadId = data.threadId;
if (data.title !== undefined && data.title !== this.title) this.title = data.title;
if (Array.isArray(data.tweets) && JSON.stringify(data.tweets) !== JSON.stringify(this.tweets)) this.tweets = data.tweets;
if (data.platform !== undefined && data.platform !== this.platform) this.platform = data.platform;
if (data.status !== undefined && data.status !== this.status) this.status = data.status;
if (data.imageUrl !== undefined && data.imageUrl !== this.imageUrl) this.imageUrl = data.imageUrl;
if (data.spaceSlug !== undefined && data.spaceSlug !== this.spaceSlug) this.spaceSlug = data.spaceSlug;
}
override toJSON() {
return {
...super.toJSON(),
type: "folk-social-thread",
threadId: this.#threadId,
title: this.#title,
tweets: this.#tweets,
platform: this.#platform,
status: this.#status,
imageUrl: this.#imageUrl,
spaceSlug: this.#spaceSlug,
};
}
}