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

355 lines
9.7 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: 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;
background: linear-gradient(135deg, #7c3aed, #a855f7);
}
.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-label {
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;
}
.subject-line {
font-size: 14px;
font-weight: 600;
color: var(--rs-text-primary);
margin-bottom: 8px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.list-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.list-name {
font-size: 12px;
color: var(--rs-text-secondary);
}
.subscriber-badge {
font-size: 10px;
font-weight: 600;
padding: 2px 8px;
border-radius: 10px;
background: rgba(124, 58, 237, 0.1);
color: #7c3aed;
}
.body-preview {
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;
}
.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;
}
.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.scheduled {
background: rgba(59, 130, 246, 0.15);
color: #2563eb;
}
.status-badge.sent {
background: rgba(34, 197, 94, 0.15);
color: var(--rs-success);
}
.schedule-info {
display: flex;
align-items: center;
gap: 4px;
font-size: 11px;
color: var(--rs-text-muted);
}
`;
declare global {
interface HTMLElementTagNameMap {
"folk-social-newsletter": FolkSocialNewsletter;
}
}
export class FolkSocialNewsletter extends FolkShape {
static override tagName = "folk-social-newsletter";
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;
}
#newsletterId = "";
#subject = "Untitled Newsletter";
#listName = "";
#subscriberCount = 0;
#status: "draft" | "scheduled" | "sent" = "draft";
#scheduledAt = "";
#bodyPreview = "";
#spaceSlug = "";
#subjectEl: HTMLElement | null = null;
#statusBadgeEl: HTMLElement | null = null;
#scheduleInfoEl: HTMLElement | null = null;
get newsletterId() { return this.#newsletterId; }
set newsletterId(v: string) { this.#newsletterId = v; this.#dispatchChange(); }
get subject() { return this.#subject; }
set subject(v: string) {
this.#subject = v;
if (this.#subjectEl) this.#subjectEl.textContent = v;
this.requestUpdate("subject");
this.#dispatchChange();
}
get listName() { return this.#listName; }
set listName(v: string) { this.#listName = v; this.requestUpdate("listName"); this.#dispatchChange(); }
get subscriberCount() { return this.#subscriberCount; }
set subscriberCount(v: number) { this.#subscriberCount = v; this.requestUpdate("subscriberCount"); this.#dispatchChange(); }
get status() { return this.#status; }
set status(v: "draft" | "scheduled" | "sent") {
this.#status = v;
if (this.#statusBadgeEl) {
this.#statusBadgeEl.className = `status-badge ${v}`;
this.#statusBadgeEl.textContent = v;
}
this.requestUpdate("status");
this.#dispatchChange();
}
get scheduledAt() { return this.#scheduledAt; }
set scheduledAt(v: string) {
this.#scheduledAt = v;
if (this.#scheduleInfoEl) this.#scheduleInfoEl.textContent = this.#formatSchedule(v);
this.requestUpdate("scheduledAt");
this.#dispatchChange();
}
get bodyPreview() { return this.#bodyPreview; }
set bodyPreview(v: string) { this.#bodyPreview = v; this.requestUpdate("bodyPreview"); 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 subjectAttr = this.getAttribute("subject");
if (subjectAttr) this.#subject = subjectAttr;
const statusAttr = this.getAttribute("status") as any;
if (statusAttr) this.#status = statusAttr;
const escSubject = this.#escapeHtml(this.#subject);
const escListName = this.#escapeHtml(this.#listName || "No list");
const escPreview = this.#escapeHtml(this.#bodyPreview || "No content preview");
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-label">Newsletter</span>
</div>
<div class="header-actions">
<button class="close-btn" title="Remove">×</button>
</div>
</div>
<div class="body">
<div class="subject-line">${escSubject}</div>
<div class="list-row">
<span class="list-name">${escListName}</span>
<span class="subscriber-badge">${this.#subscriberCount} subscribers</span>
</div>
<div class="body-preview">${escPreview}</div>
</div>
<div class="footer">
<span class="status-badge ${this.#status}">${this.#status}</span>
<span class="schedule-info">${this.#formatSchedule(this.#scheduledAt)}</span>
</div>
`;
const slot = root.querySelector("slot");
const containerDiv = slot?.parentElement as HTMLElement;
if (containerDiv) containerDiv.replaceWith(wrapper);
this.#subjectEl = wrapper.querySelector(".subject-line");
this.#statusBadgeEl = wrapper.querySelector(".status-badge");
this.#scheduleInfoEl = wrapper.querySelector(".schedule-info");
const closeBtn = wrapper.querySelector(".close-btn") as HTMLButtonElement;
closeBtn.addEventListener("click", (e) => {
e.stopPropagation();
this.dispatchEvent(new CustomEvent("close"));
});
return root;
}
#formatSchedule(dateStr: string): string {
if (!dateStr) return "Not scheduled";
try {
const date = new Date(dateStr);
if (isNaN(date.getTime())) return dateStr;
return date.toLocaleDateString("en-US", { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" });
} catch {
return dateStr;
}
}
#escapeHtml(text: string): string {
const div = document.createElement("div");
div.textContent = text;
return div.innerHTML;
}
static override fromData(data: Record<string, any>): FolkSocialNewsletter {
const shape = FolkShape.fromData(data) as FolkSocialNewsletter;
if (data.newsletterId) shape.newsletterId = data.newsletterId;
if (data.subject) shape.subject = data.subject;
if (data.listName !== undefined) shape.listName = data.listName;
if (typeof data.subscriberCount === "number") shape.subscriberCount = data.subscriberCount;
if (data.status) shape.status = data.status;
if (data.scheduledAt !== undefined) shape.scheduledAt = data.scheduledAt;
if (data.bodyPreview !== undefined) shape.bodyPreview = data.bodyPreview;
if (data.spaceSlug) shape.spaceSlug = data.spaceSlug;
return shape;
}
override applyData(data: Record<string, any>): void {
super.applyData(data);
if (data.newsletterId !== undefined && data.newsletterId !== this.newsletterId) this.newsletterId = data.newsletterId;
if (data.subject !== undefined && data.subject !== this.subject) this.subject = data.subject;
if (data.listName !== undefined && data.listName !== this.listName) this.listName = data.listName;
if (typeof data.subscriberCount === "number" && data.subscriberCount !== this.subscriberCount) this.subscriberCount = data.subscriberCount;
if (data.status !== undefined && data.status !== this.status) this.status = data.status;
if (data.scheduledAt !== undefined && data.scheduledAt !== this.scheduledAt) this.scheduledAt = data.scheduledAt;
if (data.bodyPreview !== undefined && data.bodyPreview !== this.bodyPreview) this.bodyPreview = data.bodyPreview;
if (data.spaceSlug !== undefined && data.spaceSlug !== this.spaceSlug) this.spaceSlug = data.spaceSlug;
}
override toJSON() {
return {
...super.toJSON(),
type: "folk-social-newsletter",
newsletterId: this.#newsletterId,
subject: this.#subject,
listName: this.#listName,
subscriberCount: this.#subscriberCount,
status: this.#status,
scheduledAt: this.#scheduledAt,
bodyPreview: this.#bodyPreview,
spaceSlug: this.#spaceSlug,
};
}
}