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,21 @@
class AnnouncementCard extends Shadow {
announcement
constructor(announcement) {
super()
this.announcement = announcement
}
render() {
VStack(() => {
p(this.announcement.text)
.color("var(--text)")
.marginVertical(3, em)
})
.paddingLeft(2, em)
.borderTop("0.5px solid var(--divider)")
.borderBottom("0.5px solid var(--divider)")
}
}
register(AnnouncementCard, "announcement-card")

150
announcements/Old.js Normal file
View File

@@ -0,0 +1,150 @@
import './Panel.js'
import server from '/@server/serverFunctions.js'
css(`
announcements- {
font-family: 'Bona';
}
announcements- input::placeholder {
font-family: 'Bona Nova';
font-size: 0.9em;
color: var(--accent);
}
input::placeholder {
font-family: Arial;
}
input[type="checkbox"] {
appearance: none; /* remove default style */
-webkit-appearance: none;
width: 1em;
height: 1em;
border: 1px solid var(--accent);
}
input[type="checkbox"]:checked {
background-color: var(--red);
}
`)
class Announcements extends Shadow {
announcements;
constructor() {
super()
this.announcements = global.currentNetwork.data.announcements.sort((a, b) => new Date(b.created) - new Date(a.created));
console.log(this.announcements)
}
render() {
ZStack(() => {
VStack(() => {
Panel(this.announcements)
input("Message", "70%")
.paddingVertical(0.75, em)
.boxSizing("border-box")
.paddingHorizontal(2, em)
.color("var(--text)")
.background("var(--searchbackground)")
.marginBottom(1, em)
.border("0.5px solid var(--accent)")
.outline("none")
.borderRadius(100, px)
.fontFamily("Arial")
.fontSize(1, em)
.onKeyDown(async function(e) {
if (e.key === "Enter") {
const result = await server.addAnnouncement(this.value, global.currentNetwork.id, global.profile.id)
if (result.data.status === 200) {
window.dispatchEvent(new CustomEvent('new-announcement', {
detail: { announcement: result.data.announcement }
}));
} else {
// error
}
this.value = ""
}
})
})
.gap(1, em)
.boxSizing("border-box")
.width(100, pct)
.height(100, pct)
.horizontalAlign("center")
.verticalAlign("end")
.minHeight(0)
})
.backgroundColor("var(--main)")
.boxSizing("border-box")
.paddingVertical(1, em)
.width(100, pct)
.height(100, pct)
.flex("1 1 auto")
.onEvent("new-announcement", this.onNewAnnouncement)
.onEvent("deleted-announcement", this.onDeletedAnnouncement)
.onEvent("edited-announcement", this.onEditedAnnouncement)
}
connectedCallback() {
this.getAnnouncements(global.currentNetwork.id)
}
checkForUpdates(currentAnnouncements, fetchedAnnouncements) {
if (currentAnnouncements.length !== fetchedAnnouncements.length) return true;
const currentMap = new Map(currentAnnouncements.map(ann => [ann.id, ann]));
for (const fetchedAnn of fetchedAnnouncements) {
const currentAnn = currentMap.get(fetchedAnn.id);
// new event added
if (!currentAnn) return true;
// existing event changed
if (currentAnn.updated_at !== fetchedAnn.updated_at) {
return true;
}
}
return false;
}
async getAnnouncements(networkId) {
const fetchedAnnouncements = await server.getAnnouncements(networkId)
if (this.checkForUpdates(this.announcements, fetchedAnnouncements.data)) {
console.log("found updates")
this.announcements = fetchedAnnouncements.data.sort((a, b) => new Date(b.created) - new Date(a.created));
global.currentNetwork.data.announcements = this.announcements
this.rerender()
}
}
onNewAnnouncement = (e) => {
let newAnnouncement = e.detail.announcement;
this.announcements.push(newAnnouncement)
this.announcements.sort((a, b) => new Date(b.created) - new Date(a.created));
this.rerender()
}
onDeletedAnnouncement = (e) => {
let deletedId = e.detail.id
const i = this.announcements.findIndex(ann => ann.id === deletedId)
if (i !== -1) this.announcements.splice(i, 1);
this.rerender()
}
onEditedAnnouncement = (e) => {
let editedAnnouncement = e.detail
const i = this.announcements.findIndex(ann => ann.id === editedPost.id)
if (i !== -1) {
this.announcements.splice(i, 1)
this.announcements.unshift(editedAnnouncement)
}
this.rerender()
}
}
register(Announcements)

