init
This commit is contained in:
154
announcements/desktop/DesktopAnnouncementsFeed.js
Normal file
154
announcements/desktop/DesktopAnnouncementsFeed.js
Normal file
@@ -0,0 +1,154 @@
|
||||
class DesktopAnnouncementsFeed extends Shadow {
|
||||
constructor(announcements, selectedId, searchText, onSelect, onSearch) {
|
||||
super()
|
||||
this.announcements = announcements
|
||||
this.selectedId = selectedId
|
||||
this.searchText = searchText
|
||||
this.onSelect = onSelect
|
||||
this.onSearch = onSearch
|
||||
}
|
||||
|
||||
render() {
|
||||
VStack(() => {
|
||||
// Search bar
|
||||
HStack(() => {
|
||||
p("🔍")
|
||||
.margin(0).fontSize(0.78, em).opacity(0.38).flexShrink(0)
|
||||
input()
|
||||
.attr({ type: "text", placeholder: "Search announcements…", value: this.searchText })
|
||||
.flex(1).border("none").outline("none").background("transparent")
|
||||
.color("var(--headertext)").fontSize(0.85, em)
|
||||
.onInput((e) => this.onSearch(e.target.value))
|
||||
})
|
||||
.gap(0.5, em).paddingHorizontal(0.85, em).paddingVertical(0.58, em)
|
||||
.background("var(--darkaccent)").border("1px solid var(--divider)")
|
||||
.borderRadius(0.5, em).alignItems("center")
|
||||
.marginHorizontal(0.75, em).marginTop(0.75, em).marginBottom(0.5, em)
|
||||
.flexShrink(0)
|
||||
|
||||
// Count
|
||||
p(`${this.announcements.length} announcement${this.announcements.length !== 1 ? "s" : ""}`)
|
||||
.margin(0).paddingHorizontal(1.1, em).paddingBottom(0.35, em)
|
||||
.fontSize(0.68, em).fontWeight("700").letterSpacing("0.05em")
|
||||
.color("var(--headertext)").opacity(0.32)
|
||||
.flexShrink(0)
|
||||
|
||||
// List
|
||||
VStack(() => {
|
||||
if (this.announcements.length === 0) {
|
||||
VStack(() => {
|
||||
p(this.searchText ? "No results" : "No announcements yet")
|
||||
.margin(0).fontSize(0.85, em)
|
||||
.color("var(--headertext)").opacity(0.32).textAlign("center")
|
||||
})
|
||||
.flex(1).justifyContent("center").alignItems("center").paddingTop(3, em)
|
||||
} else {
|
||||
this.announcements.forEach(ann => this.renderRow(ann))
|
||||
}
|
||||
})
|
||||
.flex(1).overflowY("auto").gap(0).paddingBottom(0.75, em)
|
||||
})
|
||||
.height(100, pct).width(100, pct).boxSizing("border-box")
|
||||
}
|
||||
|
||||
renderRow(ann) {
|
||||
const isSelected = ann.id === this.selectedId
|
||||
const isEdited = ann.created !== ann.updated_at
|
||||
const isMe = ann.creator_id === global.profile.id
|
||||
const author = this.getAuthor(ann.creator_id)
|
||||
const authorName = isMe ? "You" : author
|
||||
const initials = this.getInitials(ann.creator_id)
|
||||
|
||||
VStack(() => {
|
||||
HStack(() => {
|
||||
// Avatar
|
||||
VStack(() => {
|
||||
p(initials)
|
||||
.margin(0).fontSize(0.6, em).fontWeight("700")
|
||||
.color("white").lineHeight("1")
|
||||
})
|
||||
.width(2.1, em).height(2.1, em).borderRadius(50, pct)
|
||||
.background(this.avatarColor(author))
|
||||
.justifyContent("center").alignItems("center").flexShrink(0)
|
||||
|
||||
VStack(() => {
|
||||
// Author + date
|
||||
HStack(() => {
|
||||
p(authorName)
|
||||
.margin(0).fontSize(0.8, em).fontWeight("600")
|
||||
.color(isMe ? "var(--quillred)" : "var(--headertext)")
|
||||
.flex(1).minWidth(0)
|
||||
.overflow("hidden").whiteSpace("nowrap").textOverflow("ellipsis")
|
||||
p(this.relativeDate(ann.created))
|
||||
.margin(0).fontSize(0.68, em)
|
||||
.color("var(--headertext)").opacity(0.38).flexShrink(0)
|
||||
})
|
||||
.alignItems("center").gap(0.4, em)
|
||||
|
||||
// Preview text
|
||||
p(ann.text)
|
||||
.margin(0).marginTop(0.12, em).fontSize(0.78, em)
|
||||
.color("var(--headertext)")
|
||||
.opacity(isSelected ? 0.75 : 0.45)
|
||||
.overflow("hidden").whiteSpace("nowrap").textOverflow("ellipsis")
|
||||
.lineHeight("1.4")
|
||||
})
|
||||
.flex(1).minWidth(0)
|
||||
})
|
||||
.gap(0.62, em).alignItems("flex-start")
|
||||
|
||||
if (isEdited) {
|
||||
p("edited")
|
||||
.margin(0).marginTop(0.3, em)
|
||||
.fontSize(0.62, em).fontStyle("italic")
|
||||
.color("var(--headertext)").opacity(0.28)
|
||||
.alignSelf("flex-end")
|
||||
}
|
||||
})
|
||||
.paddingHorizontal(0.85, em).paddingVertical(0.7, em)
|
||||
.marginHorizontal(0.4, em)
|
||||
.borderRadius(0.55, em)
|
||||
.background(isSelected ? "var(--accent)" : "transparent")
|
||||
.cursor("pointer")
|
||||
.width("calc(100% - 0.8em)").boxSizing("border-box")
|
||||
.onClick((done) => {
|
||||
if(done) this.onSelect(ann.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()
|
||||
}
|
||||
|
||||
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(DesktopAnnouncementsFeed)
|
||||
420
announcements/desktop/DesktopAnnouncementsViewer.js
Normal file
420
announcements/desktop/DesktopAnnouncementsViewer.js
Normal 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)
|
||||
178
announcements/desktop/announcements.js
Normal file
178
announcements/desktop/announcements.js
Normal file
@@ -0,0 +1,178 @@
|
||||
import "./DesktopAnnouncementsFeed.js"
|
||||
import "./DesktopAnnouncementsViewer.js"
|
||||
import server from "/@server/server.js"
|
||||
|
||||
css(`
|
||||
announcements- {
|
||||
font-family: 'Arial';
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
announcements- input::placeholder {
|
||||
color: var(--headertext);
|
||||
opacity: 0.35;
|
||||
}
|
||||
announcements- textarea::placeholder {
|
||||
color: var(--headertext);
|
||||
opacity: 0.3;
|
||||
}
|
||||
`)
|
||||
|
||||
class Announcements extends Shadow {
|
||||
announcements = []
|
||||
selectedId = null
|
||||
searchText = ""
|
||||
|
||||
get canPost() { return global.currentNetwork.permissions.includes("announcements.add") }
|
||||
get canEdit() { return global.currentNetwork.permissions.includes("announcements.edit") }
|
||||
get canDelete() { return global.currentNetwork.permissions.includes("announcements.delete") }
|
||||
|
||||
get filtered() {
|
||||
if (!this.searchText) return this.announcements
|
||||
const q = this.searchText.toLowerCase()
|
||||
return this.announcements.filter(a => a.text.toLowerCase().includes(q))
|
||||
}
|
||||
|
||||
get selectedAnnouncement() {
|
||||
return this.announcements.find(a => a.id === this.selectedId) || null
|
||||
}
|
||||
|
||||
render() {
|
||||
HStack(() => {
|
||||
// ── Left: feed list ───────────────────────────────────────
|
||||
VStack(() => {
|
||||
// Toolbar
|
||||
HStack(() => {
|
||||
p("Announcements")
|
||||
.fontFamily("Laandbrau")
|
||||
.margin(0).fontSize(1.8, em).fontWeight("700").color("var(--headertext)")
|
||||
.flex(1)
|
||||
|
||||
if (this.canPost) {
|
||||
button("+ New")
|
||||
.paddingHorizontal(0.85, em).paddingVertical(0.42, em)
|
||||
.background("var(--quillred)").border("none")
|
||||
.borderRadius(0.45, em).color("white")
|
||||
.fontSize(0.82, em).fontWeight("600").cursor("pointer")
|
||||
.onClick((done) => {
|
||||
// Tell the viewer to open compose mode
|
||||
if(done) {
|
||||
this.selectedId = null
|
||||
this._openCompose = true
|
||||
this.rerender()
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
.paddingHorizontal(1.1, em).paddingTop(1.1, em).paddingBottom(0.35, em)
|
||||
.alignItems("center").flexShrink(0)
|
||||
|
||||
DesktopAnnouncementsFeed(
|
||||
this.filtered,
|
||||
this.selectedId,
|
||||
this.searchText,
|
||||
(id) => {
|
||||
this.selectedId = id
|
||||
this._openCompose = false
|
||||
this.rerender()
|
||||
},
|
||||
(text) => {
|
||||
this.searchText = text
|
||||
this.rerender()
|
||||
}
|
||||
)
|
||||
.flex(1).minHeight(0).overflow("hidden")
|
||||
})
|
||||
.width(320, px).minWidth(280, px)
|
||||
.height(100, pct)
|
||||
.borderRight("1px solid var(--divider)")
|
||||
.background("var(--main)")
|
||||
.flexShrink(0).overflow("hidden")
|
||||
|
||||
// ── Right: viewer / composer ──────────────────────────────
|
||||
VStack(() => {
|
||||
const viewer = DesktopAnnouncementsViewer(
|
||||
this.selectedAnnouncement,
|
||||
this.canPost,
|
||||
this.canEdit,
|
||||
this.canDelete,
|
||||
// onNew
|
||||
(ann) => {
|
||||
this.announcements.unshift(ann)
|
||||
if (global.currentNetwork.data?.announcements) {
|
||||
global.currentNetwork.data.announcements.unshift(ann)
|
||||
}
|
||||
this.selectedId = ann.id
|
||||
this._openCompose = false
|
||||
this.rerender()
|
||||
window.dispatchEvent(new CustomEvent("new-announcement", { detail: { announcement: ann } }))
|
||||
},
|
||||
// onEdited
|
||||
(ann) => {
|
||||
const i = this.announcements.findIndex(a => a.id === ann.id)
|
||||
if (i !== -1) this.announcements[i] = ann
|
||||
if (global.currentNetwork.data?.announcements) {
|
||||
const gi = global.currentNetwork.data.announcements.findIndex(a => a.id === ann.id)
|
||||
if (gi !== -1) global.currentNetwork.data.announcements[gi] = ann
|
||||
}
|
||||
this.selectedId = ann.id
|
||||
this.rerender()
|
||||
window.dispatchEvent(new CustomEvent("edited-announcement", { detail: ann }))
|
||||
},
|
||||
// onDeleted
|
||||
(id) => {
|
||||
this.announcements = this.announcements.filter(a => a.id !== id)
|
||||
if (global.currentNetwork.data?.announcements) {
|
||||
global.currentNetwork.data.announcements = global.currentNetwork.data.announcements.filter(a => a.id !== id)
|
||||
}
|
||||
this.selectedId = null
|
||||
this.rerender()
|
||||
window.dispatchEvent(new CustomEvent("deleted-announcement", { detail: { id } }))
|
||||
}
|
||||
)
|
||||
|
||||
// If the "New" button was pressed, trigger compose mode immediately
|
||||
if (this._openCompose && viewer) {
|
||||
viewer.composing = true
|
||||
this._openCompose = false
|
||||
}
|
||||
})
|
||||
.flex(1).height(100, pct).background("var(--main)").overflow("hidden")
|
||||
})
|
||||
.height(100, pct).width(100, pct).overflow("hidden")
|
||||
.onAppear(async () => {
|
||||
const res = await server.getAnnouncements(global.currentNetwork.id)
|
||||
const data = Array.isArray(res) ? res : (res?.data || [])
|
||||
if (data.length !== this.announcements.length) {
|
||||
this.announcements = data.sort((a, b) => new Date(b.created) - new Date(a.created))
|
||||
if (global.currentNetwork.data) {
|
||||
global.currentNetwork.data.announcements = this.announcements
|
||||
}
|
||||
if (!this.selectedId && this.announcements.length) {
|
||||
this.selectedId = this.announcements[0].id
|
||||
}
|
||||
this.rerender()
|
||||
}
|
||||
})
|
||||
.onEvent("new-announcement", (e) => {
|
||||
const ann = e.detail.announcement
|
||||
if (!this.announcements.find(a => a.id === ann.id)) {
|
||||
this.announcements.unshift(ann)
|
||||
this.rerender()
|
||||
}
|
||||
})
|
||||
.onEvent("deleted-announcement", (e) => {
|
||||
const id = e.detail.id
|
||||
if (this.selectedId === id) this.selectedId = null
|
||||
this.announcements = this.announcements.filter(a => a.id !== id)
|
||||
this.rerender()
|
||||
})
|
||||
.onEvent("edited-announcement", (e) => {
|
||||
const ann = e.detail
|
||||
const i = this.announcements.findIndex(a => a.id === ann.id)
|
||||
if (i !== -1) { this.announcements[i] = ann; this.rerender() }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
register(Announcements)
|
||||
Reference in New Issue
Block a user