This commit is contained in:
metacryst
2026-04-28 20:05:00 -05:00
commit 0d6c7683ff
123 changed files with 20922 additions and 0 deletions

View File

@@ -0,0 +1,420 @@
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)