import server from "/@server/server.js" class DesktopAnnouncementsViewer extends Shadow { constructor(announcement, canPost, canEdit, canDelete, onNew, onEdited, onDeleted) { super() this.announcement = announcement this.canPost = canPost this.canEdit = canEdit this.canDelete = canDelete this.onNew = onNew this.onEdited = onEdited this.onDeleted = onDeleted this.composing = false this.editingId = null this.draftText = "" this.sending = false this.errorMsg = "" this.confirmDeleteId = null } render() { VStack(() => { if (this.composing || this.editingId) { this.renderCompose() } else if (this.announcement) { this.renderViewer() } else { this.renderEmpty() } }) .height(100, pct).width(100, pct).overflow("hidden") } renderEmpty() { VStack(() => { // Stats strip VStack(() => { const members = global.currentNetwork.data?.members || [] const allAnnouncements = global.currentNetwork.data?.announcements || [] const thisWeek = allAnnouncements.filter(a => { return (Date.now() - new Date(a.created)) < 7 * 86400000 }).length HStack(() => { this.statCard("πŸ“£", "Total", allAnnouncements.length) this.statCard("πŸ“…", "This week", thisWeek) this.statCard("πŸ‘₯", "Authors", new Set(allAnnouncements.map(a => a.creator_id)).size) }) .gap(0.85, em) }) .paddingHorizontal(2, em).paddingTop(2, em).paddingBottom(1.5, em) .borderBottom("1px solid var(--divider)").flexShrink(0) VStack(() => { p("πŸ“‹") .margin(0).fontSize(2.2, em).opacity(0.15) p("Select an announcement to read it") .margin(0).marginTop(0.65, em).fontSize(0.9, em) .color("var(--headertext)").opacity(0.3).textAlign("center") if (this.canPost) { button("+ Write Announcement") .marginTop(1.25, em) .paddingHorizontal(1.25, em).paddingVertical(0.55, em) .background("var(--quillred)").border("none") .borderRadius(0.5, em).color("white") .fontSize(0.88, em).fontWeight("600").cursor("pointer") .onClick((done) => {if(!done) return; this.composing = true this.draftText = "" this.rerender() }) } }) .flex(1).justifyContent("center").alignItems("center") }) .height(100, pct).overflow("hidden") } renderViewer() { const ann = this.announcement const isMe = ann.creator_id === global.profile.id const isEdited = ann.created !== ann.updated_at const author = this.getAuthor(ann.creator_id) const authorDisplay = isMe ? "You" : author VStack(() => { // ── Header ──────────────────────────────────────────────── HStack(() => { // Big avatar VStack(() => { p(this.getInitials(ann.creator_id)) .margin(0).fontSize(0.95, em).fontWeight("700") .color("white").lineHeight("1") }) .width(3.2, em).height(3.2, em).borderRadius(50, pct) .background(this.avatarColor(author)) .justifyContent("center").alignItems("center").flexShrink(0) VStack(() => { p(authorDisplay) .margin(0).fontSize(1, em).fontWeight("700") .color(isMe ? "var(--quillred)" : "var(--headertext)") HStack(() => { p(this.formatDateTime(ann.created)) .margin(0).fontSize(0.75, em) .color("var(--headertext)").opacity(0.4) if (isEdited) { p("Β· edited " + this.relativeDate(ann.updated_at)) .margin(0).fontSize(0.72, em).fontStyle("italic") .color("var(--headertext)").opacity(0.3) } }) .gap(0.45, em).alignItems("center").marginTop(0.15, em) }) .flex(1) // Actions if ((isMe && this.canEdit) || (isMe && this.canDelete)) { HStack(() => { if (isMe && this.canEdit) { button("Edit") .paddingHorizontal(0.85, em).paddingVertical(0.38, em) .background("transparent").border("1px solid var(--divider)") .borderRadius(0.45, em).color("var(--headertext)") .fontSize(0.8, em).cursor("pointer").opacity(0.65) .onClick((done) => {if(!done) return; this.editingId = ann.id this.draftText = ann.text this.rerender() }) } if (isMe && this.canDelete) { if (this.confirmDeleteId === ann.id) { button("Confirm delete") .paddingHorizontal(0.85, em).paddingVertical(0.38, em) .background("rgba(239,68,68,0.1)").border("1px solid rgba(239,68,68,0.3)") .borderRadius(0.45, em).color("#ef4444") .fontSize(0.8, em).fontWeight("600").cursor("pointer") .onClick((done) => {if (!done) return; this.deleteAnnouncement(ann.id)}) } else { button("Delete") .paddingHorizontal(0.85, em).paddingVertical(0.38, em) .background("transparent").border("1px solid var(--divider)") .borderRadius(0.45, em).color("var(--headertext)") .fontSize(0.8, em).cursor("pointer").opacity(0.5) .onClick((done) => {if(!done) return; this.confirmDeleteId = ann.id; this.rerender() }) } } }) .gap(0.45, em).alignItems("center") } }) .gap(0.9, em).alignItems("flex-start") .paddingHorizontal(2, em).paddingTop(1.75, em).paddingBottom(1.35, em) .borderBottom("1px solid var(--divider)").flexShrink(0) // ── Body ────────────────────────────────────────────────── VStack(() => { p(ann.text) .margin(0) .fontSize(1, em) .lineHeight("1.75") .color("var(--headertext)") .whiteSpace("pre-wrap") .wordBreak("break-word") }) .paddingHorizontal(2, em).paddingVertical(1.75, em) .flex(1).overflowY("auto") // ── Footer: compose new ─────────────────────────────────── if (this.canPost) { HStack(() => { button("+ New Announcement") .paddingHorizontal(1.1, em).paddingVertical(0.52, em) .background("var(--quillred)").border("none") .borderRadius(0.5, em).color("white") .fontSize(0.85, em).fontWeight("600").cursor("pointer") .flexShrink(0) .onClick((done) => {if(!done) return; this.composing = true this.draftText = "" this.rerender() }) }) .paddingHorizontal(2, em).paddingVertical(1, em) .borderTop("1px solid var(--divider)").flexShrink(0) .justifyContent("flex-end") } }) .height(100, pct).overflow("hidden") } renderCompose() { const isEdit = !!this.editingId VStack(() => { // Header HStack(() => { VStack(() => { p(isEdit ? "Edit Announcement" : "New Announcement") .margin(0).fontSize(1.05, em).fontWeight("700").color("var(--headertext)") p(isEdit ? "Update your announcement below" : "Write something to share with the network") .margin(0).marginTop(0.15, em).fontSize(0.75, em) .color("var(--headertext)").opacity(0.4) }) .flex(1) button("βœ•") .border("none").background("transparent") .color("var(--headertext)").opacity(0.4) .fontSize(0.85, em).cursor("pointer").padding(0.3, em) .borderRadius(0.35, em) .onClick((done) => {if(!done) return; this.composing = false this.editingId = null this.draftText = "" this.errorMsg = "" this.rerender() }) }) .paddingHorizontal(2, em).paddingTop(1.75, em).paddingBottom(1.25, em) .borderBottom("1px solid var(--divider)").alignItems("flex-start").flexShrink(0) // Compose area VStack(() => { // Author row HStack(() => { VStack(() => { p(this.getInitials(global.profile.id)) .margin(0).fontSize(0.72, em).fontWeight("700") .color("white").lineHeight("1") }) .width(2.2, em).height(2.2, em).borderRadius(50, pct) .background(this.avatarColor(this.getAuthor(global.profile.id))) .justifyContent("center").alignItems("center").flexShrink(0) p(this.getAuthor(global.profile.id)) .margin(0).fontSize(0.88, em).fontWeight("600") .color("var(--quillred)") }) .gap(0.65, em).alignItems("center").marginBottom(1.1, em) // Text area textarea(this.draftText) .attr({ placeholder: "What would you like to announce?", rows: 10, id: "compose-textarea" }) .width(100, pct).boxSizing("border-box") .padding(1, em) .background("var(--darkaccent)") .border("1px solid var(--divider)") .borderRadius(0.55, em) .color("var(--headertext)") .fontSize(0.95, em).lineHeight("1.7") .outline("none").resize("none") .fontFamily("Arial") .onInput((e) => { this.draftText = e.target.value }) // Char count HStack(() => { if (this.errorMsg) { p(this.errorMsg) .margin(0).fontSize(0.75, em).color("#ef4444").fontWeight("500") } HStack(() => {}).flex(1) p(`${this.draftText.length} chars`) .margin(0).fontSize(0.72, em) .color("var(--headertext)").opacity(0.3) }) .alignItems("center").marginTop(0.5, em) }) .paddingHorizontal(2, em).paddingTop(1.5, em).flex(1).overflowY("auto") // Actions HStack(() => { button("Cancel") .paddingHorizontal(1.1, em).paddingVertical(0.55, em) .background("transparent").border("1px solid var(--divider)") .borderRadius(0.5, em).color("var(--headertext)") .fontSize(0.88, em).cursor("pointer").opacity(0.6) .onClick((done) => {if(!done) return; this.composing = false this.editingId = null this.draftText = "" this.errorMsg = "" this.rerender() }) button(this.sending ? "Posting…" : (isEdit ? "Save Changes" : "Post Announcement")) .paddingHorizontal(1.25, em).paddingVertical(0.55, em) .background("var(--quillred)").border("none") .borderRadius(0.5, em).color("white") .fontSize(0.88, em).fontWeight("600") .cursor(this.sending ? "default" : "pointer") .opacity(this.sending ? 0.6 : 1) .onClick((done) => {if(!done) return; isEdit ? this.submitEdit() : this.submitNew()}) }) .gap(0.65, em).justifyContent("flex-end") .paddingHorizontal(2, em).paddingVertical(1.1, em) .borderTop("1px solid var(--divider)").flexShrink(0) }) .height(100, pct).overflow("hidden") } statCard(icon, label, value) { VStack(() => { HStack(() => { p(icon).margin(0).fontSize(1.1, em).lineHeight("1").flexShrink(0) p(String(value)) .margin(0).fontSize(1.35, em).fontWeight("800") .color("var(--headertext)").lineHeight("1") }) .gap(0.45, em).alignItems("center") p(label) .margin(0).marginTop(0.35, em).fontSize(0.72, em) .color("var(--headertext)").opacity(0.4).fontWeight("500") }) .flex(1) .padding(0.9, em) .background("var(--darkaccent)") .border("1px solid var(--divider)") .borderRadius(0.55, em) .alignItems("flex-start") } async submitNew() { if (this.sending) return const text = this.draftText.trim() if (!text) { this.errorMsg = "Announcement can't be empty."; this.rerender(); return } this.sending = true this.errorMsg = "" this.rerender() const result = await server.addAnnouncement(text, global.currentNetwork.id, global.profile.id) this.sending = false if (result?.error) { this.errorMsg = result.error this.rerender() } else { this.composing = false this.draftText = "" this.onNew(result.announcement) } } async submitEdit() { if (this.sending) return const text = this.draftText.trim() if (!text) { this.errorMsg = "Announcement can't be empty."; this.rerender(); return } this.sending = true this.errorMsg = "" this.rerender() const result = await server.editAnnouncement({ id: this.editingId, text }, global.profile.id) this.sending = false if (result?.error) { this.errorMsg = result.error this.rerender() } else { this.editingId = null this.draftText = "" this.onEdited({ ...this.announcement, text, updated_at: new Date().toISOString() }) } } async deleteAnnouncement(id) { const result = await server.deleteAnnouncement(id, global.profile.id) if (!result?.error) { this.confirmDeleteId = null this.onDeleted(id) } } getAuthor(creatorId) { const members = global.currentNetwork.data?.members || [] const m = members.find(m => m.id === creatorId) return m ? `${m.first_name} ${m.last_name}` : "Unknown" } getInitials(creatorId) { const members = global.currentNetwork.data?.members || [] const m = members.find(m => m.id === creatorId) if (!m) return "?" return [m.first_name?.[0], m.last_name?.[0]].filter(Boolean).join("").toUpperCase() } formatDateTime(raw) { const d = new Date(raw) return d.toLocaleDateString([], { month: "long", day: "numeric", year: "numeric" }) + " at " + d.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" }) } relativeDate(raw) { const d = new Date(raw) const diff = Date.now() - d const mins = Math.floor(diff / 60000) const hours = Math.floor(diff / 3600000) const days = Math.floor(diff / 86400000) if (mins < 1) return "just now" if (mins < 60) return `${mins}m ago` if (hours < 24) return `${hours}h ago` if (days === 1) return "yesterday" if (days < 7) return `${days}d ago` return d.toLocaleDateString([], { month: "short", day: "numeric" }) } avatarColor(name) { const colors = ["#3b82f6", "#ef4444", "#10b981", "#f59e0b", "#8b5cf6", "#ec4899", "#06b6d4", "#84cc16"] let hash = 0 for (let i = 0; i < (name || "").length; i++) hash = name.charCodeAt(i) + ((hash << 5) - hash) return colors[Math.abs(hash) % colors.length] } } register(DesktopAnnouncementsViewer)