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

892 lines
21 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";
const styles = css`
:host {
background: white;
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
min-width: 280px;
min-height: 140px;
overflow: hidden;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
:host(:hover) {
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.12);
}
:host([data-status="posted"]) {
box-shadow: 0 0 0 2px rgba(34, 197, 94, 0.4), 0 2px 12px rgba(0, 0, 0, 0.08);
}
:host([data-status="scheduled"]) {
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.4), 0 2px 12px rgba(0, 0, 0, 0.08);
}
:host([data-status="failed"]) {
box-shadow: 0 0 0 2px rgba(239, 68, 68, 0.4), 0 2px 12px rgba(0, 0, 0, 0.08);
}
.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;
}
.platform-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;
}
.platform-name {
font-size: 13px;
font-weight: 600;
letter-spacing: 0.3px;
}
.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;
}
.post-type {
display: inline-block;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
padding: 2px 8px;
border-radius: 10px;
background: #f1f5f9;
color: #64748b;
margin-bottom: 8px;
}
.content-preview {
font-size: 13px;
line-height: 1.5;
color: #1e293b;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
margin-bottom: 8px;
}
.media-preview {
width: 100%;
height: 100px;
border-radius: 8px;
background: #f1f5f9;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 8px;
overflow: hidden;
border: 1px solid #e2e8f0;
}
.media-preview img {
width: 100%;
height: 100%;
object-fit: cover;
}
.media-placeholder {
color: #94a3b8;
font-size: 12px;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.media-placeholder .icon {
font-size: 24px;
}
.hashtags {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-bottom: 8px;
}
.hashtag {
font-size: 11px;
color: #3b82f6;
background: #eff6ff;
padding: 2px 6px;
border-radius: 4px;
}
.footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 14px;
background: #f8fafc;
border-top: 1px solid #e2e8f0;
border-radius: 0 0 12px 12px;
}
.schedule {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
color: #64748b;
}
.schedule-icon {
font-size: 13px;
}
.schedule-time {
font-weight: 500;
color: #475569;
}
.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: #f1f5f9;
color: #64748b;
}
.status-badge.scheduled {
background: #dbeafe;
color: #2563eb;
}
.status-badge.posted {
background: #dcfce7;
color: #16a34a;
}
.status-badge.failed {
background: #fee2e2;
color: #dc2626;
}
.step-number {
position: absolute;
top: -8px;
left: -8px;
width: 24px;
height: 24px;
border-radius: 50%;
background: #1e293b;
color: white;
font-size: 11px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
z-index: 1;
border: 2px solid white;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.15);
}
.edit-overlay {
display: none;
position: absolute;
inset: 0;
background: white;
z-index: 10;
border-radius: 12px;
flex-direction: column;
}
.edit-overlay.active {
display: flex;
}
.edit-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
border-bottom: 1px solid #e2e8f0;
}
.edit-header span {
font-size: 13px;
font-weight: 600;
color: #1e293b;
}
.edit-body {
flex: 1;
padding: 12px 14px;
overflow-y: auto;
}
.edit-field {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 12px;
}
.edit-field label {
font-size: 11px;
font-weight: 500;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.edit-field input,
.edit-field textarea,
.edit-field select {
padding: 8px 10px;
border: 1px solid #e2e8f0;
border-radius: 6px;
font-size: 13px;
outline: none;
font-family: inherit;
}
.edit-field input:focus,
.edit-field textarea:focus,
.edit-field select:focus {
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.edit-field textarea {
min-height: 80px;
resize: vertical;
}
.edit-actions {
display: flex;
gap: 8px;
padding: 10px 14px;
border-top: 1px solid #e2e8f0;
}
.edit-actions button {
flex: 1;
padding: 8px;
border-radius: 6px;
font-size: 12px;
font-weight: 500;
cursor: pointer;
border: 1px solid #e2e8f0;
}
.edit-actions .save-btn {
background: #3b82f6;
color: white;
border-color: #3b82f6;
}
.edit-actions .save-btn:hover {
background: #2563eb;
}
.edit-actions .cancel-btn {
background: white;
color: #64748b;
}
.edit-actions .cancel-btn:hover {
background: #f1f5f9;
}
`;
export type SocialPlatform =
| "x"
| "linkedin"
| "instagram"
| "youtube"
| "threads"
| "bluesky"
| "tiktok"
| "facebook";
export type PostStatus = "draft" | "scheduled" | "posted" | "failed";
export type PostType =
| "text"
| "image"
| "video"
| "carousel"
| "story"
| "reel"
| "thread"
| "article"
| "short";
const PLATFORM_CONFIG: Record<
SocialPlatform,
{ icon: string; label: string; color: string }
> = {
x: { icon: "𝕏", label: "X", color: "#000000" },
linkedin: { icon: "in", label: "LinkedIn", color: "#0A66C2" },
instagram: { icon: "📷", label: "Instagram", color: "#E4405F" },
youtube: { icon: "▶", label: "YouTube", color: "#FF0000" },
threads: { icon: "@", label: "Threads", color: "#000000" },
bluesky: { icon: "🩷", label: "Bluesky", color: "#0085FF" },
tiktok: { icon: "♫", label: "TikTok", color: "#010101" },
facebook: { icon: "f", label: "Facebook", color: "#1877F2" },
};
declare global {
interface HTMLElementTagNameMap {
"folk-social-post": FolkSocialPost;
}
}
export class FolkSocialPost extends FolkShape {
static override tagName = "folk-social-post";
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;
}
#platform: SocialPlatform = "x";
#postType: PostType = "text";
#content = "";
#mediaUrl = "";
#mediaType = ""; // "image", "video", "carousel"
#scheduledAt = "";
#status: PostStatus = "draft";
#hashtags: string[] = [];
#stepNumber = 0;
#isEditing = false;
// DOM references
#contentPreviewEl: HTMLElement | null = null;
#statusBadgeEl: HTMLElement | null = null;
#scheduleTimeEl: HTMLElement | null = null;
#editOverlay: HTMLElement | null = null;
#mediaPreviewEl: HTMLElement | null = null;
#hashtagsEl: HTMLElement | null = null;
#postTypeEl: HTMLElement | null = null;
#stepNumberEl: HTMLElement | null = null;
get platform() {
return this.#platform;
}
set platform(value: SocialPlatform) {
this.#platform = value;
this.requestUpdate("platform");
this.#dispatchChange();
}
get postType() {
return this.#postType;
}
set postType(value: PostType) {
this.#postType = value;
if (this.#postTypeEl) this.#postTypeEl.textContent = value;
this.requestUpdate("postType");
this.#dispatchChange();
}
get content() {
return this.#content;
}
set content(value: string) {
this.#content = value;
if (this.#contentPreviewEl) this.#contentPreviewEl.textContent = value;
this.requestUpdate("content");
this.#dispatchChange();
}
get mediaUrl() {
return this.#mediaUrl;
}
set mediaUrl(value: string) {
this.#mediaUrl = value;
this.#renderMedia();
this.requestUpdate("mediaUrl");
this.#dispatchChange();
}
get mediaType() {
return this.#mediaType;
}
set mediaType(value: string) {
this.#mediaType = value;
this.#renderMedia();
this.requestUpdate("mediaType");
this.#dispatchChange();
}
get scheduledAt() {
return this.#scheduledAt;
}
set scheduledAt(value: string) {
this.#scheduledAt = value;
if (this.#scheduleTimeEl)
this.#scheduleTimeEl.textContent = this.#formatSchedule(value);
this.requestUpdate("scheduledAt");
this.#dispatchChange();
}
get status(): PostStatus {
return this.#status;
}
set status(value: PostStatus) {
this.#status = value;
this.setAttribute("data-status", value);
if (this.#statusBadgeEl) {
this.#statusBadgeEl.className = `status-badge ${value}`;
this.#statusBadgeEl.textContent = value;
}
this.requestUpdate("status");
this.#dispatchChange();
}
get hashtags(): string[] {
return this.#hashtags;
}
set hashtags(value: string[]) {
this.#hashtags = value;
this.#renderHashtags();
this.requestUpdate("hashtags");
this.#dispatchChange();
}
get stepNumber(): number {
return this.#stepNumber;
}
set stepNumber(value: number) {
this.#stepNumber = value;
if (this.#stepNumberEl) {
this.#stepNumberEl.textContent = String(value);
this.#stepNumberEl.style.display = value > 0 ? "flex" : "none";
}
this.requestUpdate("stepNumber");
this.#dispatchChange();
}
#dispatchChange() {
this.dispatchEvent(
new CustomEvent("content-change", {
detail: this.toJSON(),
}),
);
}
override createRenderRoot() {
const root = super.createRenderRoot();
// Parse attributes
const platformAttr = this.getAttribute("platform") as SocialPlatform;
if (platformAttr && platformAttr in PLATFORM_CONFIG)
this.#platform = platformAttr;
const postTypeAttr = this.getAttribute("post-type") as PostType;
if (postTypeAttr) this.#postType = postTypeAttr;
const contentAttr = this.getAttribute("content");
if (contentAttr) this.#content = contentAttr;
const statusAttr = this.getAttribute("status") as PostStatus;
if (statusAttr) this.#status = statusAttr;
const stepAttr = this.getAttribute("step");
if (stepAttr) this.#stepNumber = parseInt(stepAttr, 10);
const config = PLATFORM_CONFIG[this.#platform];
const wrapper = document.createElement("div");
wrapper.style.position = "relative";
wrapper.style.height = "100%";
wrapper.innerHTML = html`
<span
class="step-number"
style="display: ${this.#stepNumber > 0 ? "flex" : "none"}"
>${this.#stepNumber}</span
>
<div class="header" style="background: ${config.color}">
<div class="header-left">
<span class="platform-icon">${config.icon}</span>
<span class="platform-name">${config.label}</span>
</div>
<div class="header-actions">
<button class="edit-btn" title="Edit">✏</button>
<button class="close-btn" title="Remove">×</button>
</div>
</div>
<div class="body">
<span class="post-type">${this.#escapeHtml(this.#postType)}</span>
<div class="content-preview">
${this.#escapeHtml(this.#content) || "No content yet..."}
</div>
<div class="media-preview">
<div class="media-placeholder">
<span class="icon">🖼</span>
<span>No media</span>
</div>
</div>
<div class="hashtags"></div>
</div>
<div class="footer">
<div class="schedule">
<span class="schedule-icon">📅</span>
<span class="schedule-time"
>${this.#formatSchedule(this.#scheduledAt)}</span
>
</div>
<span class="status-badge ${this.#status}">${this.#status}</span>
</div>
<div class="edit-overlay">
<div class="edit-header">
<span>Edit Post</span>
</div>
<div class="edit-body">
<div class="edit-field">
<label>Platform</label>
<select class="edit-platform">
<option value="x">X (Twitter)</option>
<option value="linkedin">LinkedIn</option>
<option value="instagram">Instagram</option>
<option value="youtube">YouTube</option>
<option value="threads">Threads</option>
<option value="bluesky">Bluesky</option>
<option value="tiktok">TikTok</option>
<option value="facebook">Facebook</option>
</select>
</div>
<div class="edit-field">
<label>Post Type</label>
<select class="edit-post-type">
<option value="text">Text</option>
<option value="image">Image</option>
<option value="video">Video</option>
<option value="carousel">Carousel</option>
<option value="story">Story</option>
<option value="reel">Reel</option>
<option value="thread">Thread</option>
<option value="article">Article</option>
<option value="short">Short</option>
</select>
</div>
<div class="edit-field">
<label>Content</label>
<textarea
class="edit-content"
placeholder="Write your post..."
></textarea>
</div>
<div class="edit-field">
<label>Media URL</label>
<input
type="text"
class="edit-media-url"
placeholder="https://..."
/>
</div>
<div class="edit-field">
<label>Scheduled At</label>
<input type="datetime-local" class="edit-scheduled" />
</div>
<div class="edit-field">
<label>Hashtags (comma-separated)</label>
<input
type="text"
class="edit-hashtags"
placeholder="#launch, #product"
/>
</div>
<div class="edit-field">
<label>Status</label>
<select class="edit-status">
<option value="draft">Draft</option>
<option value="scheduled">Scheduled</option>
<option value="posted">Posted</option>
<option value="failed">Failed</option>
</select>
</div>
</div>
<div class="edit-actions">
<button class="cancel-btn">Cancel</button>
<button class="save-btn">Save</button>
</div>
</div>
`;
// Replace the container div (slot's parent) with our wrapper
const slot = root.querySelector("slot");
const containerDiv = slot?.parentElement as HTMLElement;
if (containerDiv) {
containerDiv.replaceWith(wrapper);
}
// Cache DOM refs
this.#contentPreviewEl = wrapper.querySelector(".content-preview");
this.#statusBadgeEl = wrapper.querySelector(".status-badge");
this.#scheduleTimeEl = wrapper.querySelector(".schedule-time");
this.#editOverlay = wrapper.querySelector(".edit-overlay");
this.#mediaPreviewEl = wrapper.querySelector(".media-preview");
this.#hashtagsEl = wrapper.querySelector(".hashtags");
this.#postTypeEl = wrapper.querySelector(".post-type");
this.#stepNumberEl = wrapper.querySelector(".step-number");
// Set initial attribute state
this.setAttribute("data-status", this.#status);
// Edit button
const editBtn = wrapper.querySelector(".edit-btn") as HTMLButtonElement;
editBtn.addEventListener("click", (e) => {
e.stopPropagation();
this.#openEditor();
});
// Close button
const closeBtn = wrapper.querySelector(".close-btn") as HTMLButtonElement;
closeBtn.addEventListener("click", (e) => {
e.stopPropagation();
this.dispatchEvent(new CustomEvent("close"));
});
// Save button
const saveBtn = wrapper.querySelector(".save-btn") as HTMLButtonElement;
saveBtn.addEventListener("click", (e) => {
e.stopPropagation();
this.#saveEdit(wrapper);
});
// Cancel button
const cancelBtn = wrapper.querySelector(
".cancel-btn",
) as HTMLButtonElement;
cancelBtn.addEventListener("click", (e) => {
e.stopPropagation();
this.#closeEditor();
});
// Render media and hashtags
this.#renderMedia();
this.#renderHashtags();
return root;
}
#openEditor() {
if (!this.#editOverlay) return;
this.#isEditing = true;
this.#editOverlay.classList.add("active");
const root = this.#editOverlay.closest("div") as HTMLElement;
const platformSel = root.querySelector(
".edit-platform",
) as HTMLSelectElement;
const postTypeSel = root.querySelector(
".edit-post-type",
) as HTMLSelectElement;
const contentArea = root.querySelector(
".edit-content",
) as HTMLTextAreaElement;
const mediaInput = root.querySelector(
".edit-media-url",
) as HTMLInputElement;
const scheduledInput = root.querySelector(
".edit-scheduled",
) as HTMLInputElement;
const hashtagsInput = root.querySelector(
".edit-hashtags",
) as HTMLInputElement;
const statusSel = root.querySelector(
".edit-status",
) as HTMLSelectElement;
if (platformSel) platformSel.value = this.#platform;
if (postTypeSel) postTypeSel.value = this.#postType;
if (contentArea) contentArea.value = this.#content;
if (mediaInput) mediaInput.value = this.#mediaUrl;
if (scheduledInput) scheduledInput.value = this.#scheduledAt;
if (hashtagsInput) hashtagsInput.value = this.#hashtags.join(", ");
if (statusSel) statusSel.value = this.#status;
}
#closeEditor() {
if (!this.#editOverlay) return;
this.#isEditing = false;
this.#editOverlay.classList.remove("active");
}
#saveEdit(wrapper: HTMLElement) {
const platformSel = wrapper.querySelector(
".edit-platform",
) as HTMLSelectElement;
const postTypeSel = wrapper.querySelector(
".edit-post-type",
) as HTMLSelectElement;
const contentArea = wrapper.querySelector(
".edit-content",
) as HTMLTextAreaElement;
const mediaInput = wrapper.querySelector(
".edit-media-url",
) as HTMLInputElement;
const scheduledInput = wrapper.querySelector(
".edit-scheduled",
) as HTMLInputElement;
const hashtagsInput = wrapper.querySelector(
".edit-hashtags",
) as HTMLInputElement;
const statusSel = wrapper.querySelector(
".edit-status",
) as HTMLSelectElement;
if (platformSel)
this.#platform = platformSel.value as SocialPlatform;
if (postTypeSel) this.postType = postTypeSel.value as PostType;
if (contentArea) this.content = contentArea.value;
if (mediaInput) this.mediaUrl = mediaInput.value;
if (scheduledInput) this.scheduledAt = scheduledInput.value;
if (hashtagsInput)
this.hashtags = hashtagsInput.value
.split(",")
.map((t) => t.trim())
.filter(Boolean);
if (statusSel) this.status = statusSel.value as PostStatus;
// Update header color for new platform
const header = wrapper.querySelector(".header") as HTMLElement;
const config = PLATFORM_CONFIG[this.#platform];
if (header && config) {
header.style.background = config.color;
const iconEl = header.querySelector(".platform-icon");
const nameEl = header.querySelector(".platform-name");
if (iconEl) iconEl.textContent = config.icon;
if (nameEl) nameEl.textContent = config.label;
}
this.#closeEditor();
}
#renderMedia() {
if (!this.#mediaPreviewEl) return;
if (this.#mediaUrl) {
this.#mediaPreviewEl.innerHTML = `<img src="${this.#escapeHtml(this.#mediaUrl)}" alt="Post media" onerror="this.parentElement.innerHTML='<div class=\\'media-placeholder\\'><span class=\\'icon\\'>⚠️</span><span>Failed to load</span></div>'" />`;
} else {
const mediaIcons: Record<string, string> = {
image: "🖼",
video: "🎬",
carousel: "📸",
reel: "🎬",
short: "🎬",
story: "📱",
};
const icon = mediaIcons[this.#postType] || "🖼";
const label =
this.#postType === "text" ? "No media" : `${this.#postType} media`;
this.#mediaPreviewEl.innerHTML = `<div class="media-placeholder"><span class="icon">${icon}</span><span>${label}</span></div>`;
}
}
#renderHashtags() {
if (!this.#hashtagsEl) return;
if (this.#hashtags.length === 0) {
this.#hashtagsEl.innerHTML = "";
return;
}
this.#hashtagsEl.innerHTML = this.#hashtags
.map((tag) => {
const t = tag.startsWith("#") ? tag : `#${tag}`;
return `<span class="hashtag">${this.#escapeHtml(t)}</span>`;
})
.join("");
}
#formatSchedule(dateStr: string): string {
if (!dateStr) return "Not scheduled";
try {
const date = new Date(dateStr);
if (isNaN(date.getTime())) return dateStr;
const opts: Intl.DateTimeFormatOptions = {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
};
return date.toLocaleDateString("en-US", opts);
} catch {
return dateStr;
}
}
#escapeHtml(text: string): string {
const div = document.createElement("div");
div.textContent = text;
return div.innerHTML;
}
override toJSON() {
return {
...super.toJSON(),
type: "folk-social-post",
platform: this.#platform,
postType: this.#postType,
content: this.#content,
mediaUrl: this.#mediaUrl,
mediaType: this.#mediaType,
scheduledAt: this.#scheduledAt,
status: this.#status,
hashtags: this.#hashtags,
stepNumber: this.#stepNumber,
};
}
}