150
announcements/Panel.js Normal file
View File

@@ -0,0 +1,150 @@
import "/_/code/components/LoadingCircle.js"
css(`
panel- {
scrollbar-width: none;
-ms-overflow-style: none;
}
panel-::-webkit-scrollbar {
display: none;
width: 0px;
height: 0px;
}
panel-::-webkit-scrollbar-thumb {
background: transparent;
}
panel-::-webkit-scrollbar-track {
background: transparent;
}
`)
class Panel extends Shadow {
announcements = []
isSending = false
constructor(announcements) {
super()
this.announcements = announcements
}
render() {
VStack(() => {
if(this.announcements.length > 0) {
let previousDate = null
for(let i=0; i<this.announcements.length; i++) {
let announcement = this.announcements[i]
const isMe = announcement.creator_id === global.profile.id
const dateParts = this.parseDate(announcement.created);
const { date, time } = dateParts;
if (previousDate !== date) {
previousDate = date;
p(date)
.textAlign("center")
.opacity(0.6)
.fontWeight("bold")
.paddingTop(1, em)
.paddingBottom(0.5, em)
.color("var(--quillred)")
.borderTop(`1px solid var(--${i == 0 ? "transparent" : "divider"})`)
}
VStack(() => {
HStack(() => {
h3(isMe ? "Me" : this.getAuthorName(announcement))
.color(isMe ? "var(--quillred)" : "var(--headertext")
.opacity(0.75)
.margin(0)
h3(`${date} ${time}`)
.opacity(0.5)
.color("var(--headertext)")
.margin(0)
.marginLeft(0.5, em)
.fontSize(1, em)
if (announcement.created !== announcement.updated_at) {
p("(edited)")
.color("var(--headertext)")
.letterSpacing(0.8, "px")
.opacity(0.5)
.fontWeight("bold")
.paddingLeft(0.25, em)
.fontSize(0.9, em)
}
})
.verticalAlign("center")
.marginBottom(0.1, em)
p(announcement.text)
.color("var(--text)")
.marginHorizontal(0.2, em)
.paddingVertical(0.2, em)
.boxSizing("border-box")
})
.marginBottom(0.05, em)
}
} else {
LoadingCircle()
}
})
.gap(1, em)
.fontSize(1.1, em)
.boxSizing("border-box")
.flex("1 1 auto")
.minHeight(0)
.overflowY("auto")
.width(100, pct)
.paddingBottom(2, em)
.paddingHorizontal(4, pct)
.backgroundColor("var(--main)")
.onAppear(async () => {
requestAnimationFrame(() => {
this.scrollTo({ top: 0, behavior: "smooth" });
});
})
}
getAuthorName(announcement) {
const members = global.currentNetwork.data.members;
const creator = members.find(m => m.id === announcement.creator_id);
if (creator) {
return `${creator.first_name} ${creator.last_name}`
} else {
return "No name"
}
}
parseDate(str) {
// Format: YYYY-MM-DDTHH:MM:SS.mmmZ
const match = str.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):\d{2}\.\d+Z$/);
if (!match) return null;
const [, yyyy, mm, dd, hh, min] = match;
// Convert 24h to 12h
const hour24 = parseInt(hh, 10);
const ampm = hour24 >= 12 ? 'pm' : 'am';
const hour12 = hour24 % 12 || 12;
const date = `${mm}/${dd}/${yyyy}`;
const time = `${hour12}:${min}${ampm}`;
return { date, time };
}
formatTime(str) {
const match = str.match(/-(\d+:\d+):\d+.*(am|pm)/i);
if (!match) return null;
const [_, hourMin, ampm] = match;
return hourMin + ampm.toLowerCase();
}
}
register(Panel)

View File

