395 lines
10 KiB
TypeScript
395 lines
10 KiB
TypeScript
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,
|
||
};
|
||
}
|
||
}
|