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

396 lines
12 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 PLATFORM_ICONS: Record<string, string> = {
x: "𝕏", linkedin: "in", instagram: "📷", youtube: "▶",
threads: "@", bluesky: "🩷", tiktok: "♫", facebook: "f",
};
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: 280px;
min-height: 140px;
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;
background: linear-gradient(135deg, #6366f1, #8b5cf6);
}
.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;
}
.description {
font-size: 13px;
line-height: 1.4;
color: var(--rs-text-secondary);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
margin-bottom: 10px;
}
.platform-row {
display: flex;
gap: 6px;
margin-bottom: 10px;
flex-wrap: wrap;
}
.platform-chip {
font-size: 12px;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
background: var(--rs-bg-surface-raised);
border-radius: 6px;
border: 1px solid var(--rs-border);
}
.progress-section {
margin-bottom: 10px;
}
.progress-label {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--rs-text-muted);
margin-bottom: 4px;
}
.progress-bar {
height: 6px;
background: var(--rs-bg-surface-raised);
border-radius: 3px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #6366f1, #8b5cf6);
border-radius: 3px;
transition: width 0.3s ease;
}
.stats-row {
display: flex;
gap: 12px;
}
.stat {
display: flex;
align-items: center;
gap: 4px;
font-size: 11px;
color: var(--rs-text-muted);
}
.stat-count {
font-weight: 600;
color: var(--rs-text-primary);
}
.stat-dot {
width: 6px;
height: 6px;
border-radius: 50%;
}
.stat-dot.draft { background: #94a3b8; }
.stat-dot.scheduled { background: #3b82f6; }
.stat-dot.published { background: #22c55e; }
.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;
}
.duration-label {
font-size: 11px;
color: var(--rs-text-muted);
font-weight: 500;
}
.open-link {
font-size: 11px;
color: var(--rs-primary);
cursor: pointer;
text-decoration: none;
font-weight: 500;
}
.open-link:hover {
text-decoration: underline;
}
`;
declare global {
interface HTMLElementTagNameMap {
"folk-social-campaign": FolkSocialCampaign;
}
}
export class FolkSocialCampaign extends FolkShape {
static override tagName = "folk-social-campaign";
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;
}
#campaignId = "";
#title = "Untitled Campaign";
#description = "";
#platforms: string[] = [];
#phases: { name: string; label: string; days: string }[] = [];
#postCount = 0;
#draftCount = 0;
#scheduledCount = 0;
#publishedCount = 0;
#duration = "";
#spaceSlug = "";
get campaignId() { return this.#campaignId; }
set campaignId(v: string) { this.#campaignId = v; this.#dispatchChange(); }
get title() { return this.#title; }
set title(v: string) { this.#title = v; this.requestUpdate("title"); this.#dispatchChange(); }
get description() { return this.#description; }
set description(v: string) { this.#description = v; this.requestUpdate("description"); this.#dispatchChange(); }
get platforms(): string[] { return this.#platforms; }
set platforms(v: string[]) { this.#platforms = v; this.requestUpdate("platforms"); this.#dispatchChange(); }
get phases() { return this.#phases; }
set phases(v: { name: string; label: string; days: string }[]) { this.#phases = v; this.requestUpdate("phases"); this.#dispatchChange(); }
get postCount() { return this.#postCount; }
set postCount(v: number) { this.#postCount = v; this.requestUpdate("postCount"); this.#dispatchChange(); }
get draftCount() { return this.#draftCount; }
set draftCount(v: number) { this.#draftCount = v; this.requestUpdate("draftCount"); this.#dispatchChange(); }
get scheduledCount() { return this.#scheduledCount; }
set scheduledCount(v: number) { this.#scheduledCount = v; this.requestUpdate("scheduledCount"); this.#dispatchChange(); }
get publishedCount() { return this.#publishedCount; }
set publishedCount(v: number) { this.#publishedCount = v; this.requestUpdate("publishedCount"); this.#dispatchChange(); }
get duration() { return this.#duration; }
set duration(v: string) { this.#duration = v; this.requestUpdate("duration"); 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 titleAttr = this.getAttribute("title");
if (titleAttr) this.#title = titleAttr;
const total = this.#postCount || 1;
const publishedPct = Math.round((this.#publishedCount / total) * 100);
const escTitle = this.#escapeHtml(this.#title);
const escDesc = this.#escapeHtml(this.#description || "No description");
const platformChips = this.#platforms
.map((p) => `<span class="platform-chip" title="${this.#escapeHtml(p)}">${PLATFORM_ICONS[p] || p[0]?.toUpperCase() || "?"}</span>`)
.join("");
const wrapper = document.createElement("div");
wrapper.style.position = "relative";
wrapper.style.height = "100%";
wrapper.innerHTML = html`
<div class="header">
<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">
<div class="description">${escDesc}</div>
<div class="platform-row">${platformChips || '<span style="font-size:11px;color:var(--rs-text-muted)">No platforms</span>'}</div>
<div class="progress-section">
<div class="progress-label">Phase Progress</div>
<div class="progress-bar"><div class="progress-fill" style="width: ${publishedPct}%"></div></div>
</div>
<div class="stats-row">
<div class="stat"><span class="stat-dot draft"></span><span class="stat-count">${this.#draftCount}</span> draft</div>
<div class="stat"><span class="stat-dot scheduled"></span><span class="stat-count">${this.#scheduledCount}</span> sched</div>
<div class="stat"><span class="stat-dot published"></span><span class="stat-count">${this.#publishedCount}</span> pub</div>
</div>
</div>
<div class="footer">
<span class="duration-label">${this.#escapeHtml(this.#duration || "No duration set")}</span>
<span class="open-link">Open Campaign →</span>
</div>
`;
const slot = root.querySelector("slot");
const containerDiv = slot?.parentElement as HTMLElement;
if (containerDiv) containerDiv.replaceWith(wrapper);
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/campaigns?id=${this.#campaignId}` },
}));
});
return root;
}
#escapeHtml(text: string): string {
const div = document.createElement("div");
div.textContent = text;
return div.innerHTML;
}
static override fromData(data: Record<string, any>): FolkSocialCampaign {
const shape = FolkShape.fromData(data) as FolkSocialCampaign;
if (data.campaignId) shape.campaignId = data.campaignId;
if (data.title) shape.title = data.title;
if (data.description !== undefined) shape.description = data.description;
if (Array.isArray(data.platforms)) shape.platforms = data.platforms;
if (Array.isArray(data.phases)) shape.phases = data.phases;
if (typeof data.postCount === "number") shape.postCount = data.postCount;
if (typeof data.draftCount === "number") shape.draftCount = data.draftCount;
if (typeof data.scheduledCount === "number") shape.scheduledCount = data.scheduledCount;
if (typeof data.publishedCount === "number") shape.publishedCount = data.publishedCount;
if (data.duration !== undefined) shape.duration = data.duration;
if (data.spaceSlug) shape.spaceSlug = data.spaceSlug;
return shape;
}
override applyData(data: Record<string, any>): void {
super.applyData(data);
if (data.campaignId !== undefined && data.campaignId !== this.campaignId) this.campaignId = data.campaignId;
if (data.title !== undefined && data.title !== this.title) this.title = data.title;
if (data.description !== undefined && data.description !== this.description) this.description = data.description;
if (Array.isArray(data.platforms) && JSON.stringify(data.platforms) !== JSON.stringify(this.platforms)) this.platforms = data.platforms;
if (Array.isArray(data.phases) && JSON.stringify(data.phases) !== JSON.stringify(this.phases)) this.phases = data.phases;
if (typeof data.postCount === "number" && data.postCount !== this.postCount) this.postCount = data.postCount;
if (typeof data.draftCount === "number" && data.draftCount !== this.draftCount) this.draftCount = data.draftCount;
if (typeof data.scheduledCount === "number" && data.scheduledCount !== this.scheduledCount) this.scheduledCount = data.scheduledCount;
if (typeof data.publishedCount === "number" && data.publishedCount !== this.publishedCount) this.publishedCount = data.publishedCount;
if (data.duration !== undefined && data.duration !== this.duration) this.duration = data.duration;
if (data.spaceSlug !== undefined && data.spaceSlug !== this.spaceSlug) this.spaceSlug = data.spaceSlug;
}
override toJSON() {
return {
...super.toJSON(),
type: "folk-social-campaign",
campaignId: this.#campaignId,
title: this.#title,
description: this.#description,
platforms: this.#platforms,
phases: this.#phases,
postCount: this.#postCount,
draftCount: this.#draftCount,
scheduledCount: this.#scheduledCount,
publishedCount: this.#publishedCount,
duration: this.#duration,
spaceSlug: this.#spaceSlug,
};
}
}