@@ -0,0 +1,273 @@
import server from '/@server/server.js'
import '../components/SearchBar.js'
import "/_/code/components/LoadingCircle.js"
import './AnnouncementCard.js'
css(`
announcements- {
font-family: 'Arial';
scrollbar-width: none;
-ms-overflow-style: none;
}
announcements- h1 {
font-family: 'Bona';
}
announcements- .VStack::-webkit-scrollbar {
display: none;
width: 0px;
height: 0px;
}
announcements- .VStack::-webkit-scrollbar-thumb {
background: transparent;
}
announcements- .VStack::-webkit-scrollbar-track {
background: transparent;
}
`)
class Announcements extends Shadow {
static searchableKeys = ['message', 'author'];
constructor() {
super()
this.announcements = global.currentNetwork.data.announcements.sort((a, b) => new Date(b.created) - new Date(a.created));
this.searchedAnnouncements = [];
this.searchText = "";
}
render() {
ZStack(() => {
VStack(() => {
SearchBar(this.searchText, "90vw")
VStack(() => {
if (global.appRefreshing) {
LoadingCircle()
} else if (!this.announcements || this.announcements == []) {
LoadingCircle()
} else if (this.searchText) {
if (this.searchedAnnouncements.length > 0) {
for (let i = 0; i < this.searchedAnnouncements.length; i++) {
AnnouncementCard(this.searchedAnnouncements[i])
}
} else {
h2("Could not find any announcements with your search criteria.")
.color("var(--divider)")
.fontWeight("bold")
.marginTop(7.5, em)
.marginBottom(0.5, em)
.textAlign("center")
}
} else if (this.announcements.length > 0) {
for (let i = 0; i < this.announcements.length; i++) {
AnnouncementCard(this.announcements[i])
}
} else {
h2("No Announcements")
.color("var(--divider)")
.fontWeight("bold")
.marginTop(7.5, em)
.marginBottom(0.5, em)
.textAlign("center")
}
})
.overflowY("scroll")
.gap(0.75, em)
if(!global.appRefreshing && global.currentNetwork.permissions.includes("announcements.add")) {
HStack(() => {
input("Image Upload", "0px", "0px")
.attr({ name: "image-upload", type: "file" })
.display("none")
.visibility("hidden")
.onChange((e) => {
const file = e.target.files[0]
if (!file) return
this.stagedImage = file
this.stagedImageURL = URL.createObjectURL(file)
this.rerender()
})
div("+")
.width(3, rem)
.height(3, rem)
.borderRadius(50, pct)
.border("1px solid color-mix(in srgb, var(--accent) 60%, transparent)")
.fontSize(2, em)
.transform("rotate(180deg)")
.zIndex(1001)
.display("flex")
.alignItems("center")
.justifyContent("center")
.transition("scale .2s")
.state("touched", function (touched) {
if(touched) {
this.scale("1.5")
this.color("var(--darkaccent)")
this.backgroundColor("var(--divider)")
} else {
this.scale("")
this.color("var(--divider)")
this.backgroundColor("var(--searchbackground)")
}
})
.onTouch(function (start) {
if(start) {
this.attr({touched: "true"})
} else {
this.attr({touched: ""})
}
})
.onClick((done) => {
if(done) {
const inputSelector = this.$('[name="image-upload"]');
inputSelector.click()
}
})
input("Add an Announcement")
.flex("1 1 auto")
.minWidth(0)
.color("var(--text)")
.background("var(--searchbackground)")
.paddingVertical(0, rem)
.fontSize(1, rem)
.paddingHorizontal(1, rem)
.borderRadius(100, px)
.border("1px solid color-mix(in srgb, var(--accent) 60%, transparent)")
.onTouch(function (start) {
if (start) {
this.style.backgroundColor = "var(--accent)"
} else {
setTimeout(() => {
$("appmenu-").display("none")
this.focus()
}, 20)
console.log($("appmenu-"))
this.style.backgroundColor = "var(--searchbackground)"
}
})
.addEventListener("blur", () => {
setTimeout(() => {
$("appmenu-").display("grid")
}, 20)
})
})
.width(100, pct)
.boxSizing("border-box")
.position("absolute")
.paddingHorizontal(1, rem)
.bottom(1, vh)
.gap(0.5, rem)
}
})
.boxSizing("border-box")
.height(100, pct)
.width(100, pct)
.onEvent("announcementsearch", this.onAnnouncementSearch)
.onEvent("new-announcement", this.onNewAnnouncement)
.onEvent("deleted-announcement", this.onDeletedAnnouncement)
.onEvent("edited-announcement", this.onEditedAnnouncement)
})
}
async handleUpload(file) {
try {
const body = new FormData();
body.append('image', file);
const res = await mobileUtil.authFetch(`${config.SERVER}/profile/upload-image`, {
method: "POST",
credentials: "include",
headers: {
"Accept": "application/json"
},
body: body
});
if(res.status === 401) {
return res.status
}
if (!res.ok) return res.status;
const data = await res.json()
global.profile = data.member
console.log(global.profile)
} catch (err) { // Network error / Error reaching server
console.error(err);
}
}
addPhoto() {
console.log("hey")
}
onNewAnnouncement = (e) => {
let newAnnouncement = e.detail.announcement;
this.announcements.push(newAnnouncement)
this.announcements.sort((a, b) => new Date(b.created) - new Date(a.created));
this.rerender()
}
onDeletedAnnouncement = (e) => {
let deletedId = e.detail.id
const i = this.announcements.findIndex(ann => ann.id === deletedId)
if (i !== -1) this.announcements.splice(i, 1);
this.rerender()
}
onEditedAnnouncement = (e) => {
let editedAnnouncement = e.detail
const i = this.announcements.findIndex(ann => ann.id === editedAnnouncement.id)
if (i !== -1) {
this.announcements.splice(i, 1)
this.announcements.unshift(editedAnnouncement)
}
this.rerender()
}
onAnnouncementSearch = (e) => {
let searchText = e.detail.searchText.toLowerCase().trim();
if (!searchText) {
this.searchedAnnouncements = [];
} else {
this.searchedAnnouncements = this.announcements.filter(announcement =>
Announcements.searchableKeys.some(key =>
String(announcement[key]).toLowerCase().includes(searchText)
)
);
}
this.searchText = searchText
this.rerender()
}
async getAnnouncements(networkId) {
const fetchedAnnouncements = await server.getAnnouncements(networkId)
if (this.checkForUpdates(this.announcements, fetchedAnnouncements.data)) {
this.announcements = fetchedAnnouncements.data.sort((a, b) => new Date(b.created) - new Date(a.created));
global.currentNetwork.data.announcements = this.announcements
this.rerender()
}
}
connectedCallback() {
this.getAnnouncements(global.currentNetwork.id)
}
checkForUpdates(currentAnnouncements, fetchedAnnouncements) {
if (currentAnnouncements.length !== fetchedAnnouncements.length) return true;
const currentMap = new Map(currentAnnouncements.map(ann => [ann.id, ann]));
for (const fetchedAnn of fetchedAnnouncements) {
const currentAnn = currentMap.get(fetchedAnn.id);
if (!currentAnn) return true;
if (currentAnn.updated_at !== fetchedAnn.updated_at) return true;
}
return false;
}
}
register(Announcements)

