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

892 lines
21 KiB
TypeScript

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: "\ud835\udd4f", label: "X", color: "#000000" },
linkedin: { icon: "in", label: "LinkedIn", color: "#0A66C2" },
instagram: { icon: "\ud83d\udcf7", label: "Instagram", color: "#E4405F" },
youtube: { icon: "\u25b6", label: "YouTube", color: "#FF0000" },
threads: { icon: "@", label: "Threads", color: "#000000" },
bluesky: { icon: "\ud83e\ude77", label: "Bluesky", color: "#0085FF" },
tiktok: { icon: "\u266b", 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">\u270F</button>
<button class="close-btn" title="Remove">\u00D7</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">\ud83d\uddbc</span>
<span>No media</span>
</div>
</div>
<div class="hashtags"></div>
</div>
<div class="footer">
<div class="schedule">
<span class="schedule-icon">\ud83d\udcc5</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: "\ud83d\uddbc",
video: "\ud83c\udfac",
carousel: "\ud83d\udcf8",
reel: "\ud83c\udfac",
short: "\ud83c\udfac",
story: "\ud83d\udcf1",
};
const icon = mediaIcons[this.#postType] || "\ud83d\uddbc";
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,
};
}
}