View 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)

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)

View 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)

View File

@@ -0,0 +1,6 @@
<svg width="145" height="108" viewBox="0 0 145 108" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M94.7998 1.41992C95.3518 0.800579 96.2238 0.588052 96.9971 0.87793L97.0049 0.880859C97.7807 1.1815 98.2832 1.92754 98.2832 2.74902V2.75293L97.8105 89.9863V89.9922C97.8008 90.7646 97.3472 91.4731 96.6279 91.7939H96.627C96.3712 91.9076 96.0977 91.9678 95.8193 91.9678C95.3977 91.9677 94.9804 91.835 94.6338 91.5791L94.4902 91.4619C80.9575 79.3572 67.4789 72.082 53.2793 69.1533V69.1523C53.1181 69.1217 52.9787 69.0681 52.8623 69.0127H45.1064L54.0742 104.412L54.0752 104.414C54.2388 105.068 54.0657 105.759 53.6084 106.255L53.6074 106.254C53.224 106.674 52.689 106.896 52.1484 106.896C52.0382 106.896 51.9236 106.887 51.8125 106.868L51.8076 106.867L35.9531 104.1V104.099C35.1845 103.969 34.5656 103.397 34.3701 102.65L25.4648 69.0186H21.8037C20.7053 69.0185 19.8125 68.1258 19.8125 67.0273V62.9902C18.6413 63.2528 17.4362 63.3877 16.209 63.3877C7.74514 63.3877 0.75 57.022 0.75 49.0791C0.750129 41.1363 7.74522 34.7715 16.209 34.7715C17.438 34.7715 18.6432 34.9091 19.8125 35.1699V29.7266C19.8125 28.6282 20.7053 27.7345 21.8037 27.7344L54.249 27.7227C54.3928 27.7227 54.5276 27.7403 54.6533 27.7676C70.4266 22.6561 83.9135 13.813 94.7959 1.42383L94.7988 1.41992H94.7998ZM37.8945 100.4L49.4688 102.421L41.0088 69.0176H29.583L37.8945 100.4ZM94.2715 7.85059C83.6492 18.7246 70.8894 26.6414 56.2441 31.4492L56.2393 65.7441C69.2296 68.8183 81.5908 75.3647 93.8477 85.6455L94.2715 7.85059ZM23.7949 65.042H52.2568V32.6523C51.4474 32.7815 50.6525 32.3986 50.2412 31.7109H23.7949V65.042ZM16.208 38.7471C9.81156 38.7471 4.73252 43.4502 4.73242 49.0781C4.73242 54.7061 9.81136 59.4092 16.208 59.4092C17.4559 59.4092 18.6614 59.2301 19.8115 58.8818V39.2695C18.6609 38.9253 17.4552 38.7471 16.208 38.7471Z" fill="black" stroke="black" stroke-width="1.5"/>
<path d="M134.317 17.4297C135.222 16.9839 136.334 17.2863 136.881 18.1611C137.466 19.0977 137.171 20.3199 136.252 20.9004L136.249 20.9023L118.457 32.0215L118.458 32.0225C118.13 32.2314 117.758 32.3271 117.401 32.3271C116.743 32.3271 116.092 31.9987 115.713 31.3955L115.711 31.3936C115.129 30.4614 115.411 29.2354 116.344 28.6523L134.14 17.5283L134.317 17.4297Z" fill="black" stroke="black" stroke-width="1.5"/>
<path d="M142.259 44.5488C143.345 44.5491 144.24 45.4224 144.25 46.5215C144.26 47.6228 143.373 48.5156 142.277 48.5254H142.276L121.196 48.6826H121.179C120.084 48.6825 119.197 47.8067 119.188 46.71C119.178 45.6087 120.065 44.7158 121.16 44.7061H121.161L142.24 44.5488H142.259Z" fill="black" stroke="black" stroke-width="1.5"/>
<path d="M116.345 60.7305C116.984 59.9179 118.126 59.7411 118.977 60.2812L119.143 60.3984L119.144 60.3994L136.321 73.957L136.476 74.0908C137.203 74.7933 137.289 75.9454 136.653 76.7539L136.652 76.7529C136.262 77.2507 135.678 77.5127 135.089 77.5127C134.655 77.5127 134.223 77.3758 133.854 77.085V77.084L116.676 63.5273V63.5264C115.81 62.8445 115.667 61.5924 116.345 60.7305Z" fill="black" stroke="black" stroke-width="1.5"/>
</svg>

After

Width:  |  Height:  |  Size: 2.9 KiB

View File

@@ -0,0 +1,6 @@
<svg width="145" height="108" viewBox="0 0 145 108" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M94.7998 1.41992C95.3518 0.800579 96.2238 0.588052 96.9971 0.87793L97.0049 0.880859C97.7807 1.1815 98.2832 1.92754 98.2832 2.74902V2.75293L97.8105 89.9863V89.9922C97.8008 90.7646 97.3472 91.4731 96.6279 91.7939H96.627C96.3712 91.9076 96.0977 91.9678 95.8193 91.9678C95.3977 91.9677 94.9804 91.835 94.6338 91.5791L94.4902 91.4619C80.9575 79.3572 67.4789 72.082 53.2793 69.1533V69.1523C53.1181 69.1217 52.9787 69.0681 52.8623 69.0127H45.1064L54.0742 104.412L54.0752 104.414C54.2388 105.068 54.0657 105.759 53.6084 106.255L53.6074 106.254C53.224 106.674 52.689 106.896 52.1484 106.896C52.0382 106.896 51.9236 106.887 51.8125 106.868L51.8076 106.867L35.9531 104.1V104.099C35.1845 103.969 34.5656 103.397 34.3701 102.65L25.4648 69.0186H21.8037C20.7053 69.0185 19.8125 68.1258 19.8125 67.0273V62.9902C18.6413 63.2528 17.4362 63.3877 16.209 63.3877C7.74514 63.3877 0.75 57.022 0.75 49.0791C0.750129 41.1363 7.74522 34.7715 16.209 34.7715C17.438 34.7715 18.6432 34.9091 19.8125 35.1699V29.7266C19.8125 28.6282 20.7053 27.7345 21.8037 27.7344L54.249 27.7227C54.3928 27.7227 54.5276 27.7403 54.6533 27.7676C70.4266 22.6561 83.9135 13.813 94.7959 1.42383L94.7988 1.41992H94.7998ZM37.8945 100.4L49.4688 102.421L41.0088 69.0176H29.583L37.8945 100.4ZM94.2715 7.85059C83.6492 18.7246 70.8894 26.6414 56.2441 31.4492L56.2393 65.7441C69.2296 68.8183 81.5908 75.3647 93.8477 85.6455L94.2715 7.85059ZM23.7949 65.042H52.2568V32.6523C51.4474 32.7815 50.6525 32.3986 50.2412 31.7109H23.7949V65.042ZM16.208 38.7471C9.81156 38.7471 4.73252 43.4502 4.73242 49.0781C4.73242 54.7061 9.81136 59.4092 16.208 59.4092C17.4559 59.4092 18.6614 59.2301 19.8115 58.8818V39.2695C18.6609 38.9253 17.4552 38.7471 16.208 38.7471Z" fill="#FEE9C9" stroke="#FEE9C9" stroke-width="1.5"/>
<path d="M134.317 17.4297C135.222 16.9839 136.334 17.2863 136.881 18.1611C137.466 19.0977 137.171 20.3199 136.252 20.9004L136.249 20.9023L118.457 32.0215L118.458 32.0225C118.13 32.2314 117.758 32.3271 117.401 32.3271C116.743 32.3271 116.092 31.9987 115.713 31.3955L115.711 31.3936C115.129 30.4614 115.411 29.2354 116.344 28.6523L134.14 17.5283L134.317 17.4297Z" fill="#FEE9C9" stroke="#FEE9C9" stroke-width="1.5"/>
<path d="M142.259 44.5488C143.345 44.5491 144.24 45.4224 144.25 46.5215C144.26 47.6228 143.373 48.5156 142.277 48.5254H142.276L121.196 48.6826H121.179C120.084 48.6825 119.197 47.8067 119.188 46.71C119.178 45.6087 120.065 44.7158 121.16 44.7061H121.161L142.24 44.5488H142.259Z" fill="#FEE9C9" stroke="#FEE9C9" stroke-width="1.5"/>
<path d="M116.345 60.7305C116.984 59.9179 118.126 59.7411 118.977 60.2812L119.143 60.3984L119.144 60.3994L136.321 73.957L136.476 74.0908C137.203 74.7933 137.289 75.9454 136.653 76.7539L136.652 76.7529C136.262 77.2507 135.678 77.5127 135.089 77.5127C134.655 77.5127 134.223 77.3758 133.854 77.085V77.084L116.676 63.5273V63.5264C115.81 62.8445 115.667 61.5924 116.345 60.7305Z" fill="#FEE9C9" stroke="#FEE9C9" stroke-width="1.5"/>
</svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

@@ -0,0 +1 @@
<svg fill="none" height="27" viewBox="0 0 24 27" width="24" xmlns="http://www.w3.org/2000/svg"><path d="m.5 8.95076v16.66664h23v-16.66664l-11.5-8.333328z" fill="#74000a" stroke="#ffe9c8"/><path d="m15.8357 25.6171v-10.4167h-7.66663v10.4167" fill="#74000a"/><path d="m15.8357 25.6171v-10.4167h-7.66663v10.4167" stroke="#ffe9c8"/></svg>

After

Width:  |  Height:  |  Size: 334 B