init
21
announcements/AnnouncementCard.js
Normal 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
@@ -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
@@ -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)
|
||||||
273
announcements/announcements.js
Normal 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)
|
||||||
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
@@ -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
@@ -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)
|
||||||
6
announcements/icons/announcements.svg
Normal 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 |
6
announcements/icons/announcementslight.svg
Normal 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 |
1
announcements/icons/announcementslightselected.svg
Normal 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 |
437
calendar/CalendarForm.js
Normal file
@@ -0,0 +1,437 @@
|
|||||||
|
import server from "/@server/server.js"
|
||||||
|
|
||||||
|
css(`
|
||||||
|
calendarform- {
|
||||||
|
scrollbar-width: none;
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
}
|
||||||
|
calendarform- ::-webkit-scrollbar { display: none; width: 0; height: 0; }
|
||||||
|
calendarform- ::-webkit-scrollbar-thumb { background: transparent; }
|
||||||
|
calendarform- ::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
calendarform- input::placeholder,
|
||||||
|
calendarform- textarea::placeholder {
|
||||||
|
color: var(--headertext);
|
||||||
|
opacity: 0.35;
|
||||||
|
}
|
||||||
|
#calendarform-toast-wrap {
|
||||||
|
transition: max-height 0.25s ease, opacity 0.22s ease, padding-top 0.25s ease;
|
||||||
|
}
|
||||||
|
`)
|
||||||
|
|
||||||
|
class CalendarForm extends Shadow {
|
||||||
|
static COLORS = [
|
||||||
|
"#9E1C29", "#3D6FAD", "#2A8636", "#B38A1E",
|
||||||
|
"#B85A1F", "#7A3FA3", "#B23D6B", "#2D8A87",
|
||||||
|
"#3C9A5F", "#6E9A23", "#7A8428", "#9A2F7D",
|
||||||
|
"#4F54A8", "#8A5A32", "#546B86", "#A67A1F",
|
||||||
|
]
|
||||||
|
|
||||||
|
cardInputStyles(el) {
|
||||||
|
return el
|
||||||
|
.border("none")
|
||||||
|
.outline("none")
|
||||||
|
.background("transparent")
|
||||||
|
.color("var(--text)")
|
||||||
|
.fontSize(0.9, em)
|
||||||
|
.fontFamily("Arial")
|
||||||
|
.padding(0)
|
||||||
|
.boxSizing("border-box")
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(onBack = null, onCreated = null, editCalendar = null, onDelete = null, onSaveError = null) {
|
||||||
|
super()
|
||||||
|
this.onBack = onBack;
|
||||||
|
this.onCreated = onCreated;
|
||||||
|
this.editCalendar = editCalendar;
|
||||||
|
this.onDelete = onDelete;
|
||||||
|
this.onSaveError = onSaveError;
|
||||||
|
this.selectedColor = editCalendar ? editCalendar.color : CalendarForm.COLORS[0];
|
||||||
|
|
||||||
|
if (editCalendar) {
|
||||||
|
this.originalFormData = {
|
||||||
|
name: editCalendar.name ?? "",
|
||||||
|
description: editCalendar.description || "",
|
||||||
|
color: editCalendar.color ?? ""
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.originalFormData = {
|
||||||
|
name: "",
|
||||||
|
description: "",
|
||||||
|
color: CalendarForm.COLORS[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const isEditMode = !!this.editCalendar;
|
||||||
|
|
||||||
|
form(() => {
|
||||||
|
VStack(() => {
|
||||||
|
// ── Header ────────────────────────────────────────────
|
||||||
|
HStack(() => {
|
||||||
|
button("Cancel")
|
||||||
|
.attr({ type: "button" })
|
||||||
|
.background("none")
|
||||||
|
.border("none")
|
||||||
|
.padding(0)
|
||||||
|
.color("var(--quillred)")
|
||||||
|
.fontSize(0.95, em)
|
||||||
|
.fontFamily("Arial")
|
||||||
|
.cursor("pointer")
|
||||||
|
.flexShrink(0)
|
||||||
|
.onTap(() => this.handleBack())
|
||||||
|
|
||||||
|
p(isEditMode ? "Edit Calendar" : "New Calendar")
|
||||||
|
.margin(0)
|
||||||
|
.fontSize(1, em)
|
||||||
|
.fontWeight("700")
|
||||||
|
.color("var(--headertext)")
|
||||||
|
.fontFamily("Arial")
|
||||||
|
.flex(1)
|
||||||
|
.textAlign("center")
|
||||||
|
|
||||||
|
button("Save")
|
||||||
|
.attr({ type: "submit" })
|
||||||
|
.background("none")
|
||||||
|
.border("none")
|
||||||
|
.padding(0)
|
||||||
|
.color("var(--quillred)")
|
||||||
|
.fontSize(0.95, em)
|
||||||
|
.fontWeight("600")
|
||||||
|
.fontFamily("Arial")
|
||||||
|
.cursor("pointer")
|
||||||
|
.flexShrink(0)
|
||||||
|
})
|
||||||
|
.paddingHorizontal(1.25, em)
|
||||||
|
.paddingVertical(0.9, em)
|
||||||
|
.alignItems("center")
|
||||||
|
.borderBottom("1px solid var(--divider)")
|
||||||
|
.flexShrink(0)
|
||||||
|
.background(util.darkMode() ? "transparent" : "var(--sidebottombars)")
|
||||||
|
|
||||||
|
// ── Error toast ───────────────────────────────────────
|
||||||
|
VStack(() => {
|
||||||
|
p("")
|
||||||
|
.attr({ id: "calendarform-toast" })
|
||||||
|
.margin(0)
|
||||||
|
.padding("0.55em 1.1em")
|
||||||
|
.background("var(--quillred)")
|
||||||
|
.color("white")
|
||||||
|
.fontSize(0.85, em)
|
||||||
|
.fontWeight("500")
|
||||||
|
.fontFamily("Arial")
|
||||||
|
.borderRadius("0.5em")
|
||||||
|
.boxShadow("0 2px 10px rgba(0,0,0,0.15)")
|
||||||
|
.whiteSpace("nowrap")
|
||||||
|
})
|
||||||
|
.attr({ id: "calendarform-toast-wrap" })
|
||||||
|
.alignItems("center")
|
||||||
|
.overflow("hidden")
|
||||||
|
.maxHeight(0)
|
||||||
|
.opacity(0)
|
||||||
|
.flexShrink(0)
|
||||||
|
|
||||||
|
// ── Scrollable body ───────────────────────────────────
|
||||||
|
VStack(() => {
|
||||||
|
// ── Name card ─────────────────────────────────────
|
||||||
|
VStack(() => {
|
||||||
|
input("Name", "100%")
|
||||||
|
.attr({ name: "name", type: "text", value: this.editCalendar?.name ?? "" })
|
||||||
|
.border("none")
|
||||||
|
.outline("none")
|
||||||
|
.background("transparent")
|
||||||
|
.color("var(--text)")
|
||||||
|
.fontSize(1.1, em)
|
||||||
|
.fontFamily("Arial")
|
||||||
|
.fontWeight("500")
|
||||||
|
.padding("0.9em 1em")
|
||||||
|
.boxSizing("border-box")
|
||||||
|
})
|
||||||
|
.background("var(--darkaccent)")
|
||||||
|
.border("1px solid var(--divider)")
|
||||||
|
.borderRadius(12, px)
|
||||||
|
.marginHorizontal(1, em)
|
||||||
|
.overflow("hidden")
|
||||||
|
|
||||||
|
VStack(() => {}).height(0.85, em)
|
||||||
|
|
||||||
|
// ── Description card ──────────────────────────────
|
||||||
|
VStack(() => {
|
||||||
|
HStack(() => {
|
||||||
|
p("📝")
|
||||||
|
.margin(0)
|
||||||
|
.fontSize(0.85, em)
|
||||||
|
.flexShrink(0)
|
||||||
|
.alignSelf("flex-start")
|
||||||
|
.paddingTop(0.08, em)
|
||||||
|
|
||||||
|
const editDesc = this.editCalendar?.description
|
||||||
|
textarea(editDesc ?? "Description")
|
||||||
|
.attr({ name: "description" })
|
||||||
|
.styles(this.cardInputStyles)
|
||||||
|
.flex(1)
|
||||||
|
.minHeight(3, em)
|
||||||
|
.resize("none")
|
||||||
|
.fieldSizing("content")
|
||||||
|
.lineHeight("1.45")
|
||||||
|
.onAppear(function() {
|
||||||
|
if (editDesc) this.value = editDesc
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.paddingHorizontal(1, em)
|
||||||
|
.paddingVertical(0.78, em)
|
||||||
|
.alignItems("flex-start")
|
||||||
|
.gap(0.65, em)
|
||||||
|
})
|
||||||
|
.background("var(--darkaccent)")
|
||||||
|
.border("1px solid var(--divider)")
|
||||||
|
.borderRadius(12, px)
|
||||||
|
.marginHorizontal(1, em)
|
||||||
|
.overflow("hidden")
|
||||||
|
|
||||||
|
VStack(() => {}).height(0.85, em)
|
||||||
|
|
||||||
|
// ── Color card ────────────────────────────────────
|
||||||
|
VStack(() => {
|
||||||
|
HStack(() => {
|
||||||
|
p("Color")
|
||||||
|
.margin(0)
|
||||||
|
.fontSize(0.92, em)
|
||||||
|
.color("var(--headertext)")
|
||||||
|
.flex(1)
|
||||||
|
})
|
||||||
|
.paddingHorizontal(1, em)
|
||||||
|
.paddingVertical(0.78, em)
|
||||||
|
.alignItems("center")
|
||||||
|
.borderBottom("1px solid var(--divider)")
|
||||||
|
|
||||||
|
VStack(() => {
|
||||||
|
HStack(() => {
|
||||||
|
CalendarForm.COLORS.slice(0, 8).forEach(color => {
|
||||||
|
const selected = this.selectedColor === color
|
||||||
|
p("")
|
||||||
|
.flex(1)
|
||||||
|
.height(1.8, em)
|
||||||
|
.background(color)
|
||||||
|
.borderRadius(8, px)
|
||||||
|
.border(`3px solid ${selected ? "var(--quillred)" : color}`)
|
||||||
|
.boxSizing("border-box")
|
||||||
|
.cursor("pointer")
|
||||||
|
.attr({ "data-color": color })
|
||||||
|
.onTap(() => {
|
||||||
|
const prev = this.$(`[data-color="${this.selectedColor}"]`)
|
||||||
|
if (prev) prev.style.border = `3px solid ${this.selectedColor}`
|
||||||
|
this.selectedColor = color
|
||||||
|
const next = this.$(`[data-color="${color}"]`)
|
||||||
|
if (next) next.style.border = `3px solid var(--quillred)`
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.gap(0.55, em)
|
||||||
|
|
||||||
|
HStack(() => {
|
||||||
|
CalendarForm.COLORS.slice(8, 16).forEach(color => {
|
||||||
|
const selected = this.selectedColor === color
|
||||||
|
p("")
|
||||||
|
.flex(1)
|
||||||
|
.height(1.8, em)
|
||||||
|
.background(color)
|
||||||
|
.borderRadius(8, px)
|
||||||
|
.border(`3px solid ${selected ? "var(--quillred)" : color}`)
|
||||||
|
.boxSizing("border-box")
|
||||||
|
.cursor("pointer")
|
||||||
|
.attr({ "data-color": color })
|
||||||
|
.onTap(() => {
|
||||||
|
const prev = this.$(`[data-color="${this.selectedColor}"]`)
|
||||||
|
if (prev) prev.style.border = `3px solid ${this.selectedColor}`
|
||||||
|
this.selectedColor = color
|
||||||
|
const next = this.$(`[data-color="${color}"]`)
|
||||||
|
if (next) next.style.border = `3px solid var(--quillred)`
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.gap(0.55, em)
|
||||||
|
})
|
||||||
|
.padding(1, em)
|
||||||
|
.gap(0.55, em)
|
||||||
|
})
|
||||||
|
.background("var(--darkaccent)")
|
||||||
|
.border("1px solid var(--divider)")
|
||||||
|
.borderRadius(12, px)
|
||||||
|
.marginHorizontal(1, em)
|
||||||
|
.overflow("hidden")
|
||||||
|
|
||||||
|
if (isEditMode) {
|
||||||
|
VStack(() => {}).height(0.85, em)
|
||||||
|
|
||||||
|
button("Delete Calendar")
|
||||||
|
.attr({ type: "button" })
|
||||||
|
.width("calc(100% - 2em)")
|
||||||
|
.marginHorizontal(1, em)
|
||||||
|
.padding(0.85, em)
|
||||||
|
.boxSizing("border-box")
|
||||||
|
.background("transparent")
|
||||||
|
.color("var(--quillred)")
|
||||||
|
.border("1.5px solid var(--quillred)")
|
||||||
|
.borderRadius(12, px)
|
||||||
|
.fontSize(0.95, em)
|
||||||
|
.fontFamily("Arial")
|
||||||
|
.fontWeight("600")
|
||||||
|
.cursor("pointer")
|
||||||
|
.onTap(() => this.handleDelete())
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(() => {}).height(1.5, em)
|
||||||
|
})
|
||||||
|
.overflowY("scroll")
|
||||||
|
.flex(1)
|
||||||
|
.paddingTop(0.85, em)
|
||||||
|
})
|
||||||
|
.height(100, pct)
|
||||||
|
.onSubmit((e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
this.handleSubmit(this.getFormData())
|
||||||
|
})
|
||||||
|
.onKeyDown(e => {
|
||||||
|
if (e.key === "Enter" && e.target.tagName !== "TEXTAREA" && e.target.tagName !== "BUTTON") e.preventDefault()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.height(100, pct)
|
||||||
|
}
|
||||||
|
|
||||||
|
showError(msg) {
|
||||||
|
const wrap = this.$("#calendarform-toast-wrap")
|
||||||
|
const toast = this.$("#calendarform-toast")
|
||||||
|
if (!wrap || !toast) return
|
||||||
|
clearTimeout(this._errorTimer)
|
||||||
|
if (msg) {
|
||||||
|
toast.innerText = msg
|
||||||
|
wrap.style.maxHeight = "3em"
|
||||||
|
wrap.style.opacity = "1"
|
||||||
|
wrap.style.paddingTop = "0.85em"
|
||||||
|
this._errorTimer = setTimeout(() => this.hideError(), 3500)
|
||||||
|
} else {
|
||||||
|
this.hideError()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hideError() {
|
||||||
|
const wrap = this.$("#calendarform-toast-wrap")
|
||||||
|
if (!wrap) return
|
||||||
|
clearTimeout(this._errorTimer)
|
||||||
|
wrap.style.maxHeight = "0"
|
||||||
|
wrap.style.opacity = "0"
|
||||||
|
wrap.style.paddingTop = "0"
|
||||||
|
}
|
||||||
|
|
||||||
|
getFormData() {
|
||||||
|
return {
|
||||||
|
name: this.$('[name="name"]').value,
|
||||||
|
color: this.selectedColor,
|
||||||
|
description: this.$('[name="description"]').value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isNewCalendarDirty() {
|
||||||
|
const data = this.getFormData()
|
||||||
|
const o = this.originalFormData
|
||||||
|
return (
|
||||||
|
data.name !== o.name ||
|
||||||
|
(data.description || "") !== o.description ||
|
||||||
|
data.color !== o.color
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleBack() {
|
||||||
|
this.hideError()
|
||||||
|
if (this.onBack) this.onBack()
|
||||||
|
}
|
||||||
|
|
||||||
|
async trySave() {
|
||||||
|
if (!this.editCalendar) {
|
||||||
|
if (!this.isNewCalendarDirty()) return false
|
||||||
|
const data = this.getFormData()
|
||||||
|
const payload = {
|
||||||
|
name: data.name || "New calendar",
|
||||||
|
description: data.description || null,
|
||||||
|
color: data.color
|
||||||
|
}
|
||||||
|
const result = await server.addCalendar(payload, global.currentNetwork.id)
|
||||||
|
if (result.status === 200) return result.calendar
|
||||||
|
this.showError(result.error ?? "Failed to save calendar.")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = this.getFormData()
|
||||||
|
const unchanged =
|
||||||
|
data.name === this.originalFormData.name &&
|
||||||
|
data.color === this.originalFormData.color &&
|
||||||
|
(data.description || "") === this.originalFormData.description
|
||||||
|
|
||||||
|
if (unchanged) return this.editCalendar
|
||||||
|
|
||||||
|
const result = await server.editCalendar(
|
||||||
|
this.editCalendar.id,
|
||||||
|
{
|
||||||
|
name: data.name || "New calendar",
|
||||||
|
description: data.description || null,
|
||||||
|
color: data.color
|
||||||
|
},
|
||||||
|
global.currentNetwork.id
|
||||||
|
)
|
||||||
|
|
||||||
|
if (result.status === 200) return result.calendar
|
||||||
|
|
||||||
|
this.showError(result.error ?? "Failed to save calendar.")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleSubmit(data) {
|
||||||
|
this.hideError()
|
||||||
|
|
||||||
|
if (this.editCalendar && !data.name) {
|
||||||
|
this.showError("Calendars must have a name.")
|
||||||
|
this.onSaveError?.()
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
name: data.name || "New calendar",
|
||||||
|
description: data.description || null,
|
||||||
|
color: data.color
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.editCalendar) {
|
||||||
|
const result = await server.editCalendar(this.editCalendar.id, payload, global.currentNetwork.id);
|
||||||
|
if (result.status === 200) {
|
||||||
|
if (this.onCreated) this.onCreated(result.calendar);
|
||||||
|
} else {
|
||||||
|
this.showError(result.error ?? "Failed to update calendar.")
|
||||||
|
this.onSaveError?.()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const result = await server.addCalendar(payload, global.currentNetwork.id);
|
||||||
|
if (result.status === 200) {
|
||||||
|
if (this.onCreated) this.onCreated(result.calendar);
|
||||||
|
} else {
|
||||||
|
this.showError(result.error ?? "Failed to create calendar.")
|
||||||
|
this.onSaveError?.()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleDelete() {
|
||||||
|
this.hideError()
|
||||||
|
const result = await server.deleteCalendar(
|
||||||
|
this.editCalendar.id,
|
||||||
|
global.currentNetwork.id
|
||||||
|
);
|
||||||
|
if (result.status === 200) {
|
||||||
|
if (this.onDelete) this.onDelete(this.editCalendar.id);
|
||||||
|
} else {
|
||||||
|
this.showError(result.error ?? "Failed to delete calendar.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
register(CalendarForm)
|
||||||
101
calendar/Day/DayHeaderRow.js
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import calendarUtil from "../calendarUtil.js";
|
||||||
|
|
||||||
|
class DayHeaderRow extends Shadow {
|
||||||
|
constructor(groupedDay, calendars) {
|
||||||
|
super()
|
||||||
|
this.groupedDay = groupedDay;
|
||||||
|
this.calendars = calendars;
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const day = this.groupedDay.day;
|
||||||
|
const today = calendarUtil.isToday(day);
|
||||||
|
const allDayEvents = this.groupedDay.allDay;
|
||||||
|
const maxEvents = allDayEvents.length;
|
||||||
|
|
||||||
|
VStack(() => {
|
||||||
|
HStack(() => {
|
||||||
|
VStack(() => {
|
||||||
|
p(day.toLocaleDateString("en-US", { weekday: "long" }).toUpperCase())
|
||||||
|
.margin(0)
|
||||||
|
.fontSize(0.72, em)
|
||||||
|
.fontWeight("600")
|
||||||
|
.letterSpacing(0.04, em)
|
||||||
|
.opacity(today ? 1 : 0.5)
|
||||||
|
.textAlign("left")
|
||||||
|
|
||||||
|
h3(day.toLocaleDateString("en-US", { month: "long", day: "numeric" }))
|
||||||
|
.margin(0)
|
||||||
|
.fontSize(1.35, em)
|
||||||
|
.fontWeight("700")
|
||||||
|
.lineHeight("1")
|
||||||
|
})
|
||||||
|
.color(today ? "var(--quillred)" : "var(--headertext)")
|
||||||
|
.flex(1)
|
||||||
|
.alignItems("flex-start")
|
||||||
|
.justifyContent("center")
|
||||||
|
.gap(0.3, em)
|
||||||
|
.paddingTop(0.85, em)
|
||||||
|
.paddingBottom(maxEvents > 0 ? (maxEvents * 2.0) + 0.7 : 0.35, em)
|
||||||
|
.paddingHorizontal(0.75, em)
|
||||||
|
.boxSizing("border-box")
|
||||||
|
})
|
||||||
|
.width(100, pct)
|
||||||
|
.alignItems("stretch")
|
||||||
|
|
||||||
|
if (allDayEvents.length > 0) {
|
||||||
|
this.allDaySection(allDayEvents);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.width(100, pct)
|
||||||
|
.position("relative")
|
||||||
|
.background("var(--sidebottombars)")
|
||||||
|
.borderBottom("1px solid var(--divider)")
|
||||||
|
}
|
||||||
|
|
||||||
|
allDaySection(events) {
|
||||||
|
const rowHeight = 1.75;
|
||||||
|
const gap = 0.25;
|
||||||
|
const totalHeight = events.length * rowHeight + (events.length - 1) * gap;
|
||||||
|
|
||||||
|
ZStack(() => {
|
||||||
|
events.forEach((event, slot) => {
|
||||||
|
const color = calendarUtil.getCalendarColor(this.calendars, event.calendars.find(id => this.calendars.some(c => c.id === id)) ?? event.calendars[0]);
|
||||||
|
const topEm = slot * (rowHeight + gap);
|
||||||
|
|
||||||
|
HStack(() => {
|
||||||
|
p(event.title)
|
||||||
|
.margin(0)
|
||||||
|
.fontSize(0.72, em)
|
||||||
|
.fontWeight("600")
|
||||||
|
.color("white")
|
||||||
|
.whiteSpace("nowrap")
|
||||||
|
.overflow("hidden")
|
||||||
|
})
|
||||||
|
.position("absolute")
|
||||||
|
.top(topEm, em)
|
||||||
|
.left(0.25, em)
|
||||||
|
.right(0.25, em)
|
||||||
|
.height(rowHeight, em)
|
||||||
|
.padding(0.35, em)
|
||||||
|
.background(color)
|
||||||
|
.borderRadius(0.25, em)
|
||||||
|
.alignItems("center")
|
||||||
|
.boxSizing("border-box")
|
||||||
|
.overflow("hidden")
|
||||||
|
.pointerEvents("auto")
|
||||||
|
.cursor("pointer")
|
||||||
|
.onTap(() => $("bottomsheet-").showEvent(event))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.position("absolute")
|
||||||
|
.bottom(0.25, em)
|
||||||
|
.left(0)
|
||||||
|
.right(0)
|
||||||
|
.height(totalHeight, em)
|
||||||
|
.boxSizing("border-box")
|
||||||
|
.pointerEvents("none")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
register(DayHeaderRow)
|
||||||
124
calendar/Day/DayView.js
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
import calendarUtil from "../calendarUtil.js"
|
||||||
|
import "../Week/SpacerCell.js"
|
||||||
|
import "../Week/TimedLabelsColumn.js"
|
||||||
|
import "../Week/TimedWeekGrid.js"
|
||||||
|
import "./DayHeaderRow.js"
|
||||||
|
|
||||||
|
css(`
|
||||||
|
dayview- {
|
||||||
|
scrollbar-width: none;
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
dayview- .VStack::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
width: 0px;
|
||||||
|
height: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
dayview- .VStack::-webkit-scrollbar-thumb {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
dayview- .VStack::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
`)
|
||||||
|
|
||||||
|
let _saved = null;
|
||||||
|
|
||||||
|
class DayView extends Shadow {
|
||||||
|
constructor(calendars, events, currentDate, onSlotTap = null, isCenter = false) {
|
||||||
|
super()
|
||||||
|
this.calendars = calendars;
|
||||||
|
this.events = events;
|
||||||
|
this.currentDate = currentDate;
|
||||||
|
this.onSlotTap = onSlotTap;
|
||||||
|
this.isCenter = isCenter;
|
||||||
|
this.slots = calendarUtil.generateTimeSlots({ stepMinutes: 30 });
|
||||||
|
this.slotHeight = 2;
|
||||||
|
this.sidebarWidth = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
ZStack(() => {
|
||||||
|
const filteredEvents = this.filterEventsForDay(this.events);
|
||||||
|
const groupedDay = this.groupEventsForDay(filteredEvents);
|
||||||
|
const weekNumber = calendarUtil.getWeekNumber(this.currentDate);
|
||||||
|
|
||||||
|
VStack(() => {
|
||||||
|
HStack(() => {
|
||||||
|
SpacerCell(weekNumber, this.sidebarWidth)
|
||||||
|
DayHeaderRow(groupedDay, this.calendars)
|
||||||
|
})
|
||||||
|
.boxSizing("border-box")
|
||||||
|
.position("sticky")
|
||||||
|
.top(0)
|
||||||
|
.width(100, pct)
|
||||||
|
.zIndex(2)
|
||||||
|
|
||||||
|
HStack(() => {
|
||||||
|
TimedLabelsColumn(this.slots, this.slotHeight, this.sidebarWidth)
|
||||||
|
TimedWeekGrid([groupedDay], this.slots, [groupedDay], this.calendars, this.slotHeight, "day", this.onSlotTap)
|
||||||
|
})
|
||||||
|
.boxSizing("border-box")
|
||||||
|
.onAppear(() => {
|
||||||
|
this.scrollToEight();
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.position("relative")
|
||||||
|
.gap(1, em)
|
||||||
|
.boxSizing("border-box")
|
||||||
|
.width(100, pct)
|
||||||
|
.height(100, pct)
|
||||||
|
.fontSize(0.9, em)
|
||||||
|
.overscrollBehavior("none")
|
||||||
|
.overflowY("scroll")
|
||||||
|
.display("block")
|
||||||
|
}
|
||||||
|
|
||||||
|
filterEventsForDay(events) {
|
||||||
|
const dayStart = calendarUtil.startOfDay(this.currentDate);
|
||||||
|
const dayEnd = calendarUtil.endOfDay(this.currentDate);
|
||||||
|
const expanded = calendarUtil.expandRecurringEvents(events, dayStart, dayEnd);
|
||||||
|
return expanded.filter(event => {
|
||||||
|
const end = event.all_day ? calendarUtil.endOfDay(event.time_end) : calendarUtil.timedEnd(event);
|
||||||
|
return calendarUtil.rangesOverlap(event.time_start, end, dayStart, dayEnd)
|
||||||
|
&& this.calendars.some(c => event.calendars.some(c2 => c2 === c.id));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
groupEventsForDay(events) {
|
||||||
|
const dayStart = calendarUtil.startOfDay(this.currentDate);
|
||||||
|
const dayEnd = calendarUtil.endOfDay(this.currentDate);
|
||||||
|
return {
|
||||||
|
day: this.currentDate,
|
||||||
|
allDay: events.filter(event => {
|
||||||
|
if (!event.all_day) return false;
|
||||||
|
const end = calendarUtil.endOfDay(event.time_end);
|
||||||
|
return calendarUtil.rangesOverlap(event.time_start, end, dayStart, dayEnd);
|
||||||
|
}),
|
||||||
|
timed: events.filter(event => {
|
||||||
|
return !event.all_day && calendarUtil.rangesOverlap(event.time_start, calendarUtil.timedEnd(event), dayStart, dayEnd);
|
||||||
|
})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
scrollToEight() {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const fs = parseFloat(getComputedStyle(this).fontSize);
|
||||||
|
const slotsBeforeEight = this.slots.findIndex(s => s.hour24 === 8 && s.minute === 0);
|
||||||
|
const defaultTarget = slotsBeforeEight * this.slotHeight * fs;
|
||||||
|
if (this.isCenter) {
|
||||||
|
const dateKey = calendarUtil.toDateInput(this.currentDate);
|
||||||
|
this.scrollTop = (_saved?.dateKey === dateKey) ? _saved.scrollTop : defaultTarget;
|
||||||
|
this.addEventListener('scroll', () => { _saved = { dateKey, scrollTop: this.scrollTop }; }, { passive: true });
|
||||||
|
} else {
|
||||||
|
this.scrollTop = defaultTarget;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
register(DayView)
|
||||||
157
calendar/EventFileList.js
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
class EventFileList extends Shadow {
|
||||||
|
constructor(existingAttachments, onDeleteExisting = null, onDeleteNew = null, onPreview = null) {
|
||||||
|
super();
|
||||||
|
this.existingAttachments = existingAttachments ?? [];
|
||||||
|
this.newFiles = [];
|
||||||
|
this.objectURLs = new Map();
|
||||||
|
this.onDeleteExisting = onDeleteExisting;
|
||||||
|
this.onDeleteNew = onDeleteNew;
|
||||||
|
this.onPreview = onPreview;
|
||||||
|
}
|
||||||
|
|
||||||
|
update(files) {
|
||||||
|
this.newFiles.push(...Array.from(files));
|
||||||
|
this.rerender();
|
||||||
|
}
|
||||||
|
|
||||||
|
removeExisting(fileId) {
|
||||||
|
this.existingAttachments = this.existingAttachments.filter(f => f.id !== fileId);
|
||||||
|
this.rerender();
|
||||||
|
}
|
||||||
|
|
||||||
|
removeNew(index) {
|
||||||
|
const file = this.newFiles[index];
|
||||||
|
if (this.objectURLs.has(file)) {
|
||||||
|
URL.revokeObjectURL(this.objectURLs.get(file));
|
||||||
|
this.objectURLs.delete(file);
|
||||||
|
}
|
||||||
|
this.newFiles.splice(index, 1);
|
||||||
|
this.rerender();
|
||||||
|
}
|
||||||
|
|
||||||
|
commitNew(insertedFiles) {
|
||||||
|
this.newFiles.forEach(file => {
|
||||||
|
if (this.objectURLs.has(file)) {
|
||||||
|
URL.revokeObjectURL(this.objectURLs.get(file));
|
||||||
|
this.objectURLs.delete(file);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.newFiles = [];
|
||||||
|
this.existingAttachments = [...this.existingAttachments, ...insertedFiles];
|
||||||
|
this.rerender();
|
||||||
|
}
|
||||||
|
|
||||||
|
getObjectURL(file) {
|
||||||
|
if (!this.objectURLs.has(file)) {
|
||||||
|
this.objectURLs.set(file, URL.createObjectURL(file));
|
||||||
|
}
|
||||||
|
return this.objectURLs.get(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const hasFiles = this.existingAttachments.length > 0 || this.newFiles.length > 0;
|
||||||
|
this.style.display = hasFiles ? "flex" : "none";
|
||||||
|
this.style.padding = "1em";
|
||||||
|
this.style.paddingTop = "0.5em";
|
||||||
|
this.style.boxSizing = "border-box";
|
||||||
|
|
||||||
|
VStack(() => {
|
||||||
|
this.existingAttachments.forEach(file => {
|
||||||
|
const isImage = file.type?.startsWith("image/");
|
||||||
|
const url = `${config.SERVER}/db/images/events/${file.name}`
|
||||||
|
const row = HStack(() => {
|
||||||
|
if (isImage) {
|
||||||
|
img(`${config.UI}/db/images/events/${file.name}`, "1.5em", "1.5em")
|
||||||
|
.objectFit("cover")
|
||||||
|
.borderRadius(3, px)
|
||||||
|
.flexShrink(0)
|
||||||
|
} else {
|
||||||
|
span("📎")
|
||||||
|
}
|
||||||
|
span(file.original_name ?? file.name)
|
||||||
|
.overflow("hidden")
|
||||||
|
.textOverflow("ellipsis")
|
||||||
|
.whiteSpace("nowrap")
|
||||||
|
.flex(1)
|
||||||
|
|
||||||
|
span(file.size_bytes != null ? `${(file.size_bytes / 1024).toFixed(1)} KB` : "")
|
||||||
|
.opacity(0.5)
|
||||||
|
.flexShrink(0)
|
||||||
|
.fontSize(0.85, rem)
|
||||||
|
.width("5em")
|
||||||
|
|
||||||
|
if (this.onDeleteExisting) {
|
||||||
|
span("×")
|
||||||
|
.color("var(--quillred)")
|
||||||
|
.opacity(0.7)
|
||||||
|
.fontSize(1.2, em)
|
||||||
|
.fontWeight("600")
|
||||||
|
.flexShrink(0)
|
||||||
|
.cursor("pointer")
|
||||||
|
.padding(0.5, em)
|
||||||
|
.margin(-0.5, em)
|
||||||
|
.onTap((e) => { e?.stopPropagation(); if (window.isMobile() === true) this.onDeleteExisting(file.id) })
|
||||||
|
.onClick((done, e) => { e?.stopPropagation(); if (done && window.isMobile() === false) this.onDeleteExisting(file.id) })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.alignItems("center")
|
||||||
|
.gap(0.5, em)
|
||||||
|
if (this.onPreview) {
|
||||||
|
row.cursor("pointer")
|
||||||
|
.onClick((done) => { if (done) this.onPreview(file, url) })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
this.newFiles.forEach((file, index) => {
|
||||||
|
const isImage = file.type.startsWith("image/");
|
||||||
|
const url = this.getObjectURL(file)
|
||||||
|
const row = HStack(() => {
|
||||||
|
if (isImage) {
|
||||||
|
img(url, "1.5em", "1.5em")
|
||||||
|
.objectFit("cover")
|
||||||
|
.borderRadius(3, px)
|
||||||
|
.flexShrink(0)
|
||||||
|
} else {
|
||||||
|
span("📎")
|
||||||
|
}
|
||||||
|
span(file.name)
|
||||||
|
.overflow("hidden")
|
||||||
|
.textOverflow("ellipsis")
|
||||||
|
.whiteSpace("nowrap")
|
||||||
|
.flex(1)
|
||||||
|
|
||||||
|
span(`${(file.size / 1024).toFixed(1)} KB`)
|
||||||
|
.opacity(0.5)
|
||||||
|
.flexShrink(0)
|
||||||
|
.fontSize(0.85, rem)
|
||||||
|
.width("5em")
|
||||||
|
|
||||||
|
if (this.onDeleteNew) {
|
||||||
|
span("×")
|
||||||
|
.color("var(--quillred)")
|
||||||
|
.opacity(0.7)
|
||||||
|
.fontSize(1.2, em)
|
||||||
|
.fontWeight("600")
|
||||||
|
.flexShrink(0)
|
||||||
|
.cursor("pointer")
|
||||||
|
.padding(0.5, em)
|
||||||
|
.margin(-0.5, em)
|
||||||
|
.onTap((e) => { e?.stopPropagation(); if (window.isMobile() === true) this.onDeleteNew(index) })
|
||||||
|
.onClick((done, e) => { e?.stopPropagation(); if (done && window.isMobile() === false) this.onDeleteNew(index) })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.alignItems("center")
|
||||||
|
.gap(0.5, em)
|
||||||
|
if (this.onPreview) {
|
||||||
|
row.cursor("pointer")
|
||||||
|
.onClick((done) => { if (done) this.onPreview(file, url) })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.gap(1, em)
|
||||||
|
.color("var(--text)")
|
||||||
|
.fontSize(0.85, rem)
|
||||||
|
.fontFamily("Arial")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
register(EventFileList)
|
||||||
612
calendar/Events/EventDetails.js
Normal file
@@ -0,0 +1,612 @@
|
|||||||
|
import server from "/@server/server.js"
|
||||||
|
import calendarUtil from "../calendarUtil.js"
|
||||||
|
import "./EventForm.js"
|
||||||
|
import "../../components/BottomSheet.js"
|
||||||
|
import "../../components/BackButton.js"
|
||||||
|
import "../../components/Avatar.js"
|
||||||
|
|
||||||
|
css(`
|
||||||
|
eventdetails- {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
scrollbar-width: none;
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
}
|
||||||
|
eventdetails- ::-webkit-scrollbar { display: none; width: 0; height: 0; }
|
||||||
|
eventdetails- ::-webkit-scrollbar-thumb { background: transparent; }
|
||||||
|
eventdetails- ::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
#eventdetails-toast-wrap {
|
||||||
|
transition: max-height 0.25s ease, opacity 0.22s ease, padding-top 0.25s ease;
|
||||||
|
}
|
||||||
|
`)
|
||||||
|
|
||||||
|
class EventDetails extends Shadow {
|
||||||
|
attachmentsOpen = false;
|
||||||
|
selectedCalendars = [];
|
||||||
|
_pendingCalendars = null;
|
||||||
|
_prevCalendars = null;
|
||||||
|
|
||||||
|
constructor(calendars, event = null, onEventEdited = null, onEventDeleted = null) {
|
||||||
|
super()
|
||||||
|
this.calendars = calendars;
|
||||||
|
this.event = event;
|
||||||
|
this.onEventEdited = onEventEdited;
|
||||||
|
this.onEventDeleted = onEventDeleted;
|
||||||
|
this.selectedCalendars = calendars.filter(c => event?.calendars?.includes(c.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
this.editSheet = BottomSheet(100) // separate sheet for the edit form, layered above this one
|
||||||
|
|
||||||
|
const isOwner = this.event?.creator_id === global.profile?.id;
|
||||||
|
|
||||||
|
VStack(() => {
|
||||||
|
this.renderHeader(isOwner)
|
||||||
|
|
||||||
|
// ── Error toast ───────────────────────────────────────
|
||||||
|
VStack(() => {
|
||||||
|
p("")
|
||||||
|
.attr({ id: "eventdetails-toast" })
|
||||||
|
.margin(0)
|
||||||
|
.padding("0.55em 1.1em")
|
||||||
|
.background("var(--quillred)")
|
||||||
|
.color("white")
|
||||||
|
.fontSize(0.85, em)
|
||||||
|
.fontWeight("500")
|
||||||
|
.fontFamily("Arial")
|
||||||
|
.borderRadius("0.5em")
|
||||||
|
.boxShadow("0 2px 10px rgba(0,0,0,0.15)")
|
||||||
|
.whiteSpace("nowrap")
|
||||||
|
})
|
||||||
|
.attr({ id: "eventdetails-toast-wrap" })
|
||||||
|
.alignItems("center")
|
||||||
|
.overflow("hidden")
|
||||||
|
.maxHeight(0)
|
||||||
|
.opacity(0)
|
||||||
|
.flexShrink(0)
|
||||||
|
|
||||||
|
// ── Scrollable body ───────────────────────────────────
|
||||||
|
VStack(() => {
|
||||||
|
if (!this.event) return
|
||||||
|
|
||||||
|
// VStack(() => {}).height(0.85, em).flexShrink(0)
|
||||||
|
|
||||||
|
// ── Calendar / Location / Notes card ──────────────
|
||||||
|
VStack(() => {
|
||||||
|
|
||||||
|
// Calendar row — tappable to expand
|
||||||
|
HStack(() => {
|
||||||
|
p("Calendar")
|
||||||
|
.margin(0).fontSize(0.92, em).color("var(--headertext)").flexShrink(0)
|
||||||
|
|
||||||
|
HStack(() => {
|
||||||
|
this.selectedCalendars.forEach(cal => {
|
||||||
|
HStack(() => {
|
||||||
|
p("").width(0.6, em).height(0.6, em)
|
||||||
|
.background(cal.color)
|
||||||
|
.borderRadius(50, pct)
|
||||||
|
.flexShrink(0)
|
||||||
|
p(cal.name)
|
||||||
|
.margin(0)
|
||||||
|
.fontSize(0.82, em)
|
||||||
|
.fontWeight("600")
|
||||||
|
.color("var(--text)")
|
||||||
|
.whiteSpace("nowrap")
|
||||||
|
})
|
||||||
|
.gap(0.3, em)
|
||||||
|
.alignItems("center")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.attr({ id: "calendar-display" })
|
||||||
|
.flex(1)
|
||||||
|
.justifyContent("flex-end")
|
||||||
|
.flexWrap("wrap")
|
||||||
|
.gap(0.5, em)
|
||||||
|
|
||||||
|
let chevron = p("›")
|
||||||
|
chevron
|
||||||
|
.attr({ id: "cal-chevron" })
|
||||||
|
.margin(0).opacity(0.3).fontSize(1.1, em).flexShrink(0)
|
||||||
|
.transition("transform 0.25s ease")
|
||||||
|
.state(chevron.parentElement, "open", function (value) {
|
||||||
|
if(value === "true") {
|
||||||
|
this.style.transform = "rotate(90deg)"
|
||||||
|
} else {
|
||||||
|
console.log("no trans")
|
||||||
|
this.style.transform = ""
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.attr({id: "calendar-row"})
|
||||||
|
.paddingHorizontal(1, em).paddingVertical(0.78, em)
|
||||||
|
.alignItems("center").gap(0.5, em)
|
||||||
|
.borderBottom("1px solid var(--divider)").cursor("pointer")
|
||||||
|
.onTap(function () {
|
||||||
|
const isOpen = this.getAttribute("open") === "true"
|
||||||
|
if (isOpen) {
|
||||||
|
this.setAttribute("open", "false")
|
||||||
|
} else {
|
||||||
|
this.setAttribute("open", "true")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Calendar Expandable List
|
||||||
|
VStack(() => {
|
||||||
|
this.calendars.forEach(cal => {
|
||||||
|
const isSelected = this.selectedCalendars.some(c => c.id === cal.id)
|
||||||
|
HStack(() => {
|
||||||
|
HStack(() => {
|
||||||
|
p("").width(0.65, em).height(0.65, em)
|
||||||
|
.background(cal.color).borderRadius(50, pct).flexShrink(0)
|
||||||
|
p(cal.name)
|
||||||
|
.margin(0).fontSize(0.9, em).color("var(--text)").fontFamily("Arial")
|
||||||
|
})
|
||||||
|
.gap(0.45, em).alignItems("center").flex(1)
|
||||||
|
|
||||||
|
p("✓")
|
||||||
|
.attr({ id: `cal-check-${cal.id}` })
|
||||||
|
.margin(0).fontSize(0.88, em)
|
||||||
|
.color("var(--quillred)").fontWeight("700")
|
||||||
|
.display(isSelected ? "" : "none")
|
||||||
|
})
|
||||||
|
.paddingHorizontal(1.25, em).paddingVertical(0.72, em)
|
||||||
|
.alignItems("center")
|
||||||
|
.borderBottom("1px solid var(--divider)")
|
||||||
|
.cursor("pointer")
|
||||||
|
.onTap(() => {
|
||||||
|
const prevCalendars = [...this.selectedCalendars]
|
||||||
|
const i = this.selectedCalendars.findIndex(c => c.id === cal.id)
|
||||||
|
if (i >= 0) {
|
||||||
|
if (this.selectedCalendars.length > 1) {
|
||||||
|
this.selectedCalendars.splice(i, 1)
|
||||||
|
const check = this.$(`#cal-check-${cal.id}`)
|
||||||
|
if (check) check.style.display = "none"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.selectedCalendars.push(cal)
|
||||||
|
const check = this.$(`#cal-check-${cal.id}`)
|
||||||
|
if (check) check.style.display = ""
|
||||||
|
}
|
||||||
|
this.updateCalendarDisplay()
|
||||||
|
this.saveCalendars(prevCalendars)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.state(this.$("#calendar-row"), "open", function (value) {
|
||||||
|
if(value === "false") {
|
||||||
|
this.style.maxHeight = "0"
|
||||||
|
} else {
|
||||||
|
this.style.maxHeight = this.scrollHeight + "px"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.attr({ id: "cal-picker"})
|
||||||
|
.overflow("hidden").maxHeight(0)
|
||||||
|
.transition("max-height 0.3s ease")
|
||||||
|
|
||||||
|
// Location row
|
||||||
|
if (this.event.location) {
|
||||||
|
HStack(() => {
|
||||||
|
p("📍").margin(0).fontSize(0.85, em).flexShrink(0)
|
||||||
|
p(this.event.location)
|
||||||
|
.margin(0).fontSize(0.9, em).color("var(--text)").fontFamily("Arial")
|
||||||
|
})
|
||||||
|
.paddingHorizontal(1, em).paddingVertical(0.78, em)
|
||||||
|
.alignItems("center").gap(0.65, em)
|
||||||
|
.borderBottom("1px solid var(--divider)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notes row
|
||||||
|
if (this.event.description) {
|
||||||
|
HStack(() => {
|
||||||
|
p("📝").margin(0).fontSize(0.85, em).flexShrink(0).alignSelf("flex-start").paddingTop(0.1, em)
|
||||||
|
p(this.event.description)
|
||||||
|
.margin(0).fontSize(0.9, em).color("var(--text)").fontFamily("Arial")
|
||||||
|
.whiteSpace("pre-wrap").lineHeight("1.45")
|
||||||
|
})
|
||||||
|
.paddingHorizontal(1, em).paddingVertical(0.78, em)
|
||||||
|
.alignItems("flex-start").gap(0.65, em)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.background("var(--darkaccent)").border("1px solid var(--divider)")
|
||||||
|
.borderRadius(12, px).marginHorizontal(1, em).overflow("hidden").flexShrink(0)
|
||||||
|
|
||||||
|
// ── Attachments card ──────────────────────────────
|
||||||
|
if (this.event.attachments?.length > 0) {
|
||||||
|
// VStack(() => {}).height(0.85, em).flexShrink(0)
|
||||||
|
VStack(() => {
|
||||||
|
HStack(() => {
|
||||||
|
p("📎").margin(0).fontSize(0.85, em).flexShrink(0)
|
||||||
|
p("Attachments")
|
||||||
|
.margin(0).fontSize(0.92, em).color("var(--headertext)").flex(1)
|
||||||
|
p("▼")
|
||||||
|
.attr({ id: "attachments-chevron" })
|
||||||
|
.margin(0).fontSize(0.7, em).color("var(--text)").opacity(0.5)
|
||||||
|
.display("inline-block")
|
||||||
|
.transition("transform 0.3s ease")
|
||||||
|
.transform(this.attachmentsOpen ? "rotate(180deg)" : "rotate(0deg)")
|
||||||
|
})
|
||||||
|
.paddingHorizontal(1, em).paddingVertical(0.78, em)
|
||||||
|
.alignItems("center").gap(0.65, em).cursor("pointer")
|
||||||
|
.onTap(() => this.toggleAttachments())
|
||||||
|
|
||||||
|
VStack(() => {
|
||||||
|
VStack(() => {
|
||||||
|
this.event.attachments.forEach(file => this.renderFile(file))
|
||||||
|
})
|
||||||
|
.gap(0.75, em).width(100, pct)
|
||||||
|
.padding("0 1em 0.75em").boxSizing("border-box")
|
||||||
|
})
|
||||||
|
.attr({ id: "attachments-content" })
|
||||||
|
.overflow("hidden").maxHeight("0")
|
||||||
|
.transition("max-height 0.5s ease")
|
||||||
|
})
|
||||||
|
.background("var(--darkaccent)").border("1px solid var(--divider)")
|
||||||
|
.borderRadius(12, px).marginHorizontal(1, em).overflow("hidden").flexShrink(0)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.overflowY("scroll").flex(1).minHeight(0).paddingTop(0.85, em).paddingBottom(1.5, em).gap(0.85, em)
|
||||||
|
|
||||||
|
// ── Footer: creator avatar + timestamps ───────────────
|
||||||
|
if (this.event) {
|
||||||
|
const members = global.currentNetwork.data?.members || []
|
||||||
|
const creator = members.find(m => m.id === this.event.creator_id)
|
||||||
|
if (creator) {
|
||||||
|
HStack(() => {
|
||||||
|
Avatar(creator, 2)
|
||||||
|
VStack(() => {
|
||||||
|
p(`Created ${calendarUtil.timeAgo(this.event.created)} by ${creator.first_name}`)
|
||||||
|
.margin(0).fontSize(0.9, em).color("var(--headertext)").opacity(0.5)
|
||||||
|
if (this.event.updated_at && this.event.updated_at !== this.event.created) {
|
||||||
|
p(`Last updated ${calendarUtil.timeAgo(this.event.updated_at)}`)
|
||||||
|
.margin(0).fontSize(0.9, em).color("var(--headertext)").opacity(0.4)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.gap(0.15, em)
|
||||||
|
})
|
||||||
|
.paddingHorizontal(1, em)
|
||||||
|
.paddingVertical(0.65, em)
|
||||||
|
.alignItems("center")
|
||||||
|
.gap(0.5, em)
|
||||||
|
.flexShrink(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
})
|
||||||
|
.height(100, pct)
|
||||||
|
}
|
||||||
|
|
||||||
|
renderHeader(isOwner) {
|
||||||
|
VStack(() => {
|
||||||
|
HStack(() => {
|
||||||
|
BackButton(false, true, () => $("bottomsheet-").toggle())
|
||||||
|
if (isOwner) {
|
||||||
|
HStack(() => {
|
||||||
|
// ── Delete button ─────────────────────────────────
|
||||||
|
button("Delete")
|
||||||
|
.attr({ type: "button" })
|
||||||
|
.padding(0.4, rem)
|
||||||
|
.fontSize(1.25, em)
|
||||||
|
.boxSizing("border-box")
|
||||||
|
.outline("none")
|
||||||
|
.border("none")
|
||||||
|
.background("transparent")
|
||||||
|
.color("var(--quillred)")
|
||||||
|
.onTap(() => this.handleDelete())
|
||||||
|
|
||||||
|
button("Edit")
|
||||||
|
.padding(0.4, rem)
|
||||||
|
.fontSize(1.25, em)
|
||||||
|
.color("var(--darkaccent)")
|
||||||
|
.boxSizing("border-box")
|
||||||
|
.outline("none")
|
||||||
|
.border("none")
|
||||||
|
.zIndex(3)
|
||||||
|
.onTap((e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
let formEl
|
||||||
|
const closeForm = () => {
|
||||||
|
this.editSheet._closeOverride = null
|
||||||
|
this.editSheet.setSheet(false)
|
||||||
|
}
|
||||||
|
const onSaveError = () => {
|
||||||
|
this.editSheet._closeOverride = () => this.editSheet.forceClose()
|
||||||
|
}
|
||||||
|
this.editSheet.show(() => {
|
||||||
|
// For override rows, attach template dates so scope='all' anchors correctly
|
||||||
|
let eventForForm = this.event;
|
||||||
|
if (this.event.recurrence_parent_id && !this.event._templateStart) {
|
||||||
|
const template = global.currentNetwork.data.events.find(e => e.id === this.event.recurrence_parent_id);
|
||||||
|
if (template) {
|
||||||
|
eventForForm = {
|
||||||
|
...this.event,
|
||||||
|
_templateStart: new Date(template.time_start),
|
||||||
|
_templateEnd: new Date(template.time_end)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
formEl = EventForm(
|
||||||
|
this.calendars,
|
||||||
|
(updateResult) => {
|
||||||
|
closeForm()
|
||||||
|
const updatedEvent = updateResult?.scope ? updateResult.event : updateResult;
|
||||||
|
this.event = { ...updatedEvent, time_start: new Date(updatedEvent.time_start), time_end: new Date(updatedEvent.time_end) }
|
||||||
|
this.selectedCalendars = this.calendars.filter(c => updatedEvent.calendars?.includes(c.id))
|
||||||
|
setTimeout(() => {
|
||||||
|
this.onEventEdited(updateResult)
|
||||||
|
this.rerender()
|
||||||
|
}, 300)
|
||||||
|
},
|
||||||
|
eventForForm,
|
||||||
|
closeForm,
|
||||||
|
(deleteResult) => {
|
||||||
|
closeForm()
|
||||||
|
$("bottomsheet-").toggle()
|
||||||
|
this.onEventDeleted(deleteResult)
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
onSaveError
|
||||||
|
)
|
||||||
|
})
|
||||||
|
this.editSheet._closeOverride = () => {
|
||||||
|
this.editSheet.setSheet(true)
|
||||||
|
formEl?.handleBack()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.fontFamily("Arial")
|
||||||
|
.cursor("pointer")
|
||||||
|
.paddingHorizontal(0.8, rem)
|
||||||
|
.gap(0.4, rem)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.width(100, pct)
|
||||||
|
.justifyContent("space-between")
|
||||||
|
.alignItems("center")
|
||||||
|
|
||||||
|
VStack(() => {
|
||||||
|
h2(this.event?.title ?? "")
|
||||||
|
.color("var(--headertext)")
|
||||||
|
.fontFamily("Arial")
|
||||||
|
.margin(0)
|
||||||
|
.fontSize(1.4, em)
|
||||||
|
p(this.event ? calendarUtil.formatEventTime(this.event) : "")
|
||||||
|
.margin(0)
|
||||||
|
.color("var(--headertext)")
|
||||||
|
.opacity(0.7)
|
||||||
|
.fontSize(0.85, em)
|
||||||
|
})
|
||||||
|
.paddingHorizontal(1, em)
|
||||||
|
.paddingBottom(1, em)
|
||||||
|
.gap(0.3, em)
|
||||||
|
.alignItems("flex-start")
|
||||||
|
})
|
||||||
|
.width(100, pct)
|
||||||
|
.background(util.darkMode() ? "var(--darkred)" : "var(--sidebottombars)")
|
||||||
|
.borderTopLeftRadius("10px").borderTopRightRadius("10px")
|
||||||
|
.border("1px solid var(--divider)")
|
||||||
|
.boxSizing("border-box").flexShrink(0)
|
||||||
|
.alignItems("flex-start")
|
||||||
|
}
|
||||||
|
|
||||||
|
updateCalendarDisplay() {
|
||||||
|
const el = this.$("#calendar-display")
|
||||||
|
if (!el) return
|
||||||
|
el.innerHTML = this.selectedCalendars.map(cal => `
|
||||||
|
<div style="display:flex;align-items:center;gap:0.3em;">
|
||||||
|
<div style="width:0.6em;height:0.6em;background:${cal.color};border-radius:50%;flex-shrink:0;"></div>
|
||||||
|
<p style="font-size:0.82em;font-weight:600;margin:0;color:var(--text);white-space:nowrap;">${cal.name}</p>
|
||||||
|
</div>
|
||||||
|
`).join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
showError(msg) {
|
||||||
|
const wrap = this.$("#eventdetails-toast-wrap")
|
||||||
|
const toast = this.$("#eventdetails-toast")
|
||||||
|
if (!wrap || !toast) return
|
||||||
|
clearTimeout(this._errorTimer)
|
||||||
|
if (msg) {
|
||||||
|
toast.innerText = msg
|
||||||
|
wrap.style.maxHeight = "3em"
|
||||||
|
wrap.style.opacity = "1"
|
||||||
|
wrap.style.paddingTop = "0.85em"
|
||||||
|
this._errorTimer = setTimeout(() => this.hideError(), 3500)
|
||||||
|
} else {
|
||||||
|
this.hideError()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hideError() {
|
||||||
|
const wrap = this.$("#eventdetails-toast-wrap")
|
||||||
|
if (!wrap) return
|
||||||
|
clearTimeout(this._errorTimer)
|
||||||
|
wrap.style.maxHeight = "0"
|
||||||
|
wrap.style.opacity = "0"
|
||||||
|
wrap.style.paddingTop = "0"
|
||||||
|
}
|
||||||
|
|
||||||
|
saveCalendars(prevCalendars) {
|
||||||
|
const event = this.event;
|
||||||
|
const newCalendars = this.selectedCalendars.map(c => c.id);
|
||||||
|
const isRecurring = !!(event._isOccurrence || event.recurrence_parent_id || event.recurrence_id);
|
||||||
|
|
||||||
|
this._prevCalendars = prevCalendars;
|
||||||
|
this._pendingCalendars = newCalendars;
|
||||||
|
|
||||||
|
if (isRecurring) {
|
||||||
|
$('actionsheetpopup-').show(
|
||||||
|
"Edit Recurring Event",
|
||||||
|
[
|
||||||
|
{ label: "Edit just this event", onTap: () => this.performCalendarSave('single'), destructive: false },
|
||||||
|
{ label: "Edit this and future events", onTap: () => this.performCalendarSave('future'), destructive: false },
|
||||||
|
{ label: "Edit all events in series", onTap: () => this.performCalendarSave('all'), destructive: false },
|
||||||
|
],
|
||||||
|
() => {
|
||||||
|
this._pendingCalendars = null;
|
||||||
|
this._revertCalendars(prevCalendars);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.performCalendarSave(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
_revertCalendars(prev) {
|
||||||
|
this.selectedCalendars = prev;
|
||||||
|
this.calendars.forEach(cal => {
|
||||||
|
const check = this.$(`#cal-check-${cal.id}`);
|
||||||
|
if (check) check.style.display = prev.some(c => c.id === cal.id) ? "" : "none";
|
||||||
|
});
|
||||||
|
this.updateCalendarDisplay();
|
||||||
|
}
|
||||||
|
|
||||||
|
async performCalendarSave(scope) {
|
||||||
|
const event = this.event;
|
||||||
|
const newCalendars = this._pendingCalendars ?? this.selectedCalendars.map(c => c.id);
|
||||||
|
this._pendingCalendars = null;
|
||||||
|
const prevCalendars = this._prevCalendars;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (scope) {
|
||||||
|
const isOverride = !!event.recurrence_parent_id;
|
||||||
|
const templateId = isOverride ? event.recurrence_parent_id : event.id;
|
||||||
|
const occurrenceDate = isOverride
|
||||||
|
? (event.recurrence_exception_date instanceof Date
|
||||||
|
? event.recurrence_exception_date.toISOString()
|
||||||
|
: event.recurrence_exception_date) ?? null
|
||||||
|
: event._occurrenceDate?.toISOString() ?? null;
|
||||||
|
const serverEventId = (scope === 'single' && isOverride) ? event.id : templateId;
|
||||||
|
|
||||||
|
const result = await server.editEvent(serverEventId, {
|
||||||
|
title: event.title,
|
||||||
|
description: event.description ?? null,
|
||||||
|
location: event.location ?? null,
|
||||||
|
time_start: event.time_start instanceof Date ? event.time_start.toISOString() : event.time_start,
|
||||||
|
time_end: event.time_end instanceof Date ? event.time_end.toISOString() : event.time_end,
|
||||||
|
all_day: event.all_day,
|
||||||
|
calendars: newCalendars,
|
||||||
|
scope,
|
||||||
|
exception_date: occurrenceDate
|
||||||
|
}, global.currentNetwork.id);
|
||||||
|
|
||||||
|
if (result.status === 200) {
|
||||||
|
const editResult = {
|
||||||
|
scope,
|
||||||
|
event: { ...result.event, calendars: newCalendars, attachments: event.attachments ?? [] },
|
||||||
|
templateId,
|
||||||
|
occurrenceDate
|
||||||
|
};
|
||||||
|
this.event = { ...editResult.event, time_start: new Date(result.event.time_start), time_end: new Date(result.event.time_end) };
|
||||||
|
this.selectedCalendars = this.calendars.filter(c => newCalendars.includes(c.id));
|
||||||
|
this._prevCalendars = null;
|
||||||
|
$("bottomsheet-")._closeOverride = null;
|
||||||
|
this.onEventEdited(editResult);
|
||||||
|
} else {
|
||||||
|
this._revertCalendars(prevCalendars);
|
||||||
|
this.showError(result.error ?? "Failed to update calendars.");
|
||||||
|
$("bottomsheet-")._closeOverride = () => $("bottomsheet-").forceClose();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const result = await server.editEvent(event.id, { ...event, calendars: newCalendars }, global.currentNetwork.id);
|
||||||
|
if (result.status === 200) {
|
||||||
|
this.event = { ...event, calendars: newCalendars };
|
||||||
|
this._prevCalendars = null;
|
||||||
|
$("bottomsheet-")._closeOverride = null;
|
||||||
|
this.onEventEdited(this.event);
|
||||||
|
} else {
|
||||||
|
this._revertCalendars(prevCalendars);
|
||||||
|
this.showError(result.error ?? "Failed to update calendars.");
|
||||||
|
$("bottomsheet-")._closeOverride = () => $("bottomsheet-").forceClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to update calendars:", err);
|
||||||
|
this._revertCalendars(prevCalendars);
|
||||||
|
this.showError("Failed to update calendars.");
|
||||||
|
$("bottomsheet-")._closeOverride = () => $("bottomsheet-").forceClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleDelete() {
|
||||||
|
const event = this.event;
|
||||||
|
const isRecurring = !!(event?._isOccurrence || event?.recurrence_parent_id || event?.recurrence_id);
|
||||||
|
if (isRecurring) {
|
||||||
|
$('actionsheetpopup-').show(
|
||||||
|
"Delete Recurring Event",
|
||||||
|
[
|
||||||
|
{ label: "Delete just this event", onTap: () => this.performDelete('single') },
|
||||||
|
{ label: "Delete this and future events", onTap: () => this.performDelete('future') },
|
||||||
|
{ label: "Delete all events in series", onTap: () => this.performDelete('all') },
|
||||||
|
],
|
||||||
|
() => {}
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.performDelete(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async performDelete(scope) {
|
||||||
|
const event = this.event;
|
||||||
|
const isOverride = !!event.recurrence_parent_id;
|
||||||
|
const templateId = isOverride ? event.recurrence_parent_id : event.id;
|
||||||
|
const occurrenceDate = isOverride
|
||||||
|
? (event.recurrence_exception_date instanceof Date
|
||||||
|
? event.recurrence_exception_date.toISOString()
|
||||||
|
: event.recurrence_exception_date) ?? null
|
||||||
|
: event._occurrenceDate?.toISOString() ?? null;
|
||||||
|
const serverEventId = (scope === 'single' && isOverride) ? event.id : templateId;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await server.deleteEvent(serverEventId, global.currentNetwork.id, scope, occurrenceDate)
|
||||||
|
if (result.status === 200) {
|
||||||
|
$("bottomsheet-").toggle()
|
||||||
|
const deleteResult = { scope: scope ?? 'all', templateId, occurrenceDate, overrideId: isOverride ? event.id : null };
|
||||||
|
setTimeout(() => this.onEventDeleted(deleteResult), 300)
|
||||||
|
} else {
|
||||||
|
this.showError(result.error ?? "Failed to delete event.")
|
||||||
|
$("bottomsheet-")._closeOverride = () => $("bottomsheet-").forceClose()
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to delete event:", err)
|
||||||
|
this.showError("Failed to delete event.")
|
||||||
|
$("bottomsheet-")._closeOverride = () => $("bottomsheet-").forceClose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderFile(file) {
|
||||||
|
const isImage = file.type?.startsWith("image/");
|
||||||
|
const url = `${config.SERVER}/db/images/events/${file.name}`;
|
||||||
|
|
||||||
|
if (isImage) {
|
||||||
|
img(url, "100%", "100%")
|
||||||
|
.borderRadius(8, px).display("block").boxSizing("border-box")
|
||||||
|
.cursor("pointer")
|
||||||
|
.onTap(() => $("filepreview-")?.open(file, url))
|
||||||
|
} else {
|
||||||
|
HStack(() => {
|
||||||
|
p("📎").margin(0).fontSize(1, em)
|
||||||
|
p(file.original_name ?? file.name)
|
||||||
|
.margin(0).color("var(--text)").fontSize(0.9, em)
|
||||||
|
.overflow("hidden").whiteSpace("nowrap").textOverflow("ellipsis")
|
||||||
|
})
|
||||||
|
.gap(0.5, em).alignItems("center")
|
||||||
|
.padding(0.5, em)
|
||||||
|
.background("var(--searchbackground)")
|
||||||
|
.borderRadius(8, px).boxSizing("border-box")
|
||||||
|
.cursor("pointer")
|
||||||
|
.onTap(() => $("filepreview-")?.open(file, url))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleAttachments() {
|
||||||
|
this.attachmentsOpen = !this.attachmentsOpen;
|
||||||
|
const content = this.$("#attachments-content");
|
||||||
|
const chevron = this.$("#attachments-chevron");
|
||||||
|
if (content) content.style.maxHeight = this.attachmentsOpen ? content.scrollHeight + "px" : "0";
|
||||||
|
if (chevron) chevron.style.transform = this.attachmentsOpen ? "rotate(180deg)" : "rotate(0deg)";
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
register(EventDetails)
|
||||||
1156
calendar/Events/EventForm.js
Normal file
298
calendar/Events/FilePreview.js
Normal file
@@ -0,0 +1,298 @@
|
|||||||
|
css(`
|
||||||
|
filepreview- {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 300;
|
||||||
|
pointer-events: none;
|
||||||
|
font-family: 'Arial';
|
||||||
|
}
|
||||||
|
`)
|
||||||
|
|
||||||
|
class FilePreview extends Shadow {
|
||||||
|
_file = null
|
||||||
|
_url = null
|
||||||
|
_panelEl = null
|
||||||
|
|
||||||
|
open(file, url) {
|
||||||
|
this._file = file
|
||||||
|
this._url = url
|
||||||
|
this.rerender()
|
||||||
|
requestAnimationFrame(() => requestAnimationFrame(() => {
|
||||||
|
if (this._panelEl) {
|
||||||
|
this._panelEl.style.transition = "transform 0.35s cubic-bezier(0.32, 0.72, 0, 1)"
|
||||||
|
this._panelEl.style.transform = "translateY(0)"
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
if (!this._panelEl) return
|
||||||
|
this._panelEl.style.transition = "transform 0.3s cubic-bezier(0.32, 0.72, 0, 1)"
|
||||||
|
this._panelEl.style.transform = "translateY(100%)"
|
||||||
|
setTimeout(() => {
|
||||||
|
this._file = null
|
||||||
|
this._url = null
|
||||||
|
this._panelEl = null
|
||||||
|
this.rerender()
|
||||||
|
}, 300)
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (!this._file) {
|
||||||
|
this.style.pointerEvents = "none"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.style.pointerEvents = "all"
|
||||||
|
|
||||||
|
const type = this._file?.type ?? ""
|
||||||
|
const isImage = type.startsWith("image/")
|
||||||
|
const isPDF = type === "application/pdf"
|
||||||
|
const displayName = this._file?.original_name ?? this._file?.name ?? "File"
|
||||||
|
|
||||||
|
this._panelEl = VStack(() => {
|
||||||
|
this._renderHeader(displayName, isImage)
|
||||||
|
|
||||||
|
if (isImage) {
|
||||||
|
this._renderImageViewer()
|
||||||
|
} else if (isPDF) {
|
||||||
|
this._renderPDFViewer()
|
||||||
|
} else {
|
||||||
|
this._renderFileFallback(displayName)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.position("fixed")
|
||||||
|
.inset(0)
|
||||||
|
.background(isImage ? "#000" : "var(--main)")
|
||||||
|
.transform("translateY(100%)")
|
||||||
|
|
||||||
|
this._addPanelSwipe()
|
||||||
|
}
|
||||||
|
|
||||||
|
_renderHeader(displayName, isImage) {
|
||||||
|
HStack(() => {
|
||||||
|
button("⬇")
|
||||||
|
.fontSize(1.15, em)
|
||||||
|
.color("var(--headertext)")
|
||||||
|
.background("transparent")
|
||||||
|
.border("none")
|
||||||
|
.outline("none")
|
||||||
|
.padding(0)
|
||||||
|
.paddingLeft(1, rem)
|
||||||
|
.flexShrink(0)
|
||||||
|
.cursor("pointer")
|
||||||
|
.onTap(() => window.open(this._url, "_blank"))
|
||||||
|
|
||||||
|
p(displayName)
|
||||||
|
.margin(0)
|
||||||
|
.flex(1)
|
||||||
|
.fontSize(0.88, em)
|
||||||
|
.fontWeight("600")
|
||||||
|
.color("var(--headertext)")
|
||||||
|
.overflow("hidden")
|
||||||
|
.whiteSpace("nowrap")
|
||||||
|
.textOverflow("ellipsis")
|
||||||
|
.textAlign("center")
|
||||||
|
|
||||||
|
button("Done")
|
||||||
|
.fontSize(0.88, em)
|
||||||
|
.fontWeight("600")
|
||||||
|
.color("var(--quillred)")
|
||||||
|
.background("transparent")
|
||||||
|
.border("none")
|
||||||
|
.outline("none")
|
||||||
|
.padding(0)
|
||||||
|
.paddingRight(1, rem)
|
||||||
|
.flexShrink(0)
|
||||||
|
.cursor("pointer")
|
||||||
|
.onTap(() => this.close())
|
||||||
|
})
|
||||||
|
.width(100, pct)
|
||||||
|
.height(52, px)
|
||||||
|
.gap(0.75, rem)
|
||||||
|
.alignItems("center")
|
||||||
|
.justifyContent("center")
|
||||||
|
.background(util.darkMode() ? "var(--darkaccent)" : "var(--sidebottombars)")
|
||||||
|
.borderBottom("1px solid var(--divider)")
|
||||||
|
.boxSizing("border-box")
|
||||||
|
.flexShrink(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
_renderImageViewer() {
|
||||||
|
let imgEl = null
|
||||||
|
|
||||||
|
const container = VStack(() => {
|
||||||
|
imgEl = img(this._url, "auto", "auto")
|
||||||
|
.display("block")
|
||||||
|
.maxWidth(100, pct)
|
||||||
|
.maxHeight(100, pct)
|
||||||
|
.objectFit("contain")
|
||||||
|
.pointerEvents("none")
|
||||||
|
})
|
||||||
|
.flex(1)
|
||||||
|
.width(100, pct)
|
||||||
|
.alignItems("center")
|
||||||
|
.justifyContent("center")
|
||||||
|
.overflow("hidden")
|
||||||
|
|
||||||
|
if (imgEl) this._setupImageGestures(container, imgEl)
|
||||||
|
}
|
||||||
|
|
||||||
|
_setupImageGestures(container, imgEl) {
|
||||||
|
let scale = 1, tx = 0, ty = 0
|
||||||
|
let pinchStartDist = null
|
||||||
|
let panStartX = 0, panStartY = 0
|
||||||
|
let swipeStartY = null, swipeStartTime = null
|
||||||
|
let lastTap = 0
|
||||||
|
|
||||||
|
container.style.touchAction = "none"
|
||||||
|
|
||||||
|
const applyTransform = (animated = false) => {
|
||||||
|
imgEl.style.transition = animated ? "transform 0.3s ease" : ""
|
||||||
|
imgEl.style.transform = `translate(${tx}px, ${ty}px) scale(${scale})`
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetZoom = () => {
|
||||||
|
scale = 1; tx = 0; ty = 0
|
||||||
|
applyTransform(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
container.addEventListener("touchstart", (e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
if (e.touches.length === 2) {
|
||||||
|
pinchStartDist = Math.hypot(
|
||||||
|
e.touches[1].clientX - e.touches[0].clientX,
|
||||||
|
e.touches[1].clientY - e.touches[0].clientY
|
||||||
|
)
|
||||||
|
swipeStartY = null
|
||||||
|
} else if (e.touches.length === 1) {
|
||||||
|
panStartX = e.touches[0].clientX - tx
|
||||||
|
panStartY = e.touches[0].clientY - ty
|
||||||
|
if (scale <= 1) {
|
||||||
|
swipeStartY = e.touches[0].clientY
|
||||||
|
swipeStartTime = Date.now()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, { passive: true })
|
||||||
|
|
||||||
|
container.addEventListener("touchmove", (e) => {
|
||||||
|
if (e.touches.length === 2 && pinchStartDist !== null) {
|
||||||
|
const dist = Math.hypot(
|
||||||
|
e.touches[1].clientX - e.touches[0].clientX,
|
||||||
|
e.touches[1].clientY - e.touches[0].clientY
|
||||||
|
)
|
||||||
|
scale = Math.min(Math.max(scale * (dist / pinchStartDist), 1), 6)
|
||||||
|
pinchStartDist = dist
|
||||||
|
applyTransform()
|
||||||
|
} else if (e.touches.length === 1 && scale > 1) {
|
||||||
|
tx = e.touches[0].clientX - panStartX
|
||||||
|
ty = e.touches[0].clientY - panStartY
|
||||||
|
applyTransform()
|
||||||
|
} else if (e.touches.length === 1 && swipeStartY !== null) {
|
||||||
|
const dy = e.touches[0].clientY - swipeStartY
|
||||||
|
if (dy > 5 && this._panelEl) {
|
||||||
|
this._panelEl.style.transition = ""
|
||||||
|
this._panelEl.style.transform = `translateY(${Math.max(0, dy)}px)`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, { passive: true })
|
||||||
|
|
||||||
|
container.addEventListener("touchend", (e) => {
|
||||||
|
if (e.touches.length < 2) pinchStartDist = null
|
||||||
|
|
||||||
|
if (swipeStartY !== null) {
|
||||||
|
const dy = e.changedTouches[0].clientY - swipeStartY
|
||||||
|
const vel = dy / Math.max(1, Date.now() - swipeStartTime)
|
||||||
|
if (dy > window.innerHeight * 0.25 || vel > 0.5) {
|
||||||
|
this.close()
|
||||||
|
} else if (this._panelEl) {
|
||||||
|
this._panelEl.style.transition = "transform 0.3s ease"
|
||||||
|
this._panelEl.style.transform = "translateY(0)"
|
||||||
|
}
|
||||||
|
swipeStartY = null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scale < 1) resetZoom()
|
||||||
|
|
||||||
|
const now = Date.now()
|
||||||
|
if (e.changedTouches.length === 1 && now - lastTap < 300) {
|
||||||
|
scale > 1 ? resetZoom() : (() => { scale = 2.5; applyTransform(true) })()
|
||||||
|
}
|
||||||
|
lastTap = now
|
||||||
|
}, { passive: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
_addPanelSwipe() {
|
||||||
|
let startY = null, startTime = null
|
||||||
|
|
||||||
|
this._panelEl.addEventListener("touchstart", (e) => {
|
||||||
|
if (e.touches.length !== 1) { startY = null; return }
|
||||||
|
startY = e.touches[0].clientY
|
||||||
|
startTime = Date.now()
|
||||||
|
}, { passive: true })
|
||||||
|
|
||||||
|
this._panelEl.addEventListener("touchmove", (e) => {
|
||||||
|
if (startY === null || e.touches.length !== 1) return
|
||||||
|
const dy = e.touches[0].clientY - startY
|
||||||
|
if (dy > 0 && this._panelEl) {
|
||||||
|
this._panelEl.style.transition = ""
|
||||||
|
this._panelEl.style.transform = `translateY(${dy}px)`
|
||||||
|
}
|
||||||
|
}, { passive: true })
|
||||||
|
|
||||||
|
this._panelEl.addEventListener("touchend", (e) => {
|
||||||
|
if (startY === null) return
|
||||||
|
const dy = e.changedTouches[0].clientY - startY
|
||||||
|
const vel = dy / Math.max(1, Date.now() - startTime)
|
||||||
|
if (dy > window.innerHeight * 0.25 || vel > 0.5) {
|
||||||
|
this.close()
|
||||||
|
} else if (this._panelEl) {
|
||||||
|
this._panelEl.style.transition = "transform 0.3s ease"
|
||||||
|
this._panelEl.style.transform = "translateY(0)"
|
||||||
|
}
|
||||||
|
startY = null
|
||||||
|
}, { passive: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
_renderPDFViewer() {
|
||||||
|
const wrap = div()
|
||||||
|
.flex(1)
|
||||||
|
.width(100, pct)
|
||||||
|
.overflow("hidden")
|
||||||
|
wrap.innerHTML = `<iframe src="${this._url}" style="width:100%;height:100%;border:none;display:block;"></iframe>`
|
||||||
|
}
|
||||||
|
|
||||||
|
_renderFileFallback(displayName) {
|
||||||
|
const type = this._file?.type ?? ""
|
||||||
|
const icon = {
|
||||||
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': "📄",
|
||||||
|
'application/msword': "📄",
|
||||||
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': "📊",
|
||||||
|
'application/vnd.ms-excel': "📊",
|
||||||
|
'application/vnd.openxmlformats-officedocument.presentationml.presentation': "📋",
|
||||||
|
'application/vnd.ms-powerpoint': "📋",
|
||||||
|
'text/plain': "📄",
|
||||||
|
}[type] ?? "📎"
|
||||||
|
|
||||||
|
VStack(() => {
|
||||||
|
p(icon).fontSize(3, em).margin(0)
|
||||||
|
p(displayName)
|
||||||
|
.margin(0)
|
||||||
|
.fontSize(0.9, em)
|
||||||
|
.color("var(--headertext)")
|
||||||
|
.textAlign("center")
|
||||||
|
a(this._url, "Open / Download")
|
||||||
|
.attr({ download: displayName })
|
||||||
|
.fontSize(0.9, em)
|
||||||
|
.color("var(--quillred)")
|
||||||
|
.fontWeight("600")
|
||||||
|
.textDecoration("none")
|
||||||
|
.marginTop(0.5, em)
|
||||||
|
})
|
||||||
|
.flex(1)
|
||||||
|
.alignItems("center")
|
||||||
|
.justifyContent("center")
|
||||||
|
.gap(0.75, em)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
register(FilePreview)
|
||||||
219
calendar/Month/MonthGrid.js
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
import calendarUtil from "../calendarUtil.js";
|
||||||
|
|
||||||
|
css(`
|
||||||
|
monthgrid- {
|
||||||
|
scrollbar-width: none;
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
}
|
||||||
|
monthgrid-::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
`)
|
||||||
|
|
||||||
|
class MonthGrid extends Shadow {
|
||||||
|
constructor(weeks, calendars, onDayTap = null) {
|
||||||
|
super()
|
||||||
|
this.weeks = weeks;
|
||||||
|
this.calendars = calendars;
|
||||||
|
this.onDayTap = onDayTap;
|
||||||
|
this.maxVisible = 3; // caps both rendering and row height calculation
|
||||||
|
|
||||||
|
// em
|
||||||
|
this.dateFontSize = 1.2;
|
||||||
|
this.dateLineHeight = 1;
|
||||||
|
this.paddingTop = 1.5;
|
||||||
|
this.paddingBottom = 0.55;
|
||||||
|
|
||||||
|
// em
|
||||||
|
this.pillHeight = 1.15;
|
||||||
|
this.pillGap = 0.2;
|
||||||
|
this.rowBottomPadding = 0.2;
|
||||||
|
|
||||||
|
this.headerHeight = this.paddingTop + (this.dateFontSize * this.dateLineHeight) + this.paddingBottom;
|
||||||
|
this.rowHeight = this.headerHeight + this.maxVisible * (this.pillHeight + this.pillGap) + this.rowBottomPadding;
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
VStack(() => {
|
||||||
|
this.weeks.forEach((week, wi) => {
|
||||||
|
this.renderWeekRow(week, wi === this.weeks.length - 1);
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.width(100, pct)
|
||||||
|
.height(this.rowHeight * this.weeks.length, em)
|
||||||
|
.flexShrink(0)
|
||||||
|
.flex(1)
|
||||||
|
.overflowY("scroll")
|
||||||
|
}
|
||||||
|
|
||||||
|
renderWeekRow(week, isLastWeek) {
|
||||||
|
ZStack(() => {
|
||||||
|
this.renderCellLayer(week, isLastWeek)
|
||||||
|
this.renderPillLayer(week)
|
||||||
|
})
|
||||||
|
.position("relative")
|
||||||
|
.width(100, pct)
|
||||||
|
.height(this.rowHeight + 0.5, em)
|
||||||
|
.flexShrink(0)
|
||||||
|
.alignItems("stretch")
|
||||||
|
}
|
||||||
|
|
||||||
|
renderCellLayer(week, isLastWeek) {
|
||||||
|
HStack(() => {
|
||||||
|
week.days.forEach((dayData, di) => {
|
||||||
|
this.renderDayCell(dayData, di === 6, isLastWeek)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.width(100, pct)
|
||||||
|
.alignItems("stretch")
|
||||||
|
.height(100, pct)
|
||||||
|
}
|
||||||
|
|
||||||
|
renderDayCell(dayData, isLast, isLastWeek) {
|
||||||
|
const { day, isCurrentMonth } = dayData;
|
||||||
|
const today = calendarUtil.isToday(day);
|
||||||
|
|
||||||
|
VStack(() => {
|
||||||
|
HStack(() => {
|
||||||
|
p(day.getDate())
|
||||||
|
.margin(0)
|
||||||
|
.fontSize(this.dateFontSize, em)
|
||||||
|
.fontWeight(today ? "700" : "500")
|
||||||
|
.color(today ? "white" : "var(--headertext)")
|
||||||
|
.background(today ? "var(--quillred)" : "transparent")
|
||||||
|
.paddingHorizontal(0.2, em)
|
||||||
|
.paddingVertical(0.125, em)
|
||||||
|
.borderRadius(25, pct)
|
||||||
|
.textAlign("center")
|
||||||
|
.opacity(isCurrentMonth ? 1 : 0)
|
||||||
|
.lineHeight(`${this.dateLineHeight}`)
|
||||||
|
|
||||||
|
})
|
||||||
|
.position("relative")
|
||||||
|
.justifyContent("center")
|
||||||
|
.paddingTop(this.paddingTop, em)
|
||||||
|
.paddingHorizontal(0.4, em)
|
||||||
|
.paddingBottom(this.paddingBottom, em)
|
||||||
|
})
|
||||||
|
.flex(1)
|
||||||
|
.width(0, px)
|
||||||
|
.minWidth(0)
|
||||||
|
.height(100, pct)
|
||||||
|
.borderBottom(isLastWeek ? "1px solid transparent" : "0.5px solid var(--divider)")
|
||||||
|
.boxSizing("border-box")
|
||||||
|
.overflow("hidden")
|
||||||
|
.alignItems("stretch")
|
||||||
|
.cursor("pointer")
|
||||||
|
.onTap(() => { this.onDayTap(day) })
|
||||||
|
}
|
||||||
|
|
||||||
|
renderPillLayer(week) {
|
||||||
|
ZStack(() => {
|
||||||
|
const maxSlots = Math.max(0, ...week.slotMap.map(s => s.length));
|
||||||
|
|
||||||
|
for (let row = 0; row < Math.min(maxSlots, this.maxVisible); row++) {
|
||||||
|
this.collectSpans(week, row).forEach(span => this.renderPill(span, week, row))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Overflow labels
|
||||||
|
week.days.forEach((dayData, col) => {
|
||||||
|
if (!dayData.isCurrentMonth) return;
|
||||||
|
const overflow = Math.max(0, dayData.events.length - this.maxVisible);
|
||||||
|
if (overflow === 0) return;
|
||||||
|
|
||||||
|
const leftPct = (col / 7) * 100;
|
||||||
|
|
||||||
|
p(`+${overflow} more`)
|
||||||
|
.margin(0)
|
||||||
|
.fontSize(0.62, em)
|
||||||
|
.fontWeight("600")
|
||||||
|
.color("var(--headertext)")
|
||||||
|
.opacity(0.5)
|
||||||
|
.position("absolute")
|
||||||
|
.bottom(this.rowBottomPadding, em)
|
||||||
|
.left(leftPct, pct)
|
||||||
|
.width(100 / 7, pct)
|
||||||
|
.paddingHorizontal(0.4, em)
|
||||||
|
.zIndex(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.position("absolute")
|
||||||
|
.top(0).left(0).right(0).bottom(0)
|
||||||
|
.pointerEvents("none")
|
||||||
|
}
|
||||||
|
|
||||||
|
renderPill({ startCol, endCol, event }, week, row) {
|
||||||
|
const color = calendarUtil.getCalendarColor(this.calendars, event.calendars.find(id => this.calendars.some(c => c.id === id)) ?? event.calendars[0]);
|
||||||
|
const leftPct = (startCol / 7) * 100;
|
||||||
|
const widthPct = ((endCol - startCol + 1) / 7) * 100;
|
||||||
|
const topEm = this.headerHeight + row * (this.pillHeight + this.pillGap);
|
||||||
|
|
||||||
|
const clippedLeft = startCol === 0 && calendarUtil.startOfDay(event.time_start) < calendarUtil.startOfDay(week.days[0].day);
|
||||||
|
const clippedRight = endCol === 6 && calendarUtil.endOfDay(event.time_end) > calendarUtil.endOfDay(week.days[6].day);
|
||||||
|
|
||||||
|
const colWidthPct = 100 / 7;
|
||||||
|
const leftInsetPct = clippedLeft ? 0 : 0.025 * colWidthPct;
|
||||||
|
const rightInsetPct = clippedRight ? 0 : 0.025 * colWidthPct;
|
||||||
|
|
||||||
|
const brLeft = clippedLeft ? 0 : 5;
|
||||||
|
const brRight = clippedRight ? 0 : 5;
|
||||||
|
|
||||||
|
HStack(() => {
|
||||||
|
p(event.title || "Untitled")
|
||||||
|
.margin(0)
|
||||||
|
.fontSize(0.9, em)
|
||||||
|
.fontWeight("600")
|
||||||
|
.color("white")
|
||||||
|
.whiteSpace("nowrap")
|
||||||
|
.overflow("hidden")
|
||||||
|
})
|
||||||
|
.position("absolute")
|
||||||
|
.top(topEm, em)
|
||||||
|
.left(leftPct + leftInsetPct, pct)
|
||||||
|
.width(widthPct - leftInsetPct - rightInsetPct, pct)
|
||||||
|
.height(this.pillHeight, em)
|
||||||
|
.paddingHorizontal(0.4, em)
|
||||||
|
.background(color)
|
||||||
|
.borderTopLeftRadius(`${brLeft}px`)
|
||||||
|
.borderBottomLeftRadius(`${brLeft}px`)
|
||||||
|
.borderTopRightRadius(`${brRight}px`)
|
||||||
|
.borderBottomRightRadius(`${brRight}px`)
|
||||||
|
.alignItems("center")
|
||||||
|
.overflow("hidden")
|
||||||
|
.boxSizing("border-box")
|
||||||
|
.zIndex(1)
|
||||||
|
.pointerEvents("auto")
|
||||||
|
.cursor("pointer")
|
||||||
|
.onTap(() => $("bottomsheet-").showEvent(event))
|
||||||
|
}
|
||||||
|
|
||||||
|
collectSpans(week, row) {
|
||||||
|
const spans = [];
|
||||||
|
let current = null;
|
||||||
|
|
||||||
|
for (let col = 0; col < 7; col++) {
|
||||||
|
const slot = (week.slotMap[col] || [])[row] ?? null;
|
||||||
|
|
||||||
|
if (slot && current && slot.event === current.event) {
|
||||||
|
current.endCol = col;
|
||||||
|
} else {
|
||||||
|
if (current) spans.push(current);
|
||||||
|
current = slot ? { startCol: col, endCol: col, event: slot.event } : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current) spans.push(current);
|
||||||
|
|
||||||
|
// Only render spans that touch at least one current-month day
|
||||||
|
return spans.filter(span => {
|
||||||
|
for (let col = span.startCol; col <= span.endCol; col++) {
|
||||||
|
if (week.days[col]?.isCurrentMonth) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
register(MonthGrid)
|
||||||
31
calendar/Month/MonthHeaderRow.js
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
class MonthHeaderRow extends Shadow {
|
||||||
|
constructor(weekStartsOn) {
|
||||||
|
super()
|
||||||
|
this.weekStartsOn = weekStartsOn;
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const dayNames = ["S", "M", "T", "W", "T", "F", "S"];
|
||||||
|
const ordered = Array.from({ length: 7}, (_, i) => dayNames[(this.weekStartsOn + i) % 7]);
|
||||||
|
|
||||||
|
HStack(() => {
|
||||||
|
ordered.forEach(name => {
|
||||||
|
p(name)
|
||||||
|
.margin(0)
|
||||||
|
.fontSize(.8, em)
|
||||||
|
.fontWeight("500")
|
||||||
|
.letterSpacing(0.04, em)
|
||||||
|
.color("var(--headertext)")
|
||||||
|
.opacity(0.5)
|
||||||
|
.flex(1)
|
||||||
|
.textAlign("center")
|
||||||
|
.paddingVertical(0.6, em)
|
||||||
|
.boxSizing("border-box")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.width(100, pct)
|
||||||
|
.borderBottom("1px solid var(--divider)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
register(MonthHeaderRow)
|
||||||
137
calendar/Month/MonthView.js
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import calendarUtil from "../calendarUtil.js"
|
||||||
|
import "./MonthHeaderRow.js"
|
||||||
|
import "./MonthGrid.js"
|
||||||
|
|
||||||
|
css(`
|
||||||
|
monthview- {
|
||||||
|
scrollbar-width: none;
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
}
|
||||||
|
`)
|
||||||
|
|
||||||
|
class MonthView extends Shadow {
|
||||||
|
constructor(calendars, events, currentDate, weekStartsOn = 0, onDayTap = null) {
|
||||||
|
super()
|
||||||
|
this.calendars = calendars;
|
||||||
|
this.events = events;
|
||||||
|
this.currentDate = currentDate;
|
||||||
|
this.weekStartsOn = weekStartsOn;
|
||||||
|
this.onDayTap = onDayTap;
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const weeks = this.buildMonthWeeks();
|
||||||
|
|
||||||
|
VStack(() => {
|
||||||
|
MonthHeaderRow(this.weekStartsOn)
|
||||||
|
MonthGrid(weeks, this.calendars, this.onDayTap)
|
||||||
|
})
|
||||||
|
.width(100, pct)
|
||||||
|
.height(100, pct)
|
||||||
|
.boxSizing("border-box")
|
||||||
|
.fontSize(0.9, em)
|
||||||
|
}
|
||||||
|
|
||||||
|
buildMonthWeeks() {
|
||||||
|
const month = this.currentDate.getMonth();
|
||||||
|
|
||||||
|
const allDays = calendarUtil.buildMonthGrid(this.currentDate, this.weekStartsOn);
|
||||||
|
const gridStart = allDays[0];
|
||||||
|
|
||||||
|
// Split into weeks
|
||||||
|
const weeks = [];
|
||||||
|
for (let w = 0; w < 6; w++) {
|
||||||
|
weeks.push(allDays.slice(w * 7, w * 7 + 7));
|
||||||
|
}
|
||||||
|
|
||||||
|
const gridEnd = calendarUtil.endOfDay(weeks[weeks.length - 1][6]);
|
||||||
|
const expanded = calendarUtil.expandRecurringEvents(this.events, gridStart, gridEnd);
|
||||||
|
const relevantEvents = expanded.filter(event =>
|
||||||
|
calendarUtil.rangesOverlap(event.time_start, event.time_end, gridStart, gridEnd)
|
||||||
|
&& this.calendars.some(c => event.calendars?.some(id => id === c.id))
|
||||||
|
);
|
||||||
|
|
||||||
|
// Build week data with slot maps (for spanning event row alignment)
|
||||||
|
return weeks.map(week => this.buildWeekData(week, month, relevantEvents));
|
||||||
|
}
|
||||||
|
|
||||||
|
buildWeekData(week, currentMonth, events) {
|
||||||
|
const weekStart = calendarUtil.startOfDay(week[0]);
|
||||||
|
const weekEnd = calendarUtil.endOfDay(week[6]);
|
||||||
|
|
||||||
|
// Events that appear in this week
|
||||||
|
const weekEvents = events.filter(event =>
|
||||||
|
calendarUtil.rangesOverlap(event.time_start, event.time_end, weekStart, weekEnd)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Sort: all-day / multi-day first, then timed; then by start
|
||||||
|
weekEvents.sort((a, b) => {
|
||||||
|
const aSpan = a.all_day || this.isMultiDay(a);
|
||||||
|
const bSpan = b.all_day || this.isMultiDay(b);
|
||||||
|
if (aSpan !== bSpan) return aSpan ? -1 : 1;
|
||||||
|
return a.time_start - b.time_start;
|
||||||
|
});
|
||||||
|
|
||||||
|
// slotRows[row] = array of 7 entries (null or event)
|
||||||
|
const slotRows = [];
|
||||||
|
|
||||||
|
weekEvents.forEach(event => {
|
||||||
|
const startCol = Math.max(0, this.dayIndex(event.time_start, week));
|
||||||
|
const endCol = Math.min(6, this.dayIndex(event.time_end, week));
|
||||||
|
|
||||||
|
// Find first slot row where all cols [startCol..endCol] are free
|
||||||
|
let row = 0;
|
||||||
|
while (true) {
|
||||||
|
if (!slotRows[row]) slotRows[row] = new Array(7).fill(null);
|
||||||
|
if (slotRows[row].slice(startCol, endCol + 1).every(v => v === null)) { break; }
|
||||||
|
row++;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let c = startCol; c <= endCol; c++) {
|
||||||
|
slotRows[row][c] = {
|
||||||
|
event,
|
||||||
|
isStart: c === startCol,
|
||||||
|
isEnd: c === endCol,
|
||||||
|
isSingleDay: startCol === endCol
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Transpose: slotMap[colIndex] = ordered list of slot entries (or null gaps)
|
||||||
|
const slotMap = Array.from({ length: 7 }, (_, col) =>
|
||||||
|
slotRows.map(row => row[col] ?? null)
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
days: week.map(day => ({
|
||||||
|
day,
|
||||||
|
isCurrentMonth: day.getMonth() === currentMonth,
|
||||||
|
events: weekEvents.filter(event => {
|
||||||
|
const effectiveEnd = event.all_day ? calendarUtil.endOfDay(event.time_end) : event.time_end;
|
||||||
|
return calendarUtil.rangesOverlap(
|
||||||
|
event.time_start, effectiveEnd,
|
||||||
|
calendarUtil.startOfDay(day), calendarUtil.endOfDay(day)
|
||||||
|
);
|
||||||
|
})
|
||||||
|
})),
|
||||||
|
slotMap
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
isMultiDay(event) {
|
||||||
|
return calendarUtil.startOfDay(event.time_start).getTime() !==
|
||||||
|
calendarUtil.startOfDay(event.time_end).getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
dayIndex(date, week) {
|
||||||
|
const dayStart = calendarUtil.startOfDay(date);
|
||||||
|
for (let i = 0; i < 7; i++) {
|
||||||
|
if (calendarUtil.startOfDay(week[i]).getTime() === dayStart.getTime()) return i;
|
||||||
|
}
|
||||||
|
// Clamp to week boundaries for events that start/end outside the week
|
||||||
|
return date.getTime() < week[0].getTime() ? 0 : 6;
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
register(MonthView)
|
||||||
126
calendar/Toolbar/BottomBar.js
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
css(`
|
||||||
|
bottombar- {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
bottombar- select {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
text-align-last: center;
|
||||||
|
-webkit-text-align-last: center;
|
||||||
|
}
|
||||||
|
`)
|
||||||
|
|
||||||
|
class BottomBar extends Shadow {
|
||||||
|
baseUrl = `${config.UI}/apps/calendar/icons`
|
||||||
|
|
||||||
|
constructor(callbacks = {}) {
|
||||||
|
super()
|
||||||
|
this.onAddEvent = callbacks.onAddEvent;
|
||||||
|
this.onCalendarOptions = callbacks.onCalendarOptions;
|
||||||
|
this.onChangeView = callbacks.onChangeView;
|
||||||
|
this.viewMode = callbacks.viewMode;
|
||||||
|
this.hideViewSelect = callbacks.hideViewSelect ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
getImageURL(iconName) {
|
||||||
|
let imgUrl = `${this.baseUrl}/${iconName}`
|
||||||
|
if(util.darkMode()) {
|
||||||
|
imgUrl += "light"
|
||||||
|
}
|
||||||
|
imgUrl += ".svg"
|
||||||
|
imgUrl = imgUrl.toLowerCase()
|
||||||
|
return imgUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
HStack(() => {
|
||||||
|
// Left: view mode select
|
||||||
|
if (!this.hideViewSelect) select(() => {
|
||||||
|
["day", "week", "month"].forEach(mode => {
|
||||||
|
const o = option(mode.charAt(0).toUpperCase() + mode.slice(1))
|
||||||
|
.attr({ value: mode })
|
||||||
|
if (mode === this.viewMode) o.attr({ selected: true })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.fontSize(1, em)
|
||||||
|
.paddingVertical(0.6, em)
|
||||||
|
.paddingHorizontal(0.75, em)
|
||||||
|
.background("var(--button-color)")
|
||||||
|
.backdropFilter("blur(10px)")
|
||||||
|
.borderRadius(100, px)
|
||||||
|
.pointerEvents("auto")
|
||||||
|
.border(`.5px solid ${util.darkMode() ? "var(--accent)" : "transparent"}`)
|
||||||
|
.color(util.darkMode() ? "var(--text)" : "white")
|
||||||
|
.onTouch(function (start) {
|
||||||
|
if(start) {
|
||||||
|
this.background("var(--main)")
|
||||||
|
} else {
|
||||||
|
this.background("var(--button-color)")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.fontFamily("Arial")
|
||||||
|
.outline("none")
|
||||||
|
.appearance("none")
|
||||||
|
.textAlign("center")
|
||||||
|
.onChange(e => this.onChangeView(e.target.value))
|
||||||
|
|
||||||
|
// Right: add event + calendar options
|
||||||
|
HStack(() => {
|
||||||
|
const addEventImg = img(this.getImageURL("addevent"), "1em", "1em")
|
||||||
|
.paddingVertical(0.75, em).paddingHorizontal(1, em)
|
||||||
|
.onTap(async () => {
|
||||||
|
this.onAddEvent()
|
||||||
|
addEventImg.src = `${this.baseUrl}/addeventlightselected.svg`
|
||||||
|
const sheet = $("bottomsheet-")
|
||||||
|
const revert = () => {
|
||||||
|
if (!sheet.isOpen) {
|
||||||
|
addEventImg.src = this.getImageURL("addevent")
|
||||||
|
sheet.sheetEl.removeEventListener("transitionend", revert)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sheet.sheetEl.addEventListener("transitionend", revert)
|
||||||
|
})
|
||||||
|
|
||||||
|
const calendarSheetImg = img(this.getImageURL("calbutton"), "1.1em")
|
||||||
|
.paddingVertical(0.75, em).paddingHorizontal(1, em)
|
||||||
|
.onTap(async () => {
|
||||||
|
this.onCalendarOptions()
|
||||||
|
calendarSheetImg.src = `${this.baseUrl}/calbuttonlightselected.svg`
|
||||||
|
const sheet = $("bottomsheet-")
|
||||||
|
const revert = () => {
|
||||||
|
if (!sheet.isOpen) {
|
||||||
|
calendarSheetImg.src = this.getImageURL("calbutton")
|
||||||
|
sheet.sheetEl.removeEventListener("transitionend", revert)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sheet.sheetEl.addEventListener("transitionend", revert)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.background("var(--button-color)")
|
||||||
|
.pointerEvents("auto")
|
||||||
|
.onTouch(function (start) {
|
||||||
|
if(start) {
|
||||||
|
this.background("var(--main)")
|
||||||
|
} else {
|
||||||
|
this.background("var(--button-color)")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.backdropFilter("blur(10px)")
|
||||||
|
.alignItems("center")
|
||||||
|
.flexShrink(0)
|
||||||
|
.borderRadius(100, px)
|
||||||
|
.border(`0.5px solid ${util.darkMode() ? "var(--accent)" : "transparent"}`)
|
||||||
|
.overflow("hidden")
|
||||||
|
})
|
||||||
|
.position("absolute")
|
||||||
|
.left(4, vw)
|
||||||
|
.bottom(0.75, em)
|
||||||
|
.zIndex(2)
|
||||||
|
.justifyContent(this.hideViewSelect ? "flex-end" : "space-between")
|
||||||
|
.width(92, vw)
|
||||||
|
.onEvent("themechange", () => {
|
||||||
|
this.rerender()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
register(BottomBar)
|
||||||
256
calendar/Toolbar/CalendarOptions.js
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
import "../../components/BackButton.js"
|
||||||
|
import "../CalendarForm.js"
|
||||||
|
|
||||||
|
class CalendarOptions extends Shadow {
|
||||||
|
baseUrl = `${config.UI}/apps/calendar/icons`
|
||||||
|
|
||||||
|
getImageURL(iconName) {
|
||||||
|
let imgUrl = `${this.baseUrl}/${iconName}`
|
||||||
|
if (util.darkMode()) imgUrl += "light"
|
||||||
|
imgUrl += ".svg"
|
||||||
|
return imgUrl.toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(calendars = [], selectedCalendars = [], actions) {
|
||||||
|
super()
|
||||||
|
this.calendars = calendars;
|
||||||
|
this.selectedCalendars = selectedCalendars;
|
||||||
|
this.onSelection = actions.onSelection;
|
||||||
|
this.onCalendarAdded = actions.onCalendarAdded;
|
||||||
|
this.onCalendarUpdated = actions.onCalendarUpdated;
|
||||||
|
this.onCalendarDeleted = actions.onCalendarDeleted;
|
||||||
|
}
|
||||||
|
|
||||||
|
isSelected(calendar) {
|
||||||
|
return this.selectedCalendars.some(c => c.id === calendar.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleCalendar(calendar) {
|
||||||
|
const isSelected = this.isSelected(calendar);
|
||||||
|
|
||||||
|
if (isSelected && this.selectedCalendars.length === 1) return;
|
||||||
|
|
||||||
|
let newSelectedIds;
|
||||||
|
if (isSelected) {
|
||||||
|
newSelectedIds = this.selectedCalendars
|
||||||
|
.filter(c => c.id !== calendar.id)
|
||||||
|
.map(c => c.id);
|
||||||
|
} else {
|
||||||
|
newSelectedIds = [...this.selectedCalendars.map(c => c.id), calendar.id];
|
||||||
|
}
|
||||||
|
|
||||||
|
this.selectedCalendars = this.calendars.filter(c => newSelectedIds.includes(c.id));
|
||||||
|
this.rerender();
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
this.calFormSheet = BottomSheet(200)
|
||||||
|
|
||||||
|
const doClose = () => {
|
||||||
|
$("bottomsheet-")._closeOverride = null
|
||||||
|
$("bottomsheet-").setSheet(false)
|
||||||
|
setTimeout(() => this.onSelection(this.selectedCalendars), 300)
|
||||||
|
}
|
||||||
|
$("bottomsheet-")._closeOverride = doClose
|
||||||
|
|
||||||
|
VStack(() => {
|
||||||
|
HStack(() => {
|
||||||
|
BackButton(false, true, doClose)
|
||||||
|
.zIndex(3)
|
||||||
|
|
||||||
|
h2("Calendars")
|
||||||
|
.position("absolute")
|
||||||
|
.zIndex(2)
|
||||||
|
.color("var(--headertext)")
|
||||||
|
.paddingVertical(0.5, em)
|
||||||
|
.margin(0)
|
||||||
|
.top(0)
|
||||||
|
.left(0)
|
||||||
|
.width(100, pct)
|
||||||
|
})
|
||||||
|
.position("relative")
|
||||||
|
.background(util.darkMode() ? "var(--darkred)" : "var(--sidebottombars)")
|
||||||
|
.borderTopLeftRadius("10px").borderTopRightRadius("10px")
|
||||||
|
.border("1px solid var(--divider)")
|
||||||
|
.textAlign("center")
|
||||||
|
|
||||||
|
if (this.calendars.length > 0) {
|
||||||
|
h3("Visibility")
|
||||||
|
.marginBottom(0)
|
||||||
|
.marginLeft(1.25, em)
|
||||||
|
|
||||||
|
VStack(() => {
|
||||||
|
this.calendars.forEach(cal => {
|
||||||
|
const selected = this.isSelected(cal);
|
||||||
|
|
||||||
|
HStack(() => {
|
||||||
|
HStack(() => {
|
||||||
|
p("")
|
||||||
|
.width(2, em)
|
||||||
|
.height(2, em)
|
||||||
|
.background(selected ? cal.color : "transparent")
|
||||||
|
.borderRadius(100, pct)
|
||||||
|
.border(`3.5px solid ${cal.color}`)
|
||||||
|
.boxSizing("border-box")
|
||||||
|
p(cal.name)
|
||||||
|
.fontSize(1.1, em)
|
||||||
|
.opacity(selected ? 1 : 0.5)
|
||||||
|
})
|
||||||
|
.gap(1, em)
|
||||||
|
.alignItems("center")
|
||||||
|
.flex(1)
|
||||||
|
.cursor("pointer")
|
||||||
|
.onTap(async () => {
|
||||||
|
await capacitor.Haptics.impact({ style: capacitor.ImpactStyle.Light });
|
||||||
|
this.toggleCalendar(cal)
|
||||||
|
})
|
||||||
|
|
||||||
|
p("i")
|
||||||
|
.width(1.5, em)
|
||||||
|
.height(1.5, em)
|
||||||
|
.borderRadius(100, pct)
|
||||||
|
.border("2px solid var(--text)")
|
||||||
|
.boxSizing("border-box")
|
||||||
|
.display(cal.owner_id === global.profile.id ? "" : "none")
|
||||||
|
.textAlign("center")
|
||||||
|
.lineHeight("1.4em")
|
||||||
|
.fontSize(1.3, em)
|
||||||
|
.fontWeight("bold")
|
||||||
|
.color("var(--text)")
|
||||||
|
.opacity(0.5)
|
||||||
|
.flexShrink(0)
|
||||||
|
.cursor("pointer")
|
||||||
|
.onTap(async () => {
|
||||||
|
await capacitor.Haptics.impact({ style: capacitor.ImpactStyle.Light });
|
||||||
|
let calFormEl
|
||||||
|
const closeCalForm = () => {
|
||||||
|
this.calFormSheet._closeOverride = null
|
||||||
|
this.calFormSheet.setSheet(false)
|
||||||
|
}
|
||||||
|
const onSaveErrorEdit = () => {
|
||||||
|
this.calFormSheet._closeOverride = () => this.calFormSheet.forceClose()
|
||||||
|
}
|
||||||
|
this.calFormSheet.show(() => {
|
||||||
|
calFormEl = CalendarForm(
|
||||||
|
closeCalForm,
|
||||||
|
(updated) => {
|
||||||
|
closeCalForm()
|
||||||
|
setTimeout(() => {
|
||||||
|
this.calendars = this.calendars.map(c => c.id === updated.id ? updated : c);
|
||||||
|
this.selectedCalendars = this.selectedCalendars.map(c => c.id === updated.id ? updated : c);
|
||||||
|
this.onCalendarUpdated(updated);
|
||||||
|
this.rerender();
|
||||||
|
}, 300);
|
||||||
|
},
|
||||||
|
cal,
|
||||||
|
(deletedId) => {
|
||||||
|
closeCalForm()
|
||||||
|
setTimeout(() => {
|
||||||
|
this.calendars = this.calendars.filter(c => c.id !== deletedId);
|
||||||
|
this.selectedCalendars = this.selectedCalendars.filter(c => c.id !== deletedId);
|
||||||
|
this.onCalendarDeleted(deletedId);
|
||||||
|
this.rerender();
|
||||||
|
}, 300);
|
||||||
|
},
|
||||||
|
onSaveErrorEdit
|
||||||
|
).height(100, pct)
|
||||||
|
})
|
||||||
|
this.calFormSheet._closeOverride = async () => {
|
||||||
|
const saved = await calFormEl?.trySave()
|
||||||
|
if (saved === null) {
|
||||||
|
this.calFormSheet.setSheet(true)
|
||||||
|
this.calFormSheet._closeOverride = () => this.calFormSheet.forceClose()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
closeCalForm()
|
||||||
|
if (saved !== cal) {
|
||||||
|
setTimeout(() => {
|
||||||
|
this.calendars = this.calendars.map(c => c.id === saved.id ? saved : c)
|
||||||
|
this.selectedCalendars = this.selectedCalendars.map(c => c.id === saved.id ? saved : c)
|
||||||
|
this.onCalendarUpdated(saved)
|
||||||
|
this.rerender()
|
||||||
|
}, 300)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.padding(0.75, em)
|
||||||
|
.gap(0.75, em)
|
||||||
|
.alignItems("center")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.background("var(--darkaccent)")
|
||||||
|
.margin(1, em)
|
||||||
|
.marginTop(0.5, em)
|
||||||
|
.border("1px solid var(--divider)")
|
||||||
|
.borderRadius(12, px)
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack(() => {
|
||||||
|
img(this.getImageURL("calendar"), "1.5em", "1.5em")
|
||||||
|
p("Add Calendar")
|
||||||
|
.fontSize(1.1, em)
|
||||||
|
.paddingLeft(0.5, em)
|
||||||
|
})
|
||||||
|
.background("var(--darkaccent)")
|
||||||
|
.color("var(--text)")
|
||||||
|
.gap(0.75, em)
|
||||||
|
.alignItems("center")
|
||||||
|
.padding(1, em)
|
||||||
|
.paddingVertical(0.75, em)
|
||||||
|
.marginHorizontal(1, em)
|
||||||
|
.border("1px solid var(--divider)")
|
||||||
|
.borderRadius(12, px)
|
||||||
|
.cursor("pointer")
|
||||||
|
.onTap(async () => {
|
||||||
|
await capacitor.Haptics.impact({ style: capacitor.ImpactStyle.Light });
|
||||||
|
let calFormEl
|
||||||
|
const closeDirect = () => {
|
||||||
|
this.calFormSheet._closeOverride = null
|
||||||
|
this.calFormSheet.setSheet(false)
|
||||||
|
}
|
||||||
|
const onSaveErrorAdd = () => {
|
||||||
|
this.calFormSheet._closeOverride = () => this.calFormSheet.forceClose()
|
||||||
|
}
|
||||||
|
this.calFormSheet.show(() => {
|
||||||
|
calFormEl = CalendarForm(
|
||||||
|
closeDirect,
|
||||||
|
(calendar) => {
|
||||||
|
closeDirect()
|
||||||
|
setTimeout(() => {
|
||||||
|
this.calendars = [...this.calendars, calendar]
|
||||||
|
this.selectedCalendars = [...this.selectedCalendars, calendar]
|
||||||
|
this.onCalendarAdded(calendar)
|
||||||
|
this.rerender()
|
||||||
|
}, 300)
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
onSaveErrorAdd
|
||||||
|
).height(100, pct)
|
||||||
|
})
|
||||||
|
this.calFormSheet._closeOverride = async () => {
|
||||||
|
const saved = await calFormEl?.trySave()
|
||||||
|
if (saved === null) {
|
||||||
|
this.calFormSheet.setSheet(true)
|
||||||
|
this.calFormSheet._closeOverride = () => this.calFormSheet.forceClose()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
closeDirect()
|
||||||
|
if (saved) {
|
||||||
|
setTimeout(() => {
|
||||||
|
this.calendars = [...this.calendars, saved]
|
||||||
|
this.selectedCalendars = [...this.selectedCalendars, saved]
|
||||||
|
this.onCalendarAdded(saved)
|
||||||
|
this.rerender()
|
||||||
|
}, 300)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.width(100, pct)
|
||||||
|
.boxSizing("border-box")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
register(CalendarOptions)
|
||||||
123
calendar/Toolbar/CalendarToolbar.js
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import calendarUtil from "../calendarUtil.js"
|
||||||
|
import "./ToolbarPopout.js";
|
||||||
|
|
||||||
|
class CalendarToolbar extends Shadow {
|
||||||
|
constructor(currentDate, weekStartsOn, actions, calendars, events, showPopout, options = {}) {
|
||||||
|
super()
|
||||||
|
this.currentDate = currentDate
|
||||||
|
this.weekStartsOn = weekStartsOn
|
||||||
|
this.goToPrevious = actions.goToPrevious;
|
||||||
|
this.goToCurrent = actions.goToCurrent;
|
||||||
|
this.goToNext = actions.goToNext;
|
||||||
|
this.goToDate = actions.goToDate;
|
||||||
|
this.calendars = calendars;
|
||||||
|
this.events = events;
|
||||||
|
this.showPopout = showPopout;
|
||||||
|
this.onBack = options.onBack ?? null;
|
||||||
|
this.viewModeOverride = options.viewModeOverride ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
let popout;
|
||||||
|
|
||||||
|
VStack(() => {
|
||||||
|
HStack(() => {
|
||||||
|
if (this.onBack) {
|
||||||
|
BackButton(true, false, this.onBack)
|
||||||
|
.position("absolute")
|
||||||
|
.left(0.75, em)
|
||||||
|
.bottom(0.2, em)
|
||||||
|
.transform("scale(0.75)")
|
||||||
|
.transformOrigin("left center")
|
||||||
|
}
|
||||||
|
|
||||||
|
h2(this.getToolbarLabel(this.currentDate))
|
||||||
|
.margin(0)
|
||||||
|
.fontWeight("600")
|
||||||
|
.color("var(--headertext)")
|
||||||
|
.paddingLeft(this.onBack ? 2 : 0, em)
|
||||||
|
.state("label", function (label) {
|
||||||
|
if (label) {
|
||||||
|
this.innerText = label;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
HStack(() => {
|
||||||
|
if (!window.isMobile()) {
|
||||||
|
this.navButton("‹", this.goToPrevious)
|
||||||
|
this.navButton("Today", this.goToCurrent)
|
||||||
|
this.navButton("›", this.goToNext)
|
||||||
|
}
|
||||||
|
this.navButton("⊞", () => this.togglePopout(popout))
|
||||||
|
})
|
||||||
|
.gap(0.5, em)
|
||||||
|
.alignItems("center")
|
||||||
|
})
|
||||||
|
.boxSizing("border-box")
|
||||||
|
.width(100, pct)
|
||||||
|
.alignItems("flex-end")
|
||||||
|
.position("relative")
|
||||||
|
.overflow("visible")
|
||||||
|
.paddingBottom(0.9, em)
|
||||||
|
.paddingHorizontal(1, em)
|
||||||
|
.justifyContent("space-between")
|
||||||
|
|
||||||
|
popout = ToolbarPopout(this.currentDate, this.weekStartsOn, this.calendars, this.events, this.showPopout, (date) => this.goToDate(date), (date) => {
|
||||||
|
this.$("h2").attr({ "label": this.getToolbarLabel(date)});
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.overflow("visible")
|
||||||
|
.borderBottom("1px solid var(--main)")
|
||||||
|
.width(100, pct)
|
||||||
|
}
|
||||||
|
|
||||||
|
togglePopout(popout) {
|
||||||
|
this.showPopout = !this.showPopout;
|
||||||
|
$("calendar-").showPopout = this.showPopout;
|
||||||
|
if (popout) {
|
||||||
|
popout.showPopout = this.showPopout;
|
||||||
|
popout.style.maxHeight = this.showPopout ? `${popout.layout.openHeight}em` : "0em";
|
||||||
|
}
|
||||||
|
if (!this.showPopout) {
|
||||||
|
const newLabel = this.getToolbarLabel(this.currentDate)
|
||||||
|
const oldLabel = this.$("h2").innerText;
|
||||||
|
this.$("h2").attr({ "label": newLabel });
|
||||||
|
if (newLabel !== oldLabel) {
|
||||||
|
setTimeout(() => {
|
||||||
|
this.rerender();
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
navButton(label, handler) {
|
||||||
|
const isWide = label === "Today";
|
||||||
|
return button(label)
|
||||||
|
.onTap(handler)
|
||||||
|
.paddingVertical(0.45, em)
|
||||||
|
.fontSize(1, rem)
|
||||||
|
.paddingHorizontal(isWide ? 0.9 : 0.8, em)
|
||||||
|
.border("1px solid var(--desktop-item-border)")
|
||||||
|
.borderRadius(0.6, em)
|
||||||
|
.background("var(--desktop-item-background)")
|
||||||
|
.color("var(--text)")
|
||||||
|
.cursor("pointer");
|
||||||
|
}
|
||||||
|
|
||||||
|
getToolbarLabel(date) {
|
||||||
|
const viewMode = this.viewModeOverride ?? $("calendar-").viewMode;
|
||||||
|
|
||||||
|
if (viewMode === "week") {
|
||||||
|
return date.toLocaleDateString(undefined, { month: "long", year: "numeric" });
|
||||||
|
}
|
||||||
|
if (viewMode === "day") {
|
||||||
|
return date.toLocaleDateString(undefined, { month: "long", day: "numeric", year: "numeric" });
|
||||||
|
}
|
||||||
|
if (viewMode === "month") {
|
||||||
|
return date.toLocaleDateString(undefined, { month: "long", year: "numeric" });
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
register(CalendarToolbar)
|
||||||
519
calendar/Toolbar/ToolbarPopout.js
Normal file
@@ -0,0 +1,519 @@
|
|||||||
|
import calendarUtil from "../calendarUtil.js";
|
||||||
|
|
||||||
|
class ToolbarPopout extends Shadow {
|
||||||
|
swipeTranslate = 0;
|
||||||
|
isSwiping = false;
|
||||||
|
swipeDragStartX = null;
|
||||||
|
swipeDragStartY = null;
|
||||||
|
swipeDragStartTime = null;
|
||||||
|
swipeAxisLocked = false;
|
||||||
|
swipeIsHorizontal = false;
|
||||||
|
|
||||||
|
SWIPE_COMMIT_DISTANCE = window.outerWidth * 0.25;
|
||||||
|
SWIPE_VELOCITY_THRESHOLD = 0.4;
|
||||||
|
|
||||||
|
static DAY_NAMES = ["S", "M", "T", "W", "T", "F", "S"];
|
||||||
|
|
||||||
|
constructor(currentDate, weekStartsOn, calendars, events, showPopout, goToDate, onMonthChange) {
|
||||||
|
super()
|
||||||
|
this.currentDate = currentDate;
|
||||||
|
this.weekStartsOn = weekStartsOn;
|
||||||
|
this.calendars = calendars;
|
||||||
|
this.events = events;
|
||||||
|
|
||||||
|
this.selectedDate = this.currentDate;
|
||||||
|
this.showPopout = showPopout;
|
||||||
|
this.goToDate = goToDate;
|
||||||
|
this.onMonthChange = onMonthChange;
|
||||||
|
|
||||||
|
this.swipeDidMove = false;
|
||||||
|
|
||||||
|
if (this.miniCurrentDate === undefined) {
|
||||||
|
this.miniCurrentDate = this.currentDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.layout = this.buildLayout();
|
||||||
|
|
||||||
|
// Caches
|
||||||
|
this.monthCache = new Map();
|
||||||
|
this._miniPanels = null;
|
||||||
|
this._monthEventsVersion = 0;
|
||||||
|
|
||||||
|
this.buildDerivedData();
|
||||||
|
}
|
||||||
|
|
||||||
|
buildLayout() {
|
||||||
|
const fs = 0.75, hp = 0.4, dch = 1.55, dh = 0.28, dmt = 0.15, cp = 0.4;
|
||||||
|
return {
|
||||||
|
fontSize: fs,
|
||||||
|
headerPadding: hp,
|
||||||
|
headerRowHeight: fs + hp * 2,
|
||||||
|
dateCircleHeight: dch,
|
||||||
|
dotHeight: dh,
|
||||||
|
dotMarginTop: dmt,
|
||||||
|
cellPadding: cp,
|
||||||
|
rowHeight: cp + dch + dh + dmt,
|
||||||
|
get lastRowHeight() { return cp + dch; },
|
||||||
|
get gridHeight() { return this.rowHeight * 5 + this.lastRowHeight; },
|
||||||
|
get openHeight() { return this.headerRowHeight * fs + this.gridHeight; },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
buildDerivedData() {
|
||||||
|
this.colorByCalendarId = new Map(
|
||||||
|
(this.calendars || []).map(calendar => [calendar.id, calendar.color])
|
||||||
|
);
|
||||||
|
|
||||||
|
// Version string for invalidating month cache when events update
|
||||||
|
this.eventsVersion = JSON.stringify(
|
||||||
|
(this.events || []).map(ev => [
|
||||||
|
ev.id,
|
||||||
|
ev.calendars,
|
||||||
|
ev.recurrence_id,
|
||||||
|
+new Date(ev.time_start),
|
||||||
|
+new Date(ev.time_end)
|
||||||
|
])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
getMiniPanels() {
|
||||||
|
if (!this._miniPanels) {
|
||||||
|
this._miniPanels = this.$$("[data-mini-panel]");
|
||||||
|
}
|
||||||
|
return this._miniPanels;
|
||||||
|
}
|
||||||
|
|
||||||
|
getMonthKey(date) {
|
||||||
|
return [
|
||||||
|
date.getFullYear(),
|
||||||
|
date.getMonth(),
|
||||||
|
this.weekStartsOn,
|
||||||
|
this.eventsVersion
|
||||||
|
].join("|");
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const l = this.layout;
|
||||||
|
const ordered = Array.from(
|
||||||
|
{ length: 7 },
|
||||||
|
(_, i) => ToolbarPopout.DAY_NAMES[(this.weekStartsOn + i) % 7]
|
||||||
|
);
|
||||||
|
const today = new Date();
|
||||||
|
|
||||||
|
// Remove stale cache
|
||||||
|
this._miniPanels = null;
|
||||||
|
|
||||||
|
// Only render the current month panel
|
||||||
|
// Previous/Next injected programmatically whenever user begins
|
||||||
|
// swiping - touch event never interrupted by rerenders
|
||||||
|
const panelOffsets = [0];
|
||||||
|
|
||||||
|
VStack(() => {
|
||||||
|
HStack(() => {
|
||||||
|
ordered.forEach(name => {
|
||||||
|
p(name)
|
||||||
|
.margin(0)
|
||||||
|
.fontSize(l.fontSize, em)
|
||||||
|
.fontWeight("600")
|
||||||
|
.color("var(--headertext)")
|
||||||
|
.opacity(0.45)
|
||||||
|
.flex(1)
|
||||||
|
.textAlign("center")
|
||||||
|
.paddingVertical(l.headerPadding, em)
|
||||||
|
.boxSizing("border-box")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.width(100, pct)
|
||||||
|
|
||||||
|
ZStack(() => {
|
||||||
|
panelOffsets.forEach(offset => {
|
||||||
|
const viewDate = calendarUtil.addMonths(this.miniCurrentDate, offset);
|
||||||
|
const { weeks } = this.getMonthData(viewDate);
|
||||||
|
|
||||||
|
VStack(() => {
|
||||||
|
weeks.forEach((week, wi) => {
|
||||||
|
const isLast = wi === weeks.length - 1;
|
||||||
|
|
||||||
|
HStack(() => {
|
||||||
|
week.forEach(({ day, isCurrentMonth, calendarColors }) => {
|
||||||
|
const isToday = calendarUtil.isSameDay(day, today);
|
||||||
|
const isSelected = calendarUtil.isSameDay(day, this.selectedDate);
|
||||||
|
|
||||||
|
VStack(() => {
|
||||||
|
p(day.getDate())
|
||||||
|
.margin(0)
|
||||||
|
.fontSize(l.fontSize, em)
|
||||||
|
.fontWeight(isSelected ? "700" : "400")
|
||||||
|
.color(isSelected || isToday ? "white" : "var(--headertext)")
|
||||||
|
.background(
|
||||||
|
isToday
|
||||||
|
? "var(--quillred)"
|
||||||
|
: isSelected
|
||||||
|
? "var(--lightaccent)"
|
||||||
|
: "transparent"
|
||||||
|
)
|
||||||
|
.width(l.dateCircleHeight, em)
|
||||||
|
.height(l.dateCircleHeight, em)
|
||||||
|
.borderRadius(25, pct)
|
||||||
|
.textAlign("center")
|
||||||
|
.lineHeight(`${l.dateCircleHeight}em`)
|
||||||
|
.opacity(isCurrentMonth ? 1 : 0.28)
|
||||||
|
.boxSizing("border-box")
|
||||||
|
|
||||||
|
// Only show event dots if they exist
|
||||||
|
if (calendarColors.length > 0) {
|
||||||
|
HStack(() => {
|
||||||
|
calendarColors.slice(0, 3).forEach(color => {
|
||||||
|
VStack(() => {})
|
||||||
|
.width(l.dotHeight, em)
|
||||||
|
.height(l.dotHeight, em)
|
||||||
|
.borderRadius(50, pct)
|
||||||
|
.background(color)
|
||||||
|
.flexShrink(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.gap(0.18, em)
|
||||||
|
.justifyContent("center")
|
||||||
|
.alignItems("center")
|
||||||
|
.marginTop(l.dotMarginTop, em)
|
||||||
|
.height(l.dotHeight, em)
|
||||||
|
} else {
|
||||||
|
// Spacer for dot-less cells
|
||||||
|
VStack(() => {})
|
||||||
|
.marginTop(l.dotMarginTop, em)
|
||||||
|
.height(l.dotHeight, em)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.flex(1)
|
||||||
|
.alignItems("center")
|
||||||
|
.paddingTop(l.cellPadding, em)
|
||||||
|
.paddingBottom(isLast ? 0 : l.cellPadding, em)
|
||||||
|
.boxSizing("border-box")
|
||||||
|
.onTap(() => {
|
||||||
|
if (!this.swipeDidMove) {
|
||||||
|
this.selectedDate = day;
|
||||||
|
if (!this.goToDate(day)) {
|
||||||
|
this.rerender();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.width(100, pct)
|
||||||
|
.background("var(--minicalendarbackground)")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.position("absolute")
|
||||||
|
.width(100, pct)
|
||||||
|
.height(100, pct)
|
||||||
|
.transform(`translateX(${offset * 100}%)`)
|
||||||
|
.willChange("transform")
|
||||||
|
.attr({ "data-mini-panel": offset })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.position("relative")
|
||||||
|
.overflow("hidden")
|
||||||
|
.width(100, pct)
|
||||||
|
.height(l.gridHeight, em)
|
||||||
|
.onTouch((start, e) => this.handleSwipeTouch(start, e))
|
||||||
|
})
|
||||||
|
.width(100, pct)
|
||||||
|
.boxSizing("border-box")
|
||||||
|
.overflow("hidden")
|
||||||
|
.maxHeight(this.showPopout ? l.openHeight : 0, em)
|
||||||
|
.transition("max-height 0.3s ease")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build previous/next calendars and insert them without triggering a rerender
|
||||||
|
injectSidePanels() {
|
||||||
|
const currentPanel = this.$("[data-mini-panel='0']");
|
||||||
|
if (!currentPanel) return;
|
||||||
|
const container = currentPanel.parentElement;
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
// Already injected (shouldn't happen, but guard anyway).
|
||||||
|
if (this.$("[data-mini-panel='-1']")) return;
|
||||||
|
|
||||||
|
const l = this.layout;
|
||||||
|
const today = new Date();
|
||||||
|
|
||||||
|
[-1, 1].forEach(offset => {
|
||||||
|
const viewDate = calendarUtil.addMonths(this.miniCurrentDate, offset);
|
||||||
|
const { weeks } = this.getMonthData(viewDate);
|
||||||
|
|
||||||
|
// Outer panel div — mirrors the VStack styling applied in render().
|
||||||
|
const panel = document.createElement("div");
|
||||||
|
panel.classList.add("VStack");
|
||||||
|
panel.setAttribute("data-mini-panel", offset);
|
||||||
|
panel.style.cssText = `
|
||||||
|
position: absolute; top: 0; left: 0;
|
||||||
|
width: 100%; height: 100%;
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
transform: translateX(${offset * 100}%);
|
||||||
|
will-change: transform;
|
||||||
|
box-sizing: border-box;
|
||||||
|
`;
|
||||||
|
|
||||||
|
weeks.forEach((week, wi) => {
|
||||||
|
const isLast = wi === weeks.length - 1;
|
||||||
|
const row = document.createElement("div");
|
||||||
|
|
||||||
|
row.classList.add("HStack");
|
||||||
|
row.style.cssText = `
|
||||||
|
display: flex; flex-direction: row;
|
||||||
|
width: 100%;
|
||||||
|
background: var(--minicalendarbackground);
|
||||||
|
`;
|
||||||
|
|
||||||
|
week.forEach(({ day, isCurrentMonth, calendarColors }) => {
|
||||||
|
const isToday = calendarUtil.isSameDay(day, today);
|
||||||
|
const isSelected = calendarUtil.isSameDay(day, this.selectedDate);
|
||||||
|
|
||||||
|
const cell = document.createElement("div");
|
||||||
|
cell.classList.add("VStack");
|
||||||
|
cell.style.cssText = `
|
||||||
|
flex: 1; display: flex; flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding-top: ${l.cellPadding}em;
|
||||||
|
padding-bottom: ${isLast ? 0 : l.cellPadding}em;
|
||||||
|
box-sizing: border-box;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const circle = document.createElement("p");
|
||||||
|
|
||||||
|
circle.textContent = day.getDate();
|
||||||
|
circle.style.cssText = `
|
||||||
|
margin: 0;
|
||||||
|
font-size: ${l.fontSize}em;
|
||||||
|
font-weight: ${isSelected ? "700" : "400"};
|
||||||
|
color: ${isSelected || isToday ? "white" : "var(--headertext)"};
|
||||||
|
background: ${isToday ? "var(--quillred)" : isSelected ? "var(--lightaccent)" : "transparent"};
|
||||||
|
width: ${l.dateCircleHeight}em;
|
||||||
|
height: ${l.dateCircleHeight}em;
|
||||||
|
border-radius: 25%;
|
||||||
|
text-align: center;
|
||||||
|
line-height: ${l.dateCircleHeight}em;
|
||||||
|
opacity: ${isCurrentMonth ? 1 : 0.28};
|
||||||
|
box-sizing: border-box;
|
||||||
|
`;
|
||||||
|
cell.appendChild(circle);
|
||||||
|
|
||||||
|
const spacer = document.createElement("div");
|
||||||
|
spacer.classList.add("HStack");
|
||||||
|
spacer.style.cssText = `
|
||||||
|
margin-top: ${l.dotMarginTop}em;
|
||||||
|
height: ${l.dotHeight}em;
|
||||||
|
display: flex; flex-direction: row;
|
||||||
|
justify-content: center; align-items: center;
|
||||||
|
gap: 0.18em;
|
||||||
|
`;
|
||||||
|
if (calendarColors.length > 0) {
|
||||||
|
calendarColors.slice(0, 3).forEach(color => {
|
||||||
|
const dot = document.createElement("div");
|
||||||
|
|
||||||
|
dot.classList.add("VStack");
|
||||||
|
dot.style.cssText = `
|
||||||
|
width: ${l.dotHeight}em; height: ${l.dotHeight}em;
|
||||||
|
border-radius: 50%; background: ${color}; flex-shrink: 0;
|
||||||
|
`;
|
||||||
|
spacer.appendChild(dot);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
cell.appendChild(spacer);
|
||||||
|
row.appendChild(cell);
|
||||||
|
});
|
||||||
|
|
||||||
|
panel.appendChild(row);
|
||||||
|
});
|
||||||
|
|
||||||
|
container.appendChild(panel);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Invalidate the panel cache so getMiniPanels() picks up the new nodes.
|
||||||
|
this._miniPanels = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleSwipeTouch(start, e) {
|
||||||
|
if (start) {
|
||||||
|
this.swipeDragStartX = e.touches[0].clientX;
|
||||||
|
this.swipeDragStartY = e.touches[0].clientY;
|
||||||
|
this.swipeDragStartTime = Date.now();
|
||||||
|
this.isSwiping = true;
|
||||||
|
this.swipeDidMove = false;
|
||||||
|
this.swipeAxisLocked = false;
|
||||||
|
this.swipeIsHorizontal = false;
|
||||||
|
|
||||||
|
// Inject previous/next calendar
|
||||||
|
this.injectSidePanels();
|
||||||
|
|
||||||
|
document.addEventListener("touchmove", this.onSwipeMove, { passive: true });
|
||||||
|
} else {
|
||||||
|
if (!this.isSwiping) return;
|
||||||
|
document.removeEventListener("touchmove", this.onSwipeMove);
|
||||||
|
|
||||||
|
if (!this.swipeIsHorizontal) {
|
||||||
|
this.isSwiping = false;
|
||||||
|
this.swipeDragStartX = null;
|
||||||
|
this.swipeDragStartY = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const endX = e.changedTouches[0].clientX;
|
||||||
|
const delta = endX - this.swipeDragStartX;
|
||||||
|
const elapsed = Date.now() - this.swipeDragStartTime;
|
||||||
|
const velocity = Math.abs(delta) / elapsed;
|
||||||
|
const shouldCommit =
|
||||||
|
Math.abs(delta) > this.SWIPE_COMMIT_DISTANCE ||
|
||||||
|
velocity > this.SWIPE_VELOCITY_THRESHOLD;
|
||||||
|
|
||||||
|
if (shouldCommit && delta < 0) this.commitSwipe("next");
|
||||||
|
else if (shouldCommit && delta > 0) this.commitSwipe("previous");
|
||||||
|
else this.snapBack();
|
||||||
|
|
||||||
|
this.isSwiping = false;
|
||||||
|
this.swipeDragStartX = null;
|
||||||
|
this.swipeDragStartY = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onSwipeMove = (e) => {
|
||||||
|
if (!this.isSwiping) return;
|
||||||
|
|
||||||
|
const dx = e.touches[0].clientX - this.swipeDragStartX;
|
||||||
|
const dy = e.touches[0].clientY - this.swipeDragStartY;
|
||||||
|
|
||||||
|
if (!this.swipeAxisLocked) {
|
||||||
|
if (Math.abs(dx) < 5 && Math.abs(dy) < 5) return;
|
||||||
|
this.swipeAxisLocked = true;
|
||||||
|
this.swipeIsHorizontal = Math.abs(dx) > Math.abs(dy);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.swipeIsHorizontal) return;
|
||||||
|
|
||||||
|
this.swipeDidMove = true;
|
||||||
|
const delta = e.touches[0].clientX - this.swipeDragStartX;
|
||||||
|
this.swipeTranslate = delta;
|
||||||
|
this.applySwipeTransform(delta, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
applySwipeTransform(delta, animated) {
|
||||||
|
const panels = this.getMiniPanels();
|
||||||
|
panels.forEach(panel => {
|
||||||
|
const offset = parseInt(panel.getAttribute("data-mini-panel"));
|
||||||
|
panel.style.transition = animated ? "transform 300ms ease" : "";
|
||||||
|
panel.style.transform = `translateX(calc(${offset * 100}% + ${delta}px))`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
commitSwipe(direction) {
|
||||||
|
const sign = direction === "next" ? 1 : -1;
|
||||||
|
const panels = this.getMiniPanels();
|
||||||
|
const container = panels[0]?.parentElement;
|
||||||
|
|
||||||
|
// Snap panels to their current drag position
|
||||||
|
panels.forEach(panel => {
|
||||||
|
const offset = parseInt(panel.getAttribute("data-mini-panel"));
|
||||||
|
panel.style.transition = "none";
|
||||||
|
panel.style.transform = `translateX(calc(${offset * 100}% + ${this.swipeTranslate}px))`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Force reflow so the browser registers the snap as a committed state
|
||||||
|
panels[0]?.getBoundingClientRect();
|
||||||
|
|
||||||
|
panels.forEach(panel => {
|
||||||
|
const offset = parseInt(panel.getAttribute("data-mini-panel"));
|
||||||
|
panel.style.transition = "transform 300ms ease";
|
||||||
|
panel.style.transform = `translateX(calc(${(offset - sign) * 100}%))`;
|
||||||
|
});
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
this.miniCurrentDate = direction === "next"
|
||||||
|
? calendarUtil.addMonths(this.miniCurrentDate, 1)
|
||||||
|
: calendarUtil.addMonths(this.miniCurrentDate, -1);
|
||||||
|
|
||||||
|
this.onMonthChange(this.miniCurrentDate)
|
||||||
|
this.swipeTranslate = 0;
|
||||||
|
this.rerender();
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
snapBack() {
|
||||||
|
const panels = this.getMiniPanels();
|
||||||
|
panels.forEach(panel => {
|
||||||
|
const offset = parseInt(panel.getAttribute("data-mini-panel"));
|
||||||
|
panel.style.transition = "transform 300ms ease";
|
||||||
|
panel.style.transform = `translateX(${offset * 100}%)`;
|
||||||
|
});
|
||||||
|
this.swipeTranslate = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
getMonthData(date) {
|
||||||
|
const monthKey = this.getMonthKey(date);
|
||||||
|
const cached = this.monthCache.get(monthKey);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = date.getMonth();
|
||||||
|
|
||||||
|
const firstOfMonth = new Date(year, month, 1);
|
||||||
|
const gridStart = calendarUtil.startOfWeek(firstOfMonth, this.weekStartsOn);
|
||||||
|
|
||||||
|
const allDays = Array.from({ length: 42 }, (_, i) => calendarUtil.addDays(gridStart, i));
|
||||||
|
const weeks = [];
|
||||||
|
for (let w = 0; w < 6; w++) {
|
||||||
|
weeks.push(allDays.slice(w * 7, w * 7 + 7));
|
||||||
|
}
|
||||||
|
|
||||||
|
const gridEnd = calendarUtil.endOfDay(allDays[41]);
|
||||||
|
|
||||||
|
const colorsByDay = new Map();
|
||||||
|
allDays.forEach(day => {
|
||||||
|
colorsByDay.set(calendarUtil.toDateInput(day), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
const expanded = calendarUtil.expandRecurringEvents(this.events || [], gridStart, gridEnd);
|
||||||
|
const relevantEvents = expanded.filter(ev => {
|
||||||
|
const end = ev.all_day ? calendarUtil.endOfDay(ev.time_end) : ev.time_end;
|
||||||
|
return calendarUtil.rangesOverlap(ev.time_start, end, gridStart, gridEnd);
|
||||||
|
});
|
||||||
|
|
||||||
|
relevantEvents.forEach(ev => {
|
||||||
|
const colors = (ev.calendars || [])
|
||||||
|
.map(id => this.colorByCalendarId.get(id))
|
||||||
|
.filter(Boolean);
|
||||||
|
if (colors.length === 0) return;
|
||||||
|
|
||||||
|
const effectiveEnd = ev.all_day ? calendarUtil.endOfDay(ev.time_end) : ev.time_end;
|
||||||
|
let cursor = calendarUtil.startOfDay(
|
||||||
|
ev.time_start > gridStart ? ev.time_start : gridStart
|
||||||
|
);
|
||||||
|
const eventEnd = effectiveEnd < gridEnd ? effectiveEnd : gridEnd;
|
||||||
|
|
||||||
|
while (cursor < eventEnd) {
|
||||||
|
const key = calendarUtil.toDateInput(cursor);
|
||||||
|
const arr = colorsByDay.get(key);
|
||||||
|
if (arr) {
|
||||||
|
colors.forEach(color => {
|
||||||
|
if (!arr.includes(color) && arr.length < 3) arr.push(color);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
cursor = calendarUtil.addDays(cursor, 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
weeks: weeks.map(week =>
|
||||||
|
week.map(day => ({
|
||||||
|
day,
|
||||||
|
isCurrentMonth: day.getMonth() === month,
|
||||||
|
calendarColors: colorsByDay.get(calendarUtil.toDateInput(day)) || []
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
this.monthCache.set(monthKey, result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
register(ToolbarPopout)
|
||||||
29
calendar/Week/SpacerCell.js
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
class SpacerCell extends Shadow {
|
||||||
|
constructor(weekNumber, sidebarWidth) {
|
||||||
|
super()
|
||||||
|
this.weekNumber = weekNumber;
|
||||||
|
this.sidebarWidth = sidebarWidth
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
VStack(() => {
|
||||||
|
p(`W${this.weekNumber}`)
|
||||||
|
.fontSize(0.9, em)
|
||||||
|
.fontWeight("600")
|
||||||
|
.color("var(--headertext)")
|
||||||
|
})
|
||||||
|
.width(this.sidebarWidth, em)
|
||||||
|
.paddingHorizontal(0.5, em)
|
||||||
|
.flexShrink(0)
|
||||||
|
.flexGrow(0)
|
||||||
|
.justifyContent("center")
|
||||||
|
.alignItems("center")
|
||||||
|
.borderRight("1px solid var(--divider)")
|
||||||
|
.borderBottom("1px solid var(--divider)")
|
||||||
|
.borderTop("1px solid var(--sidebottombars)")
|
||||||
|
.background("var(--sidebottombars)")
|
||||||
|
.boxSizing("border-box")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
register(SpacerCell)
|
||||||
37
calendar/Week/TimedLabelsColumn.js
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
class TimedLabelsColumn extends Shadow {
|
||||||
|
constructor(slots, slotHeight, sidebarWidth) {
|
||||||
|
super()
|
||||||
|
this.slots = slots;
|
||||||
|
this.slotHeight = slotHeight;
|
||||||
|
this.sidebarWidth = sidebarWidth
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
VStack(() => {
|
||||||
|
this.slots.forEach(slot => {
|
||||||
|
const isHour = slot.minute === 0;
|
||||||
|
|
||||||
|
VStack(() => {
|
||||||
|
p(isHour ? slot.label : "")
|
||||||
|
.margin(0)
|
||||||
|
.fontSize(0.9, em)
|
||||||
|
.color("var(--headertext)")
|
||||||
|
.transform("translateY(-0.55em)")
|
||||||
|
})
|
||||||
|
.height(this.slotHeight, em)
|
||||||
|
.justifyContent("flex-start")
|
||||||
|
.alignItems("center")
|
||||||
|
.paddingHorizontal(0.5, em)
|
||||||
|
.width(3, em)
|
||||||
|
.boxSizing("border-box")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.paddingTop(this.slotHeight, em)
|
||||||
|
.width(this.sidebarWidth, em)
|
||||||
|
.background("var(--sidebottombars)")
|
||||||
|
.boxSizing("border-box")
|
||||||
|
.borderRight("1px solid var(--divider)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
register(TimedLabelsColumn)
|
||||||
338
calendar/Week/TimedWeekGrid.js
Normal file
@@ -0,0 +1,338 @@
|
|||||||
|
import calendarUtil from "../calendarUtil.js";
|
||||||
|
|
||||||
|
class TimedWeekGrid extends Shadow {
|
||||||
|
constructor(weekDays, slots, groupedDays, calendars, slotHeight, viewMode = "week", onSlotTap = null) {
|
||||||
|
super()
|
||||||
|
this.weekDays = weekDays;
|
||||||
|
this.slots = slots;
|
||||||
|
this.groupedDays = groupedDays;
|
||||||
|
this.calendars = calendars;
|
||||||
|
this.slotHeight = slotHeight;
|
||||||
|
this.viewMode = viewMode;
|
||||||
|
this.onSlotTap = onSlotTap;
|
||||||
|
this.ghostSlot = null;
|
||||||
|
|
||||||
|
this._layoutCache = new WeakMap();
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const totalGridHeight = this.slots.length * this.slotHeight;
|
||||||
|
const minutesPerSlot = this.minutesPerSlot();
|
||||||
|
const gridStartMinutes = this.gridStartMinutes();
|
||||||
|
|
||||||
|
const slotGridBackground = [
|
||||||
|
`repeating-linear-gradient(to bottom,`,
|
||||||
|
` transparent,`,
|
||||||
|
` transparent calc(${this.slotHeight}em - 1px),`,
|
||||||
|
` var(--divider) calc(${this.slotHeight}em - 1px),`,
|
||||||
|
` var(--divider) ${this.slotHeight}em,`,
|
||||||
|
` transparent ${this.slotHeight}em,`,
|
||||||
|
` transparent calc(${this.slotHeight * 2}em - 1px),`,
|
||||||
|
` var(--lightDivider) calc(${this.slotHeight * 2}em - 1px),`,
|
||||||
|
` var(--lightDivider) ${this.slotHeight * 2}em`,
|
||||||
|
`)`
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
HStack(() => {
|
||||||
|
this.groupedDays.forEach((group, index) => {
|
||||||
|
const today = calendarUtil.isToday(group.day);
|
||||||
|
const isLast = index === this.groupedDays.length - 1;
|
||||||
|
const dayStart = calendarUtil.startOfDay(group.day);
|
||||||
|
const dayEnd = calendarUtil.endOfDay(group.day);
|
||||||
|
|
||||||
|
// Build ghost event for this day if applicable
|
||||||
|
const ghostForThisDay = this.ghostSlot &&
|
||||||
|
calendarUtil.startOfDay(this.ghostSlot.day).getTime() === dayStart.getTime();
|
||||||
|
const ghostEvent = ghostForThisDay ? {
|
||||||
|
_isGhost: true,
|
||||||
|
id: '__ghost__',
|
||||||
|
time_start: new Date(dayStart.getTime() + this.ghostSlot.startMinutes * 60000),
|
||||||
|
time_end: new Date(dayStart.getTime() + (this.ghostSlot.startMinutes + 30) * 60000),
|
||||||
|
calendars: [],
|
||||||
|
all_day: false,
|
||||||
|
title: '+'
|
||||||
|
} : null;
|
||||||
|
|
||||||
|
// Extend the ghost by 1ms on each side for layout only — this makes
|
||||||
|
// touching time boundaries (ghost ends exactly when event starts, or vice versa)
|
||||||
|
// register as overlapping so directly adjacent slots trigger a split.
|
||||||
|
const ghostForLayout = ghostEvent ? {
|
||||||
|
...ghostEvent,
|
||||||
|
time_start: new Date(ghostEvent.time_start.getTime() - 1),
|
||||||
|
time_end: new Date(ghostEvent.time_end.getTime() + 1),
|
||||||
|
} : null;
|
||||||
|
|
||||||
|
const layoutEvents = ghostForLayout ? [...group.timed, ghostForLayout] : group.timed;
|
||||||
|
const layout = this.computeLayout(layoutEvents, group.day);
|
||||||
|
const layoutEntries = [...layout.entries()];
|
||||||
|
|
||||||
|
ZStack(() => {
|
||||||
|
// Slot grid background
|
||||||
|
VStack(() => { })
|
||||||
|
.width(100, pct)
|
||||||
|
.height(totalGridHeight, em)
|
||||||
|
.backgroundImage(slotGridBackground)
|
||||||
|
.backgroundSize(`100% ${this.slotHeight * 2}em`)
|
||||||
|
.pointerEvents("none")
|
||||||
|
|
||||||
|
// Tap overlay — catches taps on empty slots
|
||||||
|
VStack(() => {})
|
||||||
|
.position("absolute").top(0).left(0).right(0)
|
||||||
|
.height(totalGridHeight, em)
|
||||||
|
.zIndex(0)
|
||||||
|
.pointerEvents("auto")
|
||||||
|
.onTap((e) => this.handleOverlayTap(e, group.day))
|
||||||
|
|
||||||
|
// Event pills + ghost pill
|
||||||
|
ZStack(() => {
|
||||||
|
group.timed.forEach(event => {
|
||||||
|
const color = calendarUtil.getCalendarColor(this.calendars, event.calendars.find(id => this.calendars.some(c => c.id === id)) ?? event.calendars[0]);
|
||||||
|
const top = this.eventTopEm(event, minutesPerSlot, gridStartMinutes, dayStart);
|
||||||
|
const height = this.eventHeightEm(event, minutesPerSlot, dayStart, dayEnd);
|
||||||
|
|
||||||
|
const clippedTop = event.time_start < dayStart;
|
||||||
|
const clippedBottom = event.time_end > dayEnd;
|
||||||
|
|
||||||
|
const borderTop = clippedTop ? 0 : 7.5;
|
||||||
|
const borderBottom = clippedBottom ? 0 : 7.5;
|
||||||
|
|
||||||
|
const { col, total, startMs, endMs } = layout.get(event);
|
||||||
|
const isSplit = total > 1;
|
||||||
|
const gapPct = isSplit ? 1 : 0;
|
||||||
|
|
||||||
|
let span = 1;
|
||||||
|
for (let nextCol = col + 1; nextCol < total; nextCol++) {
|
||||||
|
const blocked = layoutEntries.some(([other, o]) =>
|
||||||
|
other !== event && o.col === nextCol &&
|
||||||
|
calendarUtil.rangesOverlap(startMs, endMs, o.startMs, o.endMs)
|
||||||
|
);
|
||||||
|
if (blocked) break;
|
||||||
|
span++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const widthPct = (span * 100 / total) - gapPct;
|
||||||
|
const leftPct = col * (100 / total) + (col > 0 ? gapPct : 0);
|
||||||
|
|
||||||
|
VStack(() => {
|
||||||
|
p(event.title || "Untitled")
|
||||||
|
.margin(0)
|
||||||
|
.fontSize(this.viewMode === "day" ? 1 : 0.5, em)
|
||||||
|
.fontWeight("600")
|
||||||
|
.color("white")
|
||||||
|
.lineHeight(this.viewMode === "day" ? "1.6" : "1.2")
|
||||||
|
.overflow("hidden")
|
||||||
|
})
|
||||||
|
.position("absolute")
|
||||||
|
.top(top + 2, em)
|
||||||
|
.left(leftPct, pct)
|
||||||
|
.width(widthPct, pct)
|
||||||
|
.height(height, em)
|
||||||
|
.minHeight(0.25, em)
|
||||||
|
.paddingVertical(isSplit ? 0.2 : 0.35, em)
|
||||||
|
.paddingHorizontal(0.35, em)
|
||||||
|
.background(color)
|
||||||
|
.borderTopLeftRadius(`${borderTop}px`)
|
||||||
|
.borderTopRightRadius(`${borderTop}px`)
|
||||||
|
.borderBottomLeftRadius(`${borderBottom}px`)
|
||||||
|
.borderBottomRightRadius(`${borderBottom}px`)
|
||||||
|
.boxSizing("border-box")
|
||||||
|
.zIndex(1)
|
||||||
|
.textAlign("left")
|
||||||
|
.pointerEvents("auto")
|
||||||
|
.cursor("pointer")
|
||||||
|
.onTap(() => $("bottomsheet-").showEvent(event))
|
||||||
|
})
|
||||||
|
|
||||||
|
// Ghost pill
|
||||||
|
if (ghostEvent) {
|
||||||
|
const { col, total, startMs, endMs } = layout.get(ghostForLayout);
|
||||||
|
const isSplit = total > 1;
|
||||||
|
const gapPct = isSplit ? 1 : 0;
|
||||||
|
|
||||||
|
let span = 1;
|
||||||
|
for (let nextCol = col + 1; nextCol < total; nextCol++) {
|
||||||
|
const blocked = layoutEntries.some(([other, o]) =>
|
||||||
|
other !== ghostForLayout && o.col === nextCol &&
|
||||||
|
calendarUtil.rangesOverlap(startMs, endMs, o.startMs, o.endMs)
|
||||||
|
);
|
||||||
|
if (blocked) break;
|
||||||
|
span++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const widthPct = (span * 100 / total) - gapPct;
|
||||||
|
const leftPct = col * (100 / total) + (col > 0 ? gapPct : 0);
|
||||||
|
const top = this.eventTopEm(ghostEvent, minutesPerSlot, gridStartMinutes, dayStart);
|
||||||
|
const height = this.eventHeightEm(ghostEvent, minutesPerSlot, dayStart, dayEnd);
|
||||||
|
const ghostDateTime = new Date(dayStart.getTime() + this.ghostSlot.startMinutes * 60000);
|
||||||
|
|
||||||
|
VStack(() => {
|
||||||
|
p("+")
|
||||||
|
.margin(0)
|
||||||
|
.fontSize(this.viewMode === "day" ? 1.5 : 0.9, em)
|
||||||
|
.fontWeight("700")
|
||||||
|
.color("white")
|
||||||
|
})
|
||||||
|
.position("absolute")
|
||||||
|
.top(top + this.slotHeight, em)
|
||||||
|
.left(leftPct, pct)
|
||||||
|
.width(widthPct, pct)
|
||||||
|
.height(height, em)
|
||||||
|
.minHeight(0.25, em)
|
||||||
|
.background("var(--quillred)")
|
||||||
|
.borderRadius("7.5px")
|
||||||
|
.boxSizing("border-box")
|
||||||
|
.zIndex(1)
|
||||||
|
.alignItems("center")
|
||||||
|
.justifyContent("center")
|
||||||
|
.pointerEvents("auto")
|
||||||
|
.cursor("pointer")
|
||||||
|
.onTap(() => {
|
||||||
|
this.ghostSlot = null;
|
||||||
|
this.rerender();
|
||||||
|
if (this.onSlotTap) this.onSlotTap(ghostDateTime);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.position("absolute")
|
||||||
|
.top(0)
|
||||||
|
.left(0)
|
||||||
|
.right(0)
|
||||||
|
.bottom(0)
|
||||||
|
.pointerEvents("none")
|
||||||
|
.boxSizing("border-box")
|
||||||
|
})
|
||||||
|
.flex(1)
|
||||||
|
.width(0, px)
|
||||||
|
.minWidth(0)
|
||||||
|
.height(totalGridHeight, em)
|
||||||
|
.position("relative")
|
||||||
|
.background(today && this.viewMode !== "day" ? "var(--desktop-item-background)" : "transparent")
|
||||||
|
.borderRight(isLast ? "1px solid transparent" : "1px solid var(--divider)")
|
||||||
|
.boxSizing("border-box")
|
||||||
|
.overflow("hidden")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.position("relative")
|
||||||
|
.width(100, pct)
|
||||||
|
.minHeight(totalGridHeight, em)
|
||||||
|
}
|
||||||
|
|
||||||
|
handleOverlayTap(e, day) {
|
||||||
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
|
const clientY = e.clientY !== undefined ? e.clientY
|
||||||
|
: (e.changedTouches?.[0]?.clientY ?? e.touches?.[0]?.clientY ?? 0);
|
||||||
|
const fs = parseFloat(getComputedStyle(this).fontSize);
|
||||||
|
const relY = clientY - rect.top - (this.slotHeight * fs);
|
||||||
|
const raw = (relY / (this.slotHeight * fs)) * this.minutesPerSlot() + this.gridStartMinutes();
|
||||||
|
const snapped = Math.floor(raw / 30) * 30;
|
||||||
|
const clamped = Math.max(0, Math.min(23 * 60 + 30, snapped));
|
||||||
|
this.ghostSlot = { day, startMinutes: clamped };
|
||||||
|
this.rerender();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3 passes, O(n^2) worst case.
|
||||||
|
// - Adjacency list is built once after Pass 1 and shared by Passes 2 and 3
|
||||||
|
// - The inner loop also breaks early once it reaches an event that starts
|
||||||
|
// after the current one ends,
|
||||||
|
computeLayout(events, day) {
|
||||||
|
if (this._layoutCache.has(events)) return this._layoutCache.get(events);
|
||||||
|
|
||||||
|
const MIN_DURATION_MS = 30 * 60 * 1000;
|
||||||
|
const dayStart = calendarUtil.startOfDay(day);
|
||||||
|
const dayEnd = calendarUtil.endOfDay(day);
|
||||||
|
const result = new Map();
|
||||||
|
const columns = [];
|
||||||
|
|
||||||
|
const sorted = [...events].sort((a, b) => a.time_start - b.time_start);
|
||||||
|
|
||||||
|
// Pass 1: assign each event to a column — O(n log n)
|
||||||
|
sorted.forEach(event => {
|
||||||
|
const startMs = Math.max(event.time_start.getTime(), dayStart.getTime());
|
||||||
|
const rawEndMs = Math.min(event.time_end.getTime(), dayEnd.getTime());
|
||||||
|
const endMs = Math.max(rawEndMs, startMs + MIN_DURATION_MS);
|
||||||
|
|
||||||
|
let col = columns.findIndex(colEnd => colEnd <= startMs);
|
||||||
|
if (col === -1) {
|
||||||
|
col = columns.length;
|
||||||
|
columns.push(endMs);
|
||||||
|
} else {
|
||||||
|
columns[col] = endMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
result.set(event, { col, total: 0, startMs, endMs });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build adjacency list
|
||||||
|
const neighbors = new Map(sorted.map(e => [e, []]));
|
||||||
|
for (let i = 0; i < sorted.length; i++) {
|
||||||
|
const { startMs: as, endMs: ae } = result.get(sorted[i]);
|
||||||
|
for (let j = i + 1; j < sorted.length; j++) {
|
||||||
|
const { startMs: bs, endMs: be } = result.get(sorted[j]);
|
||||||
|
if (bs >= ae) break;
|
||||||
|
if (calendarUtil.rangesOverlap(as, ae, bs, be)) {
|
||||||
|
neighbors.get(sorted[i]).push(sorted[j]);
|
||||||
|
neighbors.get(sorted[j]).push(sorted[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pass 2: each event's total = highest col among its direct neighbors + 1
|
||||||
|
sorted.forEach(event => {
|
||||||
|
let maxCol = result.get(event).col;
|
||||||
|
neighbors.get(event).forEach(other => {
|
||||||
|
maxCol = Math.max(maxCol, result.get(other).col);
|
||||||
|
});
|
||||||
|
result.get(event).total = maxCol + 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pass 3: BFS — propagate max total across connected clusters so events
|
||||||
|
// linked only via a bridge event still share the same column width.
|
||||||
|
const visited = new Set();
|
||||||
|
sorted.forEach(event => {
|
||||||
|
if (visited.has(event)) return;
|
||||||
|
|
||||||
|
const cluster = [];
|
||||||
|
const queue = [event];
|
||||||
|
while (queue.length) {
|
||||||
|
const cur = queue.pop();
|
||||||
|
if (visited.has(cur)) continue;
|
||||||
|
visited.add(cur);
|
||||||
|
cluster.push(cur);
|
||||||
|
neighbors.get(cur).forEach(other => {
|
||||||
|
if (!visited.has(other)) queue.push(other);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const clusterMax = Math.max(...cluster.map(e => result.get(e).total));
|
||||||
|
cluster.forEach(e => { result.get(e).total = clusterMax; });
|
||||||
|
});
|
||||||
|
|
||||||
|
this._layoutCache.set(events, result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
eventTopEm(event, minutesPerSlot, gridStartMinutes, dayStart) {
|
||||||
|
const startMs = Math.max(event.time_start.getTime(), dayStart.getTime());
|
||||||
|
const start = new Date(startMs);
|
||||||
|
const minutesFromGridStart = (start.getHours() * 60 + start.getMinutes()) - gridStartMinutes;
|
||||||
|
return (minutesFromGridStart / minutesPerSlot) * this.slotHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
eventHeightEm(event, minutesPerSlot, dayStart, dayEnd) {
|
||||||
|
const startMs = Math.max(event.time_start.getTime(), dayStart.getTime());
|
||||||
|
const endMs = Math.min(event.time_end.getTime(), dayEnd.getTime());
|
||||||
|
const durationMinutes = Math.max(30, (endMs - startMs) / 60000);
|
||||||
|
return (durationMinutes / minutesPerSlot) * this.slotHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
minutesPerSlot() {
|
||||||
|
if (this.slots.length < 2) return 30;
|
||||||
|
return this.slots[1].totalMinutes - this.slots[0].totalMinutes;
|
||||||
|
}
|
||||||
|
|
||||||
|
gridStartMinutes() {
|
||||||
|
return this.slots.length ? this.slots[0].totalMinutes : 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
register(TimedWeekGrid)
|
||||||
160
calendar/Week/WeekHeaderRow.js
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
import calendarUtil from "../calendarUtil.js";
|
||||||
|
|
||||||
|
class WeekHeaderRow extends Shadow {
|
||||||
|
constructor(groupedDays, calendars, onDayTap = null) {
|
||||||
|
super()
|
||||||
|
this.groupedDays = groupedDays;
|
||||||
|
this.calendars = calendars;
|
||||||
|
this.onDayTap = onDayTap;
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const allDayEvents = this.collectAllDayEvents();
|
||||||
|
const maxEventsPerDay = Math.max(0, ...this.groupedDays.map(g => g.allDay.length))
|
||||||
|
|
||||||
|
VStack(() => {
|
||||||
|
HStack(() => {
|
||||||
|
this.groupedDays.forEach((group, index) => {
|
||||||
|
const day = group.day;
|
||||||
|
const today = calendarUtil.isToday(day);
|
||||||
|
const isLast = index === this.groupedDays.length - 1;
|
||||||
|
|
||||||
|
VStack(() => {
|
||||||
|
h3(day.getDate())
|
||||||
|
.margin(0)
|
||||||
|
.fontSize(1.35, em)
|
||||||
|
.fontWeight("700")
|
||||||
|
.lineHeight("1")
|
||||||
|
.textAlign("center")
|
||||||
|
|
||||||
|
p(day.toLocaleDateString("en-US", { weekday: "short" }).toUpperCase())
|
||||||
|
.margin(0)
|
||||||
|
.fontSize(0.72, em)
|
||||||
|
.fontWeight("600")
|
||||||
|
.letterSpacing(0.04, em)
|
||||||
|
.opacity(today ? 1 : 0.5)
|
||||||
|
.textAlign("center")
|
||||||
|
})
|
||||||
|
.color(today ? "var(--quillred)" : "var(--headertext)")
|
||||||
|
.flex(1)
|
||||||
|
.width(0, px)
|
||||||
|
.minWidth(0)
|
||||||
|
.justifyContent("center")
|
||||||
|
.alignItems("center")
|
||||||
|
.gap(0.5, em)
|
||||||
|
.paddingTop(0.85, em)
|
||||||
|
.background(today ? "var(--desktop-item-background)" : "transparent")
|
||||||
|
.borderRight(isLast ? "1px solid transparent" : "1px solid var(--divider)")
|
||||||
|
.boxSizing("border-box")
|
||||||
|
.paddingBottom(maxEventsPerDay > 0 ? (maxEventsPerDay * 2.0) + 0.7 : 0.35, em)
|
||||||
|
.cursor("pointer")
|
||||||
|
.onTap(() => { this.onDayTap(day) })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.width(100, pct)
|
||||||
|
.alignItems("stretch")
|
||||||
|
|
||||||
|
this.allDayRow(allDayEvents, maxEventsPerDay);
|
||||||
|
})
|
||||||
|
.width(100, pct)
|
||||||
|
.position("relative")
|
||||||
|
.background("var(--sidebottombars)")
|
||||||
|
.borderBottom("1px solid var(--divider)")
|
||||||
|
}
|
||||||
|
|
||||||
|
allDayRow(allDayEvents, maxEventsPerDay) {
|
||||||
|
if (allDayEvents.length === 0) return;
|
||||||
|
|
||||||
|
const rowHeight = 1.75;
|
||||||
|
const gap = 0.25;
|
||||||
|
const totalHeight = maxEventsPerDay * rowHeight + (maxEventsPerDay - 1) * gap;
|
||||||
|
|
||||||
|
ZStack(() => {
|
||||||
|
allDayEvents.forEach(({ event, startIndex, endIndex, clippedLeft, clippedRight }) => {
|
||||||
|
this.spanningEvent(event, startIndex, endIndex, rowHeight, gap, clippedLeft, clippedRight);
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.position("absolute")
|
||||||
|
.bottom(0.25, em)
|
||||||
|
.left(0, px)
|
||||||
|
.width(100, pct)
|
||||||
|
.height(totalHeight, em)
|
||||||
|
.boxSizing("border-box")
|
||||||
|
.pointerEvents("none")
|
||||||
|
}
|
||||||
|
|
||||||
|
spanningEvent(event, startIndex, endIndex, rowHeight, gap, clippedLeft, clippedRight) {
|
||||||
|
const totalDays = this.groupedDays.length;
|
||||||
|
const spanCount = endIndex - startIndex + 1;
|
||||||
|
const leftPct = (startIndex / totalDays) * 100;
|
||||||
|
const widthPct = (spanCount / totalDays) * 100;
|
||||||
|
const color = calendarUtil.getCalendarColor(this.calendars, event.calendars.find(id => this.calendars.some(c => c.id === id)) ?? event.calendars[0]);
|
||||||
|
|
||||||
|
const id = event.id ?? event.title;
|
||||||
|
const slot = this.groupedDays[startIndex].allDay.findIndex(e => (e.id ?? e.title) === id);
|
||||||
|
const topEm = slot * (rowHeight + gap);
|
||||||
|
|
||||||
|
const borderLeft = clippedLeft ? 0 : 0.25;
|
||||||
|
const borderRight = clippedRight ? 0 : 0.25;
|
||||||
|
const leftPad = clippedLeft ? 0 : 1.25;
|
||||||
|
const rightPad = clippedRight ? 0 : 1.25;
|
||||||
|
|
||||||
|
HStack(() => {
|
||||||
|
p(event.title)
|
||||||
|
.margin(0)
|
||||||
|
.fontSize(0.72, em)
|
||||||
|
.fontWeight("600")
|
||||||
|
.color("white")
|
||||||
|
.whiteSpace("nowrap")
|
||||||
|
.overflow("hidden")
|
||||||
|
})
|
||||||
|
.position("absolute")
|
||||||
|
.top(topEm, em)
|
||||||
|
.left(leftPct + leftPad, pct)
|
||||||
|
.width(widthPct - leftPad - rightPad, pct)
|
||||||
|
.height(rowHeight, em)
|
||||||
|
.padding(0.35, em)
|
||||||
|
.background(color)
|
||||||
|
.borderTopLeftRadius(`${borderLeft}em`)
|
||||||
|
.borderBottomLeftRadius(`${borderLeft}em`)
|
||||||
|
.borderTopRightRadius(`${borderRight}em`)
|
||||||
|
.borderBottomRightRadius(`${borderRight}em`)
|
||||||
|
.alignItems("center")
|
||||||
|
.boxSizing("border-box")
|
||||||
|
.overflow("hidden")
|
||||||
|
.pointerEvents("auto")
|
||||||
|
.cursor("pointer")
|
||||||
|
.onTap(() => $("bottomsheet-").showEvent(event))
|
||||||
|
}
|
||||||
|
|
||||||
|
collectAllDayEvents() {
|
||||||
|
const seen = new Map();
|
||||||
|
// Key by id + time_start date: events spanning multiple days share the same time_start
|
||||||
|
// so they merge into one bar; different occurrences of the same recurring template
|
||||||
|
// have different time_start dates and render as separate bars.
|
||||||
|
const eventKey = (event) => {
|
||||||
|
const d = event.time_start instanceof Date ? event.time_start : new Date(event.time_start);
|
||||||
|
return `${event.id ?? event.title}_${calendarUtil.toDateInput(d)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
this.groupedDays.forEach((group, index) => {
|
||||||
|
group.allDay.forEach(event => {
|
||||||
|
const key = eventKey(event);
|
||||||
|
if (!seen.has(key)) {
|
||||||
|
seen.set(key, { event, startIndex: index, endIndex: index });
|
||||||
|
} else {
|
||||||
|
seen.get(key).endIndex = index;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const lastIndex = this.groupedDays.length - 1;
|
||||||
|
return Array.from(seen.values()).map(entry => ({
|
||||||
|
...entry,
|
||||||
|
clippedLeft: entry.startIndex === 0 && calendarUtil.startOfDay(entry.event.time_start) < calendarUtil.startOfDay(this.groupedDays[0].day),
|
||||||
|
clippedRight: entry.endIndex === lastIndex && calendarUtil.startOfDay(entry.event.time_end) > calendarUtil.startOfDay(this.groupedDays[lastIndex].day)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
register(WeekHeaderRow)
|
||||||
142
calendar/Week/WeekView.js
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
import calendarUtil from "../calendarUtil.js"
|
||||||
|
import "./SpacerCell.js"
|
||||||
|
import "./TimedLabelsColumn.js"
|
||||||
|
import "./TimedWeekGrid.js"
|
||||||
|
import "./WeekHeaderRow.js"
|
||||||
|
|
||||||
|
css(`
|
||||||
|
weekview- {
|
||||||
|
scrollbar-width: none;
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
weekview- .VStack::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
width: 0px;
|
||||||
|
height: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
weekview- .VStack::-webkit-scrollbar-thumb {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
weekview- .VStack::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
`)
|
||||||
|
|
||||||
|
let _saved = null;
|
||||||
|
|
||||||
|
class WeekView extends Shadow {
|
||||||
|
constructor(calendars, events, currentDate, weekStartsOn = 0, onSlotTap = null, onDayTap = null, isCenter = false) {
|
||||||
|
super()
|
||||||
|
this.calendars = calendars;
|
||||||
|
this.events = events;
|
||||||
|
this.currentDate = currentDate;
|
||||||
|
this.weekStartsOn = weekStartsOn;
|
||||||
|
this.onSlotTap = onSlotTap;
|
||||||
|
this.onDayTap = onDayTap;
|
||||||
|
this.isCenter = isCenter;
|
||||||
|
this.slots = calendarUtil.generateTimeSlots({ stepMinutes: 30 });
|
||||||
|
this.slotHeight = 2;
|
||||||
|
this.sidebarWidth = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
ZStack(() => {
|
||||||
|
const visibleWeekStart = calendarUtil.startOfWeek(this.currentDate, this.weekStartsOn);
|
||||||
|
const weekDays = this.getWeekDays(visibleWeekStart);
|
||||||
|
const groupedDays = this.groupEventsByWeekDay(
|
||||||
|
this.filterEventsForWeek(this.events, weekDays),
|
||||||
|
weekDays
|
||||||
|
);
|
||||||
|
const weekNumber = calendarUtil.getWeekNumber(visibleWeekStart);
|
||||||
|
|
||||||
|
VStack(() => {
|
||||||
|
HStack(() => {
|
||||||
|
SpacerCell(weekNumber, this.sidebarWidth)
|
||||||
|
WeekHeaderRow(groupedDays, this.calendars, this.onDayTap)
|
||||||
|
})
|
||||||
|
.boxSizing("border-box")
|
||||||
|
.position("sticky")
|
||||||
|
.top(0)
|
||||||
|
.width(100, pct)
|
||||||
|
.zIndex(2)
|
||||||
|
|
||||||
|
HStack(() => {
|
||||||
|
TimedLabelsColumn(this.slots, this.slotHeight, this.sidebarWidth)
|
||||||
|
TimedWeekGrid(weekDays, this.slots, groupedDays, this.calendars, this.slotHeight, "week", this.onSlotTap)
|
||||||
|
})
|
||||||
|
.boxSizing("border-box")
|
||||||
|
.onAppear(() => {
|
||||||
|
// console.log("groupedDays:", groupedDays)
|
||||||
|
this.scrollToEight();
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.position("relative")
|
||||||
|
.gap(1, em)
|
||||||
|
.boxSizing("border-box")
|
||||||
|
.width(100, pct)
|
||||||
|
.height(100, pct)
|
||||||
|
.fontSize(0.9, em)
|
||||||
|
.overscrollBehavior("none")
|
||||||
|
.overflowY("scroll")
|
||||||
|
.display("block")
|
||||||
|
}
|
||||||
|
|
||||||
|
getWeekDays(weekStart) {
|
||||||
|
return Array.from({ length: 7 }, (_, i) => calendarUtil.addDays(weekStart, i));
|
||||||
|
}
|
||||||
|
|
||||||
|
filterEventsForWeek(events, weekDays) {
|
||||||
|
const rangeStart = calendarUtil.startOfDay(weekDays[0]);
|
||||||
|
const rangeEnd = calendarUtil.endOfDay(weekDays[6]);
|
||||||
|
const expanded = calendarUtil.expandRecurringEvents(events, rangeStart, rangeEnd);
|
||||||
|
return expanded.filter(event => {
|
||||||
|
const end = event.all_day ? calendarUtil.endOfDay(event.time_end) : calendarUtil.timedEnd(event);
|
||||||
|
return weekDays.some(day => calendarUtil.rangesOverlap(
|
||||||
|
event.time_start,
|
||||||
|
end,
|
||||||
|
calendarUtil.startOfDay(day),
|
||||||
|
calendarUtil.endOfDay(day)
|
||||||
|
) && this.calendars.some(c => event.calendars?.some(id => id === c.id)));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
scrollToEight() {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const fs = parseFloat(getComputedStyle(this).fontSize);
|
||||||
|
const slotsBeforeEight = this.slots.findIndex(s => s.hour24 === 8 && s.minute === 0);
|
||||||
|
const defaultTarget = slotsBeforeEight * this.slotHeight * fs;
|
||||||
|
if (this.isCenter) {
|
||||||
|
const dateKey = calendarUtil.toDateInput(calendarUtil.startOfWeek(this.currentDate, this.weekStartsOn));
|
||||||
|
this.scrollTop = (_saved?.dateKey === dateKey) ? _saved.scrollTop : defaultTarget;
|
||||||
|
this.addEventListener('scroll', () => { _saved = { dateKey, scrollTop: this.scrollTop }; }, { passive: true });
|
||||||
|
} else {
|
||||||
|
this.scrollTop = defaultTarget;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
groupEventsByWeekDay(events, weekDays) {
|
||||||
|
return weekDays.map(day => {
|
||||||
|
const dayStart = calendarUtil.startOfDay(day);
|
||||||
|
const dayEnd = calendarUtil.endOfDay(day)
|
||||||
|
|
||||||
|
return {
|
||||||
|
day,
|
||||||
|
allDay: events.filter(event => {
|
||||||
|
if (!event.all_day) return false;
|
||||||
|
const end = calendarUtil.endOfDay(event.time_end);
|
||||||
|
return calendarUtil.rangesOverlap(event.time_start, end, dayStart, dayEnd);
|
||||||
|
}),
|
||||||
|
timed: events.filter(event => {
|
||||||
|
return !event.all_day && calendarUtil.rangesOverlap(event.time_start, calendarUtil.timedEnd(event), dayStart, dayEnd);
|
||||||
|
})
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
register(WeekView)
|
||||||
582
calendar/calendar.js
Normal file
@@ -0,0 +1,582 @@
|
|||||||
|
import calendarUtil from "./calendarUtil.js"
|
||||||
|
import "./Week/WeekView.js"
|
||||||
|
import "./Month/MonthView.js"
|
||||||
|
import "./Day/DayView.js"
|
||||||
|
import "./Events/EventForm.js"
|
||||||
|
import "./Events/EventDetails.js"
|
||||||
|
import "./Events/FilePreview.js"
|
||||||
|
import "./Toolbar/CalendarToolbar.js"
|
||||||
|
import "./Toolbar/CalendarOptions.js"
|
||||||
|
import "./Toolbar/BottomBar.js"
|
||||||
|
import "./CalendarForm.js"
|
||||||
|
import "../components/BottomSheet.js"
|
||||||
|
import "/_/code/components/LoadingCircle.js"
|
||||||
|
|
||||||
|
css(`
|
||||||
|
calendar- {
|
||||||
|
font-family: 'Arial';
|
||||||
|
scrollbar-width: none;
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
calendar- h1 {
|
||||||
|
font-family: 'Bona';
|
||||||
|
}
|
||||||
|
`)
|
||||||
|
|
||||||
|
class Calendar extends Shadow {
|
||||||
|
swipeTranslate = 0; // current drag offset in px
|
||||||
|
isSwiping = false;
|
||||||
|
isCommitting = false;
|
||||||
|
swipeDragStartX = null;
|
||||||
|
swipeDragStartY = null;
|
||||||
|
swipeDragStartTime = null;
|
||||||
|
swipeAxisLocked = false;
|
||||||
|
swipeIsHorizontal = false;
|
||||||
|
|
||||||
|
get basePath() {
|
||||||
|
return window.location.pathname.replace(/\/day\/[^/]+$/, '').replace(/\/$/, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
get urlDayDate() {
|
||||||
|
const match = window.location.pathname.match(/\/day\/(\d{4}-\d{2}-\d{2})$/)
|
||||||
|
return match ? new Date(match[1] + 'T00:00:00') : null
|
||||||
|
}
|
||||||
|
|
||||||
|
SWIPE_COMMIT_DISTANCE = window.outerWidth * 0.35; // 35% of screen
|
||||||
|
SWIPE_VELOCITY_THRESHOLD = 0.4; // px/ms
|
||||||
|
|
||||||
|
calendars = [];
|
||||||
|
events = [];
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super()
|
||||||
|
this.currentDate = new Date();
|
||||||
|
this.viewMode = localStorage.getItem(`calendarViewMode_${global.profile.id}`) || "month";
|
||||||
|
this.weekStartsOn = 0;
|
||||||
|
this.showPopout = false;
|
||||||
|
this.calendars = [...global.currentNetwork.data.calendars];
|
||||||
|
// Restore previously-selected calendars from localStorage; fall back to all
|
||||||
|
const storedCalIds = JSON.parse(localStorage.getItem(`calendarSelection_${global.profile.id}_${global.currentNetwork.id}`) || 'null')
|
||||||
|
if (storedCalIds) {
|
||||||
|
console.log(storedCalIds)
|
||||||
|
const restored = this.calendars.filter(c => storedCalIds.includes(c.id))
|
||||||
|
this.selectedCalendars = restored.length > 0 ? restored : [...this.calendars]
|
||||||
|
} else {
|
||||||
|
console.log("nope")
|
||||||
|
this.selectedCalendars = [...this.calendars]
|
||||||
|
}
|
||||||
|
this.events = global.currentNetwork.data.events.map(event => ({
|
||||||
|
...event,
|
||||||
|
time_start: new Date(event.time_start),
|
||||||
|
time_end: new Date(event.time_end)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const dayDate = this.urlDayDate
|
||||||
|
ZStack(() => {
|
||||||
|
VStack(() => {
|
||||||
|
if (dayDate) {
|
||||||
|
CalendarToolbar(
|
||||||
|
dayDate,
|
||||||
|
this.weekStartsOn,
|
||||||
|
{
|
||||||
|
goToPrevious: () => this.subpathNavigateToDate(calendarUtil.addDays(dayDate, -1)),
|
||||||
|
goToCurrent: () => this.subpathNavigateToDate(new Date()),
|
||||||
|
goToNext: () => this.subpathNavigateToDate(calendarUtil.addDays(dayDate, 1)),
|
||||||
|
goToDate: (date) => this.subpathNavigateToDate(date),
|
||||||
|
},
|
||||||
|
this.selectedCalendars,
|
||||||
|
this.events,
|
||||||
|
this.showPopout,
|
||||||
|
{ onBack: () => navigateTo(this.basePath), viewModeOverride: "day" }
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
CalendarToolbar(this.currentDate, this.weekStartsOn, {
|
||||||
|
goToPrevious: () => this.goToPrevious(),
|
||||||
|
goToCurrent: () => this.goToCurrent(),
|
||||||
|
goToNext: () => this.goToNext(),
|
||||||
|
goToDate: (date) => this.goToDate(date)
|
||||||
|
}, this.selectedCalendars, this.events, this.showPopout)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (global.appRefreshing) {
|
||||||
|
LoadingCircle()
|
||||||
|
} else {
|
||||||
|
ZStack(() => {
|
||||||
|
// Three panels (previous/current/next) for swipe transitions
|
||||||
|
[-1, 0, 1].forEach(offset => {
|
||||||
|
let viewDate;
|
||||||
|
if (dayDate) {
|
||||||
|
viewDate = calendarUtil.addDays(dayDate, offset);
|
||||||
|
} else if (this.viewMode === "week") {
|
||||||
|
viewDate = calendarUtil.addDays(this.currentDate, offset * 7);
|
||||||
|
} else if (this.viewMode === "month") {
|
||||||
|
viewDate = calendarUtil.addMonths(this.currentDate, offset);
|
||||||
|
} else if (this.viewMode === "day") {
|
||||||
|
viewDate = calendarUtil.addDays(this.currentDate, offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
ZStack(() => {
|
||||||
|
const isCenter = offset === 0;
|
||||||
|
if (dayDate) {
|
||||||
|
DayView(this.selectedCalendars, this.events, viewDate, (dateTime) => this.openNewEventForm(dateTime), isCenter)
|
||||||
|
} else if (this.viewMode === "week") {
|
||||||
|
WeekView(this.selectedCalendars, this.events, viewDate, this.weekStartsOn, (dateTime) => this.openNewEventForm(dateTime), (day) => window.navigateTo(`${this.basePath}/day/${calendarUtil.toDateInput(day)}`), isCenter)
|
||||||
|
} else if (this.viewMode === "month") {
|
||||||
|
MonthView(this.selectedCalendars, this.events, viewDate, this.weekStartsOn, (day) => {
|
||||||
|
if (!calendarUtil.isSameMonth(day, viewDate)) {
|
||||||
|
this.commitSwipe(day > viewDate ? "next" : "previous")
|
||||||
|
} else {
|
||||||
|
window.navigateTo(`${this.basePath}/day/${calendarUtil.toDateInput(day)}`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else if (this.viewMode === "day") {
|
||||||
|
DayView(this.selectedCalendars, this.events, viewDate, (dateTime) => this.openNewEventForm(dateTime), isCenter)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.position("absolute")
|
||||||
|
.width(100, pct)
|
||||||
|
.height(100, pct)
|
||||||
|
.transform(`translateX(${offset * 100}%)`)
|
||||||
|
.attr({ "data-swipe-panel": offset })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.position("relative")
|
||||||
|
.overflow("hidden")
|
||||||
|
.width(100, pct)
|
||||||
|
.flex(1)
|
||||||
|
.onTouch((start, e) => this.handleSwipeTouch(start, e))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.height(100, pct)
|
||||||
|
|
||||||
|
ActionSheetPopup()
|
||||||
|
|
||||||
|
FilePreview()
|
||||||
|
|
||||||
|
const sheet = BottomSheet();
|
||||||
|
// Exposed so child views (WeekView, DayView, etc.) can open event details
|
||||||
|
sheet.showEvent = (event) => {
|
||||||
|
let dirty = false;
|
||||||
|
sheet.show(
|
||||||
|
() => EventDetails(
|
||||||
|
this.calendars,
|
||||||
|
event,
|
||||||
|
(updateResult) => {
|
||||||
|
if (updateResult?.scope) {
|
||||||
|
this.handleEditResult(updateResult);
|
||||||
|
} else {
|
||||||
|
const updatedEvent = updateResult;
|
||||||
|
this.events = this.events.map(e => {
|
||||||
|
if (e.id !== updatedEvent.id) return e;
|
||||||
|
if (updatedEvent._isOccurrence) return { ...e, calendars: updatedEvent.calendars };
|
||||||
|
return { ...updatedEvent, time_start: new Date(updatedEvent.time_start), time_end: new Date(updatedEvent.time_end) };
|
||||||
|
});
|
||||||
|
global.currentNetwork.data.events = global.currentNetwork.data.events.map(e => {
|
||||||
|
if (e.id !== updatedEvent.id) return e;
|
||||||
|
if (updatedEvent._isOccurrence) return { ...e, calendars: updatedEvent.calendars };
|
||||||
|
return updatedEvent;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
dirty = true;
|
||||||
|
},
|
||||||
|
(deleteResult) => {
|
||||||
|
this.handleDeleteResult(deleteResult);
|
||||||
|
dirty = false;
|
||||||
|
this.rerender();
|
||||||
|
}
|
||||||
|
),
|
||||||
|
() => { if (dirty) { dirty = false; this.rerender(); } }
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
BottomBar({
|
||||||
|
onAddEvent: () => this.openNewEventForm(dayDate ?? null),
|
||||||
|
hideViewSelect: !!dayDate,
|
||||||
|
onCalendarOptions: () => $("bottomsheet-").show(() => CalendarOptions(this.calendars, this.selectedCalendars, {
|
||||||
|
onSelection: (newSelectedCalendars) => {
|
||||||
|
this.selectedCalendars = newSelectedCalendars;
|
||||||
|
localStorage.setItem(`calendarSelection_${global.profile.id}_${global.currentNetwork.id}`, JSON.stringify(newSelectedCalendars.map(c => c.id)))
|
||||||
|
this.rerender();
|
||||||
|
},
|
||||||
|
onCalendarAdded: (newCalendar) => {
|
||||||
|
this.calendars = [...this.calendars, newCalendar];
|
||||||
|
global.currentNetwork.data.calendars = [...global.currentNetwork.data.calendars, newCalendar];
|
||||||
|
},
|
||||||
|
onCalendarUpdated: (updatedCalendar) => {
|
||||||
|
this.calendars = this.calendars.map(c => c.id === updatedCalendar.id ? updatedCalendar : c);
|
||||||
|
this.selectedCalendars = this.selectedCalendars.map(c => c.id === updatedCalendar.id ? updatedCalendar : c);
|
||||||
|
global.currentNetwork.data.calendars = global.currentNetwork.data.calendars.map(c => c.id === updatedCalendar.id ? updatedCalendar : c);
|
||||||
|
},
|
||||||
|
onCalendarDeleted: (deletedId) => {
|
||||||
|
this.calendars = this.calendars.filter(c => c.id !== deletedId);
|
||||||
|
this.selectedCalendars = this.selectedCalendars.filter(c => c.id !== deletedId);
|
||||||
|
global.currentNetwork.data.calendars = global.currentNetwork.data.calendars.filter(c => c.id !== deletedId);
|
||||||
|
}
|
||||||
|
})),
|
||||||
|
viewMode: this.viewMode,
|
||||||
|
onChangeView: (mode) => { this.viewMode = mode; localStorage.setItem(`calendarViewMode_${global.profile.id}`, mode); this.rerender(); }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.position("relative")
|
||||||
|
.overflowY("hidden")
|
||||||
|
.boxSizing("border-box")
|
||||||
|
.height(100, pct)
|
||||||
|
.width(100, pct)
|
||||||
|
.onNavigate(() => this.rerender())
|
||||||
|
}
|
||||||
|
|
||||||
|
subpathNavigateToDate(date) {
|
||||||
|
this.currentDate = date
|
||||||
|
window.history.replaceState({}, '', `${this.basePath}/day/${calendarUtil.toDateInput(date)}`)
|
||||||
|
this.rerender()
|
||||||
|
}
|
||||||
|
|
||||||
|
openNewEventForm(initialDate = null) {
|
||||||
|
let formEl
|
||||||
|
const sheet = $("bottomsheet-")
|
||||||
|
const onSaveError = () => {
|
||||||
|
sheet._closeOverride = () => sheet.forceClose()
|
||||||
|
}
|
||||||
|
sheet.show(() => {
|
||||||
|
formEl = EventForm(this.calendars, (event) => this.updateEvents(event), null, null, null, initialDate, onSaveError)
|
||||||
|
})
|
||||||
|
sheet._closeOverride = () => {
|
||||||
|
sheet.setSheet(true)
|
||||||
|
formEl?.handleBack()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleEditResult({ scope, event: resultEvent, templateId, occurrenceDate }) {
|
||||||
|
const event = { ...resultEvent, time_start: new Date(resultEvent.time_start), time_end: new Date(resultEvent.time_end) };
|
||||||
|
|
||||||
|
if (scope === 'all') {
|
||||||
|
// Preserve end_date from old template — it may have been set by a 'this and future' split.
|
||||||
|
const oldTemplate = this.events.find(e => e.id === templateId);
|
||||||
|
const oldEndDate = oldTemplate?.recurrence?.end_date ?? null;
|
||||||
|
const recurrence = event.recurrence
|
||||||
|
? { ...event.recurrence, end_date: event.recurrence.end_date ?? oldEndDate }
|
||||||
|
: null;
|
||||||
|
const mergedEvent = { ...event, recurrence };
|
||||||
|
this.events = this.events.map(e => e.id === templateId ? mergedEvent : e);
|
||||||
|
global.currentNetwork.data.events = global.currentNetwork.data.events.map(e =>
|
||||||
|
e.id === templateId ? { ...resultEvent, recurrence } : e
|
||||||
|
);
|
||||||
|
|
||||||
|
} else if (scope === 'single') {
|
||||||
|
const alreadyExists = this.events.some(e => e.id === resultEvent.id);
|
||||||
|
if (alreadyExists) {
|
||||||
|
this.events = this.events.map(e => e.id === resultEvent.id ? event : e);
|
||||||
|
global.currentNetwork.data.events = global.currentNetwork.data.events.map(e => e.id === resultEvent.id ? resultEvent : e);
|
||||||
|
} else {
|
||||||
|
this.events = [...this.events, event];
|
||||||
|
global.currentNetwork.data.events = [...global.currentNetwork.data.events, resultEvent];
|
||||||
|
}
|
||||||
|
|
||||||
|
} else if (scope === 'future') {
|
||||||
|
const capDate = occurrenceDate ? new Date(occurrenceDate) : null;
|
||||||
|
if (capDate) {
|
||||||
|
const oldTemplate = this.events.find(e => e.id === templateId);
|
||||||
|
const oldEndDate = oldTemplate?.recurrence?.end_date ? new Date(oldTemplate.recurrence.end_date) : null;
|
||||||
|
|
||||||
|
// Inherit recurrence from form (or old template's rule). A's old end_date caps the new series to avoid overlap with independent later splits.
|
||||||
|
const baseRecurrence = event.recurrence ?? oldTemplate?.recurrence;
|
||||||
|
const inheritedRecurrence = baseRecurrence
|
||||||
|
? { ...baseRecurrence, end_date: oldEndDate ? oldEndDate.toISOString() : null }
|
||||||
|
: null;
|
||||||
|
const newTemplateEvent = { ...event, recurrence: inheritedRecurrence };
|
||||||
|
const newTemplateRaw = { ...resultEvent, recurrence: inheritedRecurrence };
|
||||||
|
|
||||||
|
// Collect descendants in [capDate, oldEndDate) only; independent splits at/beyond oldEndDate are preserved
|
||||||
|
const descendantIds = new Set(
|
||||||
|
this.events
|
||||||
|
.filter(e => {
|
||||||
|
if (!(e.parent_template_id === templateId && e.recurrence_id)) return false;
|
||||||
|
const t = new Date(e.time_start);
|
||||||
|
return t >= capDate && (!oldEndDate || t < oldEndDate);
|
||||||
|
})
|
||||||
|
.map(e => e.id)
|
||||||
|
);
|
||||||
|
|
||||||
|
const newId = resultEvent.id;
|
||||||
|
const updateAndFilter = (arr) => arr.map(e => {
|
||||||
|
if (e.id === templateId && e.recurrence) {
|
||||||
|
return { ...e, recurrence: { ...e.recurrence, end_date: capDate.toISOString() } };
|
||||||
|
}
|
||||||
|
// Migrate overrides in [capDate, oldEndDate) to the new template (mirrors server migration)
|
||||||
|
if (e.recurrence_parent_id === templateId && e.recurrence_exception_date) {
|
||||||
|
const exDate = new Date(e.recurrence_exception_date);
|
||||||
|
if (exDate >= capDate && (!oldEndDate || exDate < oldEndDate)) {
|
||||||
|
return { ...e, recurrence_parent_id: newId };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return e;
|
||||||
|
}).filter(e => {
|
||||||
|
// Overrides of the old template stay if they're before capDate.
|
||||||
|
// Migrated overrides now have recurrence_parent_id = newId so they pass through.
|
||||||
|
if (e.recurrence_parent_id === templateId && e.recurrence_exception_date) {
|
||||||
|
return new Date(e.recurrence_exception_date) < capDate;
|
||||||
|
}
|
||||||
|
if (descendantIds.has(e.id) || descendantIds.has(e.recurrence_parent_id)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
this.events = [...updateAndFilter(this.events), newTemplateEvent];
|
||||||
|
global.currentNetwork.data.events = [...updateAndFilter(global.currentNetwork.data.events), newTemplateRaw];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleDeleteResult({ scope, templateId, occurrenceDate, overrideId }) {
|
||||||
|
if (scope === 'all') {
|
||||||
|
// Promote non-cancelled overrides (single-event edits) to standalone; remove cancelled placeholders and template
|
||||||
|
const promoteOverrides = (arr) => arr
|
||||||
|
.filter(e => e.id !== templateId && !(e.recurrence_parent_id === templateId && e.is_cancelled))
|
||||||
|
.map(e => e.recurrence_parent_id === templateId
|
||||||
|
? { ...e, recurrence_parent_id: null, recurrence_exception_date: null }
|
||||||
|
: e
|
||||||
|
);
|
||||||
|
this.events = promoteOverrides(this.events);
|
||||||
|
global.currentNetwork.data.events = promoteOverrides(global.currentNetwork.data.events);
|
||||||
|
} else if (scope === 'single') {
|
||||||
|
if (overrideId) {
|
||||||
|
this.events = this.events.map(e => e.id === overrideId ? { ...e, is_cancelled: true } : e);
|
||||||
|
global.currentNetwork.data.events = global.currentNetwork.data.events.map(e => e.id === overrideId ? { ...e, is_cancelled: true } : e);
|
||||||
|
} else if (occurrenceDate) {
|
||||||
|
const occDate = new Date(occurrenceDate);
|
||||||
|
const syntheticOverride = {
|
||||||
|
id: `cancelled_${templateId}_${occurrenceDate}`,
|
||||||
|
recurrence_parent_id: templateId,
|
||||||
|
recurrence_exception_date: occDate,
|
||||||
|
is_cancelled: true,
|
||||||
|
time_start: occDate,
|
||||||
|
time_end: occDate,
|
||||||
|
calendars: [],
|
||||||
|
all_day: false,
|
||||||
|
};
|
||||||
|
this.events = [...this.events, syntheticOverride];
|
||||||
|
global.currentNetwork.data.events = [...global.currentNetwork.data.events, syntheticOverride];
|
||||||
|
}
|
||||||
|
} else if (scope === 'future') {
|
||||||
|
const capDate = occurrenceDate ? new Date(occurrenceDate) : null;
|
||||||
|
if (capDate) {
|
||||||
|
const oldTemplate = this.events.find(e => e.id === templateId);
|
||||||
|
// Server does a full delete when capDate <= time_start (no occurrences would remain)
|
||||||
|
if (oldTemplate && capDate <= new Date(oldTemplate.time_start)) {
|
||||||
|
const promoteOverrides = (arr) => arr
|
||||||
|
.filter(e => e.id !== templateId && !(e.recurrence_parent_id === templateId && e.is_cancelled))
|
||||||
|
.map(e => e.recurrence_parent_id === templateId
|
||||||
|
? { ...e, recurrence_parent_id: null, recurrence_exception_date: null }
|
||||||
|
: e
|
||||||
|
);
|
||||||
|
this.events = promoteOverrides(this.events);
|
||||||
|
global.currentNetwork.data.events = promoteOverrides(global.currentNetwork.data.events);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const oldEndDate = oldTemplate?.recurrence?.end_date ? new Date(oldTemplate.recurrence.end_date) : null;
|
||||||
|
const descendantIds = new Set(
|
||||||
|
this.events
|
||||||
|
.filter(e => {
|
||||||
|
if (!(e.parent_template_id === templateId && e.recurrence_id)) return false;
|
||||||
|
const t = new Date(e.time_start);
|
||||||
|
return t >= capDate && (!oldEndDate || t < oldEndDate);
|
||||||
|
})
|
||||||
|
.map(e => e.id)
|
||||||
|
);
|
||||||
|
const updateAndFilter = (arr) => arr.map(e => {
|
||||||
|
if (e.id === templateId && e.recurrence) {
|
||||||
|
return { ...e, recurrence: { ...e.recurrence, end_date: capDate.toISOString() } };
|
||||||
|
}
|
||||||
|
// Promote future non-cancelled overrides to standalone events
|
||||||
|
if (e.recurrence_parent_id === templateId && e.recurrence_exception_date
|
||||||
|
&& new Date(e.recurrence_exception_date) >= capDate && !e.is_cancelled) {
|
||||||
|
return { ...e, recurrence_parent_id: null, recurrence_exception_date: null };
|
||||||
|
}
|
||||||
|
return e;
|
||||||
|
}).filter(e => {
|
||||||
|
// Remove future cancelled placeholders and past-promoted overrides that are still linked
|
||||||
|
if (e.recurrence_parent_id === templateId && e.recurrence_exception_date) {
|
||||||
|
return new Date(e.recurrence_exception_date) < capDate;
|
||||||
|
}
|
||||||
|
if (descendantIds.has(e.id) || descendantIds.has(e.recurrence_parent_id)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
this.events = updateAndFilter(this.events);
|
||||||
|
global.currentNetwork.data.events = updateAndFilter(global.currentNetwork.data.events);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateEvents(event) {
|
||||||
|
this.events = [...this.events, { ...event, time_start: new Date(event.time_start), time_end: new Date(event.time_end) }];
|
||||||
|
global.currentNetwork.data.events = [...global.currentNetwork.data.events, event];
|
||||||
|
this.rerender();
|
||||||
|
}
|
||||||
|
|
||||||
|
changeView(type) {
|
||||||
|
if (this.viewMode === type || this.viewMode === "day") return false;
|
||||||
|
this.viewMode = type;
|
||||||
|
localStorage.setItem('calendarViewMode', type)
|
||||||
|
this.rerender();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
goToPrevious() { this.navigate("previous"); }
|
||||||
|
goToCurrent() { this.currentDate = new Date(); this.rerender(); }
|
||||||
|
goToNext() { this.navigate("next"); }
|
||||||
|
goToDate(date) {
|
||||||
|
const prev = this.currentDate;
|
||||||
|
this.currentDate = date;
|
||||||
|
|
||||||
|
if (this.viewMode === "week") {
|
||||||
|
if (calendarUtil.isSameWeek(prev, date)) return false;
|
||||||
|
} else if (this.viewMode === "month") {
|
||||||
|
if (calendarUtil.isSameMonth(prev, date)) return false;
|
||||||
|
}
|
||||||
|
this.rerender();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
navigate(direction) {
|
||||||
|
const sign = direction === "next" ? 1 : -1;
|
||||||
|
if (this.viewMode === "week") { this.currentDate = calendarUtil.addDays(this.currentDate, sign * 7) }
|
||||||
|
else if (this.viewMode === "day") { this.currentDate = calendarUtil.addDays(this.currentDate, sign) }
|
||||||
|
else if (this.viewMode === "month") { this.currentDate = calendarUtil.addMonths(this.currentDate, sign) }
|
||||||
|
this.rerender();
|
||||||
|
}
|
||||||
|
|
||||||
|
handleSwipeTouch(start, e) {
|
||||||
|
if (start) {
|
||||||
|
// Block new swipes during active animations
|
||||||
|
if (this.isCommitting) return;
|
||||||
|
|
||||||
|
if ($("home-").sidebarOpen) return;
|
||||||
|
|
||||||
|
const startX = e.touches[0].clientX;
|
||||||
|
const sidebarOpenZone = window.outerWidth / 10;
|
||||||
|
const sidebarCloseZone = window.outerWidth * 5 / 6;
|
||||||
|
if (startX < sidebarOpenZone || startX > sidebarCloseZone) return;
|
||||||
|
|
||||||
|
this.swipeDragStartX = e.touches[0].clientX;
|
||||||
|
this.swipeDragStartY = e.touches[0].clientY;
|
||||||
|
this.swipeDragStartTime = Date.now();
|
||||||
|
this.isSwiping = true;
|
||||||
|
this.swipeAxisLocked = false;
|
||||||
|
this.swipeIsHorizontal = false;
|
||||||
|
document.addEventListener("touchmove", this.onSwipeMove, { passive: true });
|
||||||
|
} else {
|
||||||
|
if (!this.isSwiping) return;
|
||||||
|
document.removeEventListener("touchmove", this.onSwipeMove);
|
||||||
|
|
||||||
|
if (!this.swipeIsHorizontal) {
|
||||||
|
this.isSwiping = false;
|
||||||
|
this.swipeDragStartX = null;
|
||||||
|
this.swipeDragStartY = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const endX = e.changedTouches[0].clientX;
|
||||||
|
const delta = endX - this.swipeDragStartX;
|
||||||
|
const elapsed = Date.now() - this.swipeDragStartTime;
|
||||||
|
const velocity = Math.abs(delta) / elapsed;
|
||||||
|
const shouldCommit = Math.abs(delta) > this.SWIPE_COMMIT_DISTANCE || velocity > this.SWIPE_VELOCITY_THRESHOLD;
|
||||||
|
|
||||||
|
if (shouldCommit && delta < 0) {
|
||||||
|
this.commitSwipe("next");
|
||||||
|
} else if (shouldCommit && delta > 0) {
|
||||||
|
this.commitSwipe("previous");
|
||||||
|
} else {
|
||||||
|
this.snapBack();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isSwiping = false;
|
||||||
|
this.swipeDragStartX = null;
|
||||||
|
this.swipeDragStartY = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onSwipeMove = (e) => {
|
||||||
|
if (!this.isSwiping) return;
|
||||||
|
|
||||||
|
const dx = e.touches[0].clientX - this.swipeDragStartX;
|
||||||
|
const dy = e.touches[0].clientY - this.swipeDragStartY;
|
||||||
|
|
||||||
|
if (!this.swipeAxisLocked) {
|
||||||
|
if (Math.abs(dx) < 5 && Math.abs(dy) < 5) return;
|
||||||
|
this.swipeAxisLocked = true;
|
||||||
|
this.swipeIsHorizontal = Math.abs(dx) > Math.abs(dy);
|
||||||
|
}
|
||||||
|
if (!this.swipeIsHorizontal) return;
|
||||||
|
|
||||||
|
const delta = e.touches[0].clientX - this.swipeDragStartX;
|
||||||
|
this.swipeTranslate = delta;
|
||||||
|
this.applySwipeTransform(delta, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
applySwipeTransform(delta, animated) {
|
||||||
|
const panels = this.$$("[data-swipe-panel]", this);
|
||||||
|
panels.forEach(panel => {
|
||||||
|
const offset = parseInt(panel.getAttribute("data-swipe-panel"));
|
||||||
|
panel.style.transition = animated ? "transform 300ms ease" : "";
|
||||||
|
panel.style.transform = `translateX(calc(${offset * 100}% + ${delta}px))`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
commitSwipe(direction) {
|
||||||
|
const dayDate = this.urlDayDate
|
||||||
|
const sign = direction === "next" ? 1 : -1;
|
||||||
|
let nextDate;
|
||||||
|
if (dayDate) {
|
||||||
|
nextDate = calendarUtil.addDays(dayDate, sign);
|
||||||
|
} else if (this.viewMode === "week") {
|
||||||
|
nextDate = calendarUtil.addDays(this.currentDate, sign * 7);
|
||||||
|
} else if (this.viewMode === "day") {
|
||||||
|
nextDate = calendarUtil.addDays(this.currentDate, sign);
|
||||||
|
} else {
|
||||||
|
nextDate = calendarUtil.addMonths(this.currentDate, sign);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isCommitting = true;
|
||||||
|
|
||||||
|
const screenWidth = window.innerWidth;
|
||||||
|
const currentDelta = this.swipeTranslate;
|
||||||
|
|
||||||
|
const panels = this.$$("[data-swipe-panel]", this);
|
||||||
|
panels.forEach(panel => {
|
||||||
|
const offset = parseInt(panel.getAttribute("data-swipe-panel"));
|
||||||
|
panel.style.transition = "";
|
||||||
|
panel.style.transform = `translateX(calc(${offset * 100}% + ${currentDelta}px))`;
|
||||||
|
panel.getBoundingClientRect(); // force reflow so transition fires from current position
|
||||||
|
panel.style.transition = "transform 300ms ease";
|
||||||
|
panel.style.transform = `translateX(calc(${offset * 100}% + ${sign * -screenWidth}px))`;
|
||||||
|
});
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
this.swipeTranslate = 0;
|
||||||
|
this.isCommitting = false;
|
||||||
|
if (dayDate) {
|
||||||
|
this.subpathNavigateToDate(nextDate)
|
||||||
|
} else {
|
||||||
|
this.currentDate = nextDate;
|
||||||
|
this.rerender();
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
snapBack() {
|
||||||
|
const panels = this.$$("[data-swipe-panel]", this);
|
||||||
|
panels.forEach(panel => {
|
||||||
|
const offset = parseInt(panel.getAttribute("data-swipe-panel"));
|
||||||
|
panel.style.transition = "transform 300ms ease";
|
||||||
|
panel.style.transform = `translateX(${offset * 100}%)`;
|
||||||
|
});
|
||||||
|
this.swipeTranslate = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
register(Calendar)
|
||||||
307
calendar/calendarUtil.js
Normal file
@@ -0,0 +1,307 @@
|
|||||||
|
export default class calendarUtil {
|
||||||
|
static addDays(date, days) {
|
||||||
|
const d = new Date(date);
|
||||||
|
d.setDate(d.getDate() + days);
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
static addMonths(date, months) {
|
||||||
|
const d = new Date(date);
|
||||||
|
const day = d.getDate();
|
||||||
|
d.setMonth(d.getMonth() + months);
|
||||||
|
// setMonth overflows short months (e.g. Jan 31 + 1 → Mar 3); clamp to last day of target month
|
||||||
|
if (d.getDate() !== day) d.setDate(0);
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
static rangesOverlap(startA, endA, startB, endB) {
|
||||||
|
return startA < endB && endA > startB;
|
||||||
|
}
|
||||||
|
|
||||||
|
static getCalendarColor(calendars, calendarId) {
|
||||||
|
return calendars.find(c => c.id === calendarId)?.color ?? "#888888";
|
||||||
|
}
|
||||||
|
|
||||||
|
static getWeekNumber(date) {
|
||||||
|
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
|
||||||
|
const dayNum = d.getUTCDay() || 7;
|
||||||
|
d.setUTCDate(d.getUTCDate() + 4 - dayNum);
|
||||||
|
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
|
||||||
|
return Math.ceil((((d - yearStart) / 86400000) + 1) / 7);
|
||||||
|
}
|
||||||
|
|
||||||
|
static generateTimeSlots({ startHour = 0, endHour = 24, stepMinutes = 60, use12Hour = true } = {}) {
|
||||||
|
const slots = [];
|
||||||
|
|
||||||
|
for (let minutes = startHour * 60; minutes <= endHour * 60; minutes += stepMinutes) { // inclusive endHour
|
||||||
|
const hour24 = Math.floor(minutes / 60);
|
||||||
|
const minute = minutes % 60;
|
||||||
|
|
||||||
|
let label;
|
||||||
|
if (use12Hour) {
|
||||||
|
const suffix = (hour24 % 24) < 12 ? "am" : "pm";
|
||||||
|
const hour12 = hour24 % 12 || 12;
|
||||||
|
label = minute === 0
|
||||||
|
? `${hour12}${suffix}`
|
||||||
|
: `${hour12}:${String(minute).padStart(2, "0")}${suffix}`;
|
||||||
|
} else {
|
||||||
|
label = `${String(hour24).padStart(2, "0")}:${String(minute).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
slots.push({
|
||||||
|
hour24,
|
||||||
|
minute,
|
||||||
|
totalMinutes: minutes,
|
||||||
|
label
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return slots;
|
||||||
|
}
|
||||||
|
|
||||||
|
static isToday(date) {
|
||||||
|
const now = new Date();
|
||||||
|
return (
|
||||||
|
now.getFullYear() === date.getFullYear() &&
|
||||||
|
now.getMonth() === date.getMonth() &&
|
||||||
|
now.getDate() === date.getDate()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static isSameDay(a, b) {
|
||||||
|
return (
|
||||||
|
a.getFullYear() === b.getFullYear() &&
|
||||||
|
a.getMonth() === b.getMonth() &&
|
||||||
|
a.getDate() === b.getDate()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static isSameWeek(a, b) {
|
||||||
|
return calendarUtil.isSameDay(this.startOfWeek(a), this.startOfWeek(b));
|
||||||
|
}
|
||||||
|
|
||||||
|
static isSameMonth(a, b) {
|
||||||
|
return (
|
||||||
|
a.getFullYear() === b.getFullYear() &&
|
||||||
|
a.getMonth() === b.getMonth()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static startOfWeek(date, weekStartsOn = 0) {
|
||||||
|
const d = new Date(date);
|
||||||
|
d.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
const day = d.getDay(); // 0=Sun, 1=Mon ...
|
||||||
|
const diff = (day - weekStartsOn + 7) % 7;
|
||||||
|
|
||||||
|
d.setDate(d.getDate() - diff);
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
static startOfDay(date) {
|
||||||
|
const d = new Date(date);
|
||||||
|
d.setHours(0, 0, 0, 0);
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
static endOfDay(date) {
|
||||||
|
const d = new Date(date);
|
||||||
|
d.setHours(24, 0, 0, 0);
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
static toDateInput(date) {
|
||||||
|
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
static toTimeInput(date) {
|
||||||
|
return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns the effective end time for overlap checks, ensuring zero-duration
|
||||||
|
// timed events (start === end) still register as occupying their start moment.
|
||||||
|
static timedEnd(event) {
|
||||||
|
return event.time_end > event.time_start
|
||||||
|
? event.time_end
|
||||||
|
: new Date(event.time_start.getTime() + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Converts a date string + all_day flag to a UTC ISO string.
|
||||||
|
// When all_day is true and isEnd is true, sets time to 23:59:59.999 so
|
||||||
|
// rangesOverlap (strict endA > startB) catches same-day events.
|
||||||
|
static toISO(str, allDay, isEnd = false) {
|
||||||
|
if (!allDay) return new Date(str).toISOString()
|
||||||
|
const d = new Date(str + "T00:00:00")
|
||||||
|
if (isEnd) d.setHours(23, 59, 59, 999)
|
||||||
|
return d.toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Formats an event's time range as a human-readable string.
|
||||||
|
static formatEventTime(event) {
|
||||||
|
const start = new Date(event.time_start)
|
||||||
|
const end = new Date(event.time_end)
|
||||||
|
const dateFmt = { weekday: "short", month: "short", day: "numeric" }
|
||||||
|
const timeFmt = { hour: "numeric", minute: "2-digit" }
|
||||||
|
if (event.all_day) {
|
||||||
|
const startStr = start.toLocaleDateString("en-US", dateFmt)
|
||||||
|
if (calendarUtil.isSameDay(start, end)) return `All day · ${startStr}`
|
||||||
|
return `All day · ${startStr} – ${end.toLocaleDateString("en-US", dateFmt)}`
|
||||||
|
}
|
||||||
|
return `${start.toLocaleDateString("en-US", dateFmt)} · ${start.toLocaleTimeString("en-US", timeFmt)} – ${end.toLocaleTimeString("en-US", timeFmt)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Formats a Date to a short 12-hour time string, e.g. "3pm" or "3:30pm".
|
||||||
|
static formatTimeShort(date) {
|
||||||
|
const h = date.getHours()
|
||||||
|
const m = date.getMinutes()
|
||||||
|
const suffix = h < 12 ? "am" : "pm"
|
||||||
|
const h12 = h % 12 || 12
|
||||||
|
return m === 0 ? `${h12}${suffix}` : `${h12}:${String(m).padStart(2, "0")}${suffix}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns a relative time string, e.g. "3h ago", "2d ago", "just now".
|
||||||
|
static timeAgo(dateStr) {
|
||||||
|
const seconds = Math.floor((Date.now() - new Date(dateStr)) / 1000)
|
||||||
|
if (seconds < 60) return "just now"
|
||||||
|
const minutes = Math.floor(seconds / 60)
|
||||||
|
if (minutes < 60) return `${minutes}m ago`
|
||||||
|
const hours = Math.floor(minutes / 60)
|
||||||
|
if (hours < 24) return `${hours}h ago`
|
||||||
|
const days = Math.floor(hours / 24)
|
||||||
|
if (days < 7) return `${days}d ago`
|
||||||
|
const weeks = Math.floor(days / 7)
|
||||||
|
if (weeks < 5) return `${weeks}w ago`
|
||||||
|
const months = Math.floor(days / 30)
|
||||||
|
if (months < 12) return `${months}mo ago`
|
||||||
|
return `${Math.floor(days / 365)}y ago`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns a flat array of 42 Date objects covering the 6-week grid for the
|
||||||
|
// given month, aligned to weekStartsOn (0 = Sun, 1 = Mon).
|
||||||
|
static buildMonthGrid(date, weekStartsOn = 0) {
|
||||||
|
const firstOfMonth = new Date(date.getFullYear(), date.getMonth(), 1)
|
||||||
|
const gridStart = calendarUtil.startOfWeek(firstOfMonth, weekStartsOn)
|
||||||
|
return Array.from({ length: 42 }, (_, i) => calendarUtil.addDays(gridStart, i))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generates every occurrence Date of a recurring template within [rangeStart, rangeEnd].
|
||||||
|
static generateOccurrenceDates(template, rangeStart, rangeEnd) {
|
||||||
|
const rule = template.recurrence;
|
||||||
|
if (!rule) return [];
|
||||||
|
|
||||||
|
const duration = template.time_end.getTime() - template.time_start.getTime();
|
||||||
|
const seriesStart = new Date(template.time_start);
|
||||||
|
const endDate = rule.end_date ? new Date(rule.end_date) : null;
|
||||||
|
const maxCount = rule.count ?? Infinity;
|
||||||
|
const interval = rule.interval ?? 1;
|
||||||
|
const dates = [];
|
||||||
|
let count = 0;
|
||||||
|
|
||||||
|
// Generous overlap: use at least 1 day of coverage so zero-duration all-day events aren't missed.
|
||||||
|
const inRange = (occ) => {
|
||||||
|
const occEnd = new Date(occ.getTime() + Math.max(duration, 86400000));
|
||||||
|
return occ <= rangeEnd && occEnd >= rangeStart;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (rule.frequency === 'weekly' && rule.days_of_week?.length > 0) {
|
||||||
|
const sortedDays = [...rule.days_of_week].sort((a, b) => a - b);
|
||||||
|
|
||||||
|
// Anchor on the Sunday of the week containing seriesStart
|
||||||
|
const sun = new Date(seriesStart);
|
||||||
|
sun.setDate(sun.getDate() - sun.getDay());
|
||||||
|
sun.setHours(seriesStart.getHours(), seriesStart.getMinutes(), seriesStart.getSeconds(), 0);
|
||||||
|
|
||||||
|
weekLoop: for (let weekOffset = 0; ; weekOffset += interval) {
|
||||||
|
const weekSun = new Date(sun);
|
||||||
|
weekSun.setDate(sun.getDate() + weekOffset * 7);
|
||||||
|
|
||||||
|
if (weekSun > rangeEnd) break;
|
||||||
|
|
||||||
|
for (const dayIdx of sortedDays) {
|
||||||
|
const occ = new Date(weekSun);
|
||||||
|
occ.setDate(weekSun.getDate() + dayIdx);
|
||||||
|
|
||||||
|
if (occ < seriesStart) continue;
|
||||||
|
if (endDate && occ >= endDate) break weekLoop;
|
||||||
|
if (count >= maxCount) break weekLoop;
|
||||||
|
count++;
|
||||||
|
|
||||||
|
if (occ > rangeEnd) break weekLoop;
|
||||||
|
if (inRange(occ)) dates.push(occ);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const advance = (d) => {
|
||||||
|
const next = new Date(d);
|
||||||
|
const day = next.getDate();
|
||||||
|
switch (rule.frequency) {
|
||||||
|
case 'daily': next.setDate(next.getDate() + interval); break;
|
||||||
|
case 'weekly': next.setDate(next.getDate() + interval * 7); break;
|
||||||
|
case 'monthly': next.setMonth(next.getMonth() + interval); break;
|
||||||
|
case 'yearly': next.setFullYear(next.getFullYear() + interval); break;
|
||||||
|
}
|
||||||
|
// Clamp month/year overflow (e.g. Jan 31 + 1 month → Mar 3, or Feb 29 + 1 year → Mar 1)
|
||||||
|
if ((rule.frequency === 'monthly' || rule.frequency === 'yearly') && next.getDate() !== day) {
|
||||||
|
next.setDate(0);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
};
|
||||||
|
|
||||||
|
let current = new Date(seriesStart);
|
||||||
|
while (current <= rangeEnd) {
|
||||||
|
if (endDate && current >= endDate) break;
|
||||||
|
if (count >= maxCount) break;
|
||||||
|
count++;
|
||||||
|
|
||||||
|
if (inRange(current)) dates.push(new Date(current));
|
||||||
|
current = advance(current);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return dates;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expands recurring template events into concrete occurrences within [rangeStart, rangeEnd].
|
||||||
|
// Override rows replace their corresponding virtual occurrence; cancelled ones are skipped.
|
||||||
|
// Non-recurring events pass through unchanged.
|
||||||
|
static expandRecurringEvents(events, rangeStart, rangeEnd) {
|
||||||
|
const templates = events.filter(e => e.recurrence_id && !e.recurrence_parent_id);
|
||||||
|
const overrides = events.filter(e => !!e.recurrence_parent_id);
|
||||||
|
const regular = events.filter(e => !e.recurrence_id && !e.recurrence_parent_id);
|
||||||
|
|
||||||
|
const overrideMap = {};
|
||||||
|
overrides.forEach(ov => {
|
||||||
|
const pid = ov.recurrence_parent_id;
|
||||||
|
if (!overrideMap[pid]) overrideMap[pid] = {};
|
||||||
|
const key = new Date(ov.recurrence_exception_date).toDateString();
|
||||||
|
overrideMap[pid][key] = ov;
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = [...regular];
|
||||||
|
|
||||||
|
templates.forEach(template => {
|
||||||
|
const templateOverrides = overrideMap[template.id] || {};
|
||||||
|
const duration = template.time_end.getTime() - template.time_start.getTime();
|
||||||
|
|
||||||
|
calendarUtil.generateOccurrenceDates(template, rangeStart, rangeEnd).forEach(occDate => {
|
||||||
|
const override = templateOverrides[occDate.toDateString()];
|
||||||
|
|
||||||
|
if (override) {
|
||||||
|
if (!override.is_cancelled) result.push(override);
|
||||||
|
} else {
|
||||||
|
result.push({
|
||||||
|
...template,
|
||||||
|
time_start: occDate,
|
||||||
|
time_end: new Date(occDate.getTime() + duration),
|
||||||
|
_isOccurrence: true,
|
||||||
|
_occurrenceDate: occDate,
|
||||||
|
_templateStart: new Date(template.time_start),
|
||||||
|
_templateEnd: new Date(template.time_end),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
285
calendar/calendarUtil.test.js
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import calendarUtil from './calendarUtil.js'
|
||||||
|
|
||||||
|
// ─── rangesOverlap ────────────────────────────────────────────────────────────
|
||||||
|
describe('rangesOverlap', () => {
|
||||||
|
const d = (h) => new Date(`2024-01-01T${String(h).padStart(2,'0')}:00:00`)
|
||||||
|
|
||||||
|
it('overlapping ranges', () => {
|
||||||
|
expect(calendarUtil.rangesOverlap(d(1), d(5), d(3), d(7))).toBe(true)
|
||||||
|
})
|
||||||
|
it('adjacent ranges (touching) do not overlap', () => {
|
||||||
|
expect(calendarUtil.rangesOverlap(d(1), d(3), d(3), d(5))).toBe(false)
|
||||||
|
})
|
||||||
|
it('non-overlapping ranges', () => {
|
||||||
|
expect(calendarUtil.rangesOverlap(d(1), d(2), d(5), d(7))).toBe(false)
|
||||||
|
})
|
||||||
|
it('one range contained within the other', () => {
|
||||||
|
expect(calendarUtil.rangesOverlap(d(1), d(10), d(3), d(7))).toBe(true)
|
||||||
|
})
|
||||||
|
it('identical ranges overlap', () => {
|
||||||
|
expect(calendarUtil.rangesOverlap(d(1), d(5), d(1), d(5))).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ─── startOfWeek ─────────────────────────────────────────────────────────────
|
||||||
|
describe('startOfWeek', () => {
|
||||||
|
it('Sunday start — Wednesday lands on previous Sunday', () => {
|
||||||
|
const wed = new Date(2024, 0, 3) // Wednesday Jan 3 (local)
|
||||||
|
const result = calendarUtil.startOfWeek(wed, 0)
|
||||||
|
expect(result.getDay()).toBe(0)
|
||||||
|
expect(result.getDate()).toBe(31) // Dec 31
|
||||||
|
})
|
||||||
|
it('Monday start — Wednesday lands on previous Monday', () => {
|
||||||
|
const wed = new Date(2024, 0, 3) // Wednesday Jan 3 (local)
|
||||||
|
const result = calendarUtil.startOfWeek(wed, 1)
|
||||||
|
expect(result.getDay()).toBe(1)
|
||||||
|
expect(result.getDate()).toBe(1) // Jan 1
|
||||||
|
})
|
||||||
|
it('returns start of same day when date is already weekStartsOn', () => {
|
||||||
|
const sun = new Date(2024, 0, 7) // Sunday Jan 7 (local)
|
||||||
|
const result = calendarUtil.startOfWeek(sun, 0)
|
||||||
|
expect(result.toDateString()).toBe(sun.toDateString())
|
||||||
|
})
|
||||||
|
it('zeros out the time', () => {
|
||||||
|
const d = new Date(2024, 0, 3, 15, 30) // Jan 3 15:30 local
|
||||||
|
const result = calendarUtil.startOfWeek(d)
|
||||||
|
expect(result.getHours()).toBe(0)
|
||||||
|
expect(result.getMinutes()).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ─── buildMonthGrid ───────────────────────────────────────────────────────────
|
||||||
|
describe('buildMonthGrid', () => {
|
||||||
|
it('always returns 42 dates', () => {
|
||||||
|
expect(calendarUtil.buildMonthGrid(new Date(2024, 0, 1))).toHaveLength(42)
|
||||||
|
expect(calendarUtil.buildMonthGrid(new Date(2024, 1, 1))).toHaveLength(42)
|
||||||
|
})
|
||||||
|
it('grid starts on a Sunday when weekStartsOn=0', () => {
|
||||||
|
const grid = calendarUtil.buildMonthGrid(new Date(2024, 0, 1), 0)
|
||||||
|
expect(grid[0].getDay()).toBe(0)
|
||||||
|
})
|
||||||
|
it('grid starts on a Monday when weekStartsOn=1', () => {
|
||||||
|
const grid = calendarUtil.buildMonthGrid(new Date(2024, 0, 1), 1)
|
||||||
|
expect(grid[0].getDay()).toBe(1)
|
||||||
|
})
|
||||||
|
it('grid includes all days of the month', () => {
|
||||||
|
const grid = calendarUtil.buildMonthGrid(new Date(2024, 2, 1)) // March 2024
|
||||||
|
const marchDays = grid.filter(d => d.getMonth() === 2)
|
||||||
|
expect(marchDays).toHaveLength(31)
|
||||||
|
})
|
||||||
|
it('consecutive dates are exactly 1 day apart', () => {
|
||||||
|
const grid = calendarUtil.buildMonthGrid(new Date(2024, 0, 1))
|
||||||
|
for (let i = 1; i < grid.length; i++) {
|
||||||
|
const diff = grid[i] - grid[i - 1]
|
||||||
|
expect(diff).toBe(86400000)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ─── getWeekNumber ────────────────────────────────────────────────────────────
|
||||||
|
describe('getWeekNumber', () => {
|
||||||
|
it('Jan 1 2024 is week 1', () => {
|
||||||
|
expect(calendarUtil.getWeekNumber(new Date(2024, 0, 1))).toBe(1)
|
||||||
|
})
|
||||||
|
it('Dec 31 2023 is week 52', () => {
|
||||||
|
expect(calendarUtil.getWeekNumber(new Date(2023, 11, 31))).toBe(52)
|
||||||
|
})
|
||||||
|
it('Jan 4 2021 is week 1 (ISO rule: first week has Thursday)', () => {
|
||||||
|
expect(calendarUtil.getWeekNumber(new Date(2021, 0, 4))).toBe(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ─── timedEnd ─────────────────────────────────────────────────────────────────
|
||||||
|
describe('timedEnd', () => {
|
||||||
|
const t = (iso) => new Date(iso)
|
||||||
|
|
||||||
|
it('returns time_end when event has positive duration', () => {
|
||||||
|
const event = { time_start: t('2024-01-01T10:00'), time_end: t('2024-01-01T11:00') }
|
||||||
|
expect(calendarUtil.timedEnd(event)).toEqual(event.time_end)
|
||||||
|
})
|
||||||
|
it('returns start+1ms for zero-duration event', () => {
|
||||||
|
const start = t('2024-01-01T10:00')
|
||||||
|
const event = { time_start: start, time_end: start }
|
||||||
|
expect(calendarUtil.timedEnd(event).getTime()).toBe(start.getTime() + 1)
|
||||||
|
})
|
||||||
|
it('returns start+1ms when end < start', () => {
|
||||||
|
const event = { time_start: t('2024-01-01T10:00'), time_end: t('2024-01-01T09:00') }
|
||||||
|
expect(calendarUtil.timedEnd(event).getTime()).toBe(event.time_start.getTime() + 1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ─── toISO ───────────────────────────────────────────────────────────────────
|
||||||
|
describe('toISO', () => {
|
||||||
|
it('non-all-day: parses string directly', () => {
|
||||||
|
const str = '2024-06-15T14:30:00'
|
||||||
|
expect(new Date(calendarUtil.toISO(str, false)).getFullYear()).toBe(2024)
|
||||||
|
})
|
||||||
|
it('all-day start: midnight', () => {
|
||||||
|
const iso = calendarUtil.toISO('2024-06-15', true, false)
|
||||||
|
expect(new Date(iso).getHours()).toBe(0)
|
||||||
|
expect(new Date(iso).getMinutes()).toBe(0)
|
||||||
|
})
|
||||||
|
it('all-day end: 23:59:59.999', () => {
|
||||||
|
const iso = calendarUtil.toISO('2024-06-15', true, true)
|
||||||
|
const d = new Date(iso)
|
||||||
|
expect(d.getHours()).toBe(23)
|
||||||
|
expect(d.getMinutes()).toBe(59)
|
||||||
|
expect(d.getSeconds()).toBe(59)
|
||||||
|
expect(d.getMilliseconds()).toBe(999)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ─── generateOccurrenceDates ──────────────────────────────────────────────────
|
||||||
|
describe('generateOccurrenceDates', () => {
|
||||||
|
const range = [new Date('2024-01-01'), new Date('2024-12-31')]
|
||||||
|
|
||||||
|
const makeTemplate = (overrides) => ({
|
||||||
|
time_start: new Date('2024-01-01T10:00'),
|
||||||
|
time_end: new Date('2024-01-01T11:00'),
|
||||||
|
recurrence: { frequency: 'daily', interval: 1 },
|
||||||
|
...overrides,
|
||||||
|
})
|
||||||
|
|
||||||
|
it('daily — correct count for January', () => {
|
||||||
|
const tmpl = makeTemplate({ recurrence: { frequency: 'daily', interval: 1 } })
|
||||||
|
const rangeEnd = new Date('2024-01-31T23:59:59')
|
||||||
|
const dates = calendarUtil.generateOccurrenceDates(tmpl, range[0], rangeEnd)
|
||||||
|
expect(dates).toHaveLength(31)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('daily with interval=2 — every other day', () => {
|
||||||
|
const tmpl = makeTemplate({ recurrence: { frequency: 'daily', interval: 2 } })
|
||||||
|
const rangeEnd = new Date('2024-01-10')
|
||||||
|
const dates = calendarUtil.generateOccurrenceDates(tmpl, range[0], rangeEnd)
|
||||||
|
// Jan 1, 3, 5, 7, 9 = 5
|
||||||
|
expect(dates).toHaveLength(5)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('daily with count limit', () => {
|
||||||
|
const tmpl = makeTemplate({ recurrence: { frequency: 'daily', interval: 1, count: 5 } })
|
||||||
|
const dates = calendarUtil.generateOccurrenceDates(tmpl, range[0], range[1])
|
||||||
|
expect(dates).toHaveLength(5)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('daily with end_date stops before end_date', () => {
|
||||||
|
const tmpl = makeTemplate({ recurrence: { frequency: 'daily', interval: 1, end_date: '2024-01-06' } })
|
||||||
|
const dates = calendarUtil.generateOccurrenceDates(tmpl, range[0], range[1])
|
||||||
|
// Jan 1-5 (end_date exclusive)
|
||||||
|
expect(dates).toHaveLength(5)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('weekly — every Monday for 4 weeks', () => {
|
||||||
|
const tmpl = makeTemplate({
|
||||||
|
time_start: new Date('2024-01-01T10:00'), // Monday
|
||||||
|
recurrence: { frequency: 'weekly', interval: 1, count: 4 },
|
||||||
|
})
|
||||||
|
const dates = calendarUtil.generateOccurrenceDates(tmpl, range[0], range[1])
|
||||||
|
expect(dates).toHaveLength(4)
|
||||||
|
dates.forEach(d => expect(d.getDay()).toBe(1)) // all Mondays
|
||||||
|
})
|
||||||
|
|
||||||
|
it('weekly with days_of_week — Mon+Wed+Fri', () => {
|
||||||
|
const tmpl = makeTemplate({
|
||||||
|
time_start: new Date('2024-01-01T10:00'), // Monday
|
||||||
|
recurrence: { frequency: 'weekly', interval: 1, days_of_week: [1, 3, 5], count: 6 },
|
||||||
|
})
|
||||||
|
const dates = calendarUtil.generateOccurrenceDates(tmpl, range[0], range[1])
|
||||||
|
expect(dates).toHaveLength(6)
|
||||||
|
dates.forEach(d => expect([1, 3, 5]).toContain(d.getDay()))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('monthly — same day each month for 3 months', () => {
|
||||||
|
const tmpl = makeTemplate({ recurrence: { frequency: 'monthly', interval: 1, count: 3 } })
|
||||||
|
const dates = calendarUtil.generateOccurrenceDates(tmpl, range[0], range[1])
|
||||||
|
expect(dates).toHaveLength(3)
|
||||||
|
dates.forEach(d => expect(d.getDate()).toBe(1))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('yearly — same day each year', () => {
|
||||||
|
const tmpl = makeTemplate({ recurrence: { frequency: 'yearly', interval: 1, count: 3 } })
|
||||||
|
const rangeEnd = new Date('2026-12-31')
|
||||||
|
const dates = calendarUtil.generateOccurrenceDates(tmpl, range[0], rangeEnd)
|
||||||
|
expect(dates).toHaveLength(3)
|
||||||
|
expect(dates[0].getFullYear()).toBe(2024)
|
||||||
|
expect(dates[1].getFullYear()).toBe(2025)
|
||||||
|
expect(dates[2].getFullYear()).toBe(2026)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('no recurrence rule returns empty array', () => {
|
||||||
|
const tmpl = { time_start: new Date(), time_end: new Date(), recurrence: null }
|
||||||
|
expect(calendarUtil.generateOccurrenceDates(tmpl, range[0], range[1])).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ─── expandRecurringEvents ───────────────────────────────────────────────────
|
||||||
|
describe('expandRecurringEvents', () => {
|
||||||
|
const rangeStart = new Date('2024-01-01')
|
||||||
|
const rangeEnd = new Date('2024-01-31')
|
||||||
|
|
||||||
|
const baseTemplate = {
|
||||||
|
id: 1,
|
||||||
|
title: 'Standup',
|
||||||
|
recurrence_id: 'abc',
|
||||||
|
recurrence_parent_id: null,
|
||||||
|
time_start: new Date(2024, 0, 1, 9, 0),
|
||||||
|
time_end: new Date(2024, 0, 1, 9, 30),
|
||||||
|
recurrence: { frequency: 'daily', interval: 1, count: 5 },
|
||||||
|
all_day: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
it('non-recurring events pass through unchanged', () => {
|
||||||
|
const regular = { id: 99, title: 'One-off', recurrence_id: null, recurrence_parent_id: null }
|
||||||
|
const result = calendarUtil.expandRecurringEvents([regular], rangeStart, rangeEnd)
|
||||||
|
expect(result).toContain(regular)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('template expands into correct number of occurrences', () => {
|
||||||
|
const result = calendarUtil.expandRecurringEvents([baseTemplate], rangeStart, rangeEnd)
|
||||||
|
expect(result).toHaveLength(5)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('occurrences have _isOccurrence flag', () => {
|
||||||
|
const result = calendarUtil.expandRecurringEvents([baseTemplate], rangeStart, rangeEnd)
|
||||||
|
result.forEach(e => expect(e._isOccurrence).toBe(true))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('override replaces its occurrence', () => {
|
||||||
|
const override = {
|
||||||
|
id: 2,
|
||||||
|
title: 'Standup (rescheduled)',
|
||||||
|
recurrence_id: null,
|
||||||
|
recurrence_parent_id: 1,
|
||||||
|
recurrence_exception_date: new Date(2024, 0, 2, 9, 0),
|
||||||
|
time_start: new Date(2024, 0, 2, 10, 0),
|
||||||
|
time_end: new Date(2024, 0, 2, 10, 30),
|
||||||
|
is_cancelled: false,
|
||||||
|
}
|
||||||
|
const result = calendarUtil.expandRecurringEvents([baseTemplate, override], rangeStart, rangeEnd)
|
||||||
|
const jan2 = result.filter(e => {
|
||||||
|
const d = new Date(e.time_start)
|
||||||
|
return d.getMonth() === 0 && d.getDate() === 2
|
||||||
|
})
|
||||||
|
expect(jan2).toHaveLength(1)
|
||||||
|
expect(jan2[0].title).toBe('Standup (rescheduled)')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('cancelled override removes the occurrence', () => {
|
||||||
|
const cancelled = {
|
||||||
|
id: 3,
|
||||||
|
recurrence_id: null,
|
||||||
|
recurrence_parent_id: 1,
|
||||||
|
recurrence_exception_date: new Date(2024, 0, 2, 9, 0),
|
||||||
|
time_start: new Date(2024, 0, 2, 9, 0),
|
||||||
|
time_end: new Date(2024, 0, 2, 9, 30),
|
||||||
|
is_cancelled: true,
|
||||||
|
}
|
||||||
|
const result = calendarUtil.expandRecurringEvents([baseTemplate, cancelled], rangeStart, rangeEnd)
|
||||||
|
const jan2 = result.filter(e => {
|
||||||
|
const d = new Date(e.time_start)
|
||||||
|
return d.getMonth() === 0 && d.getDate() === 2
|
||||||
|
})
|
||||||
|
expect(jan2).toHaveLength(0)
|
||||||
|
expect(result).toHaveLength(4) // 5 minus the cancelled one
|
||||||
|
})
|
||||||
|
})
|
||||||
334
calendar/desktop/DesktopCalendarForm.js
Normal file
@@ -0,0 +1,334 @@
|
|||||||
|
import server from "/@server/server.js"
|
||||||
|
|
||||||
|
class DesktopCalendarForm extends Shadow {
|
||||||
|
|
||||||
|
static COLORS = [
|
||||||
|
"#9E1C29", "#3D6FAD", "#2A8636", "#B38A1E",
|
||||||
|
"#B85A1F", "#7A3FA3", "#B23D6B", "#2D8A87",
|
||||||
|
"#3C9A5F", "#6E9A23", "#7A8428", "#9A2F7D",
|
||||||
|
"#4F54A8", "#8A5A32", "#546B86", "#A67A1F",
|
||||||
|
]
|
||||||
|
|
||||||
|
constructor(calendars, onSaved, editCalendar = null, onDelete = null, onBack = null) {
|
||||||
|
super()
|
||||||
|
this.calendars = calendars
|
||||||
|
this.onSaved = onSaved
|
||||||
|
this.editCalendar = editCalendar
|
||||||
|
this.onDelete = onDelete
|
||||||
|
this.onBack = onBack
|
||||||
|
this.selectedColor = editCalendar?.color ?? DesktopCalendarForm.COLORS[0]
|
||||||
|
|
||||||
|
if (editCalendar) {
|
||||||
|
this.originalFormData = {
|
||||||
|
name: editCalendar.name ?? "",
|
||||||
|
description: editCalendar.description || "",
|
||||||
|
color: editCalendar.color ?? ""
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.originalFormData = {
|
||||||
|
name: "",
|
||||||
|
description: "",
|
||||||
|
color: DesktopCalendarForm.COLORS[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fieldStyles(el) {
|
||||||
|
return el
|
||||||
|
.border("1px solid var(--divider)")
|
||||||
|
.borderRadius(0.35, em)
|
||||||
|
.outline("none")
|
||||||
|
.background("transparent")
|
||||||
|
.color("var(--headertext)")
|
||||||
|
.fontSize(0.88, em)
|
||||||
|
.padding(0.4, em)
|
||||||
|
.boxSizing("border-box")
|
||||||
|
.onHover(function(hovering) {
|
||||||
|
this.style.border = `1px solid ${hovering ? "var(--lightDivider)" : "var(--divider)"}`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
prop(label, contentFn) {
|
||||||
|
VStack(() => {
|
||||||
|
p(label)
|
||||||
|
.margin(0)
|
||||||
|
.marginBottom(1, em)
|
||||||
|
.fontSize(0.67, em)
|
||||||
|
.fontWeight("600")
|
||||||
|
.letterSpacing("0.06em")
|
||||||
|
.color("var(--headertext)")
|
||||||
|
.opacity(0.38)
|
||||||
|
VStack(() => { contentFn() })
|
||||||
|
.width(100, pct)
|
||||||
|
})
|
||||||
|
.paddingHorizontal(1.5, em)
|
||||||
|
.paddingTop(0.75, em)
|
||||||
|
.paddingBottom(0.4, em)
|
||||||
|
.boxSizing("border-box")
|
||||||
|
.width(100, pct)
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const isEdit = !!this.editCalendar
|
||||||
|
|
||||||
|
form(() => {
|
||||||
|
VStack(() => {
|
||||||
|
this.renderHeader(isEdit)
|
||||||
|
this.renderBody(isEdit)
|
||||||
|
})
|
||||||
|
.height(100, pct)
|
||||||
|
.boxSizing("border-box")
|
||||||
|
})
|
||||||
|
.height(100, pct)
|
||||||
|
.onSubmit(e => { e.preventDefault(); this.handleSave() })
|
||||||
|
.onKeyDown(e => {
|
||||||
|
if (e.key === "Enter" && e.target.tagName !== "TEXTAREA" && e.target.tagName !== "BUTTON") e.preventDefault()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
renderHeader(isEdit) {
|
||||||
|
HStack(() => {
|
||||||
|
VStack(() => {
|
||||||
|
input("", "100%")
|
||||||
|
.attr({ name: "name", type: "text", placeholder: "Enter calendar name...", value: this.editCalendar?.name ?? "" })
|
||||||
|
.border("none")
|
||||||
|
.outline("none")
|
||||||
|
.background("transparent")
|
||||||
|
.color("var(--headertext)")
|
||||||
|
.fontSize(1.45, em)
|
||||||
|
.fontWeight("700")
|
||||||
|
.padding(0)
|
||||||
|
.onHover(function(hovering) {
|
||||||
|
this.style.opacity = hovering ? 0.82 : 1;
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.flex(1)
|
||||||
|
.paddingHorizontal(1.4, em)
|
||||||
|
.paddingTop(2.5, em)
|
||||||
|
.paddingBottom(0.5, em)
|
||||||
|
.justifyContent("center")
|
||||||
|
|
||||||
|
if (isEdit) {
|
||||||
|
button("Delete")
|
||||||
|
.attr({ type: "button" })
|
||||||
|
.fontSize(0.8, em)
|
||||||
|
.fontWeight("600")
|
||||||
|
.background("transparent")
|
||||||
|
.color("var(--quillred)")
|
||||||
|
.paddingVertical(0.34, em)
|
||||||
|
.paddingHorizontal(0.85, em)
|
||||||
|
.marginRight(1.4, em)
|
||||||
|
.marginBottom(1, em)
|
||||||
|
.border("1px solid var(--quillred)")
|
||||||
|
.borderRadius(0.45, em)
|
||||||
|
.flexShrink(0)
|
||||||
|
.cursor("pointer")
|
||||||
|
.boxSizing("border-box")
|
||||||
|
.onClick((done) => { if (done) this.handleDelete() })
|
||||||
|
.onHover(function(hovering) {
|
||||||
|
this.style.background = hovering ? "var(--quillred)" : "transparent";
|
||||||
|
this.style.color = hovering ? "white" : "var(--quillred)"
|
||||||
|
this.style.opacity = hovering ? 0.82 : 1;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
button("Save")
|
||||||
|
.attr({ type: "submit" })
|
||||||
|
.paddingVertical(0.34, em)
|
||||||
|
.paddingHorizontal(0.85, em)
|
||||||
|
.border("none")
|
||||||
|
.borderRadius(0.45, em)
|
||||||
|
.background("var(--quillred)")
|
||||||
|
.color("white")
|
||||||
|
.cursor("pointer")
|
||||||
|
.fontSize(0.8, em)
|
||||||
|
.fontWeight("600")
|
||||||
|
.marginRight(1.4, em)
|
||||||
|
.marginTop("auto")
|
||||||
|
.marginBottom(1, em)
|
||||||
|
.flexShrink(0)
|
||||||
|
.onHover(function(hovering) {
|
||||||
|
this.style.opacity = hovering ? 0.82 : 1;
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.width(100, pct)
|
||||||
|
.alignItems("flex-end")
|
||||||
|
.background("var(--darkaccent)")
|
||||||
|
.borderBottom("1px solid var(--divider)")
|
||||||
|
.boxSizing("border-box")
|
||||||
|
.flexShrink(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
renderBody() {
|
||||||
|
VStack(() => {
|
||||||
|
|
||||||
|
this.prop("COLOR", () => {
|
||||||
|
const renderSwatch = (color) => {
|
||||||
|
const selected = this.selectedColor === color
|
||||||
|
p("")
|
||||||
|
.flex(1)
|
||||||
|
.height(1.6, em)
|
||||||
|
.background(color)
|
||||||
|
.borderRadius(5, px)
|
||||||
|
.border(`3px solid ${selected ? "var(--quillred)" : color}`)
|
||||||
|
.boxSizing("border-box")
|
||||||
|
.cursor("pointer")
|
||||||
|
.attr({ "data-color": color })
|
||||||
|
.onClick((done) => {
|
||||||
|
if (!done) return
|
||||||
|
const prev = this.$(`[data-color="${this.selectedColor}"]`)
|
||||||
|
if (prev) prev.style.border = `3px solid ${this.selectedColor}`
|
||||||
|
this.selectedColor = color
|
||||||
|
const next = this.$(`[data-color="${color}"]`)
|
||||||
|
if (next) next.style.border = `3px solid var(--quillred)`
|
||||||
|
})
|
||||||
|
.onHover(function(hovering) {
|
||||||
|
this.style.opacity = hovering ? 0.82 : 1;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
VStack(() => {
|
||||||
|
HStack(() => {
|
||||||
|
DesktopCalendarForm.COLORS.slice(0, 8).forEach(renderSwatch)
|
||||||
|
})
|
||||||
|
.gap(0.55, em)
|
||||||
|
HStack(() => {
|
||||||
|
DesktopCalendarForm.COLORS.slice(8, 16).forEach(renderSwatch)
|
||||||
|
})
|
||||||
|
.gap(0.55, em)
|
||||||
|
})
|
||||||
|
.gap(0.55, em)
|
||||||
|
})
|
||||||
|
|
||||||
|
this.prop("DESCRIPTION", () => {
|
||||||
|
textarea(this.editCalendar?.description ?? "")
|
||||||
|
.attr({ name: "description" })
|
||||||
|
.styles(this.fieldStyles)
|
||||||
|
.lineHeight("1.65")
|
||||||
|
.width(100, pct)
|
||||||
|
.minHeight("3em")
|
||||||
|
.resize("none")
|
||||||
|
.fieldSizing("content")
|
||||||
|
.fontFamily("Arial")
|
||||||
|
.onAppear(function() {
|
||||||
|
this.value = this.placeholder;
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.flex(1)
|
||||||
|
.overflowY("scroll")
|
||||||
|
.width(100, pct)
|
||||||
|
.boxSizing("border-box")
|
||||||
|
}
|
||||||
|
|
||||||
|
showError(msg) {
|
||||||
|
$("modal-")?.showError(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
getFormData() {
|
||||||
|
const val = name => this.$(`[name="${name}"]`).value
|
||||||
|
return {
|
||||||
|
name: val("name"),
|
||||||
|
description: val("description") || null,
|
||||||
|
color: this.selectedColor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isUnchanged(data) {
|
||||||
|
const o = this.originalFormData
|
||||||
|
return (
|
||||||
|
data.name === o.name &&
|
||||||
|
data.color === o.color &&
|
||||||
|
(data.description || "") === o.description
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
isNewCalendarDirty() {
|
||||||
|
const data = this.getFormData()
|
||||||
|
const o = this.originalFormData
|
||||||
|
return (
|
||||||
|
data.name !== o.name ||
|
||||||
|
(data.description || "") !== o.description ||
|
||||||
|
data.color !== o.color
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async trySave() {
|
||||||
|
if (!this.editCalendar) {
|
||||||
|
// New calendar: only save if the user made edits
|
||||||
|
if (!this.isNewCalendarDirty()) return false
|
||||||
|
const data = this.getFormData()
|
||||||
|
const payload = {
|
||||||
|
name: data.name || "New calendar",
|
||||||
|
description: data.description,
|
||||||
|
color: data.color
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await server.addCalendar(payload, global.currentNetwork.id)
|
||||||
|
if (result.status === 200) return result.calendar
|
||||||
|
|
||||||
|
this.showError(result.error ?? "Failed to save calendar.")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = this.getFormData()
|
||||||
|
if (this.isUnchanged(data)) return this.editCalendar
|
||||||
|
|
||||||
|
const result = await server.editCalendar(
|
||||||
|
this.editCalendar.id,
|
||||||
|
{ ...data, name: data.name || "New calendar" },
|
||||||
|
global.currentNetwork.id
|
||||||
|
)
|
||||||
|
|
||||||
|
if (result.status === 200) return result.calendar
|
||||||
|
|
||||||
|
this.showError(result.error ?? "Failed to save calendar.")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleSave() {
|
||||||
|
$("modal-")?.showError("")
|
||||||
|
|
||||||
|
const data = this.getFormData()
|
||||||
|
|
||||||
|
if (this.editCalendar) {
|
||||||
|
if (this.isUnchanged(data)) {
|
||||||
|
if (this.onBack) this.onBack()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
name: data.name || "New calendar",
|
||||||
|
description: data.description,
|
||||||
|
color: data.color
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = this.editCalendar
|
||||||
|
? await server.editCalendar(this.editCalendar.id, payload, global.currentNetwork.id)
|
||||||
|
: await server.addCalendar(payload, global.currentNetwork.id)
|
||||||
|
|
||||||
|
if (result.status === 200) {
|
||||||
|
if (this.editCalendar) {
|
||||||
|
this.onSaved(result.calendar)
|
||||||
|
} else {
|
||||||
|
// Use forceClose so _closeOverride doesn't re-trigger trySave
|
||||||
|
$("modal-").forceClose()
|
||||||
|
this.onSaved(result.calendar)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.showError(result.error ?? "Failed to save calendar.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleDelete() {
|
||||||
|
const result = await server.deleteCalendar(this.editCalendar.id, global.currentNetwork.id)
|
||||||
|
if (result.status === 200) {
|
||||||
|
$("modal-").forceClose()
|
||||||
|
if (this.onDelete) this.onDelete(this.editCalendar.id)
|
||||||
|
} else {
|
||||||
|
this.showError(result.error ?? "Failed to delete calendar.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
register(DesktopCalendarForm)
|
||||||
320
calendar/desktop/DesktopMonthGrid.js
Normal file
@@ -0,0 +1,320 @@
|
|||||||
|
import calendarUtil from "../calendarUtil.js";
|
||||||
|
|
||||||
|
let _saved = null;
|
||||||
|
|
||||||
|
css(`
|
||||||
|
desktopmonthgrid- {
|
||||||
|
scrollbar-width: none;
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
}
|
||||||
|
`)
|
||||||
|
|
||||||
|
class DesktopMonthGrid extends Shadow {
|
||||||
|
constructor(weeks, calendars, weekStartsOn = 0, onEventClick = null, onDayDoubleClick = null) {
|
||||||
|
super()
|
||||||
|
this.weeks = weeks;
|
||||||
|
this.calendars = calendars;
|
||||||
|
this.weekStartsOn = weekStartsOn;
|
||||||
|
this.onEventClick = onEventClick;
|
||||||
|
this.onDayDoubleClick = onDayDoubleClick;
|
||||||
|
this.ghostDay = null;
|
||||||
|
this.maxVisible = 4;
|
||||||
|
|
||||||
|
// Layout in em
|
||||||
|
this.dateFontSize = 0.82;
|
||||||
|
this.dateLineHeight = 1.7;
|
||||||
|
this.paddingTop = 0.35;
|
||||||
|
this.paddingBottom = 0.25;
|
||||||
|
|
||||||
|
this.pillHeight = 1.15;
|
||||||
|
this.pillGap = 0.15;
|
||||||
|
this.overflowLabelHeight = 0.7;
|
||||||
|
this.rowBottomPadding = this.overflowLabelHeight + 0.5;
|
||||||
|
|
||||||
|
this.headerHeight = this.paddingTop + (this.dateFontSize * this.dateLineHeight) + this.paddingBottom;
|
||||||
|
this.rowHeight = this.headerHeight + this.maxVisible * (this.pillHeight + this.pillGap) + this.rowBottomPadding;
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const DAY_NAMES = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
||||||
|
const ordered = Array.from({ length: 7 }, (_, i) => DAY_NAMES[(this.weekStartsOn + i) % 7]);
|
||||||
|
|
||||||
|
VStack(() => {
|
||||||
|
// Day-name header
|
||||||
|
HStack(() => {
|
||||||
|
ordered.forEach(name => {
|
||||||
|
p(name)
|
||||||
|
.margin(0)
|
||||||
|
.fontSize(0.72, em)
|
||||||
|
.fontWeight("500")
|
||||||
|
.letterSpacing("0.015em")
|
||||||
|
.color("var(--headertext)")
|
||||||
|
.opacity(0.5)
|
||||||
|
.flex(1)
|
||||||
|
.textAlign("center")
|
||||||
|
.paddingVertical(0.55, em)
|
||||||
|
.boxSizing("border-box")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.width(100, pct)
|
||||||
|
.borderBottom("1px solid var(--divider)")
|
||||||
|
.flexShrink(0)
|
||||||
|
|
||||||
|
// Week rows
|
||||||
|
VStack(() => {
|
||||||
|
this.weeks.forEach((week, wi) => {
|
||||||
|
this.renderWeekRow(week, wi === this.weeks.length - 1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.width(100, pct)
|
||||||
|
.height(this.rowHeight * this.weeks.length, em)
|
||||||
|
.flexShrink(0)
|
||||||
|
})
|
||||||
|
.width(100, pct)
|
||||||
|
.height(100, pct)
|
||||||
|
.overflowY("auto")
|
||||||
|
.boxSizing("border-box")
|
||||||
|
.onAppear(() => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const monthKey = calendarUtil.toDateInput(this.weeks[2].days[3].day).substring(0, 7);
|
||||||
|
this.scrollTop = (_saved?.monthKey === monthKey) ? _saved.scrollTop : 0;
|
||||||
|
this.addEventListener('scroll', () => {
|
||||||
|
_saved = { monthKey, scrollTop: this.scrollTop };
|
||||||
|
}, { passive: true });
|
||||||
|
});
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
renderWeekRow(week, isLastWeek) {
|
||||||
|
const displayWeek = this.ghostDay ? this.injectGhostIntoWeek(week) : week;
|
||||||
|
ZStack(() => {
|
||||||
|
this.renderCellLayer(week, isLastWeek)
|
||||||
|
this.renderPillLayer(displayWeek)
|
||||||
|
})
|
||||||
|
.position("relative")
|
||||||
|
.width(100, pct)
|
||||||
|
.height(this.rowHeight, em)
|
||||||
|
.flexShrink(0)
|
||||||
|
.alignItems("stretch")
|
||||||
|
}
|
||||||
|
|
||||||
|
renderCellLayer(week, isLastWeek) {
|
||||||
|
HStack(() => {
|
||||||
|
week.days.forEach((dayData, di) => {
|
||||||
|
this.renderDayCell(dayData, di === 6, isLastWeek)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.width(100, pct)
|
||||||
|
.alignItems("stretch")
|
||||||
|
.height(100, pct)
|
||||||
|
}
|
||||||
|
|
||||||
|
renderDayCell(dayData, isLastCol, isLastWeek) {
|
||||||
|
const { day, isCurrentMonth } = dayData;
|
||||||
|
const today = calendarUtil.isToday(day);
|
||||||
|
|
||||||
|
VStack(() => {
|
||||||
|
HStack(() => {
|
||||||
|
p(day.getDate())
|
||||||
|
.margin(0)
|
||||||
|
.fontSize(this.dateFontSize, em)
|
||||||
|
.fontWeight(today ? "700" : "500")
|
||||||
|
.color(today ? "white" : "var(--headertext)")
|
||||||
|
.background(today ? "var(--quillred)" : "transparent")
|
||||||
|
.width(1.65, em)
|
||||||
|
.height(1.65, em)
|
||||||
|
.borderRadius(5, px)
|
||||||
|
.textAlign("center")
|
||||||
|
.lineHeight("1.65em")
|
||||||
|
.flexShrink(0)
|
||||||
|
.opacity(isCurrentMonth ? 1 : 0.3)
|
||||||
|
})
|
||||||
|
.paddingTop(this.paddingTop, em)
|
||||||
|
.paddingHorizontal(0.4, em)
|
||||||
|
.paddingBottom(this.paddingBottom, em)
|
||||||
|
})
|
||||||
|
.flex(1)
|
||||||
|
.width(0, px)
|
||||||
|
.minWidth(0)
|
||||||
|
.height(100, pct)
|
||||||
|
.background(isCurrentMonth ? "var(--darkaccent)" : "")
|
||||||
|
.borderBottom(isLastWeek ? "1px solid transparent" : "1px solid var(--divider)")
|
||||||
|
.borderRight(isLastCol ? "none" : "1px solid var(--divider)")
|
||||||
|
.boxSizing("border-box")
|
||||||
|
.overflow("hidden")
|
||||||
|
.alignItems("stretch")
|
||||||
|
.onClick((done, e) => {
|
||||||
|
if (e.detail !== 2) return
|
||||||
|
if (!done) {
|
||||||
|
this.ghostDay = day
|
||||||
|
this.rerender()
|
||||||
|
} else {
|
||||||
|
this.onDayDoubleClick(day, () => {
|
||||||
|
this.ghostDay = null
|
||||||
|
this.rerender()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
injectGhostIntoWeek(week) {
|
||||||
|
const col = week.days.findIndex(d =>
|
||||||
|
calendarUtil.startOfDay(d.day).getTime() === calendarUtil.startOfDay(this.ghostDay).getTime()
|
||||||
|
);
|
||||||
|
if (col === -1) return week;
|
||||||
|
|
||||||
|
const ghostEvent = {
|
||||||
|
id: '__ghost__',
|
||||||
|
title: 'New event',
|
||||||
|
all_day: true,
|
||||||
|
time_start: this.ghostDay,
|
||||||
|
time_end: this.ghostDay,
|
||||||
|
calendars: ['__ghost__']
|
||||||
|
};
|
||||||
|
|
||||||
|
const slotMap = week.slotMap.map(colSlots => [...colSlots]);
|
||||||
|
|
||||||
|
let row = 0;
|
||||||
|
while (row < slotMap[col].length && slotMap[col][row] !== null) row++;
|
||||||
|
|
||||||
|
for (let c = 0; c < 7; c++) {
|
||||||
|
while (slotMap[c].length <= row) slotMap[c].push(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
slotMap[col][row] = { event: ghostEvent, isStart: true, isEnd: true, isSingleDay: true };
|
||||||
|
|
||||||
|
const days = week.days.map((d, i) =>
|
||||||
|
i === col ? { ...d, events: [...d.events, ghostEvent] } : d
|
||||||
|
);
|
||||||
|
|
||||||
|
return { ...week, slotMap, days };
|
||||||
|
}
|
||||||
|
|
||||||
|
renderPillLayer(week) {
|
||||||
|
ZStack(() => {
|
||||||
|
const maxSlots = Math.max(0, ...week.slotMap.map(s => s.length));
|
||||||
|
|
||||||
|
for (let row = 0; row < Math.min(maxSlots, this.maxVisible); row++) {
|
||||||
|
this.collectSpans(week, row).forEach(span => this.renderPill(span, week, row))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Overflow labels
|
||||||
|
week.days.forEach((dayData, col) => {
|
||||||
|
const overflow = Math.max(0, dayData.events.length - this.maxVisible);
|
||||||
|
if (overflow === 0) return;
|
||||||
|
|
||||||
|
p(`+${overflow} more`)
|
||||||
|
.margin(0)
|
||||||
|
.fontSize(0.63, em)
|
||||||
|
.fontWeight("600")
|
||||||
|
.color("var(--headertext)")
|
||||||
|
.opacity(0.5)
|
||||||
|
.position("absolute")
|
||||||
|
.bottom(this.overflowLabelHeight, em)
|
||||||
|
.left((col / 7) * 100, pct)
|
||||||
|
.width(100 / 7, pct)
|
||||||
|
.paddingHorizontal(0.55, em)
|
||||||
|
.zIndex(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.position("absolute")
|
||||||
|
.top(0).left(0).right(0).bottom(0)
|
||||||
|
.pointerEvents("none")
|
||||||
|
}
|
||||||
|
|
||||||
|
renderPill({ startCol, endCol, event }, week, row) {
|
||||||
|
const isGhost = event.id === '__ghost__';
|
||||||
|
const color = isGhost ? 'var(--quillred)' : calendarUtil.getCalendarColor(this.calendars, event.calendars.find(id => this.calendars.some(c => c.id === id)) ?? event.calendars[0]);
|
||||||
|
const leftPct = (startCol / 7) * 100;
|
||||||
|
const widthPct = ((endCol - startCol + 1) / 7) * 100;
|
||||||
|
const topEm = this.headerHeight + row * (this.pillHeight + this.pillGap);
|
||||||
|
|
||||||
|
// const isSingleDay = startCol === endCol &&
|
||||||
|
// calendarUtil.startOfDay(event.time_start).getTime() === calendarUtil.startOfDay(event.time_end).getTime();
|
||||||
|
// const isTimedSingle = isSingleDay && !event.all_day;
|
||||||
|
const isTimedSingle = !event.all_day; // wasn't rendering circle on multi-day timed events
|
||||||
|
|
||||||
|
const clippedLeft = startCol === 0 &&
|
||||||
|
calendarUtil.startOfDay(event.time_start) < calendarUtil.startOfDay(week.days[0].day);
|
||||||
|
const clippedRight = endCol === 6 &&
|
||||||
|
calendarUtil.endOfDay(event.time_end) > calendarUtil.endOfDay(week.days[6].day);
|
||||||
|
|
||||||
|
const colW = 100 / 7;
|
||||||
|
const leftInset = clippedLeft ? 0 : 0.02 * colW;
|
||||||
|
const rightInset = clippedRight ? 0 : 0.02 * colW;
|
||||||
|
const brLeft = clippedLeft ? 0 : 4;
|
||||||
|
const brRight = clippedRight ? 0 : 4;
|
||||||
|
|
||||||
|
HStack(() => {
|
||||||
|
if (isTimedSingle) {
|
||||||
|
// Dot + time + title
|
||||||
|
VStack(() => {})
|
||||||
|
.width(0.42, em)
|
||||||
|
.height(0.42, em)
|
||||||
|
.borderRadius(50, pct)
|
||||||
|
.background("white")
|
||||||
|
.flexShrink(0)
|
||||||
|
.marginRight(0.3, em)
|
||||||
|
p(calendarUtil.formatTimeShort(event.time_start) + " " + (event.title || "Untitled"))
|
||||||
|
.margin(0)
|
||||||
|
.fontSize(0.68, em)
|
||||||
|
.fontWeight("500")
|
||||||
|
.color("white")
|
||||||
|
.whiteSpace("nowrap")
|
||||||
|
.overflow("hidden")
|
||||||
|
} else {
|
||||||
|
p(event.title || "Untitled")
|
||||||
|
.margin(0)
|
||||||
|
.fontSize(0.68, em)
|
||||||
|
.fontWeight("600")
|
||||||
|
.color("white")
|
||||||
|
.whiteSpace("nowrap")
|
||||||
|
.overflow("hidden")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.position("absolute")
|
||||||
|
.top(topEm, em)
|
||||||
|
.left(leftPct + leftInset, pct)
|
||||||
|
.width(widthPct - leftInset - rightInset, pct)
|
||||||
|
.height(this.pillHeight, em)
|
||||||
|
.paddingHorizontal(0.5, em)
|
||||||
|
.background(color)
|
||||||
|
.borderTopLeftRadius(`${brLeft}px`)
|
||||||
|
.borderBottomLeftRadius(`${brLeft}px`)
|
||||||
|
.borderTopRightRadius(`${brRight}px`)
|
||||||
|
.borderBottomRightRadius(`${brRight}px`)
|
||||||
|
.alignItems("center")
|
||||||
|
.overflow("hidden")
|
||||||
|
.boxSizing("border-box")
|
||||||
|
.zIndex(1)
|
||||||
|
.pointerEvents(isGhost ? "none" : "auto")
|
||||||
|
.cursor(isGhost ? "default" : "pointer")
|
||||||
|
.opacity(isGhost ? 0.75 : 1)
|
||||||
|
.onClick((done) => {
|
||||||
|
if (!isGhost && done && this.onEventClick) this.onEventClick(event)
|
||||||
|
})
|
||||||
|
.onHover(function(hovering) {
|
||||||
|
if (!isGhost) this.opacity(hovering ? 0.82 : 1)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
collectSpans(week, row) {
|
||||||
|
const spans = [];
|
||||||
|
let current = null;
|
||||||
|
|
||||||
|
for (let col = 0; col < 7; col++) {
|
||||||
|
const slot = (week.slotMap[col] || [])[row] ?? null;
|
||||||
|
|
||||||
|
if (slot && current && slot.event === current.event) {
|
||||||
|
current.endCol = col;
|
||||||
|
} else {
|
||||||
|
if (current) spans.push(current);
|
||||||
|
current = slot ? { startCol: col, endCol: col, event: slot.event } : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (current) spans.push(current);
|
||||||
|
return spans;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
register(DesktopMonthGrid)
|
||||||
118
calendar/desktop/DesktopMonthView.js
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import calendarUtil from "../calendarUtil.js"
|
||||||
|
import "./DesktopMonthGrid.js"
|
||||||
|
|
||||||
|
class DesktopMonthView extends Shadow {
|
||||||
|
constructor(calendars, events, currentDate, weekStartsOn = 0, onEventClick = null, onDayDoubleClick = null) {
|
||||||
|
super()
|
||||||
|
this.calendars = calendars;
|
||||||
|
this.events = events;
|
||||||
|
this.currentDate = currentDate;
|
||||||
|
this.weekStartsOn = weekStartsOn;
|
||||||
|
this.onEventClick = onEventClick;
|
||||||
|
this.onDayDoubleClick = onDayDoubleClick;
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const weeks = this.buildMonthWeeks();
|
||||||
|
|
||||||
|
DesktopMonthGrid(weeks, this.calendars, this.weekStartsOn, this.onEventClick, this.onDayDoubleClick)
|
||||||
|
.width(100, pct)
|
||||||
|
.height(100, pct)
|
||||||
|
}
|
||||||
|
|
||||||
|
buildMonthWeeks() {
|
||||||
|
const month = this.currentDate.getMonth();
|
||||||
|
|
||||||
|
const allDays = calendarUtil.buildMonthGrid(this.currentDate, this.weekStartsOn);
|
||||||
|
const gridStart = allDays[0];
|
||||||
|
const weeks = [];
|
||||||
|
for (let w = 0; w < 6; w++) {
|
||||||
|
weeks.push(allDays.slice(w * 7, w * 7 + 7));
|
||||||
|
}
|
||||||
|
|
||||||
|
const gridEnd = calendarUtil.endOfDay(weeks[weeks.length - 1][6]);
|
||||||
|
const monthStart = calendarUtil.startOfDay(new Date(this.currentDate.getFullYear(), this.currentDate.getMonth(), 1));
|
||||||
|
const monthEnd = calendarUtil.endOfDay(new Date(this.currentDate.getFullYear(), this.currentDate.getMonth() + 1, 0));
|
||||||
|
|
||||||
|
const expanded = calendarUtil.expandRecurringEvents(this.events, gridStart, gridEnd);
|
||||||
|
const relevantEvents = expanded.filter(event =>
|
||||||
|
calendarUtil.rangesOverlap(event.time_start, event.time_end, gridStart, gridEnd) &&
|
||||||
|
calendarUtil.rangesOverlap(event.time_start, event.time_end, monthStart, monthEnd) &&
|
||||||
|
this.calendars.some(c => event.calendars?.some(id => id === c.id))
|
||||||
|
);
|
||||||
|
|
||||||
|
return weeks.map(week => this.buildWeekData(week, month, relevantEvents));
|
||||||
|
}
|
||||||
|
|
||||||
|
buildWeekData(week, currentMonth, events) {
|
||||||
|
const weekStart = calendarUtil.startOfDay(week[0]);
|
||||||
|
const weekEnd = calendarUtil.endOfDay(week[6]);
|
||||||
|
|
||||||
|
const weekEvents = events.filter(event =>
|
||||||
|
calendarUtil.rangesOverlap(event.time_start, event.time_end, weekStart, weekEnd)
|
||||||
|
);
|
||||||
|
|
||||||
|
weekEvents.sort((a, b) => {
|
||||||
|
const aSpan = a.all_day || this.isMultiDay(a);
|
||||||
|
const bSpan = b.all_day || this.isMultiDay(b);
|
||||||
|
if (aSpan !== bSpan) return aSpan ? -1 : 1;
|
||||||
|
return a.time_start - b.time_start;
|
||||||
|
});
|
||||||
|
|
||||||
|
const slotRows = [];
|
||||||
|
weekEvents.forEach(event => {
|
||||||
|
const startCol = Math.max(0, this.dayIndex(event.time_start, week));
|
||||||
|
const endCol = Math.min(6, this.dayIndex(event.time_end, week));
|
||||||
|
|
||||||
|
let row = 0;
|
||||||
|
while (true) {
|
||||||
|
if (!slotRows[row]) slotRows[row] = new Array(7).fill(null);
|
||||||
|
if (slotRows[row].slice(startCol, endCol + 1).every(v => v === null)) break;
|
||||||
|
row++;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let c = startCol; c <= endCol; c++) {
|
||||||
|
slotRows[row][c] = {
|
||||||
|
event,
|
||||||
|
isStart: c === startCol,
|
||||||
|
isEnd: c === endCol,
|
||||||
|
isSingleDay: startCol === endCol
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const slotMap = Array.from({ length: 7 }, (_, col) =>
|
||||||
|
slotRows.map(row => row[col] ?? null)
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
days: week.map(day => ({
|
||||||
|
day,
|
||||||
|
isCurrentMonth: day.getMonth() === currentMonth,
|
||||||
|
events: weekEvents.filter(event => {
|
||||||
|
const effectiveEnd = event.all_day ? calendarUtil.endOfDay(event.time_end) : event.time_end;
|
||||||
|
return calendarUtil.rangesOverlap(
|
||||||
|
event.time_start, effectiveEnd,
|
||||||
|
calendarUtil.startOfDay(day), calendarUtil.endOfDay(day)
|
||||||
|
);
|
||||||
|
})
|
||||||
|
})),
|
||||||
|
slotMap
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
isMultiDay(event) {
|
||||||
|
return calendarUtil.startOfDay(event.time_start).getTime() !==
|
||||||
|
calendarUtil.startOfDay(event.time_end).getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
dayIndex(date, week) {
|
||||||
|
const dayStart = calendarUtil.startOfDay(date);
|
||||||
|
for (let i = 0; i < 7; i++) {
|
||||||
|
if (calendarUtil.startOfDay(week[i]).getTime() === dayStart.getTime()) return i;
|
||||||
|
}
|
||||||
|
return date.getTime() < week[0].getTime() ? 0 : 6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
register(DesktopMonthView)
|
||||||
294
calendar/desktop/DesktopSidebar.js
Normal file
@@ -0,0 +1,294 @@
|
|||||||
|
import calendarUtil from "../calendarUtil.js";
|
||||||
|
|
||||||
|
class DesktopSidebar extends Shadow {
|
||||||
|
static DAY_NAMES = ["S", "M", "T", "W", "T", "F", "S"];
|
||||||
|
|
||||||
|
constructor(currentDate, calendars, selectedCalendars, events, weekStartsOn, actions) {
|
||||||
|
super()
|
||||||
|
this.currentDate = currentDate
|
||||||
|
this.calendars = calendars
|
||||||
|
this.selectedCalendars = selectedCalendars
|
||||||
|
this.events = events
|
||||||
|
this.weekStartsOn = weekStartsOn
|
||||||
|
this.actions = actions
|
||||||
|
|
||||||
|
// Persist mini calendar month across parent rerenders
|
||||||
|
if (this.miniDate === undefined) {
|
||||||
|
this.miniDate = new Date(currentDate)
|
||||||
|
}
|
||||||
|
if (this.selectedDate === undefined) {
|
||||||
|
this.selectedDate = new Date(currentDate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { onSelectDate, onToggleCalendar, onNewCalendar, onEditCalendar } = this.actions
|
||||||
|
const ordered = Array.from({ length: 7 }, (_, i) =>
|
||||||
|
DesktopSidebar.DAY_NAMES[(this.weekStartsOn + i) % 7]
|
||||||
|
);
|
||||||
|
const weeks = this.buildMonthWeeks(this.miniDate);
|
||||||
|
const today = new Date();
|
||||||
|
|
||||||
|
VStack(() => {
|
||||||
|
// ── Mini calendar ─────────────────────────────────────────
|
||||||
|
VStack(() => {
|
||||||
|
// Month / year + nav arrows
|
||||||
|
HStack(() => {
|
||||||
|
p(this.miniDate.toLocaleDateString(undefined, { month: "long", year: "numeric" }))
|
||||||
|
.margin(0)
|
||||||
|
.fontWeight("600")
|
||||||
|
.fontSize(0.78, em)
|
||||||
|
.color("var(--headertext)")
|
||||||
|
.flex(1)
|
||||||
|
|
||||||
|
HStack(() => {
|
||||||
|
button("‹")
|
||||||
|
.onClick((done) => {if(!done) return; this.miniDate = calendarUtil.addMonths(this.miniDate, -1); this.rerender(); })
|
||||||
|
.padding("0.18em 0.42em")
|
||||||
|
.border("none")
|
||||||
|
.background("transparent")
|
||||||
|
.color("var(--headertext)")
|
||||||
|
.cursor("pointer")
|
||||||
|
.borderRadius(0.3, em)
|
||||||
|
.fontSize(0.85, em)
|
||||||
|
button("›")
|
||||||
|
.onClick((done) => {if(!done) return; this.miniDate = calendarUtil.addMonths(this.miniDate, 1); this.rerender(); })
|
||||||
|
.padding("0.18em 0.42em")
|
||||||
|
.border("none")
|
||||||
|
.background("transparent")
|
||||||
|
.color("var(--headertext)")
|
||||||
|
.cursor("pointer")
|
||||||
|
.borderRadius(0.3, em)
|
||||||
|
.fontSize(0.85, em)
|
||||||
|
})
|
||||||
|
.gap(0)
|
||||||
|
})
|
||||||
|
.alignItems("center")
|
||||||
|
.marginBottom(0.35, em)
|
||||||
|
|
||||||
|
// Day-name header row
|
||||||
|
HStack(() => {
|
||||||
|
ordered.forEach(name => {
|
||||||
|
p(name)
|
||||||
|
.margin(0)
|
||||||
|
.fontSize(0.64, em)
|
||||||
|
.fontWeight("600")
|
||||||
|
.color("var(--headertext)")
|
||||||
|
.opacity(0.42)
|
||||||
|
.flex(1)
|
||||||
|
.textAlign("center")
|
||||||
|
.paddingVertical(0.18, em)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.width(100, pct)
|
||||||
|
|
||||||
|
// Date cells
|
||||||
|
VStack(() => {
|
||||||
|
weeks.forEach(({ days }) => {
|
||||||
|
HStack(() => {
|
||||||
|
days.forEach(({ day, isCurrentMonth, calendarColors }) => {
|
||||||
|
const isToday = calendarUtil.isSameDay(day, today);
|
||||||
|
const isSelected = calendarUtil.isSameDay(day, this.selectedDate);
|
||||||
|
|
||||||
|
VStack(() => {
|
||||||
|
p(day.getDate())
|
||||||
|
.margin(0)
|
||||||
|
.fontSize(0.72, em)
|
||||||
|
.fontWeight(isSelected ? "700" : "400")
|
||||||
|
.color(isSelected || isToday ? "white" : "var(--headertext)")
|
||||||
|
.background(
|
||||||
|
isToday ? "var(--quillred)"
|
||||||
|
: isSelected ? "var(--lightaccent)"
|
||||||
|
: "transparent"
|
||||||
|
)
|
||||||
|
.width(1.52, em)
|
||||||
|
.height(1.52, em)
|
||||||
|
.borderRadius(25, pct)
|
||||||
|
.textAlign("center")
|
||||||
|
.lineHeight("1.52em")
|
||||||
|
.opacity(isCurrentMonth ? 1 : 0.27)
|
||||||
|
.boxSizing("border-box")
|
||||||
|
|
||||||
|
if (calendarColors.length > 0) {
|
||||||
|
HStack(() => {
|
||||||
|
calendarColors.slice(0, 3).forEach(color => {
|
||||||
|
VStack(() => {})
|
||||||
|
.width(0.24, em)
|
||||||
|
.height(0.24, em)
|
||||||
|
.borderRadius(50, pct)
|
||||||
|
.background(color)
|
||||||
|
.flexShrink(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.gap(0.14, em)
|
||||||
|
.justifyContent("center")
|
||||||
|
.marginTop(0.1, em)
|
||||||
|
} else {
|
||||||
|
VStack(() => {}).height(0.34, em)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.flex(1)
|
||||||
|
.alignItems("center")
|
||||||
|
.paddingVertical(0.16, em)
|
||||||
|
.cursor("pointer")
|
||||||
|
.onClick((done) => {if(!done) return;
|
||||||
|
this.selectedDate = day;
|
||||||
|
this.miniDate = new Date(day);
|
||||||
|
onSelectDate(day);
|
||||||
|
this.rerender();
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.width(100, pct)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.width(100, pct)
|
||||||
|
})
|
||||||
|
.marginTop(30, px)
|
||||||
|
.padding(1, em)
|
||||||
|
.paddingBottom(0.75, em)
|
||||||
|
|
||||||
|
// ── Divider ───────────────────────────────────────────────
|
||||||
|
VStack(() => {})
|
||||||
|
.height(1, px)
|
||||||
|
.background("var(--divider)")
|
||||||
|
.width(100, pct)
|
||||||
|
|
||||||
|
// ── Calendars list ────────────────────────────────────────
|
||||||
|
VStack(() => {
|
||||||
|
p("CALENDARS")
|
||||||
|
.margin(0)
|
||||||
|
.marginBottom(0.45, em)
|
||||||
|
.fontSize(0.63, em)
|
||||||
|
.fontWeight("700")
|
||||||
|
.letterSpacing("0.07em")
|
||||||
|
.color("var(--headertext)")
|
||||||
|
.opacity(0.38)
|
||||||
|
.paddingHorizontal(1, em)
|
||||||
|
|
||||||
|
this.calendars.forEach(cal => {
|
||||||
|
const isSelected = this.selectedCalendars.some(c => c.id === cal.id);
|
||||||
|
HStack(() => {
|
||||||
|
HStack(() => {
|
||||||
|
VStack(() => {})
|
||||||
|
.width(0.65, em)
|
||||||
|
.height(0.65, em)
|
||||||
|
.borderRadius(50, pct)
|
||||||
|
.background(isSelected ? cal.color : "transparent")
|
||||||
|
.border(`2px solid ${cal.color}`)
|
||||||
|
.boxSizing("border-box")
|
||||||
|
.flexShrink(0)
|
||||||
|
p(cal.name)
|
||||||
|
.margin(0)
|
||||||
|
.fontSize(0.78, em)
|
||||||
|
.color("var(--headertext)")
|
||||||
|
.opacity(isSelected ? 1 : 0.4)
|
||||||
|
.flex(1)
|
||||||
|
.overflow("hidden")
|
||||||
|
.whiteSpace("nowrap")
|
||||||
|
.textOverflow("ellipsis")
|
||||||
|
})
|
||||||
|
.flex(1)
|
||||||
|
.gap(0.55, em)
|
||||||
|
.alignItems("center")
|
||||||
|
.cursor("pointer")
|
||||||
|
.onClick((done) => { if (!done) return; if (onToggleCalendar) onToggleCalendar(cal); })
|
||||||
|
if (onEditCalendar && cal.owner_id === global.profile.id) {
|
||||||
|
button("···")
|
||||||
|
.border("none")
|
||||||
|
.background("transparent")
|
||||||
|
.color("var(--headertext)")
|
||||||
|
.opacity(0.4)
|
||||||
|
.fontSize(0.72, em)
|
||||||
|
.cursor("pointer")
|
||||||
|
.padding("0 0.2em")
|
||||||
|
.flexShrink(0)
|
||||||
|
.onClick((done) => { if (done) onEditCalendar(cal) })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.paddingHorizontal(1, em)
|
||||||
|
.paddingVertical(0.32, em)
|
||||||
|
.alignItems("center")
|
||||||
|
.borderRadius(0.4, em)
|
||||||
|
})
|
||||||
|
|
||||||
|
button("+ New Calendar")
|
||||||
|
.paddingVertical(0.52, em)
|
||||||
|
.paddingHorizontal(0.8, em)
|
||||||
|
.marginHorizontal(1, em)
|
||||||
|
.marginVertical(0.5, em)
|
||||||
|
.width("auto")
|
||||||
|
.background("transparent")
|
||||||
|
.border("1px solid var(--divider)")
|
||||||
|
.color("var(--headertext)")
|
||||||
|
.borderRadius(0.5, em)
|
||||||
|
.fontSize(0.83, em)
|
||||||
|
.fontWeight("600")
|
||||||
|
.cursor("pointer")
|
||||||
|
.onHover(function(hovering) {
|
||||||
|
this.style.background = hovering ? "var(--divider)" : "transparent";
|
||||||
|
})
|
||||||
|
.onClick((done) => { if (done) onNewCalendar() })
|
||||||
|
})
|
||||||
|
.paddingTop(0.7, em)
|
||||||
|
.paddingBottom(0.5, em)
|
||||||
|
})
|
||||||
|
.width(220, px)
|
||||||
|
.minWidth(220, px)
|
||||||
|
.height(100, pct)
|
||||||
|
.borderRight("1px solid var(--divider)")
|
||||||
|
.boxSizing("border-box")
|
||||||
|
.overflowY("auto")
|
||||||
|
.flexShrink(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
buildMonthWeeks(date) {
|
||||||
|
const month = date.getMonth();
|
||||||
|
const allDays = calendarUtil.buildMonthGrid(date, this.weekStartsOn);
|
||||||
|
const gridStart = allDays[0];
|
||||||
|
const gridEnd = calendarUtil.endOfDay(allDays[41]);
|
||||||
|
|
||||||
|
const colorsByDay = new Map();
|
||||||
|
allDays.forEach(day => colorsByDay.set(calendarUtil.toDateInput(day), []));
|
||||||
|
|
||||||
|
const colorByCalId = new Map((this.selectedCalendars || []).map(c => [c.id, c.color]));
|
||||||
|
const expanded = calendarUtil.expandRecurringEvents(this.events || [], gridStart, gridEnd);
|
||||||
|
expanded.filter(ev => {
|
||||||
|
const end = ev.all_day ? calendarUtil.endOfDay(ev.time_end) : ev.time_end;
|
||||||
|
return calendarUtil.rangesOverlap(ev.time_start, end, gridStart, gridEnd);
|
||||||
|
}).forEach(ev => {
|
||||||
|
const colors = (ev.calendars || [])
|
||||||
|
.map(id => colorByCalId.get(id))
|
||||||
|
.filter(Boolean);
|
||||||
|
if (colors.length === 0) return;
|
||||||
|
const effectiveEnd = ev.all_day ? calendarUtil.endOfDay(ev.time_end) : ev.time_end;
|
||||||
|
let cursor = calendarUtil.startOfDay(ev.time_start > gridStart ? ev.time_start : gridStart);
|
||||||
|
const end = effectiveEnd < gridEnd ? effectiveEnd : gridEnd;
|
||||||
|
while (cursor < end) {
|
||||||
|
const key = calendarUtil.toDateInput(cursor);
|
||||||
|
const arr = colorsByDay.get(key);
|
||||||
|
if (arr) {
|
||||||
|
colors.forEach(color => {
|
||||||
|
if (!arr.includes(color) && arr.length < 3) arr.push(color);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
cursor = calendarUtil.addDays(cursor, 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const weeks = [];
|
||||||
|
for (let w = 0; w < 6; w++) {
|
||||||
|
weeks.push({
|
||||||
|
days: allDays.slice(w * 7, w * 7 + 7).map(day => ({
|
||||||
|
day,
|
||||||
|
isCurrentMonth: day.getMonth() === month,
|
||||||
|
calendarColors: colorsByDay.get(calendarUtil.toDateInput(day)) || []
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return weeks;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
register(DesktopSidebar)
|
||||||
74
calendar/desktop/DesktopToolbar.js
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
class DesktopToolbar extends Shadow {
|
||||||
|
constructor(currentDate, actions) {
|
||||||
|
super()
|
||||||
|
this.currentDate = currentDate
|
||||||
|
this.actions = actions
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { goToPrevious, goToCurrent, goToNext } = this.actions
|
||||||
|
|
||||||
|
HStack(() => {
|
||||||
|
h2(this.currentDate.toLocaleDateString(undefined, { month: "long", year: "numeric" }))
|
||||||
|
.margin(0)
|
||||||
|
.fontWeight("700")
|
||||||
|
.fontSize(1.25, em)
|
||||||
|
.color("var(--headertext)")
|
||||||
|
|
||||||
|
HStack(() => {
|
||||||
|
button("+ New Event")
|
||||||
|
.paddingVertical(0.52, em)
|
||||||
|
.paddingRight(1, em)
|
||||||
|
.paddingLeft(0.8, em)
|
||||||
|
.marginHorizontal(0.4, em)
|
||||||
|
.background("transparent")
|
||||||
|
.color("var(--headertext)")
|
||||||
|
.border("1px solid var(--divider)")
|
||||||
|
.borderRadius(0.5, em)
|
||||||
|
.fontSize(0.83, em)
|
||||||
|
.fontWeight("600")
|
||||||
|
.cursor("pointer")
|
||||||
|
.onHover(function(hovering) {
|
||||||
|
this.style.background = hovering ? "var(--divider)" : "transparent";
|
||||||
|
})
|
||||||
|
.onClick((done) => { if (done) this.actions.onNewEvent() })
|
||||||
|
|
||||||
|
this.navBtn("‹", goToPrevious)
|
||||||
|
this.navBtn("Today", goToCurrent)
|
||||||
|
this.navBtn("›", goToNext)
|
||||||
|
})
|
||||||
|
.gap(0.4, em)
|
||||||
|
.alignItems("center")
|
||||||
|
})
|
||||||
|
.paddingHorizontal(1.5, em)
|
||||||
|
.paddingVertical(0.85, em)
|
||||||
|
.alignItems("center")
|
||||||
|
.justifyContent("space-between")
|
||||||
|
.borderBottom("1px solid var(--divider)")
|
||||||
|
.width(100, pct)
|
||||||
|
.boxSizing("border-box")
|
||||||
|
.flexShrink(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
navBtn(label, handler) {
|
||||||
|
return button(label)
|
||||||
|
.onClick((done) => {
|
||||||
|
if(done) {
|
||||||
|
handler()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.paddingVertical(0.38, em)
|
||||||
|
.paddingHorizontal(label === "Today" ? 0.9 : 0.72, em)
|
||||||
|
.border("1px solid var(--divider)")
|
||||||
|
.borderRadius(0.45, em)
|
||||||
|
.background("transparent")
|
||||||
|
.color("var(--headertext)")
|
||||||
|
.cursor("pointer")
|
||||||
|
.fontSize(0.83, em)
|
||||||
|
.onHover(function(hovering) {
|
||||||
|
this.style.background = hovering ? "var(--divider)" : "transparent";
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
register(DesktopToolbar)
|
||||||
396
calendar/desktop/Events/DesktopEventDetails.js
Normal file
@@ -0,0 +1,396 @@
|
|||||||
|
import server from "/@server/server.js"
|
||||||
|
import calendarUtil from "../../calendarUtil.js"
|
||||||
|
import "../../../components/Avatar.js"
|
||||||
|
|
||||||
|
class DesktopEventDetails extends Shadow {
|
||||||
|
attachmentsOpen = false
|
||||||
|
|
||||||
|
constructor(calendars, event, onUpdated = null, onDeleted = null, onEdit = null) {
|
||||||
|
super()
|
||||||
|
this.calendars = calendars
|
||||||
|
this.event = event
|
||||||
|
this.attachmentsOpen = (event?.attachments?.length > 0)
|
||||||
|
this.onUpdated = onUpdated
|
||||||
|
this.onDeleted = onDeleted
|
||||||
|
this.onEdit = onEdit
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (!this.event) return
|
||||||
|
|
||||||
|
const eventCals = this.calendars.filter(c => this.event.calendars?.includes(c.id))
|
||||||
|
const isOwner = this.event.creator_id === global.profile?.id
|
||||||
|
|
||||||
|
VStack(() => {
|
||||||
|
this.renderHeader(isOwner)
|
||||||
|
this.renderBody(eventCals)
|
||||||
|
HStack(() => {
|
||||||
|
const members = global.currentNetwork.data?.members || []
|
||||||
|
const creator = members.find(m => m.id === this.event.creator_id)
|
||||||
|
if (creator) {
|
||||||
|
Avatar(creator, 1.6)
|
||||||
|
VStack(() => {
|
||||||
|
p(`Created ${calendarUtil.timeAgo(this.event.created)} by ${creator.first_name}`)
|
||||||
|
.margin(0).fontSize(0.7, em).color("var(--headertext)").opacity(0.5)
|
||||||
|
if (this.event.updated_at && this.event.updated_at !== this.event.created) {
|
||||||
|
p(`Last updated ${calendarUtil.timeAgo(this.event.updated_at)}`)
|
||||||
|
.margin(0).fontSize(0.7, em).color("var(--headertext)").opacity(0.4)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.gap(0.15, em)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.paddingHorizontal(1, em)
|
||||||
|
.paddingVertical(0.65, em)
|
||||||
|
.boxSizing("border-box")
|
||||||
|
.alignItems("center")
|
||||||
|
.gap(0.5, em)
|
||||||
|
.flexShrink(0)
|
||||||
|
})
|
||||||
|
.height(100, pct)
|
||||||
|
.boxSizing("border-box")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Header ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
renderHeader(isOwner) {
|
||||||
|
HStack(() => {
|
||||||
|
VStack(() => {
|
||||||
|
h2(this.event.title || "Untitled")
|
||||||
|
.margin(0)
|
||||||
|
.fontSize(1.45, em)
|
||||||
|
.fontWeight("700")
|
||||||
|
.color("var(--headertext)")
|
||||||
|
.lineHeight("1.2")
|
||||||
|
})
|
||||||
|
.flex(1)
|
||||||
|
.paddingHorizontal(1.4, em)
|
||||||
|
.paddingTop(2.5, em)
|
||||||
|
.paddingBottom(0.5, em)
|
||||||
|
.justifyContent("center")
|
||||||
|
|
||||||
|
if (isOwner) {
|
||||||
|
button("Delete")
|
||||||
|
.paddingVertical(0.34, em)
|
||||||
|
.paddingHorizontal(0.85, em)
|
||||||
|
.border("1px solid var(--quillred)")
|
||||||
|
.borderRadius(0.45, em)
|
||||||
|
.background("transparent")
|
||||||
|
.color("var(--quillred)")
|
||||||
|
.cursor("pointer")
|
||||||
|
.fontSize(0.8, em)
|
||||||
|
.fontWeight("600")
|
||||||
|
.marginRight(0.5, em)
|
||||||
|
.marginTop("auto")
|
||||||
|
.marginBottom(1, em)
|
||||||
|
.flexShrink(0)
|
||||||
|
.onClick((done) => { if (done) this.handleDelete() })
|
||||||
|
.onHover(function(hovering) {
|
||||||
|
this.style.background = hovering ? "var(--quillred)" : "transparent";
|
||||||
|
this.style.color = hovering ? "white" : "var(--quillred)";
|
||||||
|
})
|
||||||
|
|
||||||
|
button("Edit")
|
||||||
|
.paddingVertical(0.34, em)
|
||||||
|
.paddingHorizontal(0.85, em)
|
||||||
|
.border("1px solid var(--divider)")
|
||||||
|
.borderRadius(0.45, em)
|
||||||
|
.background("transparent")
|
||||||
|
.color("var(--headertext)")
|
||||||
|
.cursor("pointer")
|
||||||
|
.fontSize(0.8, em)
|
||||||
|
.marginRight(1.4, em)
|
||||||
|
.marginTop("auto")
|
||||||
|
.marginBottom(1, em)
|
||||||
|
.flexShrink(0)
|
||||||
|
.onClick((done) => {
|
||||||
|
if (!done) return
|
||||||
|
// Attach template dates for override events so scope='all' anchors correctly
|
||||||
|
let eventForEdit = this.event
|
||||||
|
if (this.event.recurrence_parent_id && !this.event._templateStart) {
|
||||||
|
const template = global.currentNetwork.data.events.find(e => e.id === this.event.recurrence_parent_id)
|
||||||
|
if (template) {
|
||||||
|
eventForEdit = {
|
||||||
|
...this.event,
|
||||||
|
_templateStart: new Date(template.time_start),
|
||||||
|
_templateEnd: new Date(template.time_end)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.onEdit(eventForEdit)
|
||||||
|
})
|
||||||
|
.onHover(function(hovering) {
|
||||||
|
this.style.background = hovering ? "var(--divider)" : "transparent";
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.width(100, pct)
|
||||||
|
.alignItems("stretch")
|
||||||
|
.background("var(--darkaccent)")
|
||||||
|
.borderBottom("1px solid var(--divider)")
|
||||||
|
.boxSizing("border-box")
|
||||||
|
.flexShrink(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Body ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
renderBody(eventCals) {
|
||||||
|
VStack(() => {
|
||||||
|
|
||||||
|
VStack(() => {
|
||||||
|
this.prop("WHEN", () => {
|
||||||
|
p(calendarUtil.formatEventTime(this.event))
|
||||||
|
.margin(0)
|
||||||
|
.fontSize(0.88, em)
|
||||||
|
.color("var(--headertext)")
|
||||||
|
})
|
||||||
|
|
||||||
|
if (this.event.recurrence) {
|
||||||
|
this.prop("REPEATS", () => {
|
||||||
|
p(this._recurrenceLabel())
|
||||||
|
.margin(0)
|
||||||
|
.fontSize(0.88, em)
|
||||||
|
.color("var(--headertext)")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
this.prop("CALENDARS", () => {
|
||||||
|
HStack(() => {
|
||||||
|
eventCals.forEach(cal => {
|
||||||
|
p(cal.name)
|
||||||
|
.margin(0)
|
||||||
|
.fontSize(0.78, em)
|
||||||
|
.fontWeight("600")
|
||||||
|
.color("white")
|
||||||
|
.paddingHorizontal(0.65, em)
|
||||||
|
.paddingVertical(0.28, em)
|
||||||
|
.background(cal.color)
|
||||||
|
.borderRadius(0.45, em)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.flexWrap("wrap")
|
||||||
|
.gap(0.45, em)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (this.event.location) {
|
||||||
|
this.prop("LOCATION", () => {
|
||||||
|
p(this.event.location)
|
||||||
|
.margin(0)
|
||||||
|
.fontSize(0.88, em)
|
||||||
|
.color("var(--headertext)")
|
||||||
|
.lineHeight("1.5")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.event.description) {
|
||||||
|
this.prop("DESCRIPTION", () => {
|
||||||
|
p(this.event.description)
|
||||||
|
.margin(0)
|
||||||
|
.fontSize(0.88, em)
|
||||||
|
.color("var(--headertext)")
|
||||||
|
.lineHeight("1.65")
|
||||||
|
.whiteSpace("pre-wrap")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (this.event.attachments?.length > 0) {
|
||||||
|
this.renderAttachments()
|
||||||
|
}
|
||||||
|
|
||||||
|
})
|
||||||
|
.flex(1)
|
||||||
|
.overflowY("scroll")
|
||||||
|
.width(100, pct)
|
||||||
|
.boxSizing("border-box")
|
||||||
|
}
|
||||||
|
|
||||||
|
prop(label, contentFn) {
|
||||||
|
VStack(() => {
|
||||||
|
p(label)
|
||||||
|
.margin(0)
|
||||||
|
.marginBottom(1, em)
|
||||||
|
.fontSize(0.67, em)
|
||||||
|
.fontWeight("600")
|
||||||
|
.letterSpacing("0.06em")
|
||||||
|
.color("var(--headertext)")
|
||||||
|
.opacity(0.38)
|
||||||
|
|
||||||
|
VStack(() => { contentFn() })
|
||||||
|
.width(100, pct)
|
||||||
|
})
|
||||||
|
.paddingHorizontal(1.5, em)
|
||||||
|
.paddingTop(0.75, em)
|
||||||
|
.paddingBottom(0.4, em)
|
||||||
|
.boxSizing("border-box")
|
||||||
|
.width(100, pct)
|
||||||
|
}
|
||||||
|
|
||||||
|
_recurrenceLabel() {
|
||||||
|
const r = this.event.recurrence
|
||||||
|
if (!r) return ""
|
||||||
|
if (r.frequency === 'daily') return "Daily"
|
||||||
|
if (r.frequency === 'weekly' && r.interval === 2) return "Every 2 weeks"
|
||||||
|
if (r.frequency === 'weekly') return "Weekly"
|
||||||
|
if (r.frequency === 'monthly') return "Monthly"
|
||||||
|
if (r.frequency === 'yearly') return "Yearly"
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Attachments ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
renderAttachments() {
|
||||||
|
VStack(() => {
|
||||||
|
HStack(() => {
|
||||||
|
p("Attachments")
|
||||||
|
.margin(0)
|
||||||
|
.fontSize(0.82, em)
|
||||||
|
.fontWeight("600")
|
||||||
|
.color("var(--headertext)")
|
||||||
|
.opacity(0.4)
|
||||||
|
p("▼")
|
||||||
|
.attr({ id: "desktop-attachments-chevron" })
|
||||||
|
.margin(0)
|
||||||
|
.fontSize(0.65, em)
|
||||||
|
.color("var(--headertext)")
|
||||||
|
.opacity(0.4)
|
||||||
|
.display("inline-block")
|
||||||
|
.transition("transform 0.22s ease")
|
||||||
|
.transform(this.attachmentsOpen ? "rotate(180deg)" : "rotate(0deg)")
|
||||||
|
.userSelect("none")
|
||||||
|
})
|
||||||
|
.gap(0.5, em)
|
||||||
|
.alignItems("center")
|
||||||
|
.cursor("pointer")
|
||||||
|
.onClick((done) => { if (!done) return; this.toggleAttachments() })
|
||||||
|
|
||||||
|
VStack(() => {
|
||||||
|
const images = this.event.attachments.filter(f => f.type?.startsWith("image/"))
|
||||||
|
const files = this.event.attachments.filter(f => !f.type?.startsWith("image/"))
|
||||||
|
|
||||||
|
if (images.length > 0) {
|
||||||
|
HStack(() => {
|
||||||
|
images.forEach(file => {
|
||||||
|
const url = `${config.SERVER}/db/images/events/${file.name}`
|
||||||
|
VStack(() => {
|
||||||
|
img(url, "100%", "100%")
|
||||||
|
.objectFit("cover")
|
||||||
|
.display("block")
|
||||||
|
})
|
||||||
|
.width("6.5em")
|
||||||
|
.height("6.5em")
|
||||||
|
.flexShrink(0)
|
||||||
|
.border("1px solid var(--divider)")
|
||||||
|
.borderRadius(6, px)
|
||||||
|
.overflow("hidden")
|
||||||
|
.cursor("pointer")
|
||||||
|
.onClick((done) => { if (!done) return; $("filepreview-").open(file, url) })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.flexWrap("wrap")
|
||||||
|
.gap(0.5, em)
|
||||||
|
.boxSizing("border-box")
|
||||||
|
.width(100, pct)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (files.length > 0) {
|
||||||
|
VStack(() => {
|
||||||
|
files.forEach(file => this.renderFile(file))
|
||||||
|
})
|
||||||
|
.gap(0.5, em)
|
||||||
|
.width("max-content")
|
||||||
|
.boxSizing("border-box")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.attr({ id: "desktop-attachments-content" })
|
||||||
|
.width(100, pct)
|
||||||
|
.display(this.attachmentsOpen ? "" : "none")
|
||||||
|
.gap(1, em)
|
||||||
|
})
|
||||||
|
.width(100, pct)
|
||||||
|
.boxSizing("border-box")
|
||||||
|
.paddingHorizontal(1.5, em)
|
||||||
|
.paddingVertical(0.85, em)
|
||||||
|
.gap(1, em)
|
||||||
|
}
|
||||||
|
|
||||||
|
renderFile(file) {
|
||||||
|
const url = `${config.SERVER}/db/images/events/${file.name}`
|
||||||
|
HStack(() => {
|
||||||
|
p("📎")
|
||||||
|
.margin(0)
|
||||||
|
.fontSize(0.9, em)
|
||||||
|
p(file.original_name ?? file.name)
|
||||||
|
.margin(0)
|
||||||
|
.color("var(--headertext)")
|
||||||
|
.fontSize(0.85, em)
|
||||||
|
.overflow("hidden")
|
||||||
|
.whiteSpace("nowrap")
|
||||||
|
.textOverflow("ellipsis")
|
||||||
|
})
|
||||||
|
.gap(0.5, em)
|
||||||
|
.alignItems("center")
|
||||||
|
.padding(0.55, em)
|
||||||
|
.background("var(--darkaccent)")
|
||||||
|
.border("1px solid var(--divider)")
|
||||||
|
.borderRadius(0.45, em)
|
||||||
|
.boxSizing("border-box")
|
||||||
|
.cursor("pointer")
|
||||||
|
.onClick((done) => { if (!done) return; $("filepreview-").open(file, url) })
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleAttachments() {
|
||||||
|
this.attachmentsOpen = !this.attachmentsOpen
|
||||||
|
const content = this.$("#desktop-attachments-content")
|
||||||
|
const chevron = this.$("#desktop-attachments-chevron")
|
||||||
|
if (content) content.style.display = this.attachmentsOpen ? "" : "none"
|
||||||
|
if (chevron) chevron.style.transform = this.attachmentsOpen ? "rotate(180deg)" : "rotate(0deg)"
|
||||||
|
}
|
||||||
|
|
||||||
|
handleDelete() {
|
||||||
|
const event = this.event
|
||||||
|
const isRecurring = !!(event?._isOccurrence || event?.recurrence_parent_id || event?.recurrence_id)
|
||||||
|
if (isRecurring) {
|
||||||
|
$('actionsheetpopup-').show(
|
||||||
|
"Delete Recurring Event",
|
||||||
|
[
|
||||||
|
{ label: "Delete just this event", onTap: () => this.performDelete('single') },
|
||||||
|
{ label: "Delete this and future events", onTap: () => this.performDelete('future') },
|
||||||
|
{ label: "Delete all events in series", onTap: () => this.performDelete('all') },
|
||||||
|
],
|
||||||
|
() => {}
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.performDelete(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
async performDelete(scope) {
|
||||||
|
const event = this.event
|
||||||
|
const isOverride = !!event.recurrence_parent_id
|
||||||
|
const templateId = isOverride ? event.recurrence_parent_id : event.id
|
||||||
|
const occurrenceDate = isOverride
|
||||||
|
? (event.recurrence_exception_date instanceof Date
|
||||||
|
? event.recurrence_exception_date.toISOString()
|
||||||
|
: event.recurrence_exception_date) ?? null
|
||||||
|
: event._occurrenceDate?.toISOString() ?? null
|
||||||
|
const serverEventId = (scope === 'single' && isOverride) ? event.id : templateId
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await server.deleteEvent(serverEventId, global.currentNetwork.id, scope, occurrenceDate)
|
||||||
|
if (result.status === 200) {
|
||||||
|
$("modal-").forceClose()
|
||||||
|
const deleteResult = { scope: scope ?? 'all', templateId, occurrenceDate, overrideId: isOverride ? event.id : null }
|
||||||
|
if (this.onDeleted) this.onDeleted(deleteResult)
|
||||||
|
} else {
|
||||||
|
$("modal-")?.showError(result.error ?? "Failed to delete event.")
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to delete event:", err)
|
||||||
|
$("modal-")?.showError("Failed to delete event.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
register(DesktopEventDetails)
|
||||||
1011
calendar/desktop/Events/DesktopEventForm.js
Normal file
154
calendar/desktop/Events/FilePreview.js
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
class FilePreview extends Window {
|
||||||
|
_visible = false
|
||||||
|
_file = null
|
||||||
|
_url = null
|
||||||
|
|
||||||
|
open(file, url) {
|
||||||
|
this._file = file
|
||||||
|
this._url = url
|
||||||
|
this._visible = true
|
||||||
|
this.rerender()
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
this._visible = false
|
||||||
|
this.rerender()
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
this.style.position = "fixed"
|
||||||
|
this.style.inset = "0"
|
||||||
|
this.style.zIndex = "300"
|
||||||
|
this.style.pointerEvents = this._visible ? "all" : "none"
|
||||||
|
|
||||||
|
if (!this._visible) return
|
||||||
|
|
||||||
|
const x = this.getX()
|
||||||
|
const y = this.getY()
|
||||||
|
const w = this.getWidth()
|
||||||
|
const h = this.getHeight()
|
||||||
|
|
||||||
|
const type = this._file?.type ?? ""
|
||||||
|
const isImage = type.startsWith("image/")
|
||||||
|
const isPDF = type === "application/pdf"
|
||||||
|
const isOffice = [
|
||||||
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||||
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
|
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||||
|
'application/msword',
|
||||||
|
'application/vnd.ms-excel',
|
||||||
|
'application/vnd.ms-powerpoint',
|
||||||
|
'text/plain',
|
||||||
|
].includes(type)
|
||||||
|
const displayName = this._file?.original_name ?? this._file?.name ?? "File"
|
||||||
|
|
||||||
|
VStack(() => {
|
||||||
|
const panel = VStack(() => {
|
||||||
|
// Header
|
||||||
|
HStack(() => {
|
||||||
|
HStack(() => {
|
||||||
|
div().attr({ class: "tl tl-close" }).onClick((done) => { if (done) this.close() })
|
||||||
|
div().attr({ class: "tl tl-min" })
|
||||||
|
div().attr({ class: "tl tl-max" })
|
||||||
|
})
|
||||||
|
.attr({ class: "traffic-lights" })
|
||||||
|
.flexShrink(0)
|
||||||
|
|
||||||
|
p(displayName)
|
||||||
|
.margin(0)
|
||||||
|
.fontSize(0.88, em)
|
||||||
|
.fontWeight("600")
|
||||||
|
.color("var(--headertext)")
|
||||||
|
.overflow("hidden")
|
||||||
|
.whiteSpace("nowrap")
|
||||||
|
.textOverflow("ellipsis")
|
||||||
|
.flex(1)
|
||||||
|
.textAlign("center")
|
||||||
|
|
||||||
|
div().width("52px").flexShrink(0)
|
||||||
|
})
|
||||||
|
.paddingHorizontal(1, em)
|
||||||
|
.paddingVertical(0.75, em)
|
||||||
|
.alignItems("center")
|
||||||
|
.gap(1, em)
|
||||||
|
.background("var(--darkaccent)")
|
||||||
|
.borderBottom("1px solid var(--divider)")
|
||||||
|
.width(100, pct)
|
||||||
|
.boxSizing("border-box")
|
||||||
|
.flexShrink(0)
|
||||||
|
|
||||||
|
// Content
|
||||||
|
if (isImage) {
|
||||||
|
img(this._url, "auto", "auto")
|
||||||
|
.display("block")
|
||||||
|
.maxWidth(100, pct)
|
||||||
|
.maxHeight(h - 48, px)
|
||||||
|
.margin("0 auto")
|
||||||
|
} else if (isPDF) {
|
||||||
|
const wrap = div()
|
||||||
|
.flex(1)
|
||||||
|
.width(100, pct)
|
||||||
|
.overflow("hidden")
|
||||||
|
wrap.innerHTML = `<iframe src="${this._url}" style="width:100%;height:100%;border:none;display:block;"></iframe>`
|
||||||
|
} else {
|
||||||
|
const icon = {
|
||||||
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': "📄",
|
||||||
|
'application/msword': "📄",
|
||||||
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': "📊",
|
||||||
|
'application/vnd.ms-excel': "📊",
|
||||||
|
'application/vnd.openxmlformats-officedocument.presentationml.presentation': "📋",
|
||||||
|
'application/vnd.ms-powerpoint': "📋",
|
||||||
|
'text/plain': "📄",
|
||||||
|
}[type] ?? "📎"
|
||||||
|
|
||||||
|
VStack(() => {
|
||||||
|
p(icon).fontSize(3, em).margin(0)
|
||||||
|
p(displayName)
|
||||||
|
.margin(0)
|
||||||
|
.fontSize(0.9, em)
|
||||||
|
.color("var(--headertext)")
|
||||||
|
.textAlign("center")
|
||||||
|
|
||||||
|
a(this._url, "Download")
|
||||||
|
.attr({ download: displayName })
|
||||||
|
.fontSize(0.85, em)
|
||||||
|
.color("var(--quillred)")
|
||||||
|
.fontWeight("600")
|
||||||
|
.fontFamily("Arial")
|
||||||
|
.textDecoration("none")
|
||||||
|
.cursor("pointer")
|
||||||
|
.marginTop(0.25, em)
|
||||||
|
.onHover(function(hovering) {
|
||||||
|
this.style.textDecoration = hovering ? "underline" : "none"
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.gap(0.5, em)
|
||||||
|
.alignItems("center")
|
||||||
|
.padding(2, em)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.background("var(--window)")
|
||||||
|
.backdropFilter("blur(18px)")
|
||||||
|
.border("0.5px solid var(--window-border)")
|
||||||
|
.borderRadius(12, px)
|
||||||
|
.overflow("hidden")
|
||||||
|
.width(100, pct)
|
||||||
|
.boxSizing("border-box")
|
||||||
|
|
||||||
|
if (isPDF) panel.height(h, px)
|
||||||
|
else panel.maxHeight(h, px)
|
||||||
|
panel.onClick((done, e) => { e.stopPropagation() })
|
||||||
|
})
|
||||||
|
.position("fixed")
|
||||||
|
.x(x, px)
|
||||||
|
.y(y, px)
|
||||||
|
.width(w, px)
|
||||||
|
.height(h, px)
|
||||||
|
.justifyContent(isPDF ? "flex-start" : "center")
|
||||||
|
.alignItems("center")
|
||||||
|
.onClick((done) => { if (done) this.close() })
|
||||||
|
.onEvent("resize", () => this.rerender())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
register(FilePreview)
|
||||||
415
calendar/desktop/calendar.js
Normal file
@@ -0,0 +1,415 @@
|
|||||||
|
import calendarUtil from "../calendarUtil.js"
|
||||||
|
import "./DesktopToolbar.js"
|
||||||
|
import "./DesktopSidebar.js"
|
||||||
|
import "./DesktopMonthView.js"
|
||||||
|
import "./Events/DesktopEventDetails.js"
|
||||||
|
import "./Events/DesktopEventForm.js"
|
||||||
|
import "./DesktopCalendarForm.js"
|
||||||
|
|
||||||
|
css(`
|
||||||
|
calendar- {
|
||||||
|
font-family: 'Arial';
|
||||||
|
scrollbar-width: none;
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
}
|
||||||
|
`)
|
||||||
|
|
||||||
|
class Calendar extends Shadow {
|
||||||
|
constructor() {
|
||||||
|
super()
|
||||||
|
this.currentDate = new Date();
|
||||||
|
this.weekStartsOn = 0;
|
||||||
|
this.calendars = (global.currentNetwork.data?.calendars ?? []).map(c => ({ ...c }))
|
||||||
|
const storedCalIds = JSON.parse(localStorage.getItem(`calendarSelection_${global.profile.id}_${global.currentNetwork.id}`) || 'null')
|
||||||
|
if (storedCalIds) {
|
||||||
|
const restored = this.calendars.filter(c => storedCalIds.includes(c.id))
|
||||||
|
this.selectedCalendars = restored.length > 0 ? restored : [...this.calendars]
|
||||||
|
} else {
|
||||||
|
this.selectedCalendars = [...this.calendars]
|
||||||
|
}
|
||||||
|
this.events = (global.currentNetwork.data?.events ?? []).map(event => ({
|
||||||
|
...event,
|
||||||
|
time_start: new Date(event.time_start),
|
||||||
|
time_end: new Date(event.time_end)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleCalendar(calendar) {
|
||||||
|
const isSelected = this.selectedCalendars.some(c => c.id === calendar.id);
|
||||||
|
if (isSelected && this.selectedCalendars.length === 1) return;
|
||||||
|
if (isSelected) {
|
||||||
|
this.selectedCalendars = this.selectedCalendars.filter(c => c.id !== calendar.id);
|
||||||
|
} else {
|
||||||
|
this.selectedCalendars = [...this.selectedCalendars, calendar];
|
||||||
|
}
|
||||||
|
localStorage.setItem(`calendarSelection_${global.profile.id}_${global.currentNetwork.id}`, JSON.stringify(this.selectedCalendars.map(c => c.id)))
|
||||||
|
this.rerender();
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
|
||||||
|
HStack(() => {
|
||||||
|
DesktopSidebar(
|
||||||
|
this.currentDate,
|
||||||
|
this.calendars,
|
||||||
|
this.selectedCalendars,
|
||||||
|
this.events,
|
||||||
|
this.weekStartsOn,
|
||||||
|
{
|
||||||
|
onSelectDate: (date) => this.goToDate(date),
|
||||||
|
onToggleCalendar: (cal) => this.toggleCalendar(cal),
|
||||||
|
onNewCalendar: () => {
|
||||||
|
let formEl
|
||||||
|
$("modal-").open(() => {
|
||||||
|
formEl = DesktopCalendarForm(this.calendars, (calendar) => this.addCalendar(calendar))
|
||||||
|
})
|
||||||
|
$("modal-")._closeOverride = async () => {
|
||||||
|
const saved = formEl ? await formEl.trySave() : false
|
||||||
|
if (saved === null) return
|
||||||
|
if (saved) this.addCalendar(saved)
|
||||||
|
$("modal-").forceClose()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onEditCalendar: (cal) => this.openEditCalendarForm(cal)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
VStack(() => {
|
||||||
|
DesktopToolbar(this.currentDate, {
|
||||||
|
goToPrevious: () => this.goToPrevious(),
|
||||||
|
goToCurrent: () => this.goToCurrent(),
|
||||||
|
goToNext: () => this.goToNext(),
|
||||||
|
onNewEvent: () => {
|
||||||
|
let formEl
|
||||||
|
$("modal-").open(() => {
|
||||||
|
formEl = DesktopEventForm(this.calendars, (event) => this.addEvent(event))
|
||||||
|
})
|
||||||
|
$("modal-")._closeOverride = () => formEl?.handleBack()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
DesktopMonthView(this.selectedCalendars, this.events, this.currentDate, this.weekStartsOn,
|
||||||
|
(event) => this.openEventDetails(event),
|
||||||
|
(day, removeGhost) => {
|
||||||
|
let formEl
|
||||||
|
const onBack = () => {
|
||||||
|
if (removeGhost) removeGhost()
|
||||||
|
$("modal-").forceClose()
|
||||||
|
}
|
||||||
|
$("modal-").open(() => {
|
||||||
|
formEl = DesktopEventForm(this.calendars, (event) => this.addEvent(event), null, null, onBack, day)
|
||||||
|
})
|
||||||
|
$("modal-")._closeOverride = () => formEl?.handleBack()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.flex(1)
|
||||||
|
.minHeight(0)
|
||||||
|
.width(100, pct)
|
||||||
|
.overflow("hidden")
|
||||||
|
})
|
||||||
|
.flex(1)
|
||||||
|
.height(100, pct)
|
||||||
|
.overflow("hidden")
|
||||||
|
})
|
||||||
|
.height(100, pct)
|
||||||
|
.width(100, pct)
|
||||||
|
.overflow("hidden")
|
||||||
|
}
|
||||||
|
|
||||||
|
openEventDetails(event) {
|
||||||
|
$("modal-").open(() => DesktopEventDetails(
|
||||||
|
this.calendars,
|
||||||
|
event,
|
||||||
|
(editResult) => {
|
||||||
|
if (editResult?.scope) {
|
||||||
|
this.handleEditResult(editResult)
|
||||||
|
} else {
|
||||||
|
this.updateEvent(editResult)
|
||||||
|
}
|
||||||
|
this.rerender()
|
||||||
|
},
|
||||||
|
(deleteResult) => {
|
||||||
|
this.handleDeleteResult(deleteResult)
|
||||||
|
this.rerender()
|
||||||
|
},
|
||||||
|
(evt) => this.openEditForm(evt)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
openEditForm(event) {
|
||||||
|
let formEl
|
||||||
|
const goBack = () => {
|
||||||
|
$("modal-").forceClose()
|
||||||
|
const currentEvent = this.events.find(e => e.id === event.id) ?? event
|
||||||
|
this.openEventDetails({ ...currentEvent })
|
||||||
|
}
|
||||||
|
$("modal-").open(() => {
|
||||||
|
formEl = DesktopEventForm(
|
||||||
|
this.calendars,
|
||||||
|
(editResult) => {
|
||||||
|
if (editResult?.scope) {
|
||||||
|
this.handleEditResult(editResult)
|
||||||
|
this.rerender()
|
||||||
|
const findId = editResult.scope === 'all' ? editResult.templateId : editResult.event.id
|
||||||
|
const updatedEvt = this.events.find(e => e.id === findId)
|
||||||
|
if (updatedEvt) setTimeout(() => this.openEventDetails(updatedEvt), 50)
|
||||||
|
} else {
|
||||||
|
this.updateEvent(editResult)
|
||||||
|
this.openEventDetails(editResult)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
event,
|
||||||
|
(deleteResult) => {
|
||||||
|
this.handleDeleteResult(deleteResult)
|
||||||
|
this.rerender()
|
||||||
|
},
|
||||||
|
goBack
|
||||||
|
)
|
||||||
|
})
|
||||||
|
$("modal-")._closeOverride = () => formEl?.handleBack()
|
||||||
|
}
|
||||||
|
|
||||||
|
addEvent(event) {
|
||||||
|
this.events = [...this.events, this.parseEvent(event)]
|
||||||
|
global.currentNetwork.data.events = [...global.currentNetwork.data.events, event]
|
||||||
|
this.rerender()
|
||||||
|
}
|
||||||
|
|
||||||
|
openEditCalendarForm(calendar) {
|
||||||
|
let formEl
|
||||||
|
$("modal-").open(() => {
|
||||||
|
formEl = DesktopCalendarForm(
|
||||||
|
this.calendars,
|
||||||
|
(updated) => { this.updateCalendar(updated); $("modal-").forceClose() },
|
||||||
|
calendar,
|
||||||
|
(deletedId) => this.deleteCalendar(deletedId),
|
||||||
|
() => $("modal-").forceClose()
|
||||||
|
)
|
||||||
|
})
|
||||||
|
$("modal-")._closeOverride = async () => {
|
||||||
|
const saved = formEl ? await formEl.trySave() : null
|
||||||
|
if (saved === null) return
|
||||||
|
this.updateCalendar(saved)
|
||||||
|
$("modal-").forceClose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
addCalendar(calendar) {
|
||||||
|
this.calendars = [...this.calendars, calendar]
|
||||||
|
this.selectedCalendars = [...this.selectedCalendars, calendar]
|
||||||
|
global.currentNetwork.data.calendars = [...global.currentNetwork.data.calendars, calendar]
|
||||||
|
this.rerender()
|
||||||
|
}
|
||||||
|
|
||||||
|
updateCalendar(calendar) {
|
||||||
|
this.calendars = this.calendars.map(c => c.id === calendar.id ? calendar : c)
|
||||||
|
this.selectedCalendars = this.selectedCalendars.map(c => c.id === calendar.id ? calendar : c)
|
||||||
|
global.currentNetwork.data.calendars = global.currentNetwork.data.calendars.map(c => c.id === calendar.id ? calendar : c)
|
||||||
|
this.rerender()
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteCalendar(id) {
|
||||||
|
this.calendars = this.calendars.filter(c => c.id !== id)
|
||||||
|
this.selectedCalendars = this.selectedCalendars.filter(c => c.id !== id)
|
||||||
|
global.currentNetwork.data.calendars = global.currentNetwork.data.calendars.filter(c => c.id !== id)
|
||||||
|
this.rerender()
|
||||||
|
}
|
||||||
|
|
||||||
|
updateEvent(event) {
|
||||||
|
const parsed = this.parseEvent(event)
|
||||||
|
this.events = this.events.map(e => e.id === parsed.id ? parsed : e)
|
||||||
|
global.currentNetwork.data.events = global.currentNetwork.data.events.map(e => e.id === event.id ? event : e)
|
||||||
|
this.rerender()
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteEvent(id) {
|
||||||
|
this.events = this.events.filter(e => e.id !== id)
|
||||||
|
global.currentNetwork.data.events = global.currentNetwork.data.events.filter(e => e.id !== id)
|
||||||
|
this.rerender()
|
||||||
|
}
|
||||||
|
|
||||||
|
handleEditResult({ scope, event: resultEvent, templateId, occurrenceDate }) {
|
||||||
|
const event = { ...resultEvent, time_start: new Date(resultEvent.time_start), time_end: new Date(resultEvent.time_end) };
|
||||||
|
|
||||||
|
if (scope === 'all') {
|
||||||
|
// Preserve end_date from old template — it may have been set by a 'this and future' split.
|
||||||
|
const oldTemplate = this.events.find(e => e.id === templateId);
|
||||||
|
const oldEndDate = oldTemplate?.recurrence?.end_date ?? null;
|
||||||
|
const recurrence = event.recurrence
|
||||||
|
? { ...event.recurrence, end_date: event.recurrence.end_date ?? oldEndDate }
|
||||||
|
: null;
|
||||||
|
const mergedEvent = { ...event, recurrence };
|
||||||
|
this.events = this.events.map(e => e.id === templateId ? mergedEvent : e);
|
||||||
|
global.currentNetwork.data.events = global.currentNetwork.data.events.map(e =>
|
||||||
|
e.id === templateId ? { ...resultEvent, recurrence } : e
|
||||||
|
);
|
||||||
|
|
||||||
|
} else if (scope === 'single') {
|
||||||
|
const alreadyExists = this.events.some(e => e.id === resultEvent.id);
|
||||||
|
if (alreadyExists) {
|
||||||
|
this.events = this.events.map(e => e.id === resultEvent.id ? event : e);
|
||||||
|
global.currentNetwork.data.events = global.currentNetwork.data.events.map(e => e.id === resultEvent.id ? resultEvent : e);
|
||||||
|
} else {
|
||||||
|
this.events = [...this.events, event];
|
||||||
|
global.currentNetwork.data.events = [...global.currentNetwork.data.events, resultEvent];
|
||||||
|
}
|
||||||
|
|
||||||
|
} else if (scope === 'future') {
|
||||||
|
const capDate = occurrenceDate ? new Date(occurrenceDate) : null;
|
||||||
|
if (capDate) {
|
||||||
|
const oldTemplate = this.events.find(e => e.id === templateId);
|
||||||
|
const oldEndDate = oldTemplate?.recurrence?.end_date ? new Date(oldTemplate.recurrence.end_date) : null;
|
||||||
|
|
||||||
|
const baseRecurrence = event.recurrence ?? oldTemplate?.recurrence;
|
||||||
|
const inheritedRecurrence = baseRecurrence
|
||||||
|
? { ...baseRecurrence, end_date: oldEndDate ? oldEndDate.toISOString() : null }
|
||||||
|
: null;
|
||||||
|
const newTemplateEvent = { ...event, recurrence: inheritedRecurrence };
|
||||||
|
const newTemplateRaw = { ...resultEvent, recurrence: inheritedRecurrence };
|
||||||
|
|
||||||
|
const descendantIds = new Set(
|
||||||
|
this.events
|
||||||
|
.filter(e => {
|
||||||
|
if (!(e.parent_template_id === templateId && e.recurrence_id)) return false;
|
||||||
|
const t = new Date(e.time_start);
|
||||||
|
return t >= capDate && (!oldEndDate || t < oldEndDate);
|
||||||
|
})
|
||||||
|
.map(e => e.id)
|
||||||
|
);
|
||||||
|
|
||||||
|
const newId = resultEvent.id;
|
||||||
|
const updateAndFilter = (arr) => arr.map(e => {
|
||||||
|
if (e.id === templateId && e.recurrence) {
|
||||||
|
return { ...e, recurrence: { ...e.recurrence, end_date: capDate.toISOString() } };
|
||||||
|
}
|
||||||
|
if (e.recurrence_parent_id === templateId && e.recurrence_exception_date) {
|
||||||
|
const exDate = new Date(e.recurrence_exception_date);
|
||||||
|
if (exDate >= capDate && (!oldEndDate || exDate < oldEndDate)) {
|
||||||
|
return { ...e, recurrence_parent_id: newId };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return e;
|
||||||
|
}).filter(e => {
|
||||||
|
if (e.recurrence_parent_id === templateId && e.recurrence_exception_date) {
|
||||||
|
return new Date(e.recurrence_exception_date) < capDate;
|
||||||
|
}
|
||||||
|
if (descendantIds.has(e.id) || descendantIds.has(e.recurrence_parent_id)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
this.events = [...updateAndFilter(this.events), newTemplateEvent];
|
||||||
|
global.currentNetwork.data.events = [...updateAndFilter(global.currentNetwork.data.events), newTemplateRaw];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleDeleteResult({ scope, templateId, occurrenceDate, overrideId }) {
|
||||||
|
if (scope === 'all') {
|
||||||
|
// Promote non-cancelled overrides (single-event edits) to standalone; remove cancelled placeholders and template
|
||||||
|
const promoteOverrides = (arr) => arr
|
||||||
|
.filter(e => e.id !== templateId && !(e.recurrence_parent_id === templateId && e.is_cancelled))
|
||||||
|
.map(e => e.recurrence_parent_id === templateId
|
||||||
|
? { ...e, recurrence_parent_id: null, recurrence_exception_date: null }
|
||||||
|
: e
|
||||||
|
);
|
||||||
|
this.events = promoteOverrides(this.events);
|
||||||
|
global.currentNetwork.data.events = promoteOverrides(global.currentNetwork.data.events);
|
||||||
|
} else if (scope === 'single') {
|
||||||
|
if (overrideId) {
|
||||||
|
this.events = this.events.map(e => e.id === overrideId ? { ...e, is_cancelled: true } : e);
|
||||||
|
global.currentNetwork.data.events = global.currentNetwork.data.events.map(e => e.id === overrideId ? { ...e, is_cancelled: true } : e);
|
||||||
|
} else if (occurrenceDate) {
|
||||||
|
const occDate = new Date(occurrenceDate);
|
||||||
|
const syntheticOverride = {
|
||||||
|
id: `cancelled_${templateId}_${occurrenceDate}`,
|
||||||
|
recurrence_parent_id: templateId,
|
||||||
|
recurrence_exception_date: occDate,
|
||||||
|
is_cancelled: true,
|
||||||
|
time_start: occDate,
|
||||||
|
time_end: occDate,
|
||||||
|
calendars: [],
|
||||||
|
all_day: false,
|
||||||
|
};
|
||||||
|
this.events = [...this.events, syntheticOverride];
|
||||||
|
global.currentNetwork.data.events = [...global.currentNetwork.data.events, syntheticOverride];
|
||||||
|
}
|
||||||
|
} else if (scope === 'future') {
|
||||||
|
const capDate = occurrenceDate ? new Date(occurrenceDate) : null;
|
||||||
|
if (capDate) {
|
||||||
|
const oldTemplate = this.events.find(e => e.id === templateId);
|
||||||
|
// Server does a full delete when capDate <= time_start (no occurrences would remain)
|
||||||
|
if (oldTemplate && capDate <= new Date(oldTemplate.time_start)) {
|
||||||
|
const promoteOverrides = (arr) => arr
|
||||||
|
.filter(e => e.id !== templateId && !(e.recurrence_parent_id === templateId && e.is_cancelled))
|
||||||
|
.map(e => e.recurrence_parent_id === templateId
|
||||||
|
? { ...e, recurrence_parent_id: null, recurrence_exception_date: null }
|
||||||
|
: e
|
||||||
|
);
|
||||||
|
this.events = promoteOverrides(this.events);
|
||||||
|
global.currentNetwork.data.events = promoteOverrides(global.currentNetwork.data.events);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const oldEndDate = oldTemplate?.recurrence?.end_date ? new Date(oldTemplate.recurrence.end_date) : null;
|
||||||
|
const descendantIds = new Set(
|
||||||
|
this.events
|
||||||
|
.filter(e => {
|
||||||
|
if (!(e.parent_template_id === templateId && e.recurrence_id)) return false;
|
||||||
|
const t = new Date(e.time_start);
|
||||||
|
return t >= capDate && (!oldEndDate || t < oldEndDate);
|
||||||
|
})
|
||||||
|
.map(e => e.id)
|
||||||
|
);
|
||||||
|
const updateAndFilter = (arr) => arr.map(e => {
|
||||||
|
if (e.id === templateId && e.recurrence) {
|
||||||
|
return { ...e, recurrence: { ...e.recurrence, end_date: capDate.toISOString() } };
|
||||||
|
}
|
||||||
|
// Promote future non-cancelled overrides to standalone events
|
||||||
|
if (e.recurrence_parent_id === templateId && e.recurrence_exception_date
|
||||||
|
&& new Date(e.recurrence_exception_date) >= capDate && !e.is_cancelled) {
|
||||||
|
return { ...e, recurrence_parent_id: null, recurrence_exception_date: null };
|
||||||
|
}
|
||||||
|
return e;
|
||||||
|
}).filter(e => {
|
||||||
|
// Remove future cancelled placeholders (meaningless without the series)
|
||||||
|
if (e.recurrence_parent_id === templateId && e.recurrence_exception_date) {
|
||||||
|
return new Date(e.recurrence_exception_date) < capDate;
|
||||||
|
}
|
||||||
|
if (descendantIds.has(e.id) || descendantIds.has(e.recurrence_parent_id)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
this.events = updateAndFilter(this.events);
|
||||||
|
global.currentNetwork.data.events = updateAndFilter(global.currentNetwork.data.events);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
parseEvent(event) {
|
||||||
|
return { ...event, time_start: new Date(event.time_start), time_end: new Date(event.time_end) }
|
||||||
|
}
|
||||||
|
|
||||||
|
goToPrevious() {
|
||||||
|
this.currentDate = calendarUtil.addMonths(this.currentDate, -1);
|
||||||
|
this.rerender();
|
||||||
|
}
|
||||||
|
|
||||||
|
goToCurrent() {
|
||||||
|
this.currentDate = new Date();
|
||||||
|
this.rerender();
|
||||||
|
}
|
||||||
|
|
||||||
|
goToNext() {
|
||||||
|
this.currentDate = calendarUtil.addMonths(this.currentDate, 1);
|
||||||
|
this.rerender();
|
||||||
|
}
|
||||||
|
|
||||||
|
goToDate(date) {
|
||||||
|
if (calendarUtil.isSameMonth(this.currentDate, date)) return;
|
||||||
|
this.currentDate = date;
|
||||||
|
this.rerender();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
register(Calendar)
|
||||||
4
calendar/icons/addevent.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="5 5 90 90" width="90" height="90">
|
||||||
|
<path d="m38.223 43.109v13.391c-0.007812 1.4023 0.54688 2.75 1.5391 3.7383 0.98828 0.99219 2.3359 1.5469 3.7383 1.5391h13.391c1.3984 0 2.7383-0.55859 3.7188-1.5547l32.281-32.277c0.99609-0.98437 1.5586-2.3242 1.5586-3.7227 0-1.3984-0.5625-2.7422-1.5586-3.7227l-13.391-13.391c-0.98047-1-2.3203-1.5625-3.7227-1.5547-1.3984 0-2.7383 0.55859-3.7227 1.5547l-32.305 32.281c-0.97266 0.99219-1.5195 2.3281-1.5273 3.7188zm4.5273 0c0-0.19531 0.082031-0.38672 0.22266-0.52734l32.277-32.277c0.29297-0.28906 0.76172-0.28906 1.0547 0l13.391 13.391c0.28906 0.29297 0.28906 0.76172 0 1.0547l-32.277 32.277c-0.14062 0.14062-0.33203 0.22266-0.52734 0.22266h-13.391c-0.20312 0.007812-0.39844-0.070312-0.53906-0.21094-0.14063-0.14062-0.21875-0.33594-0.21094-0.53906z" fill="#FFFFFF"/>
|
||||||
|
<path d="m92.195 44.723c-1.2578 0-2.2773 1.0195-2.2773 2.2773v33.141c0 2.5898-1.0312 5.0781-2.8633 6.9141-1.8359 1.832-4.3242 2.8633-6.9141 2.8633h-60.281c-5.3984 0-9.7773-4.3789-9.7773-9.7773v-60.281c0-5.3984 4.3789-9.7773 9.7773-9.7773h33.141c0.64453 0.074219 1.2891-0.13281 1.7695-0.56641 0.48438-0.42969 0.75781-1.0469 0.75781-1.6953 0-0.64844-0.27344-1.2656-0.75781-1.6992-0.48047-0.42969-1.125-0.63672-1.7695-0.56641h-33.141c-3.7891 0.007812-7.4258 1.5195-10.105 4.1992-2.6797 2.6797-4.1914 6.3164-4.1992 10.105v60.281c0.007812 3.7891 1.5195 7.4258 4.1992 10.105 2.6797 2.6797 6.3164 4.1914 10.105 4.1992h60.281c3.7891-0.007812 7.4258-1.5195 10.105-4.1992 2.6797-2.6797 4.1914-6.3164 4.1992-10.105v-33.141c0-1.2461-1.0039-2.2617-2.25-2.2773z" fill="#FFFFFF"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.6 KiB |
4
calendar/icons/addeventlight.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="5 5 90 90" width="90" height="90">
|
||||||
|
<path d="m38.223 43.109v13.391c-0.007812 1.4023 0.54688 2.75 1.5391 3.7383 0.98828 0.99219 2.3359 1.5469 3.7383 1.5391h13.391c1.3984 0 2.7383-0.55859 3.7188-1.5547l32.281-32.277c0.99609-0.98437 1.5586-2.3242 1.5586-3.7227 0-1.3984-0.5625-2.7422-1.5586-3.7227l-13.391-13.391c-0.98047-1-2.3203-1.5625-3.7227-1.5547-1.3984 0-2.7383 0.55859-3.7227 1.5547l-32.305 32.281c-0.97266 0.99219-1.5195 2.3281-1.5273 3.7188zm4.5273 0c0-0.19531 0.082031-0.38672 0.22266-0.52734l32.277-32.277c0.29297-0.28906 0.76172-0.28906 1.0547 0l13.391 13.391c0.28906 0.29297 0.28906 0.76172 0 1.0547l-32.277 32.277c-0.14062 0.14062-0.33203 0.22266-0.52734 0.22266h-13.391c-0.20312 0.007812-0.39844-0.070312-0.53906-0.21094-0.14063-0.14062-0.21875-0.33594-0.21094-0.53906z" fill="#FFE9C8"/>
|
||||||
|
<path d="m92.195 44.723c-1.2578 0-2.2773 1.0195-2.2773 2.2773v33.141c0 2.5898-1.0312 5.0781-2.8633 6.9141-1.8359 1.832-4.3242 2.8633-6.9141 2.8633h-60.281c-5.3984 0-9.7773-4.3789-9.7773-9.7773v-60.281c0-5.3984 4.3789-9.7773 9.7773-9.7773h33.141c0.64453 0.074219 1.2891-0.13281 1.7695-0.56641 0.48438-0.42969 0.75781-1.0469 0.75781-1.6953 0-0.64844-0.27344-1.2656-0.75781-1.6992-0.48047-0.42969-1.125-0.63672-1.7695-0.56641h-33.141c-3.7891 0.007812-7.4258 1.5195-10.105 4.1992-2.6797 2.6797-4.1914 6.3164-4.1992 10.105v60.281c0.007812 3.7891 1.5195 7.4258 4.1992 10.105 2.6797 2.6797 6.3164 4.1914 10.105 4.1992h60.281c3.7891-0.007812 7.4258-1.5195 10.105-4.1992 2.6797-2.6797 4.1914-6.3164 4.1992-10.105v-33.141c0-1.2461-1.0039-2.2617-2.25-2.2773z" fill="#FFE9C8"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.6 KiB |
4
calendar/icons/addeventlightselected.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="5 5 90 90" width="90" height="90">
|
||||||
|
<path d="m38.223 43.109v13.391c-0.007812 1.4023 0.54688 2.75 1.5391 3.7383 0.98828 0.99219 2.3359 1.5469 3.7383 1.5391h13.391c1.3984 0 2.7383-0.55859 3.7188-1.5547l32.281-32.277c0.99609-0.98437 1.5586-2.3242 1.5586-3.7227 0-1.3984-0.5625-2.7422-1.5586-3.7227l-13.391-13.391c-0.98047-1-2.3203-1.5625-3.7227-1.5547-1.3984 0-2.7383 0.55859-3.7227 1.5547l-32.305 32.281c-0.97266 0.99219-1.5195 2.3281-1.5273 3.7188zm4.5273 0c0-0.19531 0.082031-0.38672 0.22266-0.52734l32.277-32.277c0.29297-0.28906 0.76172-0.28906 1.0547 0l13.391 13.391c0.28906 0.29297 0.28906 0.76172 0 1.0547l-32.277 32.277c-0.14062 0.14062-0.33203 0.22266-0.52734 0.22266h-13.391c-0.20312 0.007812-0.39844-0.070312-0.53906-0.21094-0.14063-0.14062-0.21875-0.33594-0.21094-0.53906z" fill="#BD2D2D" stroke="#FFD5A4" stroke-width="0.5"/>
|
||||||
|
<path d="m92.195 44.723c-1.2578 0-2.2773 1.0195-2.2773 2.2773v33.141c0 2.5898-1.0312 5.0781-2.8633 6.9141-1.8359 1.832-4.3242 2.8633-6.9141 2.8633h-60.281c-5.3984 0-9.7773-4.3789-9.7773-9.7773v-60.281c0-5.3984 4.3789-9.7773 9.7773-9.7773h33.141c0.64453 0.074219 1.2891-0.13281 1.7695-0.56641 0.48438-0.42969 0.75781-1.0469 0.75781-1.6953 0-0.64844-0.27344-1.2656-0.75781-1.6992-0.48047-0.42969-1.125-0.63672-1.7695-0.56641h-33.141c-3.7891 0.007812-7.4258 1.5195-10.105 4.1992-2.6797 2.6797-4.1914 6.3164-4.1992 10.105v60.281c0.007812 3.7891 1.5195 7.4258 4.1992 10.105 2.6797 2.6797 6.3164 4.1914 10.105 4.1992h60.281c3.7891-0.007812 7.4258-1.5195 10.105-4.1992 2.6797-2.6797 4.1914-6.3164 4.1992-10.105v-33.141c0-1.2461-1.0039-2.2617-2.25-2.2773z" fill="#BD2D2D" stroke="#FFD5A4" stroke-width="0.5"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.7 KiB |
3
calendar/icons/calbutton.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="4 5.5 92 89" width="90" height="89">
|
||||||
|
<path d="m26.703 4.9766c-3.5625 0-6.4766 2.9141-6.4766 6.4766v2.5938h-6.0312c-5.4688 0-9.9102 4.4531-9.9102 9.9219v61.133c0 5.4688 4.4414 9.9258 9.9102 9.9258l71.598-0.003906c5.4688 0 9.9219-4.4531 9.9219-9.9258v-61.129c0-5.4688-4.4531-9.9219-9.9219-9.9219h-6.0195l-0.003907-2.5977c0-3.5625-2.9258-6.4766-6.4883-6.4766s-6.4727 2.9141-6.4727 6.4766l0.003906 2.5977h-33.633v-2.5977c0-3.5625-2.9141-6.4766-6.4766-6.4766zm0 2.8594c2.0312 0 3.6172 1.5859 3.6172 3.6172v8.582c0 2.0312-1.5859 3.6172-3.6172 3.6172s-3.6172-1.5898-3.6172-3.6172v-8.582c0-2.0312 1.5859-3.6172 3.6172-3.6172zm46.578 0c2.0312 0 3.6289 1.5859 3.6289 3.6172v8.582c0 2.0312-1.5977 3.6172-3.6289 3.6172s-3.6172-1.5898-3.6172-3.6172l0.003907-4.5547v-4.0312c0-2.0312 1.5859-3.6172 3.6172-3.6172zm-59.086 9.0664h6.0312v3.1289c0 3.5625 2.9141 6.4766 6.4766 6.4766s6.4766-2.9141 6.4766-6.4766v-3.1289h33.629v3.1289c0 3.5625 2.9102 6.4766 6.4727 6.4766s6.4883-2.9141 6.4883-6.4766v-3.1289h6.0195c3.9375 0 7.0664 3.1289 7.0664 7.0664v9.1016h-85.711v-9.1016c0-3.9375 3.1172-7.0664 7.0547-7.0664zm-7.0547 19.023h85.715v49.176c0 3.9375-3.1289 7.0664-7.0664 7.0664l-71.594-0.003907c-3.9375 0-7.0547-3.1289-7.0547-7.0664zm15.703 7.7305c-2.4219 0-4.418 1.9844-4.418 4.4062v8.3203c0 2.4219 2 4.4102 4.418 4.4102h8.332c2.4219 0 4.418-1.9883 4.418-4.4102v-8.3203c0-2.4219-1.9961-4.4062-4.418-4.4062zm22.988 0c-2.4219 0-4.418 1.9844-4.418 4.4062v8.3203c0 2.4219 2 4.4102 4.418 4.4102h8.332c2.4219 0 4.418-1.9883 4.418-4.4102v-8.3203c0-2.4219-1.9961-4.4062-4.418-4.4062zm22.98 0c-2.4219 0-4.4102 1.9844-4.4102 4.4062v8.3203c0 2.4219 1.9883 4.4102 4.4102 4.4102h8.3398c2.4219 0 4.4102-1.9883 4.4102-4.4102v-8.3203c0-2.4219-1.9883-4.4062-4.4102-4.4062zm-45.965 2.8594h8.332c0.89062 0 1.5586 0.66406 1.5586 1.5469v8.3203c0 0.88281-0.66797 1.5508-1.5586 1.5508h-8.332c-0.89062 0-1.5625-0.66797-1.5625-1.5508v-8.3203c0-0.88281 0.67188-1.5469 1.5625-1.5469zm22.988 0h8.332c0.89062 0 1.5586 0.66406 1.5586 1.5469v8.3203c0 0.88281-0.66797 1.5508-1.5586 1.5508h-8.332c-0.89063 0-1.5586-0.66797-1.5586-1.5508l-0.003906-8.3203c0-0.88281 0.66797-1.5469 1.5586-1.5469zm22.98 0h8.3398c0.89062 0 1.5625 0.66406 1.5625 1.5469v8.3203c0 0.88281-0.67188 1.5508-1.5625 1.5508h-8.3398c-0.89062 0-1.5508-0.66797-1.5508-1.5508l-0.003906-8.3203c0-0.88281 0.66016-1.5469 1.5508-1.5469zm-45.969 20.172c-2.4219 0-4.418 2-4.418 4.418v8.3086c0 2.4219 2 4.4102 4.418 4.4102h8.332c2.4219 0 4.418-1.9883 4.418-4.4102v-8.3086c0-2.4219-1.9961-4.418-4.418-4.418zm22.988 0c-2.4219 0-4.418 2-4.418 4.418v8.3086c0 2.4219 2 4.4102 4.418 4.4102h8.332c2.4219 0 4.418-1.9883 4.418-4.4102v-8.3086c0-2.4219-1.9961-4.418-4.418-4.418zm22.98 0c-2.4219 0-4.4102 2-4.4102 4.418v8.3086c0 2.4219 1.9883 4.4102 4.4102 4.4102h8.3398c2.4219 0 4.4102-1.9883 4.4102-4.4102v-8.3086c0-2.4219-1.9883-4.418-4.4102-4.418zm-45.969 2.8555h8.332c0.89062 0 1.5586 0.67969 1.5586 1.5625v8.3086c0 0.88281-0.66797 1.5586-1.5586 1.5586h-8.332c-0.89062 0-1.5625-0.67578-1.5625-1.5586v-8.3086c0-0.88281 0.67188-1.5625 1.5625-1.5625zm22.988 0h8.332c0.89062 0 1.5586 0.67969 1.5586 1.5625v8.3086c0 0.88281-0.66797 1.5586-1.5586 1.5586l-8.332 0.003906c-0.89063 0-1.5586-0.67578-1.5586-1.5586v-8.3086c0-0.88281 0.66797-1.5625 1.5586-1.5625zm22.98 0h8.3398c0.89062 0 1.5625 0.67969 1.5625 1.5625v8.3086c0 0.88281-0.67188 1.5586-1.5625 1.5586h-8.3398c-0.89062 0-1.5508-0.67578-1.5508-1.5586v-8.3086c0-0.88281 0.66016-1.5625 1.5508-1.5625z" fill="#FFFFFF"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.5 KiB |
3
calendar/icons/calbuttonlight.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="4 5.5 92 89" width="90" height="89">
|
||||||
|
<path d="m26.703 4.9766c-3.5625 0-6.4766 2.9141-6.4766 6.4766v2.5938h-6.0312c-5.4688 0-9.9102 4.4531-9.9102 9.9219v61.133c0 5.4688 4.4414 9.9258 9.9102 9.9258l71.598-0.003906c5.4688 0 9.9219-4.4531 9.9219-9.9258v-61.129c0-5.4688-4.4531-9.9219-9.9219-9.9219h-6.0195l-0.003907-2.5977c0-3.5625-2.9258-6.4766-6.4883-6.4766s-6.4727 2.9141-6.4727 6.4766l0.003906 2.5977h-33.633v-2.5977c0-3.5625-2.9141-6.4766-6.4766-6.4766zm0 2.8594c2.0312 0 3.6172 1.5859 3.6172 3.6172v8.582c0 2.0312-1.5859 3.6172-3.6172 3.6172s-3.6172-1.5898-3.6172-3.6172v-8.582c0-2.0312 1.5859-3.6172 3.6172-3.6172zm46.578 0c2.0312 0 3.6289 1.5859 3.6289 3.6172v8.582c0 2.0312-1.5977 3.6172-3.6289 3.6172s-3.6172-1.5898-3.6172-3.6172l0.003907-4.5547v-4.0312c0-2.0312 1.5859-3.6172 3.6172-3.6172zm-59.086 9.0664h6.0312v3.1289c0 3.5625 2.9141 6.4766 6.4766 6.4766s6.4766-2.9141 6.4766-6.4766v-3.1289h33.629v3.1289c0 3.5625 2.9102 6.4766 6.4727 6.4766s6.4883-2.9141 6.4883-6.4766v-3.1289h6.0195c3.9375 0 7.0664 3.1289 7.0664 7.0664v9.1016h-85.711v-9.1016c0-3.9375 3.1172-7.0664 7.0547-7.0664zm-7.0547 19.023h85.715v49.176c0 3.9375-3.1289 7.0664-7.0664 7.0664l-71.594-0.003907c-3.9375 0-7.0547-3.1289-7.0547-7.0664zm15.703 7.7305c-2.4219 0-4.418 1.9844-4.418 4.4062v8.3203c0 2.4219 2 4.4102 4.418 4.4102h8.332c2.4219 0 4.418-1.9883 4.418-4.4102v-8.3203c0-2.4219-1.9961-4.4062-4.418-4.4062zm22.988 0c-2.4219 0-4.418 1.9844-4.418 4.4062v8.3203c0 2.4219 2 4.4102 4.418 4.4102h8.332c2.4219 0 4.418-1.9883 4.418-4.4102v-8.3203c0-2.4219-1.9961-4.4062-4.418-4.4062zm22.98 0c-2.4219 0-4.4102 1.9844-4.4102 4.4062v8.3203c0 2.4219 1.9883 4.4102 4.4102 4.4102h8.3398c2.4219 0 4.4102-1.9883 4.4102-4.4102v-8.3203c0-2.4219-1.9883-4.4062-4.4102-4.4062zm-45.965 2.8594h8.332c0.89062 0 1.5586 0.66406 1.5586 1.5469v8.3203c0 0.88281-0.66797 1.5508-1.5586 1.5508h-8.332c-0.89062 0-1.5625-0.66797-1.5625-1.5508v-8.3203c0-0.88281 0.67188-1.5469 1.5625-1.5469zm22.988 0h8.332c0.89062 0 1.5586 0.66406 1.5586 1.5469v8.3203c0 0.88281-0.66797 1.5508-1.5586 1.5508h-8.332c-0.89063 0-1.5586-0.66797-1.5586-1.5508l-0.003906-8.3203c0-0.88281 0.66797-1.5469 1.5586-1.5469zm22.98 0h8.3398c0.89062 0 1.5625 0.66406 1.5625 1.5469v8.3203c0 0.88281-0.67188 1.5508-1.5625 1.5508h-8.3398c-0.89062 0-1.5508-0.66797-1.5508-1.5508l-0.003906-8.3203c0-0.88281 0.66016-1.5469 1.5508-1.5469zm-45.969 20.172c-2.4219 0-4.418 2-4.418 4.418v8.3086c0 2.4219 2 4.4102 4.418 4.4102h8.332c2.4219 0 4.418-1.9883 4.418-4.4102v-8.3086c0-2.4219-1.9961-4.418-4.418-4.418zm22.988 0c-2.4219 0-4.418 2-4.418 4.418v8.3086c0 2.4219 2 4.4102 4.418 4.4102h8.332c2.4219 0 4.418-1.9883 4.418-4.4102v-8.3086c0-2.4219-1.9961-4.418-4.418-4.418zm22.98 0c-2.4219 0-4.4102 2-4.4102 4.418v8.3086c0 2.4219 1.9883 4.4102 4.4102 4.4102h8.3398c2.4219 0 4.4102-1.9883 4.4102-4.4102v-8.3086c0-2.4219-1.9883-4.418-4.4102-4.418zm-45.969 2.8555h8.332c0.89062 0 1.5586 0.67969 1.5586 1.5625v8.3086c0 0.88281-0.66797 1.5586-1.5586 1.5586h-8.332c-0.89062 0-1.5625-0.67578-1.5625-1.5586v-8.3086c0-0.88281 0.67188-1.5625 1.5625-1.5625zm22.988 0h8.332c0.89062 0 1.5586 0.67969 1.5586 1.5625v8.3086c0 0.88281-0.66797 1.5586-1.5586 1.5586l-8.332 0.003906c-0.89063 0-1.5586-0.67578-1.5586-1.5586v-8.3086c0-0.88281 0.66797-1.5625 1.5586-1.5625zm22.98 0h8.3398c0.89062 0 1.5625 0.67969 1.5625 1.5625v8.3086c0 0.88281-0.67188 1.5586-1.5625 1.5586h-8.3398c-0.89062 0-1.5508-0.67578-1.5508-1.5586v-8.3086c0-0.88281 0.66016-1.5625 1.5508-1.5625z" fill="#FFE9C8"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.5 KiB |
3
calendar/icons/calbuttonlightselected.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="4 5.5 92 89" width="90" height="89">
|
||||||
|
<path d="m26.703 4.9766c-3.5625 0-6.4766 2.9141-6.4766 6.4766v2.5938h-6.0312c-5.4688 0-9.9102 4.4531-9.9102 9.9219v61.133c0 5.4688 4.4414 9.9258 9.9102 9.9258l71.598-0.003906c5.4688 0 9.9219-4.4531 9.9219-9.9258v-61.129c0-5.4688-4.4531-9.9219-9.9219-9.9219h-6.0195l-0.003907-2.5977c0-3.5625-2.9258-6.4766-6.4883-6.4766s-6.4727 2.9141-6.4727 6.4766l0.003906 2.5977h-33.633v-2.5977c0-3.5625-2.9141-6.4766-6.4766-6.4766zm0 2.8594c2.0312 0 3.6172 1.5859 3.6172 3.6172v8.582c0 2.0312-1.5859 3.6172-3.6172 3.6172s-3.6172-1.5898-3.6172-3.6172v-8.582c0-2.0312 1.5859-3.6172 3.6172-3.6172zm46.578 0c2.0312 0 3.6289 1.5859 3.6289 3.6172v8.582c0 2.0312-1.5977 3.6172-3.6289 3.6172s-3.6172-1.5898-3.6172-3.6172l0.003907-4.5547v-4.0312c0-2.0312 1.5859-3.6172 3.6172-3.6172zm-59.086 9.0664h6.0312v3.1289c0 3.5625 2.9141 6.4766 6.4766 6.4766s6.4766-2.9141 6.4766-6.4766v-3.1289h33.629v3.1289c0 3.5625 2.9102 6.4766 6.4727 6.4766s6.4883-2.9141 6.4883-6.4766v-3.1289h6.0195c3.9375 0 7.0664 3.1289 7.0664 7.0664v9.1016h-85.711v-9.1016c0-3.9375 3.1172-7.0664 7.0547-7.0664zm-7.0547 19.023h85.715v49.176c0 3.9375-3.1289 7.0664-7.0664 7.0664l-71.594-0.003907c-3.9375 0-7.0547-3.1289-7.0547-7.0664zm15.703 7.7305c-2.4219 0-4.418 1.9844-4.418 4.4062v8.3203c0 2.4219 2 4.4102 4.418 4.4102h8.332c2.4219 0 4.418-1.9883 4.418-4.4102v-8.3203c0-2.4219-1.9961-4.4062-4.418-4.4062zm22.988 0c-2.4219 0-4.418 1.9844-4.418 4.4062v8.3203c0 2.4219 2 4.4102 4.418 4.4102h8.332c2.4219 0 4.418-1.9883 4.418-4.4102v-8.3203c0-2.4219-1.9961-4.4062-4.418-4.4062zm22.98 0c-2.4219 0-4.4102 1.9844-4.4102 4.4062v8.3203c0 2.4219 1.9883 4.4102 4.4102 4.4102h8.3398c2.4219 0 4.4102-1.9883 4.4102-4.4102v-8.3203c0-2.4219-1.9883-4.4062-4.4102-4.4062zm-45.965 2.8594h8.332c0.89062 0 1.5586 0.66406 1.5586 1.5469v8.3203c0 0.88281-0.66797 1.5508-1.5586 1.5508h-8.332c-0.89062 0-1.5625-0.66797-1.5625-1.5508v-8.3203c0-0.88281 0.67188-1.5469 1.5625-1.5469zm22.988 0h8.332c0.89062 0 1.5586 0.66406 1.5586 1.5469v8.3203c0 0.88281-0.66797 1.5508-1.5586 1.5508h-8.332c-0.89063 0-1.5586-0.66797-1.5586-1.5508l-0.003906-8.3203c0-0.88281 0.66797-1.5469 1.5586-1.5469zm22.98 0h8.3398c0.89062 0 1.5625 0.66406 1.5625 1.5469v8.3203c0 0.88281-0.67188 1.5508-1.5625 1.5508h-8.3398c-0.89062 0-1.5508-0.66797-1.5508-1.5508l-0.003906-8.3203c0-0.88281 0.66016-1.5469 1.5508-1.5469zm-45.969 20.172c-2.4219 0-4.418 2-4.418 4.418v8.3086c0 2.4219 2 4.4102 4.418 4.4102h8.332c2.4219 0 4.418-1.9883 4.418-4.4102v-8.3086c0-2.4219-1.9961-4.418-4.418-4.418zm22.988 0c-2.4219 0-4.418 2-4.418 4.418v8.3086c0 2.4219 2 4.4102 4.418 4.4102h8.332c2.4219 0 4.418-1.9883 4.418-4.4102v-8.3086c0-2.4219-1.9961-4.418-4.418-4.418zm22.98 0c-2.4219 0-4.4102 2-4.4102 4.418v8.3086c0 2.4219 1.9883 4.4102 4.4102 4.4102h8.3398c2.4219 0 4.4102-1.9883 4.4102-4.4102v-8.3086c0-2.4219-1.9883-4.418-4.4102-4.418zm-45.969 2.8555h8.332c0.89062 0 1.5586 0.67969 1.5586 1.5625v8.3086c0 0.88281-0.66797 1.5586-1.5586 1.5586h-8.332c-0.89062 0-1.5625-0.67578-1.5625-1.5586v-8.3086c0-0.88281 0.67188-1.5625 1.5625-1.5625zm22.988 0h8.332c0.89062 0 1.5586 0.67969 1.5586 1.5625v8.3086c0 0.88281-0.66797 1.5586-1.5586 1.5586l-8.332 0.003906c-0.89063 0-1.5586-0.67578-1.5586-1.5586v-8.3086c0-0.88281 0.66797-1.5625 1.5586-1.5625zm22.98 0h8.3398c0.89062 0 1.5625 0.67969 1.5625 1.5625v8.3086c0 0.88281-0.67188 1.5586-1.5625 1.5586h-8.3398c-0.89062 0-1.5508-0.67578-1.5508-1.5586v-8.3086c0-0.88281 0.66016-1.5625 1.5508-1.5625z" fill="#BD2D2D" stroke="#FFD5A4" stroke-width="0.5"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.5 KiB |
3
calendar/icons/calendar.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg width="92" height="73" viewBox="0 0 92 73" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M34.7556 0C34.3767 0 34.0134 0.15234 33.7439 0.42188C33.4782 0.69141 33.3259 1.05469 33.3298 1.43358V5.24998H24.6189C21.2712 5.24998 18.5251 7.99608 18.5251 11.3477V21.1758C15.5368 34.2268 9.5993 46.4138 1.1541 56.8008C1.14629 56.8086 1.13848 56.8203 1.13457 56.832L0.736131 57.3477C-1.09197 59.7305 0.704881 63.3593 3.70883 63.3593H18.5248V65.2343C18.5248 69.1445 21.7201 72.3438 25.6303 72.3438H84.3183C88.2285 72.3438 91.4277 69.1445 91.4277 65.2343V21.4574V21.3988V19.3246V19.2894V11.348C91.4277 7.9964 88.6816 5.2542 85.33 5.2542H76.6308V1.4339C76.6308 1.05499 76.4824 0.68781 76.2129 0.4183C75.9433 0.14877 75.5761 0.000329971 75.1973 0.000329971C74.8184 0.000329971 74.4551 0.15267 74.1895 0.42221C73.9199 0.69174 73.7715 1.05502 73.7715 1.43391V5.25421L36.1895 5.2503V1.4339C36.1895 1.05499 36.0411 0.687814 35.7715 0.418303C35.502 0.148773 35.1344 0 34.7556 0ZM24.6186 8.1094H33.3295V11.9375C33.3334 12.7266 33.9701 13.3594 34.7553 13.3633C35.1342 13.3633 35.4975 13.2149 35.767 12.9492C36.0365 12.6797 36.1888 12.3164 36.1888 11.9375V8.10944H73.7748V11.9375H73.7709C73.7748 12.7227 74.4115 13.3594 75.1967 13.3633C75.5756 13.3633 75.9389 13.2149 76.2084 12.9493C76.478 12.6798 76.6264 12.3165 76.6303 11.9376V8.10948H85.3295C87.1342 8.10948 88.5678 9.53918 88.5678 11.3478V19.965H21.3798V19.3634V19.2892V11.3478C21.3798 9.5431 22.8139 8.1133 24.6186 8.1094ZM21.0874 22.8204H88.1774C85.0797 35.2464 79.2985 46.8514 71.2084 56.8004C71.1966 56.8082 71.1888 56.8199 71.1771 56.8317L70.7865 57.3473C69.2631 59.3356 66.9076 60.5035 64.4037 60.5035H19.9627C19.9588 60.4996 19.951 60.4996 19.9471 60.4996C19.9432 60.4996 19.9353 60.4996 19.9314 60.5035H3.70844C2.88813 60.5035 2.51314 59.7379 3.00922 59.0894L3.37641 58.605L3.38813 58.5855C11.8881 48.1285 17.9354 35.9104 21.0874 22.8204ZM88.5644 31.5274V65.2344C88.5644 67.6016 86.6816 69.4961 84.3144 69.4961H25.6304C23.2632 69.4961 21.3804 67.6016 21.3804 65.2344V63.3594H64.4074C67.798 63.3594 70.9933 61.7774 73.0558 59.086L73.423 58.6016C80.0127 50.5 85.1264 41.3354 88.5644 31.5274Z" fill="black"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.1 KiB |
3
calendar/icons/calendarlight.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg width="88" height="69" viewBox="0 0 88 69" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M33.4525 0C33.0878 0 32.7382 0.145299 32.4788 0.40238C32.2231 0.659453 32.0765 1.00594 32.0802 1.36732V5.00732H23.6959C20.4737 5.00732 17.8306 7.6265 17.8306 10.8232V20.197C14.9543 32.6448 9.23941 44.2685 1.11083 54.1754C1.10331 54.1829 1.09579 54.1941 1.09203 54.2052L0.708533 54.697C-1.05103 56.9697 0.678454 60.4309 3.56978 60.4309H17.8303V62.2192C17.8303 65.9487 20.9058 69 24.6694 69H81.1571C84.9207 69 88 65.9487 88 62.2192V20.4656V20.4097V18.4314V18.3978V10.8235C88 7.6268 85.3568 5.01135 82.1309 5.01135H73.7579V1.36762C73.7579 1.00623 73.615 0.656019 73.3555 0.398966C73.0961 0.141894 72.7427 0.00031472 72.378 0.00031472C72.0133 0.00031472 71.6637 0.145613 71.408 0.402695C71.1486 0.659767 71.0057 1.00626 71.0057 1.36763V5.01136L34.8327 5.00763V1.36763C34.8327 1.00623 34.6898 0.656023 34.4304 0.398969C34.171 0.141897 33.8172 0 33.4525 0ZM23.6956 7.73458H32.0799V11.3857C32.0837 12.1383 32.6965 12.7419 33.4522 12.7456C33.817 12.7456 34.1666 12.6041 34.426 12.3507C34.6854 12.0937 34.8321 11.7472 34.8321 11.3858V7.73462H71.0089V11.3858H71.0052C71.0089 12.1347 71.6218 12.742 72.3775 12.7457C72.7422 12.7457 73.0919 12.6041 73.3513 12.3508C73.6107 12.0937 73.7536 11.7472 73.7573 11.3858V7.73466H82.1304C83.8674 7.73466 85.2473 9.09828 85.2473 10.8233V19.0422H20.5783V18.4684V18.3977V10.8233C20.5783 9.10202 21.9585 7.7383 23.6956 7.73458ZM20.2968 21.7656H84.8715C81.8899 33.6173 76.3255 44.6859 68.5387 54.1751C68.5274 54.1825 68.5199 54.1937 68.5086 54.2049L68.1326 54.6967C66.6663 56.5931 64.3991 57.707 61.9891 57.707H19.2143C19.2105 57.7033 19.203 57.7033 19.1992 57.7033C19.1955 57.7033 19.1879 57.7033 19.1842 57.707H3.56941C2.77985 57.707 2.41892 56.9768 2.8964 56.3582L3.24983 55.8963L3.26111 55.8776C11.4424 45.904 17.2629 34.2506 20.2968 21.7656ZM85.244 30.0702V62.2192C85.244 64.477 83.4318 66.284 81.1533 66.284H24.6695C22.391 66.284 20.5788 64.477 20.5788 62.2192V60.4309H61.9927C65.2561 60.4309 68.3316 58.922 70.3168 56.355L70.6702 55.893C77.013 48.1659 81.9349 39.4249 85.244 30.0702Z" fill="#FFE9C8"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.1 KiB |
3
calendar/icons/calendarlightselected.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg width="89" height="70" viewBox="0 0 89 70" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M72.8799 0.25C73.3087 0.25 73.7257 0.416994 74.0332 0.72168C74.3407 1.02634 74.5097 1.44034 74.5098 1.86719V5.26172H82.6328C85.9944 5.26172 88.7518 7.98625 88.752 11.3232V62.7188C88.752 66.5884 85.5587 69.75 81.6592 69.75H25.1709C21.2714 69.7497 18.082 66.5881 18.082 62.7188V61.1807H4.07129C0.977827 61.1803 -0.877206 57.485 1.0127 55.0439L1.01367 55.043L1.38574 54.5645C1.39618 54.5468 1.40769 54.5308 1.41992 54.5166L2.17188 53.5859C9.87157 43.9211 15.3033 32.6784 18.083 20.665V11.3232C18.083 7.98644 20.84 5.25781 24.1982 5.25781H32.332V1.86719C32.3283 1.43709 32.5035 1.02844 32.8037 0.726562L32.8047 0.724609C33.1103 0.421755 33.523 0.250102 33.9541 0.25C34.383 0.25 34.801 0.417052 35.1084 0.72168C35.4158 1.02632 35.5848 1.44043 35.585 1.86719V5.25781L71.2578 5.26074V1.86719C71.2579 1.44034 71.4264 1.03058 71.7324 0.726562L71.8516 0.618164C72.1394 0.381566 72.503 0.250021 72.8799 0.25ZM85.4961 31.9873C82.1901 40.8638 77.4261 49.1646 71.3701 56.5439L71.3711 56.5449L71.0176 57.0068L71.0166 57.0078C68.9839 59.6362 65.8347 61.1807 62.4941 61.1807H21.3311V62.7197C21.3313 64.838 23.0304 66.5342 25.1719 66.5342H81.6553C83.7967 66.5342 85.4958 64.838 85.4961 62.7197V31.9873ZM20.9941 22.5156C17.9401 34.9588 12.1246 46.5722 3.96777 56.5205L3.96582 56.5254L3.95898 56.5371L3.9502 56.5479L3.59668 57.0098V57.0107C3.40752 57.2558 3.40294 57.4916 3.4834 57.6533C3.56401 57.8154 3.75623 57.957 4.07129 57.957H19.6533C19.6787 57.9529 19.7005 57.9531 19.7012 57.9531C19.7016 57.9531 19.7235 57.9529 19.749 57.957H62.4912C64.8238 57.957 67.0177 56.8788 68.4365 55.0439L68.8115 54.5527L68.8223 54.5391L68.835 54.5273L68.8359 54.5264C68.8373 54.5249 68.8394 54.5221 68.8418 54.5195C68.8444 54.5167 68.8488 54.5135 68.8535 54.5088C76.5454 45.133 82.0603 34.211 85.0508 22.5156H20.9941ZM23.9014 8.5C22.4425 8.64669 21.3301 9.84168 21.3301 11.3232V19.292H85.499V11.3232C85.499 9.73882 84.2339 8.48461 82.6328 8.48438H74.5098V11.8887C74.5053 12.3144 74.3363 12.724 74.0293 13.0283L74.0283 13.0293C73.7219 13.3286 73.3091 13.496 72.8799 13.4961H72.8779C71.9873 13.4915 71.2613 12.7747 71.2568 11.8867L71.2559 11.6357H71.2607V8.48438H35.584V11.8857C35.584 12.3152 35.4093 12.7253 35.1035 13.0283L35.1025 13.0293C34.796 13.3287 34.3835 13.4961 33.9541 13.4961H33.9531C33.0628 13.4917 32.3365 12.779 32.332 11.8867V8.48438H24.1982L23.9014 8.5Z" fill="#BD2D2D" stroke="#FFD5A4" stroke-width="0.5"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.4 KiB |
619
chat/chat.js
Normal file
@@ -0,0 +1,619 @@
|
|||||||
|
import server from "/@server/server.js"
|
||||||
|
|
||||||
|
css(`
|
||||||
|
chat-, chat- * {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
chat- {
|
||||||
|
font-family: 'Arial';
|
||||||
|
scrollbar-width: none;
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
}
|
||||||
|
chat-::-webkit-scrollbar { display: none; }
|
||||||
|
chat- input::placeholder {
|
||||||
|
color: var(--headertext);
|
||||||
|
opacity: 0.35;
|
||||||
|
}
|
||||||
|
`)
|
||||||
|
|
||||||
|
class Chat extends Shadow {
|
||||||
|
selectedChatId = null
|
||||||
|
searchText = ""
|
||||||
|
searchOpen = false
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super()
|
||||||
|
|
||||||
|
this.currentUser = { id: 1, name: "You" }
|
||||||
|
|
||||||
|
const ago = (h) => new Date(Date.now() - h * 3600000)
|
||||||
|
const msg = (senderId, senderName, text, hoursAgo) => ({ id: Math.random(), senderId, senderName, text, sentAt: ago(hoursAgo) })
|
||||||
|
|
||||||
|
this.chats = [
|
||||||
|
{
|
||||||
|
id: 1, type: "dm", name: "Sarah McIntyre",
|
||||||
|
members: ["Sarah McIntyre"], memberCount: 2, unread: 2,
|
||||||
|
lastMessage: { senderId: 2, senderName: "Sarah McIntyre", text: "Can you review the PR when you get a chance?", sentAt: ago(0.3) },
|
||||||
|
messages: [
|
||||||
|
msg(1, "You", "Hey Sarah, did you see the new design mockups?", 24),
|
||||||
|
msg(2, "Sarah McIntyre", "Just looked — they're really clean. I love the new sidebar.", 23.5),
|
||||||
|
msg(1, "You", "Agreed. Alex did a great job.", 23.4),
|
||||||
|
msg(2, "Sarah McIntyre", "Are we going to ship this week or wait for the backend?", 5),
|
||||||
|
msg(1, "You", "Let's aim for Thursday. I'll sync with Marcus.", 4.8),
|
||||||
|
msg(2, "Sarah McIntyre", "Sounds good 👍", 4.7),
|
||||||
|
msg(2, "Sarah McIntyre", "Can you review the PR when you get a chance?", 0.3),
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2, type: "dm", name: "Marcus Webb",
|
||||||
|
members: ["Marcus Webb"], memberCount: 2, unread: 0,
|
||||||
|
lastMessage: { senderId: 1, senderName: "You", text: "I'll send over the specs by EOD", sentAt: ago(1.5) },
|
||||||
|
messages: [
|
||||||
|
msg(3, "Marcus Webb", "Hey, the API endpoint is returning 500s on staging.", 3),
|
||||||
|
msg(1, "You", "Oh no — is it the auth middleware again?", 2.9),
|
||||||
|
msg(3, "Marcus Webb", "Yep. Same issue as last week.", 2.8),
|
||||||
|
msg(1, "You", "I'll patch it now. Give me 20 mins.", 2.75),
|
||||||
|
msg(3, "Marcus Webb", "Thanks, no rush.", 2.7),
|
||||||
|
msg(1, "You", "Fixed. Can you redeploy and check?", 2.2),
|
||||||
|
msg(3, "Marcus Webb", "All green 🎉 Thanks!", 2.1),
|
||||||
|
msg(1, "You", "I'll send over the specs by EOD", 1.5),
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3, type: "dm", name: "Priya Anand",
|
||||||
|
members: ["Priya Anand"], memberCount: 2, unread: 0,
|
||||||
|
lastMessage: { senderId: 4, senderName: "Priya Anand", text: "See you at the standup!", sentAt: ago(18) },
|
||||||
|
messages: [
|
||||||
|
msg(4, "Priya Anand", "Quick question — what's the launch date for v2?", 20),
|
||||||
|
msg(1, "You", "Still TBD, but we're targeting end of May.", 19.8),
|
||||||
|
msg(4, "Priya Anand", "Got it. I'll update the roadmap doc.", 19.5),
|
||||||
|
msg(1, "You", "Perfect, thanks Priya.", 19.4),
|
||||||
|
msg(4, "Priya Anand", "See you at the standup!", 18),
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4, type: "group", name: "Product Team",
|
||||||
|
members: ["Sarah McIntyre", "Marcus Webb", "Priya Anand", "You"], memberCount: 4, unread: 5,
|
||||||
|
lastMessage: { senderId: 2, senderName: "Sarah McIntyre", text: "I've updated the Figma file with the new flows", sentAt: ago(0.15) },
|
||||||
|
messages: [
|
||||||
|
msg(3, "Marcus Webb", "Morning everyone! API docs are updated.", 8),
|
||||||
|
msg(4, "Priya Anand", "Nice work Marcus 🙌", 7.9),
|
||||||
|
msg(1, "You", "I'll start on the integration tests today.", 7.8),
|
||||||
|
msg(2, "Sarah McIntyre", "Great. I'm finishing up the onboarding screens.", 7.5),
|
||||||
|
msg(4, "Priya Anand", "Can we do a quick sync at 2pm?", 2),
|
||||||
|
msg(1, "You", "Works for me.", 1.95),
|
||||||
|
msg(3, "Marcus Webb", "Same", 1.9),
|
||||||
|
msg(2, "Sarah McIntyre", "I'll send the invite.", 1.85),
|
||||||
|
msg(2, "Sarah McIntyre", "I've updated the Figma file with the new flows", 0.15),
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 5, type: "group", name: "Design Review",
|
||||||
|
members: ["Sarah McIntyre", "You", "Jordan Kim"], memberCount: 3, unread: 0,
|
||||||
|
lastMessage: { senderId: 5, senderName: "Jordan Kim", text: "The contrast on mobile looks off — can we bump it?", sentAt: ago(26) },
|
||||||
|
messages: [
|
||||||
|
msg(5, "Jordan Kim", "Hey, sharing the first round of designs for the settings page.", 30),
|
||||||
|
msg(2, "Sarah McIntyre", "These look great! Love the card layout.", 29.5),
|
||||||
|
msg(1, "You", "Agreed. One thought — the spacing on the form feels a bit tight.", 29),
|
||||||
|
msg(5, "Jordan Kim", "Good call. I'll loosen it up.", 28.8),
|
||||||
|
msg(2, "Sarah McIntyre", "Also maybe we increase the font size slightly?", 28),
|
||||||
|
msg(5, "Jordan Kim", "The contrast on mobile looks off — can we bump it?", 26),
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 6, type: "channel", name: "general",
|
||||||
|
members: ["Sarah McIntyre", "Marcus Webb", "Priya Anand", "Jordan Kim", "You"], memberCount: 24, unread: 11,
|
||||||
|
lastMessage: { senderId: 3, senderName: "Marcus Webb", text: "Just pushed the hotfix to production", sentAt: ago(0.08) },
|
||||||
|
messages: [
|
||||||
|
msg(4, "Priya Anand", "Good morning team! Reminder: all-hands is Thursday at 10am.", 9),
|
||||||
|
msg(2, "Sarah McIntyre", "Thanks for the reminder!", 8.9),
|
||||||
|
msg(5, "Jordan Kim", "Will there be a recording for those in other time zones?", 8.7),
|
||||||
|
msg(4, "Priya Anand", "Yes — I'll post the link in #announcements after.", 8.6),
|
||||||
|
msg(1, "You", "Thanks Priya 🙏", 8.5),
|
||||||
|
msg(3, "Marcus Webb", "Staging is back up btw, had a brief outage this morning.", 4),
|
||||||
|
msg(2, "Sarah McIntyre", "Oh I didn't even notice, nice quick fix!", 3.8),
|
||||||
|
msg(3, "Marcus Webb", "Just pushed the hotfix to production", 0.08),
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 7, type: "channel", name: "engineering",
|
||||||
|
members: ["Marcus Webb", "You"], memberCount: 8, unread: 0,
|
||||||
|
lastMessage: { senderId: 1, senderName: "You", text: "PR is up: #247 — adds rate limiting to the auth routes", sentAt: ago(3) },
|
||||||
|
messages: [
|
||||||
|
msg(3, "Marcus Webb", "Heads up: I'm updating the CI pipeline today. Builds might be slow for a bit.", 5),
|
||||||
|
msg(1, "You", "Noted, thanks for the warning.", 4.9),
|
||||||
|
msg(3, "Marcus Webb", "Back to normal now.", 4),
|
||||||
|
msg(1, "You", "PR is up: #247 — adds rate limiting to the auth routes", 3),
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 8, type: "announcement", name: "Announcements",
|
||||||
|
members: [], memberCount: 24, unread: 1,
|
||||||
|
lastMessage: { senderId: 4, senderName: "Priya Anand", text: "Q2 planning kick-off is next Monday at 9am.", sentAt: ago(12) },
|
||||||
|
messages: [
|
||||||
|
msg(4, "Priya Anand", "Welcome to the team, Jordan! 🎉 Jordan joins us as a Product Designer.", 72),
|
||||||
|
msg(4, "Priya Anand", "Reminder: expense reports for March are due this Friday.", 48),
|
||||||
|
msg(4, "Priya Anand", "Q2 planning kick-off is next Monday at 9am. Please come prepared with your team's priorities.", 12),
|
||||||
|
]
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
get selectedChat() {
|
||||||
|
return this.chats.find(c => c.id === this.selectedChatId) || null
|
||||||
|
}
|
||||||
|
|
||||||
|
get filteredChats() {
|
||||||
|
if (!this.searchText) return this.chats
|
||||||
|
const q = this.searchText.toLowerCase()
|
||||||
|
return this.chats.filter(c => c.name.toLowerCase().includes(q))
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
VStack(() => {
|
||||||
|
if (this.selectedChatId === null) {
|
||||||
|
this.renderList()
|
||||||
|
} else {
|
||||||
|
this.renderThread()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.height(100, pct)
|
||||||
|
.width(100, pct)
|
||||||
|
.overflow("hidden")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── List view ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
renderList() {
|
||||||
|
VStack(() => {
|
||||||
|
this.renderListHeader()
|
||||||
|
this.renderChatList()
|
||||||
|
})
|
||||||
|
.height(100, pct)
|
||||||
|
.width(100, vw)
|
||||||
|
.overflow("hidden")
|
||||||
|
}
|
||||||
|
|
||||||
|
renderListHeader() {
|
||||||
|
VStack(() => {
|
||||||
|
|
||||||
|
// Search bar
|
||||||
|
if (this.searchOpen) {
|
||||||
|
HStack(() => {
|
||||||
|
p("🔍").margin(0).fontSize(0.78, em).opacity(0.4).flexShrink(0)
|
||||||
|
input()
|
||||||
|
.attr({ type: "text", placeholder: "Search…", autofocus: "true" })
|
||||||
|
.flex(1).border("none").outline("none")
|
||||||
|
.background("transparent")
|
||||||
|
.color("var(--headertext)").fontSize(0.9, em)
|
||||||
|
.onInput((e) => { this.searchText = e.target.value; this.rerender() })
|
||||||
|
})
|
||||||
|
.gap(0.5, em)
|
||||||
|
.paddingHorizontal(0.85, em).paddingVertical(0.55, em)
|
||||||
|
.background("var(--darkaccent)")
|
||||||
|
.border("1px solid var(--divider)")
|
||||||
|
.borderRadius(0.6, em)
|
||||||
|
.alignItems("center")
|
||||||
|
.marginHorizontal(1.1, em)
|
||||||
|
.marginBottom(0.5, em)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.flexShrink(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
renderChatList() {
|
||||||
|
const chats = this.filteredChats
|
||||||
|
const groups = {
|
||||||
|
dms: chats.filter(c => c.type === "dm"),
|
||||||
|
groups: chats.filter(c => c.type === "group"),
|
||||||
|
channels: chats.filter(c => c.type === "channel" || c.type === "announcement"),
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(() => {
|
||||||
|
if (chats.length === 0) {
|
||||||
|
VStack(() => {
|
||||||
|
p("No results").margin(0).fontSize(0.88, em)
|
||||||
|
.color("var(--headertext)").opacity(0.35).textAlign("center")
|
||||||
|
}).flex(1).justifyContent("center").alignItems("center")
|
||||||
|
} else {
|
||||||
|
if (groups.dms.length > 0) {
|
||||||
|
this.sectionLabel("DIRECT MESSAGES")
|
||||||
|
groups.dms.forEach(c => this.renderRow(c))
|
||||||
|
}
|
||||||
|
if (groups.groups.length > 0) {
|
||||||
|
this.sectionLabel("GROUPS")
|
||||||
|
groups.groups.forEach(c => this.renderRow(c))
|
||||||
|
}
|
||||||
|
if (groups.channels.length > 0) {
|
||||||
|
this.sectionLabel("CHANNELS")
|
||||||
|
groups.channels.forEach(c => this.renderRow(c))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.flex(1).overflowY("auto")
|
||||||
|
.paddingBottom(2, em)
|
||||||
|
}
|
||||||
|
|
||||||
|
sectionLabel(text) {
|
||||||
|
p(text)
|
||||||
|
.margin(0).marginTop(0.85, em).marginBottom(0.2, em)
|
||||||
|
.paddingHorizontal(1.1, em)
|
||||||
|
.fontSize(0.62, em).fontWeight("700").letterSpacing("0.07em")
|
||||||
|
.color("var(--headertext)").opacity(0.35)
|
||||||
|
}
|
||||||
|
|
||||||
|
renderRow(chat) {
|
||||||
|
const hasUnread = chat.unread > 0
|
||||||
|
HStack(() => {
|
||||||
|
this.renderIcon(chat)
|
||||||
|
|
||||||
|
VStack(() => {
|
||||||
|
HStack(() => {
|
||||||
|
p(chat.name)
|
||||||
|
.margin(0).fontSize(0.95, em)
|
||||||
|
.fontWeight(hasUnread ? "700" : "500")
|
||||||
|
.color("var(--headertext)")
|
||||||
|
.flex(1).minWidth(0)
|
||||||
|
.overflow("hidden").whiteSpace("nowrap").textOverflow("ellipsis")
|
||||||
|
|
||||||
|
p(this.formatTime(chat.lastMessage?.sentAt))
|
||||||
|
.margin(0).fontSize(0.7, em)
|
||||||
|
.color("var(--headertext)")
|
||||||
|
.opacity(hasUnread ? 0.7 : 0.35)
|
||||||
|
.fontWeight(hasUnread ? "600" : "400")
|
||||||
|
.flexShrink(0)
|
||||||
|
})
|
||||||
|
.alignItems("center").width(100, pct)
|
||||||
|
|
||||||
|
HStack(() => {
|
||||||
|
p(this.previewText(chat))
|
||||||
|
.margin(0).fontSize(0.82, em)
|
||||||
|
.color("var(--headertext)")
|
||||||
|
.opacity(hasUnread ? 0.65 : 0.38)
|
||||||
|
.fontWeight(hasUnread ? "500" : "400")
|
||||||
|
.flex(1).minWidth(0)
|
||||||
|
.overflow("hidden").whiteSpace("nowrap").textOverflow("ellipsis")
|
||||||
|
|
||||||
|
if (hasUnread) {
|
||||||
|
p(chat.unread > 99 ? "99+" : String(chat.unread))
|
||||||
|
.margin(0)
|
||||||
|
.paddingHorizontal(0.45, em).paddingVertical(0.1, em)
|
||||||
|
.background("var(--quillred)").color("white")
|
||||||
|
.fontSize(0.65, em).fontWeight("700")
|
||||||
|
.borderRadius(100, px)
|
||||||
|
.minWidth(1.2, em).textAlign("center")
|
||||||
|
.flexShrink(0)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.alignItems("center").width(100, pct).marginTop(0.2, em)
|
||||||
|
})
|
||||||
|
.flex(1).minWidth(0)
|
||||||
|
})
|
||||||
|
.gap(0.75, em)
|
||||||
|
.paddingHorizontal(1.1, em).paddingVertical(0.8, em)
|
||||||
|
.alignItems("center")
|
||||||
|
.borderBottom("1px solid var(--divider)")
|
||||||
|
.onTouch((start) => {
|
||||||
|
if (!start) {
|
||||||
|
this.selectedChatId = chat.id
|
||||||
|
chat.unread = 0
|
||||||
|
this.rerender()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Thread view ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
renderThread() {
|
||||||
|
const chat = this.selectedChat
|
||||||
|
if (!chat) return
|
||||||
|
|
||||||
|
VStack(() => {
|
||||||
|
this.renderThreadHeader(chat)
|
||||||
|
this.renderMessages(chat)
|
||||||
|
this.renderComposer(chat)
|
||||||
|
})
|
||||||
|
.height(100, pct)
|
||||||
|
.width(100, pct)
|
||||||
|
.overflow("hidden")
|
||||||
|
}
|
||||||
|
|
||||||
|
renderThreadHeader(chat) {
|
||||||
|
HStack(() => {
|
||||||
|
// Back button
|
||||||
|
p("‹")
|
||||||
|
.margin(0).fontSize(1.8, em).lineHeight("1")
|
||||||
|
.color("var(--headertext)").paddingRight(0.5, em)
|
||||||
|
.cursor("pointer")
|
||||||
|
.onTouch((start) => {
|
||||||
|
if (!start) {
|
||||||
|
this.selectedChatId = null
|
||||||
|
this.rerender()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
this.renderHeaderIcon(chat)
|
||||||
|
|
||||||
|
VStack(() => {
|
||||||
|
p(chat.type === "channel" ? "#" + chat.name : chat.name)
|
||||||
|
.margin(0).fontSize(0.95, em).fontWeight("700")
|
||||||
|
.color("var(--headertext)")
|
||||||
|
.overflow("hidden").whiteSpace("nowrap").textOverflow("ellipsis")
|
||||||
|
|
||||||
|
p(this.headerSubtitle(chat))
|
||||||
|
.margin(0).marginTop(0.06, em)
|
||||||
|
.fontSize(0.7, em).color("var(--headertext)").opacity(0.4)
|
||||||
|
})
|
||||||
|
.flex(1).minWidth(0)
|
||||||
|
})
|
||||||
|
.gap(0.6, em)
|
||||||
|
.paddingHorizontal(1.1, em).paddingVertical(0.85, em)
|
||||||
|
.borderBottom("1px solid var(--divider)")
|
||||||
|
.alignItems("center")
|
||||||
|
.flexShrink(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
renderMessages(chat) {
|
||||||
|
const messages = chat.messages || []
|
||||||
|
const grouped = this.groupByDate(messages)
|
||||||
|
|
||||||
|
VStack(() => {
|
||||||
|
if (messages.length === 0) {
|
||||||
|
VStack(() => {
|
||||||
|
p("No messages yet. Say hello!")
|
||||||
|
.margin(0).fontSize(0.88, em)
|
||||||
|
.color("var(--headertext)").opacity(0.3)
|
||||||
|
}).flex(1).justifyContent("center").alignItems("center")
|
||||||
|
} else {
|
||||||
|
grouped.forEach(({ label, messages: dayMsgs }) => {
|
||||||
|
// Date separator
|
||||||
|
HStack(() => {
|
||||||
|
VStack(() => {}).flex(1).height(1, px).background("var(--divider)")
|
||||||
|
p(label)
|
||||||
|
.margin(0).marginHorizontal(0.75, em)
|
||||||
|
.fontSize(0.65, em).fontWeight("600")
|
||||||
|
.color("var(--headertext)").opacity(0.38).whiteSpace("nowrap")
|
||||||
|
VStack(() => {}).flex(1).height(1, px).background("var(--divider)")
|
||||||
|
})
|
||||||
|
.alignItems("center")
|
||||||
|
.paddingHorizontal(1.1, em).paddingVertical(0.75, em)
|
||||||
|
.flexShrink(0)
|
||||||
|
|
||||||
|
const runs = this.groupIntoRuns(dayMsgs)
|
||||||
|
runs.forEach(run => this.renderRun(run, chat))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.flex(1).overflowY("auto")
|
||||||
|
.paddingBottom(0.5, em)
|
||||||
|
.width(100, pct).boxSizing("border-box")
|
||||||
|
.onAppear(function () { this.scrollTop = this.scrollHeight })
|
||||||
|
}
|
||||||
|
|
||||||
|
renderRun(run, chat) {
|
||||||
|
const isMe = run.senderId === this.currentUser.id
|
||||||
|
const showSenderName = !isMe && chat.type !== "dm"
|
||||||
|
|
||||||
|
VStack(() => {
|
||||||
|
if (showSenderName) {
|
||||||
|
HStack(() => {
|
||||||
|
p(run.senderName)
|
||||||
|
.margin(0).fontSize(0.75, em).fontWeight("600")
|
||||||
|
.color(this.avatarColor(run.senderName))
|
||||||
|
p(this.formatMsgTime(run.messages[0].sentAt))
|
||||||
|
.margin(0).fontSize(0.68, em)
|
||||||
|
.color("var(--headertext)").opacity(0.32)
|
||||||
|
})
|
||||||
|
.gap(0.5, em).alignItems("baseline").marginBottom(0.2, em)
|
||||||
|
}
|
||||||
|
|
||||||
|
run.messages.forEach((msg, i) => {
|
||||||
|
this.renderBubble(msg, isMe, i === run.messages.length - 1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.paddingHorizontal(1.1, em)
|
||||||
|
.paddingTop(0.55, em).paddingBottom(0.08, em)
|
||||||
|
.alignItems(isMe ? "flex-end" : "flex-start")
|
||||||
|
.width(100, pct).boxSizing("border-box")
|
||||||
|
.flexShrink(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
renderBubble(msg, isMe, showTime) {
|
||||||
|
VStack(() => {
|
||||||
|
VStack(() => {
|
||||||
|
p(msg.text)
|
||||||
|
.margin(0).fontSize(0.92, em).lineHeight("1.5")
|
||||||
|
.color(isMe ? "white" : "var(--headertext)")
|
||||||
|
.whiteSpace("pre-wrap").wordBreak("break-word")
|
||||||
|
})
|
||||||
|
.paddingHorizontal(0.9, em).paddingVertical(0.6, em)
|
||||||
|
.background(isMe ? "var(--quillred)" : "var(--darkaccent)")
|
||||||
|
.borderRadius(isMe ? "1em 1em 0.25em 1em" : "1em 1em 1em 0.25em")
|
||||||
|
.maxWidth("75vw").boxSizing("border-box")
|
||||||
|
|
||||||
|
if (showTime) {
|
||||||
|
p(this.formatMsgTime(msg.sentAt))
|
||||||
|
.margin(0).marginTop(0.22, em)
|
||||||
|
.fontSize(0.62, em).color("var(--headertext)").opacity(0.32)
|
||||||
|
.alignSelf(isMe ? "flex-end" : "flex-start")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.alignItems(isMe ? "flex-end" : "flex-start")
|
||||||
|
.marginBottom(0.2, em)
|
||||||
|
}
|
||||||
|
|
||||||
|
renderComposer(chat) {
|
||||||
|
HStack(() => {
|
||||||
|
HStack(() => {
|
||||||
|
input()
|
||||||
|
.attr({ type: "text", placeholder: `Message…`, id: `composer-${chat.id}` })
|
||||||
|
.flex(1).border("none").outline("none")
|
||||||
|
.background("transparent")
|
||||||
|
.color("var(--headertext)").fontSize(0.95, em)
|
||||||
|
})
|
||||||
|
.flex(1)
|
||||||
|
.paddingHorizontal(1, em).paddingVertical(0.7, em)
|
||||||
|
.background("var(--darkaccent)")
|
||||||
|
.border("1px solid var(--divider)")
|
||||||
|
.borderRadius(1.5, em)
|
||||||
|
.alignItems("center")
|
||||||
|
|
||||||
|
button("↑")
|
||||||
|
.paddingHorizontal(0.85, em).paddingVertical(0.7, em)
|
||||||
|
.background("var(--quillred)").color("white")
|
||||||
|
.border("none").borderRadius(50, pct)
|
||||||
|
.fontSize(1.05, em).fontWeight("700")
|
||||||
|
.cursor("pointer").flexShrink(0)
|
||||||
|
.onClick((done) => { if (done) this.sendMessage(chat) })
|
||||||
|
})
|
||||||
|
.gap(0.55, em)
|
||||||
|
.paddingHorizontal(1.1, em).paddingVertical(0.75, em)
|
||||||
|
.borderTop("1px solid var(--divider)")
|
||||||
|
.alignItems("center")
|
||||||
|
.flexShrink(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
sendMessage(chat) {
|
||||||
|
const input = this.$(`#composer-${chat.id}`)
|
||||||
|
if (!input) return
|
||||||
|
const text = input.value.trim()
|
||||||
|
if (!text) return
|
||||||
|
|
||||||
|
chat.messages.push({
|
||||||
|
id: Date.now(),
|
||||||
|
senderId: this.currentUser.id,
|
||||||
|
senderName: this.currentUser.name,
|
||||||
|
text,
|
||||||
|
sentAt: new Date()
|
||||||
|
})
|
||||||
|
chat.lastMessage = { senderId: this.currentUser.id, senderName: this.currentUser.name, text, sentAt: new Date() }
|
||||||
|
chat.unread = 0
|
||||||
|
input.value = ""
|
||||||
|
this.rerender()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Shared icon rendering ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
renderIcon(chat, size = 2.5) {
|
||||||
|
if (chat.type === "channel") {
|
||||||
|
VStack(() => {
|
||||||
|
p("#").margin(0).fontSize(1.05, em).fontWeight("700").color("white").lineHeight("1")
|
||||||
|
})
|
||||||
|
.width(size, em).height(size, em).borderRadius(0.45, em)
|
||||||
|
.background("#5865f2").justifyContent("center").alignItems("center").flexShrink(0)
|
||||||
|
} else if (chat.type === "announcement") {
|
||||||
|
VStack(() => {
|
||||||
|
p("📣").margin(0).fontSize(0.9, em).lineHeight("1")
|
||||||
|
})
|
||||||
|
.width(size, em).height(size, em).borderRadius(0.45, em)
|
||||||
|
.background("#f59e0b").justifyContent("center").alignItems("center").flexShrink(0)
|
||||||
|
} else if (chat.type === "group") {
|
||||||
|
const s = size * 0.76
|
||||||
|
ZStack(() => {
|
||||||
|
VStack(() => {
|
||||||
|
p((chat.members[1] || "B")[0].toUpperCase())
|
||||||
|
.margin(0).fontSize(0.6, em).fontWeight("700").color("white")
|
||||||
|
})
|
||||||
|
.width(s, em).height(s, em).borderRadius(50, pct)
|
||||||
|
.background(this.avatarColor(chat.members[1] || "B"))
|
||||||
|
.justifyContent("center").alignItems("center")
|
||||||
|
.position("absolute").bottom(0).right(0)
|
||||||
|
|
||||||
|
VStack(() => {
|
||||||
|
p((chat.members[0] || "A")[0].toUpperCase())
|
||||||
|
.margin(0).fontSize(0.6, em).fontWeight("700").color("white")
|
||||||
|
})
|
||||||
|
.width(s, em).height(s, em).borderRadius(50, pct)
|
||||||
|
.background(this.avatarColor(chat.members[0] || "A"))
|
||||||
|
.justifyContent("center").alignItems("center")
|
||||||
|
.position("absolute").top(0).left(0)
|
||||||
|
})
|
||||||
|
.width(size, em).height(size, em).position("relative").flexShrink(0)
|
||||||
|
} else {
|
||||||
|
VStack(() => {
|
||||||
|
p(chat.name[0].toUpperCase())
|
||||||
|
.margin(0).fontSize(0.9, em).fontWeight("700").color("white").lineHeight("1")
|
||||||
|
})
|
||||||
|
.width(size, em).height(size, em).borderRadius(50, pct)
|
||||||
|
.background(this.avatarColor(chat.name))
|
||||||
|
.justifyContent("center").alignItems("center").flexShrink(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderHeaderIcon(chat) {
|
||||||
|
this.renderIcon(chat, 2.2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
previewText(chat) {
|
||||||
|
const msg = chat.lastMessage
|
||||||
|
if (!msg) return "No messages yet"
|
||||||
|
const sender = chat.type === "dm" ? "" : (msg.senderName?.split(" ")[0] + ": ")
|
||||||
|
return sender + msg.text
|
||||||
|
}
|
||||||
|
|
||||||
|
headerSubtitle(chat) {
|
||||||
|
if (chat.type === "channel" || chat.type === "announcement") return `${chat.memberCount} members`
|
||||||
|
if (chat.type === "group") return chat.members?.join(", ") || ""
|
||||||
|
return "Active recently"
|
||||||
|
}
|
||||||
|
|
||||||
|
formatTime(date) {
|
||||||
|
if (!date) return ""
|
||||||
|
const d = new Date(date), now = new Date()
|
||||||
|
const diffDays = Math.floor((now - d) / 86400000)
|
||||||
|
if (diffDays === 0) return d.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })
|
||||||
|
if (diffDays === 1) return "Yesterday"
|
||||||
|
if (diffDays < 7) return d.toLocaleDateString([], { weekday: "short" })
|
||||||
|
return d.toLocaleDateString([], { month: "short", day: "numeric" })
|
||||||
|
}
|
||||||
|
|
||||||
|
formatMsgTime(date) {
|
||||||
|
return new Date(date).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })
|
||||||
|
}
|
||||||
|
|
||||||
|
groupByDate(messages) {
|
||||||
|
const map = new Map()
|
||||||
|
messages.forEach(msg => {
|
||||||
|
const key = new Date(msg.sentAt).toDateString()
|
||||||
|
if (!map.has(key)) map.set(key, [])
|
||||||
|
map.get(key).push(msg)
|
||||||
|
})
|
||||||
|
return Array.from(map.entries()).map(([key, messages]) => ({
|
||||||
|
label: this.dateSeparatorLabel(new Date(key)),
|
||||||
|
messages
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
groupIntoRuns(messages) {
|
||||||
|
const runs = []
|
||||||
|
messages.forEach(msg => {
|
||||||
|
const last = runs[runs.length - 1]
|
||||||
|
if (last && last.senderId === msg.senderId) {
|
||||||
|
last.messages.push(msg)
|
||||||
|
} else {
|
||||||
|
runs.push({ senderId: msg.senderId, senderName: msg.senderName, messages: [msg] })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return runs
|
||||||
|
}
|
||||||
|
|
||||||
|
dateSeparatorLabel(date) {
|
||||||
|
const now = new Date()
|
||||||
|
const diffDays = Math.floor((now - date) / 86400000)
|
||||||
|
if (diffDays === 0) return "Today"
|
||||||
|
if (diffDays === 1) return "Yesterday"
|
||||||
|
if (diffDays < 7) return date.toLocaleDateString([], { weekday: "long" })
|
||||||
|
return date.toLocaleDateString([], { month: "long", day: "numeric", year: "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(Chat)
|
||||||
317
chat/desktop/DesktopChatSidebar.js
Normal file
@@ -0,0 +1,317 @@
|
|||||||
|
class DesktopChatSidebar extends Shadow {
|
||||||
|
constructor(chats, selectedId, onSelect) {
|
||||||
|
super()
|
||||||
|
this.chats = chats
|
||||||
|
this.selectedId = selectedId
|
||||||
|
this.onSelect = onSelect
|
||||||
|
this.searchText = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
get filtered() {
|
||||||
|
const q = this.searchText.toLowerCase();
|
||||||
|
if (!q) return this.chats;
|
||||||
|
return this.chats.filter(c => c.name.toLowerCase().includes(q));
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
VStack(() => {
|
||||||
|
// ── Header ────────────────────────────────────────────────
|
||||||
|
VStack(() => {
|
||||||
|
|
||||||
|
// Search
|
||||||
|
HStack(() => {
|
||||||
|
p("🔍")
|
||||||
|
.margin(0)
|
||||||
|
.fontSize(0.78, em)
|
||||||
|
.opacity(0.4)
|
||||||
|
.flexShrink(0)
|
||||||
|
input()
|
||||||
|
.attr({ type: "text", placeholder: "Search…" })
|
||||||
|
.flex(1)
|
||||||
|
.border("none")
|
||||||
|
.outline("none")
|
||||||
|
.background("transparent")
|
||||||
|
.color("var(--headertext)")
|
||||||
|
.fontSize(0.85, em)
|
||||||
|
.onInput((e) => {
|
||||||
|
this.searchText = e.target.value;
|
||||||
|
this.rerender();
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.gap(0.5, em)
|
||||||
|
.paddingHorizontal(0.75, em)
|
||||||
|
.paddingVertical(0.52, em)
|
||||||
|
.background("var(--darkaccent)")
|
||||||
|
.border("1px solid var(--divider)")
|
||||||
|
.borderRadius(0.5, em)
|
||||||
|
.alignItems("center")
|
||||||
|
.marginTop(0.75, em)
|
||||||
|
})
|
||||||
|
.paddingHorizontal(1, em)
|
||||||
|
.paddingTop(1.1, em)
|
||||||
|
.paddingBottom(0.75, em)
|
||||||
|
.flexShrink(0)
|
||||||
|
|
||||||
|
// ── Chat list ─────────────────────────────────────────────
|
||||||
|
VStack(() => {
|
||||||
|
const groups = this.groupChats(this.filtered);
|
||||||
|
|
||||||
|
if (groups.dms.length > 0) {
|
||||||
|
this.sectionLabel("DIRECT MESSAGES")
|
||||||
|
groups.dms.forEach(c => this.renderRow(c))
|
||||||
|
}
|
||||||
|
if (groups.groups.length > 0) {
|
||||||
|
this.sectionLabel("GROUPS")
|
||||||
|
groups.groups.forEach(c => this.renderRow(c))
|
||||||
|
}
|
||||||
|
if (groups.channels.length > 0) {
|
||||||
|
this.sectionLabel("CHANNELS")
|
||||||
|
groups.channels.forEach(c => this.renderRow(c))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.filtered.length === 0) {
|
||||||
|
p("No results")
|
||||||
|
.margin(0)
|
||||||
|
.marginTop(2, em)
|
||||||
|
.fontSize(0.85, em)
|
||||||
|
.color("var(--headertext)")
|
||||||
|
.opacity(0.35)
|
||||||
|
.textAlign("center")
|
||||||
|
.width(100, pct)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.gap(0)
|
||||||
|
.flex(1)
|
||||||
|
.overflowY("auto")
|
||||||
|
.paddingBottom(1, em)
|
||||||
|
})
|
||||||
|
.height(100, pct)
|
||||||
|
.width(100, pct)
|
||||||
|
.boxSizing("border-box")
|
||||||
|
}
|
||||||
|
|
||||||
|
groupChats(chats) {
|
||||||
|
return {
|
||||||
|
dms: chats.filter(c => c.type === "dm"),
|
||||||
|
groups: chats.filter(c => c.type === "group"),
|
||||||
|
channels: chats.filter(c => c.type === "channel" || c.type === "announcement"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
sectionLabel(text) {
|
||||||
|
p(text)
|
||||||
|
.margin(0)
|
||||||
|
.marginTop(0.85, em)
|
||||||
|
.marginBottom(0.2, em)
|
||||||
|
.paddingHorizontal(1.1, em)
|
||||||
|
.fontSize(0.62, em)
|
||||||
|
.fontWeight("700")
|
||||||
|
.letterSpacing("0.07em")
|
||||||
|
.color("var(--headertext)")
|
||||||
|
.opacity(0.35)
|
||||||
|
.flexShrink(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
renderRow(chat) {
|
||||||
|
const self = this
|
||||||
|
const isSelected = chat.id === this.selectedId;
|
||||||
|
const hasUnread = chat.unread > 0;
|
||||||
|
|
||||||
|
HStack(() => {
|
||||||
|
// Avatar / icon
|
||||||
|
this.renderIcon(chat)
|
||||||
|
|
||||||
|
// Name + preview
|
||||||
|
VStack(() => {
|
||||||
|
HStack(() => {
|
||||||
|
p(chat.name)
|
||||||
|
.margin(0)
|
||||||
|
.fontSize(0.88, em)
|
||||||
|
.fontWeight(hasUnread ? "700" : "500")
|
||||||
|
.color("var(--headertext)")
|
||||||
|
.flex(1)
|
||||||
|
.minWidth(0)
|
||||||
|
.overflow("hidden")
|
||||||
|
.whiteSpace("nowrap")
|
||||||
|
.textOverflow("ellipsis")
|
||||||
|
|
||||||
|
p(this.formatTime(chat.lastMessage?.sentAt))
|
||||||
|
.margin(0)
|
||||||
|
.fontSize(0.68, em)
|
||||||
|
.color("var(--headertext)")
|
||||||
|
.opacity(hasUnread ? 0.7 : 0.35)
|
||||||
|
.flexShrink(0)
|
||||||
|
.fontWeight(hasUnread ? "600" : "400")
|
||||||
|
})
|
||||||
|
.alignItems("center")
|
||||||
|
.width(100, pct)
|
||||||
|
|
||||||
|
HStack(() => {
|
||||||
|
p(this.previewText(chat))
|
||||||
|
.margin(0)
|
||||||
|
.fontSize(0.78, em)
|
||||||
|
.color("var(--headertext)")
|
||||||
|
.opacity(hasUnread ? 0.65 : 0.38)
|
||||||
|
.flex(1)
|
||||||
|
.minWidth(0)
|
||||||
|
.overflow("hidden")
|
||||||
|
.whiteSpace("nowrap")
|
||||||
|
.textOverflow("ellipsis")
|
||||||
|
.fontWeight(hasUnread ? "500" : "400")
|
||||||
|
|
||||||
|
if (hasUnread) {
|
||||||
|
p(chat.unread > 99 ? "99+" : String(chat.unread))
|
||||||
|
.margin(0)
|
||||||
|
.paddingHorizontal(0.45, em)
|
||||||
|
.paddingVertical(0.1, em)
|
||||||
|
.background("var(--quillred)")
|
||||||
|
.color("white")
|
||||||
|
.fontSize(0.65, em)
|
||||||
|
.fontWeight("700")
|
||||||
|
.borderRadius(100, px)
|
||||||
|
.minWidth(1.2, em)
|
||||||
|
.textAlign("center")
|
||||||
|
.flexShrink(0)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.alignItems("center")
|
||||||
|
.width(100, pct)
|
||||||
|
.marginTop(0.18, em)
|
||||||
|
})
|
||||||
|
.flex(1)
|
||||||
|
.minWidth(0)
|
||||||
|
})
|
||||||
|
.gap(0.65, em)
|
||||||
|
.paddingHorizontal(1, em)
|
||||||
|
.paddingVertical(0.6, em)
|
||||||
|
.marginHorizontal(0.4, em)
|
||||||
|
.borderRadius(0.55, em)
|
||||||
|
.background(isSelected ? "var(--app)" : "transparent")
|
||||||
|
.cursor("pointer")
|
||||||
|
.alignItems("center")
|
||||||
|
.width("calc(100% - 0.8em)")
|
||||||
|
.boxSizing("border-box")
|
||||||
|
.onClick(function(done){ if(done){ self.onSelect(chat.id) } })
|
||||||
|
}
|
||||||
|
|
||||||
|
renderIcon(chat) {
|
||||||
|
if (chat.type === "channel") {
|
||||||
|
VStack(() => {
|
||||||
|
p("#")
|
||||||
|
.margin(0)
|
||||||
|
.fontSize(1.05, em)
|
||||||
|
.fontWeight("700")
|
||||||
|
.color("white")
|
||||||
|
.lineHeight("1")
|
||||||
|
})
|
||||||
|
.width(2.35, em)
|
||||||
|
.height(2.35, em)
|
||||||
|
.borderRadius(0.45, em)
|
||||||
|
.background("#5865f2")
|
||||||
|
.justifyContent("center")
|
||||||
|
.alignItems("center")
|
||||||
|
.flexShrink(0)
|
||||||
|
} else if (chat.type === "announcement") {
|
||||||
|
VStack(() => {
|
||||||
|
p("📣")
|
||||||
|
.margin(0)
|
||||||
|
.fontSize(0.9, em)
|
||||||
|
.lineHeight("1")
|
||||||
|
})
|
||||||
|
.width(2.35, em)
|
||||||
|
.height(2.35, em)
|
||||||
|
.borderRadius(0.45, em)
|
||||||
|
.background("#f59e0b")
|
||||||
|
.justifyContent("center")
|
||||||
|
.alignItems("center")
|
||||||
|
.flexShrink(0)
|
||||||
|
} else if (chat.type === "group") {
|
||||||
|
// Stacked initials for group
|
||||||
|
ZStack(() => {
|
||||||
|
// Back circle
|
||||||
|
VStack(() => {
|
||||||
|
p(chat.members[1] ? chat.members[1][0].toUpperCase() : "?")
|
||||||
|
.margin(0)
|
||||||
|
.fontSize(0.6, em)
|
||||||
|
.fontWeight("700")
|
||||||
|
.color("white")
|
||||||
|
})
|
||||||
|
.width(1.8, em)
|
||||||
|
.height(1.8, em)
|
||||||
|
.borderRadius(50, pct)
|
||||||
|
.background(this.avatarColor(chat.members[1] || "B"))
|
||||||
|
.justifyContent("center")
|
||||||
|
.alignItems("center")
|
||||||
|
.position("absolute")
|
||||||
|
.bottom(0).right(0)
|
||||||
|
.boxSizing("border-box")
|
||||||
|
|
||||||
|
// Front circle
|
||||||
|
VStack(() => {
|
||||||
|
p(chat.members[0] ? chat.members[0][0].toUpperCase() : "?")
|
||||||
|
.margin(0)
|
||||||
|
.fontSize(0.6, em)
|
||||||
|
.fontWeight("700")
|
||||||
|
.color("white")
|
||||||
|
})
|
||||||
|
.width(1.8, em)
|
||||||
|
.height(1.8, em)
|
||||||
|
.borderRadius(50, pct)
|
||||||
|
.background(this.avatarColor(chat.members[0] || "A"))
|
||||||
|
.justifyContent("center")
|
||||||
|
.alignItems("center")
|
||||||
|
.position("absolute")
|
||||||
|
.top(0).left(0)
|
||||||
|
.boxSizing("border-box")
|
||||||
|
})
|
||||||
|
.width(2.35, em)
|
||||||
|
.height(2.35, em)
|
||||||
|
.position("relative")
|
||||||
|
.flexShrink(0)
|
||||||
|
} else {
|
||||||
|
// DM — single avatar
|
||||||
|
VStack(() => {
|
||||||
|
p(chat.name[0].toUpperCase())
|
||||||
|
.margin(0)
|
||||||
|
.fontSize(0.88, em)
|
||||||
|
.fontWeight("700")
|
||||||
|
.color("white")
|
||||||
|
.lineHeight("1")
|
||||||
|
})
|
||||||
|
.width(2.35, em)
|
||||||
|
.height(2.35, em)
|
||||||
|
.borderRadius(50, pct)
|
||||||
|
.background(this.avatarColor(chat.name))
|
||||||
|
.justifyContent("center")
|
||||||
|
.alignItems("center")
|
||||||
|
.flexShrink(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
previewText(chat) {
|
||||||
|
const msg = chat.lastMessage;
|
||||||
|
if (!msg) return "No messages yet";
|
||||||
|
const sender = chat.type === "dm" ? "" : (msg.senderName?.split(" ")[0] + ": ");
|
||||||
|
return sender + msg.text;
|
||||||
|
}
|
||||||
|
|
||||||
|
formatTime(date) {
|
||||||
|
if (!date) return "";
|
||||||
|
const d = new Date(date);
|
||||||
|
const now = new Date();
|
||||||
|
const diffDays = Math.floor((now - d) / 86400000);
|
||||||
|
if (diffDays === 0) return d.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
|
||||||
|
if (diffDays === 1) return "Yesterday";
|
||||||
|
if (diffDays < 7) return d.toLocaleDateString([], { weekday: "short" });
|
||||||
|
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(DesktopChatSidebar)
|
||||||
387
chat/desktop/DesktopChatThread.js
Normal file
@@ -0,0 +1,387 @@
|
|||||||
|
class DesktopChatThread extends Shadow {
|
||||||
|
constructor(chat, currentUser) {
|
||||||
|
super()
|
||||||
|
this.chat = chat
|
||||||
|
this.currentUser = currentUser
|
||||||
|
this.draftText = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (!this.chat) {
|
||||||
|
VStack(() => {
|
||||||
|
p("💬")
|
||||||
|
.margin(0)
|
||||||
|
.fontSize(2.8, em)
|
||||||
|
.opacity(0.18)
|
||||||
|
p("Select a conversation")
|
||||||
|
.margin(0)
|
||||||
|
.marginTop(0.75, em)
|
||||||
|
.fontSize(0.92, em)
|
||||||
|
.color("var(--headertext)")
|
||||||
|
.opacity(0.32)
|
||||||
|
})
|
||||||
|
.flex(1)
|
||||||
|
.justifyContent("center")
|
||||||
|
.alignItems("center")
|
||||||
|
.height(100, pct)
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(() => {
|
||||||
|
this.renderHeader()
|
||||||
|
this.renderMessages()
|
||||||
|
this.renderComposer()
|
||||||
|
})
|
||||||
|
.height(100, pct)
|
||||||
|
.width(100, pct)
|
||||||
|
.overflow("hidden")
|
||||||
|
}
|
||||||
|
|
||||||
|
renderHeader() {
|
||||||
|
HStack(() => {
|
||||||
|
// Chat icon (same logic as sidebar, compact)
|
||||||
|
this.renderHeaderIcon()
|
||||||
|
|
||||||
|
VStack(() => {
|
||||||
|
p(this.chat.name)
|
||||||
|
.margin(0)
|
||||||
|
.fontSize(0.95, em)
|
||||||
|
.fontWeight("700")
|
||||||
|
.color("var(--headertext)")
|
||||||
|
|
||||||
|
p(this.headerSubtitle())
|
||||||
|
.margin(0)
|
||||||
|
.marginTop(0.08, em)
|
||||||
|
.fontSize(0.72, em)
|
||||||
|
.color("var(--headertext)")
|
||||||
|
.opacity(0.42)
|
||||||
|
})
|
||||||
|
.flex(1)
|
||||||
|
.minWidth(0)
|
||||||
|
})
|
||||||
|
.gap(0.75, em)
|
||||||
|
.paddingHorizontal(1.4, em)
|
||||||
|
.paddingVertical(0.85, em)
|
||||||
|
.borderBottom("1px solid var(--divider)")
|
||||||
|
.alignItems("center")
|
||||||
|
.flexShrink(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
renderHeaderIcon() {
|
||||||
|
const chat = this.chat;
|
||||||
|
|
||||||
|
if (chat.type === "channel") {
|
||||||
|
VStack(() => {
|
||||||
|
p("#")
|
||||||
|
.margin(0)
|
||||||
|
.fontSize(1.05, em)
|
||||||
|
.fontWeight("700")
|
||||||
|
.color("white")
|
||||||
|
.lineHeight("1")
|
||||||
|
})
|
||||||
|
.width(2.2, em).height(2.2, em)
|
||||||
|
.borderRadius(0.45, em)
|
||||||
|
.background("#5865f2")
|
||||||
|
.justifyContent("center").alignItems("center")
|
||||||
|
.flexShrink(0)
|
||||||
|
} else if (chat.type === "announcement") {
|
||||||
|
VStack(() => {
|
||||||
|
p("📣").margin(0).fontSize(0.88, em).lineHeight("1")
|
||||||
|
})
|
||||||
|
.width(2.2, em).height(2.2, em)
|
||||||
|
.borderRadius(0.45, em)
|
||||||
|
.background("#f59e0b")
|
||||||
|
.justifyContent("center").alignItems("center")
|
||||||
|
.flexShrink(0)
|
||||||
|
} else if (chat.type === "group") {
|
||||||
|
ZStack(() => {
|
||||||
|
VStack(() => {
|
||||||
|
p(chat.members[1]?.[0]?.toUpperCase() || "?")
|
||||||
|
.margin(0).fontSize(0.55, em).fontWeight("700").color("white")
|
||||||
|
})
|
||||||
|
.width(1.65, em).height(1.65, em).borderRadius(50, pct)
|
||||||
|
.background(this.avatarColor(chat.members[1] || "B"))
|
||||||
|
.justifyContent("center").alignItems("center")
|
||||||
|
.position("absolute").bottom(0).right(0)
|
||||||
|
.boxSizing("border-box")
|
||||||
|
|
||||||
|
VStack(() => {
|
||||||
|
p(chat.members[0]?.[0]?.toUpperCase() || "?")
|
||||||
|
.margin(0).fontSize(0.55, em).fontWeight("700").color("white")
|
||||||
|
})
|
||||||
|
.width(1.65, em).height(1.65, em).borderRadius(50, pct)
|
||||||
|
.background(this.avatarColor(chat.members[0] || "A"))
|
||||||
|
.justifyContent("center").alignItems("center")
|
||||||
|
.position("absolute").top(0).left(0)
|
||||||
|
.boxSizing("border-box")
|
||||||
|
})
|
||||||
|
.width(2.2, em).height(2.2, em).position("relative").flexShrink(0)
|
||||||
|
} else {
|
||||||
|
VStack(() => {
|
||||||
|
p(chat.name[0].toUpperCase())
|
||||||
|
.margin(0).fontSize(0.85, em).fontWeight("700").color("white").lineHeight("1")
|
||||||
|
})
|
||||||
|
.width(2.2, em).height(2.2, em).borderRadius(50, pct)
|
||||||
|
.background(this.avatarColor(chat.name))
|
||||||
|
.justifyContent("center").alignItems("center").flexShrink(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
headerSubtitle() {
|
||||||
|
const { type, members } = this.chat;
|
||||||
|
if (type === "channel" || type === "announcement") {
|
||||||
|
return `${this.chat.memberCount || members?.length || 0} members`;
|
||||||
|
}
|
||||||
|
if (type === "group") {
|
||||||
|
return members?.join(", ") || "";
|
||||||
|
}
|
||||||
|
return "Active recently";
|
||||||
|
}
|
||||||
|
|
||||||
|
renderMessages() {
|
||||||
|
const messages = this.chat.messages || [];
|
||||||
|
const grouped = this.groupByDate(messages);
|
||||||
|
|
||||||
|
VStack(() => {
|
||||||
|
if (messages.length === 0) {
|
||||||
|
VStack(() => {
|
||||||
|
p("No messages yet. Say hello!")
|
||||||
|
.margin(0)
|
||||||
|
.fontSize(0.88, em)
|
||||||
|
.color("var(--headertext)")
|
||||||
|
.opacity(0.3)
|
||||||
|
})
|
||||||
|
.flex(1)
|
||||||
|
.justifyContent("center")
|
||||||
|
.alignItems("center")
|
||||||
|
} else {
|
||||||
|
grouped.forEach(({ label, messages: dayMsgs }) => {
|
||||||
|
// Date separator
|
||||||
|
HStack(() => {
|
||||||
|
VStack(() => {}).flex(1).height(1, px).background("var(--divider)")
|
||||||
|
p(label)
|
||||||
|
.margin(0)
|
||||||
|
.marginHorizontal(0.85, em)
|
||||||
|
.fontSize(0.68, em)
|
||||||
|
.fontWeight("600")
|
||||||
|
.color("var(--headertext)")
|
||||||
|
.opacity(0.38)
|
||||||
|
.whiteSpace("nowrap")
|
||||||
|
VStack(() => {}).flex(1).height(1, px).background("var(--divider)")
|
||||||
|
})
|
||||||
|
.alignItems("center")
|
||||||
|
.paddingHorizontal(1.4, em)
|
||||||
|
.paddingVertical(0.85, em)
|
||||||
|
.flexShrink(0)
|
||||||
|
|
||||||
|
// Render message runs (consecutive messages from same sender)
|
||||||
|
const runs = this.groupIntoRuns(dayMsgs);
|
||||||
|
runs.forEach(run => this.renderRun(run))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.flex(1)
|
||||||
|
.overflowY("auto")
|
||||||
|
.paddingBottom(0.5, em)
|
||||||
|
.gap(0)
|
||||||
|
.width(100, pct)
|
||||||
|
.boxSizing("border-box")
|
||||||
|
.attr({ id: `thread-${this.chat.id}` })
|
||||||
|
.onAppear(function () { this.scrollTop = this.scrollHeight; })
|
||||||
|
}
|
||||||
|
|
||||||
|
renderRun(run) {
|
||||||
|
const isMe = run.senderId === this.currentUser.id;
|
||||||
|
const showAvatar = !isMe && this.chat.type !== "dm";
|
||||||
|
|
||||||
|
VStack(() => {
|
||||||
|
// Sender name + time (only at run start, non-DM)
|
||||||
|
if (!isMe && this.chat.type !== "dm") {
|
||||||
|
HStack(() => {
|
||||||
|
p(run.senderName)
|
||||||
|
.margin(0)
|
||||||
|
.fontSize(0.75, em)
|
||||||
|
.fontWeight("600")
|
||||||
|
.color(this.avatarColor(run.senderName))
|
||||||
|
|
||||||
|
p(this.formatMsgTime(run.messages[0].sentAt))
|
||||||
|
.margin(0)
|
||||||
|
.fontSize(0.68, em)
|
||||||
|
.color("var(--headertext)")
|
||||||
|
.opacity(0.32)
|
||||||
|
})
|
||||||
|
.gap(0.55, em)
|
||||||
|
.alignItems("baseline")
|
||||||
|
.marginBottom(0.2, em)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bubble stack
|
||||||
|
run.messages.forEach((msg, i) => {
|
||||||
|
const isLast = i === run.messages.length - 1;
|
||||||
|
this.renderBubble(msg, isMe, isLast)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.paddingHorizontal(1.4, em)
|
||||||
|
.paddingTop(0.55, em)
|
||||||
|
.paddingBottom(0.08, em)
|
||||||
|
.alignItems(isMe ? "flex-end" : "flex-start")
|
||||||
|
.width(100, pct)
|
||||||
|
.boxSizing("border-box")
|
||||||
|
.flexShrink(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
renderBubble(msg, isMe, showTime) {
|
||||||
|
VStack(() => {
|
||||||
|
VStack(() => {
|
||||||
|
p(msg.text)
|
||||||
|
.margin(0)
|
||||||
|
.fontSize(0.88, em)
|
||||||
|
.color(isMe ? "white" : "var(--headertext)")
|
||||||
|
.lineHeight("1.5")
|
||||||
|
.whiteSpace("pre-wrap")
|
||||||
|
.wordBreak("break-word")
|
||||||
|
})
|
||||||
|
.paddingHorizontal(0.85, em)
|
||||||
|
.paddingVertical(0.55, em)
|
||||||
|
.background(isMe ? "var(--quillred)" : "var(--darkaccent)")
|
||||||
|
.borderRadius(isMe ? "1em 1em 0.25em 1em" : "1em 1em 1em 0.25em")
|
||||||
|
.maxWidth(32, em)
|
||||||
|
.boxSizing("border-box")
|
||||||
|
|
||||||
|
if (showTime) {
|
||||||
|
p(this.formatMsgTime(msg.sentAt))
|
||||||
|
.margin(0)
|
||||||
|
.marginTop(0.22, em)
|
||||||
|
.fontSize(0.65, em)
|
||||||
|
.color("var(--headertext)")
|
||||||
|
.opacity(0.32)
|
||||||
|
.alignSelf(isMe ? "flex-end" : "flex-start")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.alignItems(isMe ? "flex-end" : "flex-start")
|
||||||
|
.marginBottom(0.2, em)
|
||||||
|
}
|
||||||
|
|
||||||
|
renderComposer() {
|
||||||
|
const self = this
|
||||||
|
HStack(() => {
|
||||||
|
// Input
|
||||||
|
HStack(() => {
|
||||||
|
input()
|
||||||
|
.attr({ type: "text", placeholder: `Message ${this.chat.name}…`, id: `composer-${this.chat.id}` })
|
||||||
|
.flex(1)
|
||||||
|
.border("none")
|
||||||
|
.outline("none")
|
||||||
|
.background("transparent")
|
||||||
|
.color("var(--headertext)")
|
||||||
|
.fontSize(0.9, em)
|
||||||
|
.onKeyDown((e) => {
|
||||||
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
this.sendMessage();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.flex(1)
|
||||||
|
.paddingHorizontal(1, em)
|
||||||
|
.paddingVertical(0.62, em)
|
||||||
|
.background("var(--darkaccent)")
|
||||||
|
.border("1px solid var(--divider)")
|
||||||
|
.borderRadius(0.65, em)
|
||||||
|
.alignItems("center")
|
||||||
|
|
||||||
|
// Send button
|
||||||
|
button("↑")
|
||||||
|
.paddingHorizontal(0.75, em)
|
||||||
|
.paddingVertical(0.62, em)
|
||||||
|
.background("var(--quillred)")
|
||||||
|
.color("white")
|
||||||
|
.border("none")
|
||||||
|
.borderRadius(0.65, em)
|
||||||
|
.fontSize(1, em)
|
||||||
|
.fontWeight("700")
|
||||||
|
.cursor("pointer")
|
||||||
|
.flexShrink(0)
|
||||||
|
.onClick(function(done){ if(done){ self.sendMessage() } })
|
||||||
|
})
|
||||||
|
.gap(0.55, em)
|
||||||
|
.paddingHorizontal(1.4, em)
|
||||||
|
.paddingVertical(0.9, em)
|
||||||
|
.borderTop("1px solid var(--divider)")
|
||||||
|
.alignItems("center")
|
||||||
|
.flexShrink(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
sendMessage() {
|
||||||
|
const input = this.$(`#composer-${this.chat.id}`);
|
||||||
|
if (!input) return;
|
||||||
|
const text = input.value.trim();
|
||||||
|
if (!text) return;
|
||||||
|
|
||||||
|
this.chat.messages.push({
|
||||||
|
id: Date.now(),
|
||||||
|
senderId: this.currentUser.id,
|
||||||
|
senderName: this.currentUser.name,
|
||||||
|
text,
|
||||||
|
sentAt: new Date()
|
||||||
|
});
|
||||||
|
this.chat.lastMessage = {
|
||||||
|
senderId: this.currentUser.id,
|
||||||
|
senderName: this.currentUser.name,
|
||||||
|
text,
|
||||||
|
sentAt: new Date()
|
||||||
|
};
|
||||||
|
this.chat.unread = 0;
|
||||||
|
input.value = "";
|
||||||
|
this.rerender();
|
||||||
|
}
|
||||||
|
|
||||||
|
groupByDate(messages) {
|
||||||
|
const map = new Map();
|
||||||
|
messages.forEach(msg => {
|
||||||
|
const key = new Date(msg.sentAt).toDateString();
|
||||||
|
if (!map.has(key)) map.set(key, []);
|
||||||
|
map.get(key).push(msg);
|
||||||
|
});
|
||||||
|
|
||||||
|
return Array.from(map.entries()).map(([key, messages]) => ({
|
||||||
|
label: this.dateSeparatorLabel(new Date(key)),
|
||||||
|
messages
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
groupIntoRuns(messages) {
|
||||||
|
const runs = [];
|
||||||
|
messages.forEach(msg => {
|
||||||
|
const last = runs[runs.length - 1];
|
||||||
|
if (last && last.senderId === msg.senderId) {
|
||||||
|
last.messages.push(msg);
|
||||||
|
} else {
|
||||||
|
runs.push({ senderId: msg.senderId, senderName: msg.senderName, messages: [msg] });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return runs;
|
||||||
|
}
|
||||||
|
|
||||||
|
dateSeparatorLabel(date) {
|
||||||
|
const now = new Date();
|
||||||
|
const diffDays = Math.floor((now - date) / 86400000);
|
||||||
|
if (diffDays === 0) return "Today";
|
||||||
|
if (diffDays === 1) return "Yesterday";
|
||||||
|
if (diffDays < 7) return date.toLocaleDateString([], { weekday: "long" });
|
||||||
|
return date.toLocaleDateString([], { month: "long", day: "numeric", year: "numeric" });
|
||||||
|
}
|
||||||
|
|
||||||
|
formatMsgTime(date) {
|
||||||
|
return new Date(date).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
|
||||||
|
}
|
||||||
|
|
||||||
|
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(DesktopChatThread)
|
||||||
213
chat/desktop/chat.js
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
import "./DesktopChatSidebar.js"
|
||||||
|
import "./DesktopChatThread.js"
|
||||||
|
|
||||||
|
css(`
|
||||||
|
chat- {
|
||||||
|
font-family: 'Arial';
|
||||||
|
scrollbar-width: none;
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
}
|
||||||
|
chat- input::placeholder {
|
||||||
|
color: var(--headertext);
|
||||||
|
opacity: 0.35;
|
||||||
|
}
|
||||||
|
`)
|
||||||
|
|
||||||
|
class Chat extends Shadow {
|
||||||
|
constructor() {
|
||||||
|
super()
|
||||||
|
|
||||||
|
this.currentUser = { id: 1, name: "You" }
|
||||||
|
this.selectedChatId = null
|
||||||
|
|
||||||
|
const ago = (h) => new Date(Date.now() - h * 3600000);
|
||||||
|
const msg = (senderId, senderName, text, hoursAgo) => ({ id: Math.random(), senderId, senderName, text, sentAt: ago(hoursAgo) });
|
||||||
|
|
||||||
|
this.chats = [
|
||||||
|
// ── Direct messages ───────────────────────────────────────
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
type: "dm",
|
||||||
|
name: "Sarah McIntyre",
|
||||||
|
members: ["Sarah McIntyre"],
|
||||||
|
memberCount: 2,
|
||||||
|
unread: 2,
|
||||||
|
lastMessage: { senderId: 2, senderName: "Sarah McIntyre", text: "Can you review the PR when you get a chance?", sentAt: ago(0.3) },
|
||||||
|
messages: [
|
||||||
|
msg(1, "You", "Hey Sarah, did you see the new design mockups?", 24),
|
||||||
|
msg(2, "Sarah McIntyre", "Just looked — they're really clean. I love the new sidebar.", 23.5),
|
||||||
|
msg(1, "You", "Agreed. Alex did a great job.", 23.4),
|
||||||
|
msg(2, "Sarah McIntyre", "Are we going to ship this week or wait for the backend?", 5),
|
||||||
|
msg(1, "You", "Let's aim for Thursday. I'll sync with Marcus.", 4.8),
|
||||||
|
msg(2, "Sarah McIntyre", "Sounds good 👍", 4.7),
|
||||||
|
msg(2, "Sarah McIntyre", "Can you review the PR when you get a chance?", 0.3),
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
type: "dm",
|
||||||
|
name: "Marcus Webb",
|
||||||
|
members: ["Marcus Webb"],
|
||||||
|
memberCount: 2,
|
||||||
|
unread: 0,
|
||||||
|
lastMessage: { senderId: 1, senderName: "You", text: "I'll send over the specs by EOD", sentAt: ago(1.5) },
|
||||||
|
messages: [
|
||||||
|
msg(3, "Marcus Webb", "Hey, the API endpoint is returning 500s on staging.", 3),
|
||||||
|
msg(1, "You", "Oh no — is it the auth middleware again?", 2.9),
|
||||||
|
msg(3, "Marcus Webb", "Yep. Same issue as last week.", 2.8),
|
||||||
|
msg(1, "You", "I'll patch it now. Give me 20 mins.", 2.75),
|
||||||
|
msg(3, "Marcus Webb", "Thanks, no rush.", 2.7),
|
||||||
|
msg(1, "You", "Fixed. Can you redeploy and check?", 2.2),
|
||||||
|
msg(3, "Marcus Webb", "All green 🎉 Thanks!", 2.1),
|
||||||
|
msg(1, "You", "I'll send over the specs by EOD", 1.5),
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
type: "dm",
|
||||||
|
name: "Priya Anand",
|
||||||
|
members: ["Priya Anand"],
|
||||||
|
memberCount: 2,
|
||||||
|
unread: 0,
|
||||||
|
lastMessage: { senderId: 4, senderName: "Priya Anand", text: "See you at the standup!", sentAt: ago(18) },
|
||||||
|
messages: [
|
||||||
|
msg(4, "Priya Anand", "Quick question — what's the launch date for v2?", 20),
|
||||||
|
msg(1, "You", "Still TBD, but we're targeting end of May.", 19.8),
|
||||||
|
msg(4, "Priya Anand", "Got it. I'll update the roadmap doc.", 19.5),
|
||||||
|
msg(1, "You", "Perfect, thanks Priya.", 19.4),
|
||||||
|
msg(4, "Priya Anand", "See you at the standup!", 18),
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Groups ────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
type: "group",
|
||||||
|
name: "Product Team",
|
||||||
|
members: ["Sarah McIntyre", "Marcus Webb", "Priya Anand", "You"],
|
||||||
|
memberCount: 4,
|
||||||
|
unread: 5,
|
||||||
|
lastMessage: { senderId: 2, senderName: "Sarah McIntyre", text: "I've updated the Figma file with the new flows", sentAt: ago(0.15) },
|
||||||
|
messages: [
|
||||||
|
msg(3, "Marcus Webb", "Morning everyone! API docs are updated.", 8),
|
||||||
|
msg(4, "Priya Anand", "Nice work Marcus 🙌", 7.9),
|
||||||
|
msg(1, "You", "I'll start on the integration tests today.", 7.8),
|
||||||
|
msg(2, "Sarah McIntyre", "Great. I'm finishing up the onboarding screens.", 7.5),
|
||||||
|
msg(4, "Priya Anand", "Can we do a quick sync at 2pm?", 2),
|
||||||
|
msg(1, "You", "Works for me.", 1.95),
|
||||||
|
msg(3, "Marcus Webb", "Same", 1.9),
|
||||||
|
msg(2, "Sarah McIntyre", "I'll send the invite.", 1.85),
|
||||||
|
msg(2, "Sarah McIntyre", "I've updated the Figma file with the new flows", 0.15),
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 5,
|
||||||
|
type: "group",
|
||||||
|
name: "Design Review",
|
||||||
|
members: ["Sarah McIntyre", "You", "Jordan Kim"],
|
||||||
|
memberCount: 3,
|
||||||
|
unread: 0,
|
||||||
|
lastMessage: { senderId: 5, senderName: "Jordan Kim", text: "The contrast on mobile looks off — can we bump it?", sentAt: ago(26) },
|
||||||
|
messages: [
|
||||||
|
msg(5, "Jordan Kim", "Hey, sharing the first round of designs for the settings page.", 30),
|
||||||
|
msg(2, "Sarah McIntyre", "These look great! Love the card layout.", 29.5),
|
||||||
|
msg(1, "You", "Agreed. One thought — the spacing on the form feels a bit tight.", 29),
|
||||||
|
msg(5, "Jordan Kim", "Good call. I'll loosen it up.", 28.8),
|
||||||
|
msg(2, "Sarah McIntyre", "Also maybe we increase the font size slightly?", 28),
|
||||||
|
msg(5, "Jordan Kim", "The contrast on mobile looks off — can we bump it?", 26),
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Channels ──────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
id: 6,
|
||||||
|
type: "channel",
|
||||||
|
name: "general",
|
||||||
|
members: ["Sarah McIntyre", "Marcus Webb", "Priya Anand", "Jordan Kim", "You"],
|
||||||
|
memberCount: 24,
|
||||||
|
unread: 11,
|
||||||
|
lastMessage: { senderId: 3, senderName: "Marcus Webb", text: "Just pushed the hotfix to production", sentAt: ago(0.08) },
|
||||||
|
messages: [
|
||||||
|
msg(4, "Priya Anand", "Good morning team! Reminder: all-hands is Thursday at 10am.", 9),
|
||||||
|
msg(2, "Sarah McIntyre", "Thanks for the reminder!", 8.9),
|
||||||
|
msg(5, "Jordan Kim", "Will there be a recording for those in other time zones?", 8.7),
|
||||||
|
msg(4, "Priya Anand", "Yes — I'll post the link in #announcements after.", 8.6),
|
||||||
|
msg(1, "You", "Thanks Priya 🙏", 8.5),
|
||||||
|
msg(3, "Marcus Webb", "Staging is back up btw, had a brief outage this morning.", 4),
|
||||||
|
msg(2, "Sarah McIntyre", "Oh I didn't even notice, nice quick fix!", 3.8),
|
||||||
|
msg(3, "Marcus Webb", "Just pushed the hotfix to production", 0.08),
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 7,
|
||||||
|
type: "channel",
|
||||||
|
name: "engineering",
|
||||||
|
members: ["Marcus Webb", "You"],
|
||||||
|
memberCount: 8,
|
||||||
|
unread: 0,
|
||||||
|
lastMessage: { senderId: 1, senderName: "You", text: "PR is up: #247 — adds rate limiting to the auth routes", sentAt: ago(3) },
|
||||||
|
messages: [
|
||||||
|
msg(3, "Marcus Webb", "Heads up: I'm updating the CI pipeline today. Builds might be slow for a bit.", 5),
|
||||||
|
msg(1, "You", "Noted, thanks for the warning.", 4.9),
|
||||||
|
msg(3, "Marcus Webb", "Back to normal now.", 4),
|
||||||
|
msg(1, "You", "PR is up: #247 — adds rate limiting to the auth routes", 3),
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Announcements ─────────────────────────────────────────
|
||||||
|
{
|
||||||
|
id: 8,
|
||||||
|
type: "announcement",
|
||||||
|
name: "Announcements",
|
||||||
|
members: [],
|
||||||
|
memberCount: 24,
|
||||||
|
unread: 1,
|
||||||
|
lastMessage: { senderId: 4, senderName: "Priya Anand", text: "Q2 planning kick-off is next Monday at 9am. Please come prepared with your team's priorities.", sentAt: ago(12) },
|
||||||
|
messages: [
|
||||||
|
msg(4, "Priya Anand", "Welcome to the team, Jordan! 🎉 Jordan joins us as a Product Designer and will be working closely with Sarah.", 72),
|
||||||
|
msg(4, "Priya Anand", "Reminder: expense reports for March are due this Friday.", 48),
|
||||||
|
msg(4, "Priya Anand", "Q2 planning kick-off is next Monday at 9am. Please come prepared with your team's priorities.", 12),
|
||||||
|
]
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
this.selectedChatId = this.chats[0].id
|
||||||
|
}
|
||||||
|
|
||||||
|
get selectedChat() {
|
||||||
|
return this.chats.find(c => c.id === this.selectedChatId) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
HStack(() => {
|
||||||
|
// Left sidebar
|
||||||
|
VStack(() => {
|
||||||
|
DesktopChatSidebar(this.chats, this.selectedChatId, (id) => {
|
||||||
|
this.selectedChatId = id;
|
||||||
|
const chat = this.chats.find(c => c.id === id);
|
||||||
|
if (chat) chat.unread = 0;
|
||||||
|
this.rerender();
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.width(280, px)
|
||||||
|
.minWidth(240, px)
|
||||||
|
.height(100, pct)
|
||||||
|
.borderRight("1px solid var(--divider)")
|
||||||
|
.flexShrink(0)
|
||||||
|
.overflow("hidden")
|
||||||
|
|
||||||
|
// Right thread
|
||||||
|
VStack(() => {
|
||||||
|
DesktopChatThread(this.selectedChat, this.currentUser)
|
||||||
|
})
|
||||||
|
.flex(1)
|
||||||
|
.height(100, pct)
|
||||||
|
.overflow("hidden")
|
||||||
|
})
|
||||||
|
.height(100, pct)
|
||||||
|
.width(100, pct)
|
||||||
|
.overflow("hidden")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
register(Chat)
|
||||||
3
chat/icons/chat.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg width="155" height="155" viewBox="0 0 155 155" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M76.471 0.0125703C75.8033 0.019182 75.1291 0.0324015 74.4614 0.0588431C54.9548 0.78599 36.4448 8.86384 22.636 22.6725H22.6426H22.6228C-1.51899 46.8397 -6.91899 83.9712 9.23007 113.995L2.76513 149.155V149.149C2.6197 149.929 2.86429 150.735 3.42616 151.297C3.98142 151.859 4.78129 152.11 5.56128 151.971L40.7417 145.5C70.7657 161.642 107.897 156.228 132.064 132.087C162.261 101.891 162.261 52.8625 132.064 22.6658C117.317 7.91809 97.2682 -0.225398 76.4771 0.00474876L76.471 0.0125703ZM76.5239 4.85142C96.0187 4.62667 114.804 12.2748 128.627 26.0977C156.979 54.4498 156.979 100.332 128.627 128.684C105.768 151.523 70.601 156.495 42.3148 140.874H42.3215C42.3148 140.874 42.3016 140.874 42.295 140.867C42.0438 140.735 41.7794 140.649 41.5018 140.616C41.4555 140.603 41.4158 140.596 41.3696 140.596C41.1514 140.57 40.9267 140.576 40.7151 140.616L8.12561 146.592L14.1213 114.029C14.2337 113.414 14.1081 112.78 13.7643 112.258C-1.74371 83.9985 3.24727 48.923 26.0398 26.1046C38.9829 13.1615 56.3346 5.59272 74.6262 4.90566C75.2542 4.87922 75.8888 4.866 76.5168 4.85939L76.5239 4.85142ZM38.448 62.1428C37.8068 62.1362 37.1855 62.394 36.7294 62.8502C36.2733 63.3063 36.0221 63.921 36.0221 64.5688C36.0221 65.21 36.2799 65.8248 36.736 66.2809C37.1921 66.7304 37.8069 66.9816 38.4481 66.9816H116.253C117.582 66.9816 118.659 65.8975 118.666 64.5687C118.666 63.9275 118.415 63.3128 117.959 62.8567C117.509 62.4006 116.894 62.1428 116.253 62.1428L38.448 62.1428ZM38.448 87.7907C37.8068 87.7841 37.1855 88.0419 36.7294 88.498C36.2733 88.9541 36.0221 89.5689 36.0221 90.2167C36.0287 90.8579 36.2865 91.4727 36.736 91.9222C37.1921 92.3717 37.8069 92.6229 38.4481 92.6229H84.6146C85.9433 92.6229 87.0208 91.5454 87.0208 90.2166C87.0274 89.5754 86.7762 88.9541 86.3201 88.498C85.8706 88.0485 85.2558 87.7907 84.6146 87.7907L38.448 87.7907Z" fill="black"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.9 KiB |
3
chat/icons/chatlight.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg width="155" height="155" viewBox="0 0 155 155" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M76.471 0.0125703C75.8033 0.019182 75.1291 0.0324015 74.4614 0.0588431C54.9548 0.78599 36.4448 8.86384 22.636 22.6725H22.6426H22.6228C-1.51899 46.8397 -6.91899 83.9712 9.23007 113.995L2.76513 149.155V149.149C2.6197 149.929 2.86429 150.735 3.42616 151.297C3.98142 151.859 4.78129 152.11 5.56128 151.971L40.7417 145.5C70.7657 161.642 107.897 156.228 132.064 132.087C162.261 101.891 162.261 52.8625 132.064 22.6658C117.317 7.91809 97.2682 -0.225398 76.4771 0.00474876L76.471 0.0125703ZM76.5239 4.85142C96.0187 4.62667 114.804 12.2748 128.627 26.0977C156.979 54.4498 156.979 100.332 128.627 128.684C105.768 151.523 70.601 156.495 42.3148 140.874H42.3215C42.3148 140.874 42.3016 140.874 42.295 140.867C42.0438 140.735 41.7794 140.649 41.5018 140.616C41.4555 140.603 41.4158 140.596 41.3696 140.596C41.1514 140.57 40.9267 140.576 40.7151 140.616L8.12561 146.592L14.1213 114.029C14.2337 113.414 14.1081 112.78 13.7643 112.258C-1.74371 83.9985 3.24727 48.923 26.0398 26.1046C38.9829 13.1615 56.3346 5.59272 74.6262 4.90566C75.2542 4.87922 75.8888 4.866 76.5168 4.85939L76.5239 4.85142ZM38.448 62.1428C37.8068 62.1362 37.1855 62.394 36.7294 62.8502C36.2733 63.3063 36.0221 63.921 36.0221 64.5688C36.0221 65.21 36.2799 65.8248 36.736 66.2809C37.1921 66.7304 37.8069 66.9816 38.4481 66.9816H116.253C117.582 66.9816 118.659 65.8975 118.666 64.5687C118.666 63.9275 118.415 63.3128 117.959 62.8567C117.509 62.4006 116.894 62.1428 116.253 62.1428L38.448 62.1428ZM38.448 87.7907C37.8068 87.7841 37.1855 88.0419 36.7294 88.498C36.2733 88.9541 36.0221 89.5689 36.0221 90.2167C36.0287 90.8579 36.2865 91.4727 36.736 91.9222C37.1921 92.3717 37.8069 92.6229 38.4481 92.6229H84.6146C85.9433 92.6229 87.0208 91.5454 87.0208 90.2166C87.0274 89.5754 86.7762 88.9541 86.3201 88.498C85.8706 88.0485 85.2558 87.7907 84.6146 87.7907L38.448 87.7907Z" fill="#FFE9C8"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.9 KiB |
33
components/AddButton.js
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
class AddButton extends Shadow {
|
||||||
|
render() {
|
||||||
|
p("+")
|
||||||
|
.fontWeight("bolder")
|
||||||
|
.paddingVertical(1, em)
|
||||||
|
.boxSizing("border-box")
|
||||||
|
.paddingHorizontal(1.25, em)
|
||||||
|
.background("var(--quillred)")
|
||||||
|
.color("var(--headertext)")
|
||||||
|
.marginBottom(1, em)
|
||||||
|
.border("1px solid var(--headertext)")
|
||||||
|
.borderRadius(100, px)
|
||||||
|
.onTap(() => {
|
||||||
|
this.handleAdd()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
handleAdd() {
|
||||||
|
const app = document.documentElement.attr("app")
|
||||||
|
switch (app) {
|
||||||
|
case "jobs":
|
||||||
|
$("jobform-").toggle()
|
||||||
|
break;
|
||||||
|
case "calendar":
|
||||||
|
$("eventform-").toggle()
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
register(AddButton)
|
||||||
8
components/AppTitle.js
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
function AppTitle(text) {
|
||||||
|
return h1(text)
|
||||||
|
.fontFamily("Laandbrau")
|
||||||
|
.letterSpacing(2, px)
|
||||||
|
.fontSize(2.3, em)
|
||||||
|
}
|
||||||
|
|
||||||
|
window.AppTitle = AppTitle
|
||||||
36
components/Avatar.js
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
class Avatar extends Shadow {
|
||||||
|
constructor(person, size) {
|
||||||
|
super()
|
||||||
|
this.person = person
|
||||||
|
this.size = size
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { person, size } = this
|
||||||
|
if (person.image_path) {
|
||||||
|
img(`${config.SERVER}${person.image_path}`, `${size}em`, `${size}em`)
|
||||||
|
.borderRadius(50, pct)
|
||||||
|
.objectFit("cover")
|
||||||
|
.flexShrink(0)
|
||||||
|
} else {
|
||||||
|
const initials = [person.first_name?.[0], person.last_name?.[0]].filter(Boolean).join("").toUpperCase()
|
||||||
|
VStack(() => {
|
||||||
|
p(initials)
|
||||||
|
.margin(0).fontSize(size * 0.35, em).fontWeight("700")
|
||||||
|
.color("white").lineHeight("1")
|
||||||
|
})
|
||||||
|
.width(size, em).height(size, em).borderRadius(50, pct)
|
||||||
|
.background(this.avatarColor(`${person.first_name} ${person.last_name}`))
|
||||||
|
.justifyContent("center").alignItems("center").flexShrink(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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(Avatar)
|
||||||
62
components/BackButton.js
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
class BackButton extends Shadow {
|
||||||
|
constructor(withBackground = true, isDown = false, toggle) {
|
||||||
|
super()
|
||||||
|
this.withBackground = withBackground;
|
||||||
|
this.isDown = isDown;
|
||||||
|
this.toggle = toggle;
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const toggle = this.toggle;
|
||||||
|
const btn = div("➩")
|
||||||
|
.display("flex")
|
||||||
|
.fontSize(2, em)
|
||||||
|
.paddingTop(0, rem)
|
||||||
|
.paddingHorizontal(0.5, rem)
|
||||||
|
.paddingBottom(0.9, rem)
|
||||||
|
.zIndex(3)
|
||||||
|
.color("var(--darkaccent)")
|
||||||
|
.transform(this.isDown ? "rotate(90deg)" : "rotate(180deg)")
|
||||||
|
.transition("scale .2s, color .2s")
|
||||||
|
.onTouch(function (start, e) {
|
||||||
|
if (start) {
|
||||||
|
this.scale("1.5")
|
||||||
|
this.style.color = "var(--quillred)"
|
||||||
|
} else {
|
||||||
|
this.scale("")
|
||||||
|
this.style.color = "var(--darkaccent)"
|
||||||
|
e.stopPropagation();
|
||||||
|
toggle();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (this.withBackground) {
|
||||||
|
btn
|
||||||
|
.width(3, rem)
|
||||||
|
.height(3, rem)
|
||||||
|
.padding(0)
|
||||||
|
.borderRadius(50, pct)
|
||||||
|
.color("var(--divider)")
|
||||||
|
.background("var(--darkaccent)")
|
||||||
|
.color()
|
||||||
|
.border("1.5px solid var(--divider)")
|
||||||
|
.alignItems("center")
|
||||||
|
.justifyContent("center")
|
||||||
|
.onTouch(function (start, e) {
|
||||||
|
if (start) {
|
||||||
|
this.scale("1.5")
|
||||||
|
this.color("var(--darkaccent)")
|
||||||
|
this.backgroundColor("var(--divider)")
|
||||||
|
} else {
|
||||||
|
this.color("var(--divider)")
|
||||||
|
this.backgroundColor("var(--darkaccent)")
|
||||||
|
this.scale("")
|
||||||
|
e.stopPropagation();
|
||||||
|
toggle();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
register(BackButton)
|
||||||
193
components/BottomSheet.js
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
css(`
|
||||||
|
bottomsheet- {
|
||||||
|
scrollbar-width: none;
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
bottomsheet- .VStack::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
width: 0px;
|
||||||
|
height: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
bottomsheet- .VStack::-webkit-scrollbar-thumb {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
bottomsheet- .VStack::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
`)
|
||||||
|
|
||||||
|
class BottomSheet extends Shadow {
|
||||||
|
swipeDragStartX = null;
|
||||||
|
swipeDragStartY = null;
|
||||||
|
swipeDragStartTime = null;
|
||||||
|
isSwiping = false;
|
||||||
|
swipeAxisLocked = false;
|
||||||
|
swipeIsVertical = false;
|
||||||
|
swipeTranslate = 0;
|
||||||
|
|
||||||
|
SWIPE_COMMIT_DISTANCE = window.outerHeight * 0.25;
|
||||||
|
SWIPE_VELOCITY_THRESHOLD = 0.4;
|
||||||
|
|
||||||
|
renderContent = null;
|
||||||
|
_closeOverride = null;
|
||||||
|
|
||||||
|
constructor(zOffset = 0) {
|
||||||
|
super()
|
||||||
|
this.zOffset = zOffset;
|
||||||
|
}
|
||||||
|
|
||||||
|
show(renderFn, onClose = null) {
|
||||||
|
this._closeOverride = null;
|
||||||
|
this.renderContent = renderFn;
|
||||||
|
this._onClose = onClose;
|
||||||
|
this.rerender();
|
||||||
|
requestAnimationFrame(() => requestAnimationFrame(() => this.setSheet(true)));
|
||||||
|
}
|
||||||
|
|
||||||
|
replace(renderFn) {
|
||||||
|
this.renderContent = renderFn;
|
||||||
|
this.rerender();
|
||||||
|
if (this.sheetEl) {
|
||||||
|
this.sheetEl.style.transition = "none";
|
||||||
|
this.sheetEl.style.top = "7.5%";
|
||||||
|
this.sheetEl.style.pointerEvents = "auto";
|
||||||
|
}
|
||||||
|
if (this.overlayEl) {
|
||||||
|
this.overlayEl.style.transition = "none";
|
||||||
|
this.overlayEl.style.background = "rgba(0, 0, 0, 0.3)";
|
||||||
|
this.overlayEl.style.pointerEvents = "auto";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
ZStack(() => {
|
||||||
|
this.overlayEl = ZStack()
|
||||||
|
.position("fixed")
|
||||||
|
.inset(0)
|
||||||
|
.zIndex(90 + this.zOffset)
|
||||||
|
.background("transparent")
|
||||||
|
.pointerEvents("none")
|
||||||
|
.onTap(() => this.toggle())
|
||||||
|
|
||||||
|
this.sheetEl = ZStack(() => {
|
||||||
|
VStack(() => {
|
||||||
|
if (this.renderContent) this.renderContent()
|
||||||
|
})
|
||||||
|
.height(100, pct)
|
||||||
|
.position("relative")
|
||||||
|
})
|
||||||
|
.position("fixed")
|
||||||
|
.height(92.5, pct)
|
||||||
|
.top(100, pct)
|
||||||
|
.left(0).right(0)
|
||||||
|
.zIndex(100 + this.zOffset)
|
||||||
|
.background("var(--main)")
|
||||||
|
.borderTopLeftRadius("10px").borderTopRightRadius("10px")
|
||||||
|
.boxSizing("border-box")
|
||||||
|
.transition("top .3s")
|
||||||
|
.pointerEvents("none")
|
||||||
|
.onTouch((start, e) => this.handleSwipeTouch(start, e))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
get isOpen() {
|
||||||
|
return this.sheetEl?.style.top === "7.5%";
|
||||||
|
}
|
||||||
|
|
||||||
|
toggle() {
|
||||||
|
this.setSheet(!this.isOpen);
|
||||||
|
}
|
||||||
|
|
||||||
|
forceClose() {
|
||||||
|
this._closeOverride = null
|
||||||
|
this.setSheet(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
setSheet(open) {
|
||||||
|
if (!open && this._closeOverride) { this._closeOverride(); return }
|
||||||
|
if (this.overlayEl) {
|
||||||
|
this.overlayEl.style.transition = "background 0.3s";
|
||||||
|
this.overlayEl.style.pointerEvents = open ? "auto" : "none";
|
||||||
|
this.overlayEl.style.background = open ? "rgba(0, 0, 0, 0.3)" : "transparent";
|
||||||
|
}
|
||||||
|
if (this.sheetEl) {
|
||||||
|
this.sheetEl.style.transition = "top 0.3s";
|
||||||
|
this.sheetEl.style.top = open ? "7.5%" : "100%";
|
||||||
|
this.sheetEl.style.pointerEvents = open ? "auto" : "none";
|
||||||
|
}
|
||||||
|
if (!open) {
|
||||||
|
if (this._onClose) {
|
||||||
|
const cb = this._onClose;
|
||||||
|
this._onClose = null;
|
||||||
|
setTimeout(cb, 300);
|
||||||
|
}
|
||||||
|
this.isSwiping = false;
|
||||||
|
this.swipeTranslate = 0;
|
||||||
|
this.swipeDragStartX = null;
|
||||||
|
this.swipeDragStartY = null;
|
||||||
|
document.removeEventListener("touchmove", this.onSwipeMove);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleSwipeTouch(start, e) {
|
||||||
|
if (start) {
|
||||||
|
if (this.sheetEl?.style.top !== "7.5%") return;
|
||||||
|
e.stopPropagation();
|
||||||
|
this.swipeDragStartX = e.touches[0].clientX;
|
||||||
|
this.swipeDragStartY = e.touches[0].clientY;
|
||||||
|
this.swipeDragStartTime = Date.now();
|
||||||
|
this.isSwiping = true;
|
||||||
|
this.swipeAxisLocked = false;
|
||||||
|
this.swipeIsVertical = false;
|
||||||
|
document.addEventListener("touchmove", this.onSwipeMove, { passive: false });
|
||||||
|
} else {
|
||||||
|
if (!this.isSwiping) return;
|
||||||
|
document.removeEventListener("touchmove", this.onSwipeMove);
|
||||||
|
|
||||||
|
if (!this.swipeIsVertical) {
|
||||||
|
this.isSwiping = false;
|
||||||
|
this.setSheet(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const delta = e.changedTouches[0].clientY - this.swipeDragStartY;
|
||||||
|
const velocity = Math.abs(delta) / (Date.now() - this.swipeDragStartTime);
|
||||||
|
const shouldCommit = delta > 0 && (delta > this.SWIPE_COMMIT_DISTANCE || velocity > this.SWIPE_VELOCITY_THRESHOLD);
|
||||||
|
|
||||||
|
this.swipeTranslate = 0;
|
||||||
|
this.setSheet(!shouldCommit);
|
||||||
|
|
||||||
|
this.isSwiping = false;
|
||||||
|
this.swipeDragStartX = null;
|
||||||
|
this.swipeDragStartY = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onSwipeMove = (e) => {
|
||||||
|
if (!this.isSwiping) return;
|
||||||
|
|
||||||
|
const dx = e.touches[0].clientX - this.swipeDragStartX;
|
||||||
|
const dy = e.touches[0].clientY - this.swipeDragStartY;
|
||||||
|
|
||||||
|
if (!this.swipeAxisLocked) {
|
||||||
|
if (Math.abs(dx) < 5 && Math.abs(dy) < 5) return;
|
||||||
|
this.swipeAxisLocked = true;
|
||||||
|
this.swipeIsVertical = Math.abs(dy) > Math.abs(dx);
|
||||||
|
}
|
||||||
|
if (!this.swipeIsVertical) return;
|
||||||
|
|
||||||
|
const delta = e.touches[0].clientY - this.swipeDragStartY;
|
||||||
|
const scrollEl = this.querySelector(".VStack");
|
||||||
|
if (delta <= 0 || (scrollEl && scrollEl.scrollTop > 0)) return;
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
this.swipeTranslate = delta;
|
||||||
|
this.sheetEl.style.transition = "";
|
||||||
|
this.sheetEl.style.top = `calc(7.5% + ${delta}px)`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
register(BottomSheet)
|
||||||
72
components/SearchBar.js
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
css(`
|
||||||
|
searchbar- input::placeholder {
|
||||||
|
color: #5C504D;
|
||||||
|
}
|
||||||
|
`)
|
||||||
|
|
||||||
|
class SearchBar extends Shadow {
|
||||||
|
searchText
|
||||||
|
width
|
||||||
|
|
||||||
|
constructor(searchText, width) {
|
||||||
|
super()
|
||||||
|
this.searchText = searchText
|
||||||
|
this.width = width
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
form(() => {
|
||||||
|
input("Search", this.width)
|
||||||
|
.attr({ name: "searchText", type: "text" })
|
||||||
|
.attr({ value: this.searchText ? this.searchText : "" })
|
||||||
|
.paddingVertical(0.75, em)
|
||||||
|
.boxSizing("border-box")
|
||||||
|
.paddingHorizontal(1, em)
|
||||||
|
.background("var(--searchbackground)")
|
||||||
|
.color("gray")
|
||||||
|
.marginBottom(1, em)
|
||||||
|
.marginLeft(1, em)
|
||||||
|
.marginRight(0.5, em)
|
||||||
|
.border("1px solid color-mix(in srgb, var(--accent) 60%, transparent)")
|
||||||
|
.borderRadius(100, px)
|
||||||
|
.fontFamily("Arial")
|
||||||
|
.fontSize(1, em)
|
||||||
|
.cursor("not-allowed")
|
||||||
|
.onTouch(function (start) {
|
||||||
|
if (start) {
|
||||||
|
this.style.backgroundColor = "var(--accent)"
|
||||||
|
} else {
|
||||||
|
this.style.backgroundColor = "var(--searchbackground)"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.onSubmit(async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const data = new FormData(e.target);
|
||||||
|
this.dispatchSearchEvent(data.get("searchText"))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatchSearchEvent(searchText) {
|
||||||
|
const app = document.documentElement.attr("app")
|
||||||
|
switch (app) {
|
||||||
|
case "Jobs":
|
||||||
|
window.dispatchEvent(new CustomEvent('jobsearch', {
|
||||||
|
detail: { searchText: searchText }
|
||||||
|
}));
|
||||||
|
break;
|
||||||
|
case "Events":
|
||||||
|
window.dispatchEvent(new CustomEvent('eventsearch', {
|
||||||
|
detail: { searchText: searchText }
|
||||||
|
}));
|
||||||
|
break;
|
||||||
|
case "Announcements":
|
||||||
|
window.dispatchEvent(new CustomEvent('announcementsearch', {
|
||||||
|
detail: { searchText: searchText }
|
||||||
|
}));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
register(SearchBar)
|
||||||
425
donations/desktop/donations.js
Normal file
@@ -0,0 +1,425 @@
|
|||||||
|
import donationServer from "/donations/@server/donations.js"
|
||||||
|
|
||||||
|
css(`
|
||||||
|
donations- {
|
||||||
|
font-family: 'Arial';
|
||||||
|
scrollbar-width: none;
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
}
|
||||||
|
donations- input::placeholder {
|
||||||
|
color: var(--headertext);
|
||||||
|
opacity: 0.35;
|
||||||
|
}
|
||||||
|
`)
|
||||||
|
|
||||||
|
class Donations extends Shadow {
|
||||||
|
donations = []
|
||||||
|
subscriptions = []
|
||||||
|
typeFilter = "all"
|
||||||
|
searchText = ""
|
||||||
|
sortKey = "created"
|
||||||
|
sortDir = "desc"
|
||||||
|
|
||||||
|
get allTransactions() {
|
||||||
|
const d = this.donations.map(don => ({
|
||||||
|
id: `d-${don.id}`,
|
||||||
|
created: don.created,
|
||||||
|
name: don.name || "—",
|
||||||
|
email: don.email || "—",
|
||||||
|
amount: Number(don.amount),
|
||||||
|
type: "donation",
|
||||||
|
product: "Donation",
|
||||||
|
status: "complete"
|
||||||
|
}))
|
||||||
|
const s = this.subscriptions.map(sub => ({
|
||||||
|
id: `s-${sub.id}`,
|
||||||
|
created: sub.created,
|
||||||
|
name: [sub.first_name, sub.last_name].filter(Boolean).join(" ") || sub.email || "—",
|
||||||
|
email: sub.email || "—",
|
||||||
|
amount: Number(sub.plan_price),
|
||||||
|
type: "subscription",
|
||||||
|
product: sub.plan_name || "Subscription",
|
||||||
|
status: sub.active ? "active" : "inactive"
|
||||||
|
}))
|
||||||
|
return [...d, ...s]
|
||||||
|
}
|
||||||
|
|
||||||
|
get transactions() {
|
||||||
|
let all = this.allTransactions
|
||||||
|
|
||||||
|
if (this.typeFilter === "donations") all = all.filter(t => t.type === "donation")
|
||||||
|
else if (this.typeFilter === "subscriptions") all = all.filter(t => t.type === "subscription")
|
||||||
|
|
||||||
|
if (this.searchText) {
|
||||||
|
const q = this.searchText.toLowerCase()
|
||||||
|
all = all.filter(t =>
|
||||||
|
t.name.toLowerCase().includes(q) ||
|
||||||
|
t.email.toLowerCase().includes(q) ||
|
||||||
|
t.product.toLowerCase().includes(q)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
all.sort((a, b) => {
|
||||||
|
let av = a[this.sortKey], bv = b[this.sortKey]
|
||||||
|
if (this.sortKey === "created") { av = new Date(av); bv = new Date(bv) }
|
||||||
|
else if (this.sortKey === "amount") { av = Number(av); bv = Number(bv) }
|
||||||
|
else { av = String(av).toLowerCase(); bv = String(bv).toLowerCase() }
|
||||||
|
if (av < bv) return this.sortDir === "asc" ? -1 : 1
|
||||||
|
if (av > bv) return this.sortDir === "asc" ? 1 : -1
|
||||||
|
return 0
|
||||||
|
})
|
||||||
|
|
||||||
|
return all
|
||||||
|
}
|
||||||
|
|
||||||
|
get stats() {
|
||||||
|
const all = this.allTransactions
|
||||||
|
const totalRevenue = all.reduce((s, t) => s + t.amount, 0)
|
||||||
|
const now = new Date()
|
||||||
|
const thisMonth = all.filter(t => {
|
||||||
|
const d = new Date(t.created)
|
||||||
|
return d.getMonth() === now.getMonth() && d.getFullYear() === now.getFullYear()
|
||||||
|
})
|
||||||
|
const monthRevenue = thisMonth.reduce((s, t) => s + t.amount, 0)
|
||||||
|
const activeSubs = this.subscriptions.filter(s => s.active).length
|
||||||
|
const mrr = this.subscriptions.filter(s => s.active).reduce((s, sub) => s + Number(sub.plan_price), 0)
|
||||||
|
const donorCount = new Set(this.donations.filter(d => d.email).map(d => d.email)).size
|
||||||
|
return { totalRevenue, monthRevenue, activeSubs, mrr, donorCount }
|
||||||
|
}
|
||||||
|
|
||||||
|
get monthlyData() {
|
||||||
|
const now = new Date()
|
||||||
|
const months = []
|
||||||
|
for (let i = 5; i >= 0; i--) {
|
||||||
|
const d = new Date(now.getFullYear(), now.getMonth() - i, 1)
|
||||||
|
months.push({ label: d.toLocaleString("en-US", { month: "short" }), year: d.getFullYear(), month: d.getMonth(), donations: 0, subscriptions: 0 })
|
||||||
|
}
|
||||||
|
this.donations.forEach(t => {
|
||||||
|
const d = new Date(t.created)
|
||||||
|
const m = months.find(m => m.year === d.getFullYear() && m.month === d.getMonth())
|
||||||
|
if (m) m.donations += Number(t.amount)
|
||||||
|
})
|
||||||
|
this.subscriptions.forEach(t => {
|
||||||
|
const d = new Date(t.created)
|
||||||
|
const m = months.find(m => m.year === d.getFullYear() && m.month === d.getMonth())
|
||||||
|
if (m) m.subscriptions += Number(t.plan_price)
|
||||||
|
})
|
||||||
|
return months
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
VStack(() => {
|
||||||
|
this.renderToolbar()
|
||||||
|
|
||||||
|
HStack(() => {
|
||||||
|
this.renderStats()
|
||||||
|
this.renderChart()
|
||||||
|
})
|
||||||
|
.paddingHorizontal(1.25, em)
|
||||||
|
.paddingBottom(1, em)
|
||||||
|
.gap(1, em)
|
||||||
|
.alignItems("stretch")
|
||||||
|
.flexShrink(0)
|
||||||
|
|
||||||
|
VStack(() => {}).height(1, px).background("var(--divider)").marginHorizontal(1.25, em).flexShrink(0)
|
||||||
|
|
||||||
|
this.renderTable()
|
||||||
|
})
|
||||||
|
.height(100, pct)
|
||||||
|
.width(100, pct)
|
||||||
|
.boxSizing("border-box")
|
||||||
|
.overflow("hidden")
|
||||||
|
.onAppear(async () => {
|
||||||
|
const data = await donationServer.getMoneyData(global.currentNetwork.id)
|
||||||
|
if(this.donations.length !== data.purchases.length || this.subscriptions.length !== data.subscriptions.length) {
|
||||||
|
this.donations = data.purchases || []
|
||||||
|
this.subscriptions = data.subscriptions || []
|
||||||
|
console.log(this.donations, this.subscriptions)
|
||||||
|
this.rerender()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
renderToolbar() {
|
||||||
|
HStack(() => {
|
||||||
|
HStack(() => {
|
||||||
|
this.filterTab("All", "all")
|
||||||
|
this.filterTab("Donations", "donations")
|
||||||
|
this.filterTab("Subscriptions", "subscriptions")
|
||||||
|
})
|
||||||
|
.background("var(--darkaccent)")
|
||||||
|
.border("1px solid var(--divider)")
|
||||||
|
.borderRadius(0.5, em)
|
||||||
|
.padding(0.2, em)
|
||||||
|
.gap(0.15, em)
|
||||||
|
.flexShrink(0)
|
||||||
|
|
||||||
|
input("", "180px")
|
||||||
|
.paddingHorizontal(0.65, em)
|
||||||
|
.paddingVertical(0.42, em)
|
||||||
|
.background("var(--darkaccent)")
|
||||||
|
.border("1px solid var(--divider)")
|
||||||
|
.borderRadius(0.45, em)
|
||||||
|
.fontSize(0.85, em)
|
||||||
|
.color("var(--headertext)")
|
||||||
|
.outline("none")
|
||||||
|
.onInput(v => { this.searchText = v; this.rerender() })
|
||||||
|
})
|
||||||
|
.marginTop(20, px)
|
||||||
|
.paddingHorizontal(1.25, em)
|
||||||
|
.paddingTop(1.1, em)
|
||||||
|
.paddingBottom(0.85, em)
|
||||||
|
.alignItems("center")
|
||||||
|
.gap(0.75, em)
|
||||||
|
.flexShrink(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
filterTab(label, value) {
|
||||||
|
const isActive = this.typeFilter === value
|
||||||
|
p(label)
|
||||||
|
.margin(0)
|
||||||
|
.fontSize(0.8, em)
|
||||||
|
.fontWeight(isActive ? "600" : "400")
|
||||||
|
.color("var(--headertext)")
|
||||||
|
.opacity(isActive ? 1 : 0.5)
|
||||||
|
.paddingHorizontal(0.7, em)
|
||||||
|
.paddingVertical(0.32, em)
|
||||||
|
.borderRadius(0.35, em)
|
||||||
|
.background(isActive ? "var(--app)" : "transparent")
|
||||||
|
.cursor("pointer")
|
||||||
|
.onClick((done) => { if(!done) return; this.typeFilter = value; this.rerender() })
|
||||||
|
}
|
||||||
|
|
||||||
|
renderStats() {
|
||||||
|
const s = this.stats
|
||||||
|
HStack(() => {
|
||||||
|
this.statCard("Total Revenue", this.fmt(s.totalRevenue), "📊")
|
||||||
|
this.statCard("This Month", this.fmt(s.monthRevenue), "📅")
|
||||||
|
this.statCard("Active Subs", String(s.activeSubs), "🔄")
|
||||||
|
this.statCard("MRR", this.fmt(s.mrr), "💰")
|
||||||
|
this.statCard("Donors", String(s.donorCount), "👥")
|
||||||
|
})
|
||||||
|
.gap(0.65, em)
|
||||||
|
.flexShrink(0)
|
||||||
|
.alignSelf("stretch")
|
||||||
|
}
|
||||||
|
|
||||||
|
statCard(label, value, icon) {
|
||||||
|
VStack(() => {
|
||||||
|
p(icon).margin(0).fontSize(1.05, em).lineHeight("1")
|
||||||
|
p(value)
|
||||||
|
.margin(0).marginTop(0.5, em)
|
||||||
|
.fontSize(1.2, em).fontWeight("700")
|
||||||
|
.color("var(--headertext)").lineHeight("1")
|
||||||
|
p(label)
|
||||||
|
.margin(0).marginTop(0.28, em)
|
||||||
|
.fontSize(0.68, em)
|
||||||
|
.color("var(--headertext)").opacity(0.42)
|
||||||
|
})
|
||||||
|
.padding(0.9, em)
|
||||||
|
.background("var(--darkaccent)")
|
||||||
|
.border("1px solid var(--divider)")
|
||||||
|
.borderRadius(0.6, em)
|
||||||
|
.minWidth(0)
|
||||||
|
.flex(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
renderChart() {
|
||||||
|
const months = this.monthlyData
|
||||||
|
const max = Math.max(...months.map(m => m.donations + m.subscriptions), 1)
|
||||||
|
|
||||||
|
VStack(() => {
|
||||||
|
p("Monthly Revenue")
|
||||||
|
.margin(0).marginBottom(0.6, em)
|
||||||
|
.fontSize(0.68, em).fontWeight("700").letterSpacing("0.06em")
|
||||||
|
.color("var(--headertext)").opacity(0.4)
|
||||||
|
|
||||||
|
HStack(() => {
|
||||||
|
months.forEach(m => {
|
||||||
|
const total = m.donations + m.subscriptions
|
||||||
|
const barPct = Math.max((total / max) * 100, total > 0 ? 3 : 0)
|
||||||
|
|
||||||
|
VStack(() => {
|
||||||
|
VStack(() => {
|
||||||
|
if (m.subscriptions > 0) {
|
||||||
|
VStack(() => {}).flex(m.subscriptions).background("#3b82f6")
|
||||||
|
}
|
||||||
|
if (m.donations > 0) {
|
||||||
|
VStack(() => {}).flex(m.donations).background("var(--quillred)")
|
||||||
|
}
|
||||||
|
if (total === 0) {
|
||||||
|
VStack(() => {}).flex(1).background("var(--divider)")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.height(barPct, pct)
|
||||||
|
.width(60, pct)
|
||||||
|
.borderRadius(3, px)
|
||||||
|
.overflow("hidden")
|
||||||
|
.alignSelf("center")
|
||||||
|
.minHeight(total === 0 ? 2 : barPct < 3 ? 3 : barPct, px)
|
||||||
|
|
||||||
|
p(m.label)
|
||||||
|
.margin(0).marginTop(0.4, em)
|
||||||
|
.fontSize(0.65, em)
|
||||||
|
.color("var(--headertext)").opacity(0.4)
|
||||||
|
.textAlign("center")
|
||||||
|
})
|
||||||
|
.flex(1)
|
||||||
|
.height(100, pct)
|
||||||
|
.alignItems("stretch")
|
||||||
|
.justifyContent("flex-end")
|
||||||
|
.boxSizing("border-box")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.flex(1)
|
||||||
|
.alignItems("flex-end")
|
||||||
|
.gap(0.3, em)
|
||||||
|
|
||||||
|
HStack(() => {
|
||||||
|
HStack(() => {
|
||||||
|
VStack(() => {}).width(8, px).height(8, px).borderRadius(50, pct).background("var(--quillred)").flexShrink(0)
|
||||||
|
p("Donations").margin(0).fontSize(0.65, em).color("var(--headertext)").opacity(0.45)
|
||||||
|
}).gap(0.3, em).alignItems("center")
|
||||||
|
|
||||||
|
HStack(() => {
|
||||||
|
VStack(() => {}).width(8, px).height(8, px).borderRadius(50, pct).background("#3b82f6").flexShrink(0)
|
||||||
|
p("Subscriptions").margin(0).fontSize(0.65, em).color("var(--headertext)").opacity(0.45)
|
||||||
|
}).gap(0.3, em).alignItems("center")
|
||||||
|
})
|
||||||
|
.gap(0.85, em)
|
||||||
|
.marginTop(0.55, em)
|
||||||
|
.flexShrink(0)
|
||||||
|
})
|
||||||
|
.padding(1, em)
|
||||||
|
.background("var(--darkaccent)")
|
||||||
|
.border("1px solid var(--divider)")
|
||||||
|
.borderRadius(0.6, em)
|
||||||
|
.flex(1)
|
||||||
|
.minWidth(0)
|
||||||
|
.height(160, px)
|
||||||
|
.boxSizing("border-box")
|
||||||
|
.flexShrink(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
renderTable() {
|
||||||
|
const txs = this.transactions
|
||||||
|
const COLS = [
|
||||||
|
{ label: "Date", key: "created", width: "140px" },
|
||||||
|
{ label: "Name", key: "name", width: "1fr" },
|
||||||
|
{ label: "Email", key: "email", width: "220px" },
|
||||||
|
{ label: "Type", key: "type", width: "130px" },
|
||||||
|
{ label: "Product", key: "product", width: "160px" },
|
||||||
|
{ label: "Amount", key: "amount", width: "110px" },
|
||||||
|
{ label: "Status", key: "status", width: "100px" },
|
||||||
|
]
|
||||||
|
const gridCols = COLS.map(c => c.width).join(" ")
|
||||||
|
|
||||||
|
VStack(() => {
|
||||||
|
// Header row
|
||||||
|
HStack(() => {
|
||||||
|
COLS.forEach(col => {
|
||||||
|
const isSort = this.sortKey === col.key
|
||||||
|
HStack(() => {
|
||||||
|
p(col.label)
|
||||||
|
.margin(0).fontSize(0.7, em).fontWeight("700").letterSpacing("0.05em")
|
||||||
|
.color("var(--headertext)").opacity(isSort ? 0.9 : 0.38)
|
||||||
|
if (isSort) {
|
||||||
|
p(this.sortDir === "asc" ? "↑" : "↓")
|
||||||
|
.margin(0).marginLeft(0.2, em).fontSize(0.65, em)
|
||||||
|
.color("var(--headertext)").opacity(0.5)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.alignItems("center")
|
||||||
|
.cursor("pointer")
|
||||||
|
.onClick((done) => {
|
||||||
|
if(!done) return
|
||||||
|
if (this.sortKey === col.key) this.sortDir = this.sortDir === "asc" ? "desc" : "asc"
|
||||||
|
else { this.sortKey = col.key; this.sortDir = "desc" }
|
||||||
|
this.rerender()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.attr({ style: `display: grid; grid-template-columns: ${gridCols};` })
|
||||||
|
.paddingHorizontal(1.25, em)
|
||||||
|
.paddingVertical(0.65, em)
|
||||||
|
.borderBottom("1px solid var(--divider)")
|
||||||
|
.flexShrink(0)
|
||||||
|
|
||||||
|
// Body
|
||||||
|
VStack(() => {
|
||||||
|
if (txs.length === 0) {
|
||||||
|
VStack(() => {
|
||||||
|
p("No transactions match your filters")
|
||||||
|
.margin(0).fontSize(0.9, em)
|
||||||
|
.color("var(--headertext)").opacity(0.4)
|
||||||
|
.textAlign("center")
|
||||||
|
})
|
||||||
|
.flex(1).justifyContent("center").alignItems("center")
|
||||||
|
} else {
|
||||||
|
txs.forEach((tx, i) => {
|
||||||
|
HStack(() => {
|
||||||
|
p(this.formatDate(tx.created))
|
||||||
|
.margin(0).fontSize(0.78, em)
|
||||||
|
.color("var(--headertext)").opacity(0.65)
|
||||||
|
|
||||||
|
p(tx.name)
|
||||||
|
.margin(0).fontSize(0.82, em).color("var(--headertext)")
|
||||||
|
.overflow("hidden").whiteSpace("nowrap").textOverflow("ellipsis")
|
||||||
|
.minWidth(0)
|
||||||
|
|
||||||
|
p(tx.email)
|
||||||
|
.margin(0).fontSize(0.75, em)
|
||||||
|
.color("var(--headertext)").opacity(0.5)
|
||||||
|
.overflow("hidden").whiteSpace("nowrap").textOverflow("ellipsis")
|
||||||
|
.minWidth(0)
|
||||||
|
|
||||||
|
HStack(() => {
|
||||||
|
p(tx.type === "donation" ? "Donation" : "Subscription")
|
||||||
|
.margin(0).fontSize(0.7, em).fontWeight("600")
|
||||||
|
.color(tx.type === "donation" ? "var(--quillred)" : "#3b82f6")
|
||||||
|
.background(tx.type === "donation" ? "rgba(159,28,41,0.12)" : "rgba(59,130,246,0.12)")
|
||||||
|
.paddingHorizontal(0.55, em).paddingVertical(0.18, em)
|
||||||
|
.borderRadius(100, px).whiteSpace("nowrap")
|
||||||
|
})
|
||||||
|
|
||||||
|
p(tx.product)
|
||||||
|
.margin(0).fontSize(0.78, em)
|
||||||
|
.color("var(--headertext)").opacity(0.65)
|
||||||
|
.overflow("hidden").whiteSpace("nowrap").textOverflow("ellipsis")
|
||||||
|
.minWidth(0)
|
||||||
|
|
||||||
|
p(this.fmt(tx.amount))
|
||||||
|
.margin(0).fontSize(0.85, em).fontWeight("600")
|
||||||
|
.color("var(--headertext)")
|
||||||
|
|
||||||
|
p(tx.status === "active" ? "Active" : tx.status === "complete" ? "Complete" : "Inactive")
|
||||||
|
.margin(0).fontSize(0.72, em).fontWeight("500")
|
||||||
|
.color(tx.status === "inactive" ? "var(--headertext)" : "#10b981")
|
||||||
|
.opacity(tx.status === "inactive" ? 0.35 : 1)
|
||||||
|
})
|
||||||
|
.attr({ style: `display: grid; grid-template-columns: ${gridCols}; align-items: center;` })
|
||||||
|
.paddingHorizontal(1.25, em)
|
||||||
|
.paddingVertical(0.65, em)
|
||||||
|
.background(i % 2 === 1 ? "var(--darkaccent)" : "transparent")
|
||||||
|
.borderBottom("1px solid var(--divider)")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.flex(1)
|
||||||
|
.overflowY("auto")
|
||||||
|
})
|
||||||
|
.flex(1)
|
||||||
|
.minHeight(0)
|
||||||
|
.overflow("hidden")
|
||||||
|
}
|
||||||
|
|
||||||
|
formatDate(time) {
|
||||||
|
return new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", year: "numeric" }).format(new Date(time))
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt(amount) {
|
||||||
|
return "$" + Number(amount).toLocaleString("en-US", { minimumFractionDigits: 0, maximumFractionDigits: 0 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
register(Donations)
|
||||||
305
donations/donations.js
Normal file
@@ -0,0 +1,305 @@
|
|||||||
|
import donationServer from "/donations/@server/donations.js"
|
||||||
|
import "/_/code/components/LoadingCircle.js"
|
||||||
|
|
||||||
|
css(`
|
||||||
|
donations- {
|
||||||
|
font-family: 'Arial';
|
||||||
|
scrollbar-width: none;
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
}
|
||||||
|
donations-::-webkit-scrollbar { display: none; }
|
||||||
|
donations- input::placeholder {
|
||||||
|
color: var(--headertext);
|
||||||
|
opacity: 0.35;
|
||||||
|
}
|
||||||
|
`)
|
||||||
|
|
||||||
|
class Donations extends Shadow {
|
||||||
|
donations = []
|
||||||
|
subscriptions = []
|
||||||
|
typeFilter = "all"
|
||||||
|
searchText = ""
|
||||||
|
searchOpen = false
|
||||||
|
|
||||||
|
get allTransactions() {
|
||||||
|
const d = this.donations.map(don => ({
|
||||||
|
id: `d-${don.id}`,
|
||||||
|
created: don.created,
|
||||||
|
name: don.name || "—",
|
||||||
|
email: don.email || "—",
|
||||||
|
amount: Number(don.amount),
|
||||||
|
type: "donation",
|
||||||
|
product: "Donation",
|
||||||
|
status: "complete"
|
||||||
|
}))
|
||||||
|
const s = this.subscriptions.map(sub => ({
|
||||||
|
id: `s-${sub.id}`,
|
||||||
|
created: sub.created,
|
||||||
|
name: [sub.first_name, sub.last_name].filter(Boolean).join(" ") || sub.email || "—",
|
||||||
|
email: sub.email || "—",
|
||||||
|
amount: Number(sub.plan_price),
|
||||||
|
type: "subscription",
|
||||||
|
product: sub.plan_name || "Subscription",
|
||||||
|
status: sub.active ? "active" : "inactive"
|
||||||
|
}))
|
||||||
|
return [...d, ...s].sort((a, b) => new Date(b.created) - new Date(a.created))
|
||||||
|
}
|
||||||
|
|
||||||
|
get transactions() {
|
||||||
|
let all = this.allTransactions
|
||||||
|
if (this.typeFilter === "donations") all = all.filter(t => t.type === "donation")
|
||||||
|
else if (this.typeFilter === "subscriptions") all = all.filter(t => t.type === "subscription")
|
||||||
|
if (this.searchText) {
|
||||||
|
const q = this.searchText.toLowerCase()
|
||||||
|
all = all.filter(t =>
|
||||||
|
t.name.toLowerCase().includes(q) ||
|
||||||
|
t.email.toLowerCase().includes(q) ||
|
||||||
|
t.product.toLowerCase().includes(q)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return all
|
||||||
|
}
|
||||||
|
|
||||||
|
get stats() {
|
||||||
|
const all = this.allTransactions
|
||||||
|
const totalRevenue = all.reduce((s, t) => s + t.amount, 0)
|
||||||
|
const now = new Date()
|
||||||
|
const monthRevenue = all
|
||||||
|
.filter(t => {
|
||||||
|
const d = new Date(t.created)
|
||||||
|
return d.getMonth() === now.getMonth() && d.getFullYear() === now.getFullYear()
|
||||||
|
})
|
||||||
|
.reduce((s, t) => s + t.amount, 0)
|
||||||
|
const activeSubs = this.subscriptions.filter(s => s.active).length
|
||||||
|
const mrr = this.subscriptions.filter(s => s.active).reduce((s, sub) => s + Number(sub.plan_price), 0)
|
||||||
|
const donorCount = new Set(this.donations.filter(d => d.email).map(d => d.email)).size
|
||||||
|
return { totalRevenue, monthRevenue, activeSubs, mrr, donorCount }
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
VStack(() => {
|
||||||
|
this.renderHeader()
|
||||||
|
this.renderStats()
|
||||||
|
this.renderFilters()
|
||||||
|
this.renderList()
|
||||||
|
})
|
||||||
|
.height(100, pct)
|
||||||
|
.width(100, pct)
|
||||||
|
.maxWidth(100, vw)
|
||||||
|
.boxSizing("border-box")
|
||||||
|
.overflow("hidden")
|
||||||
|
.onAppear(async () => {
|
||||||
|
const data = await donationServer.getMoneyData(global.currentNetwork.id)
|
||||||
|
if (
|
||||||
|
this.donations.length !== (data.purchases || []).length ||
|
||||||
|
this.subscriptions.length !== (data.subscriptions || []).length
|
||||||
|
) {
|
||||||
|
this.donations = data.purchases || []
|
||||||
|
this.subscriptions = data.subscriptions || []
|
||||||
|
this.rerender()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
renderHeader() {
|
||||||
|
|
||||||
|
if (this.searchOpen) {
|
||||||
|
HStack(() => {
|
||||||
|
input(this.searchText, "100%")
|
||||||
|
.paddingHorizontal(0.75, em)
|
||||||
|
.paddingVertical(0.5, em)
|
||||||
|
.background("var(--darkaccent)")
|
||||||
|
.border("1px solid var(--divider)")
|
||||||
|
.borderRadius(0.65, em)
|
||||||
|
.fontSize(0.9, em)
|
||||||
|
.color("var(--headertext)")
|
||||||
|
.outline("none")
|
||||||
|
.attr({ autofocus: "true" })
|
||||||
|
.onInput(v => { this.searchText = v; this.rerender() })
|
||||||
|
})
|
||||||
|
.paddingHorizontal(1.1, em)
|
||||||
|
.paddingBottom(0.65, em)
|
||||||
|
.flexShrink(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderStats() {
|
||||||
|
const s = this.stats
|
||||||
|
const cards = [
|
||||||
|
{ label: "Total", value: this.fmt(s.totalRevenue), icon: "📊" },
|
||||||
|
{ label: "This Month", value: this.fmt(s.monthRevenue), icon: "📅" },
|
||||||
|
{ label: "MRR", value: this.fmt(s.mrr), icon: "🔄" },
|
||||||
|
{ label: "Donors", value: String(s.donorCount), icon: "👥" },
|
||||||
|
{ label: "Active Subs", value: String(s.activeSubs), icon: "💰" },
|
||||||
|
]
|
||||||
|
|
||||||
|
HStack(() => {
|
||||||
|
cards.forEach(c => {
|
||||||
|
VStack(() => {
|
||||||
|
p(c.icon).margin(0).fontSize(1, em).lineHeight("1")
|
||||||
|
p(c.value)
|
||||||
|
.margin(0).marginTop(0.35, em)
|
||||||
|
.fontSize(1.05, em).fontWeight("700")
|
||||||
|
.color("var(--headertext)").lineHeight("1")
|
||||||
|
p(c.label)
|
||||||
|
.margin(0).marginTop(0.22, em)
|
||||||
|
.fontSize(0.62, em)
|
||||||
|
.color("var(--headertext)").opacity(0.42)
|
||||||
|
})
|
||||||
|
.padding(0.8, em)
|
||||||
|
.background("var(--darkaccent)")
|
||||||
|
.border("1px solid var(--divider)")
|
||||||
|
.borderRadius(0.75, em)
|
||||||
|
.alignItems("center")
|
||||||
|
.minWidth(80, px)
|
||||||
|
.flexShrink(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.paddingHorizontal(1.1, em)
|
||||||
|
.paddingBottom(0.75, em)
|
||||||
|
.gap(0.6, em)
|
||||||
|
.overflowX("auto")
|
||||||
|
.flexShrink(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
renderFilters() {
|
||||||
|
HStack(() => {
|
||||||
|
this.filterTab("All", "all")
|
||||||
|
this.filterTab("Donations", "donations")
|
||||||
|
this.filterTab("Subscriptions", "subscriptions")
|
||||||
|
})
|
||||||
|
.paddingHorizontal(1.1, em)
|
||||||
|
.paddingBottom(0.65, em)
|
||||||
|
.gap(0.4, em)
|
||||||
|
.flexShrink(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
filterTab(label, value) {
|
||||||
|
const isActive = this.typeFilter === value
|
||||||
|
p(label)
|
||||||
|
.margin(0)
|
||||||
|
.fontSize(0.8, em)
|
||||||
|
.fontWeight(isActive ? "600" : "400")
|
||||||
|
.color("var(--headertext)")
|
||||||
|
.opacity(isActive ? 1 : 0.5)
|
||||||
|
.paddingHorizontal(0.9, em)
|
||||||
|
.paddingVertical(0.45, em)
|
||||||
|
.borderRadius(100, px)
|
||||||
|
.background(isActive ? "var(--darkaccent)" : "transparent")
|
||||||
|
.border(`1px solid ${isActive ? "var(--divider)" : "transparent"}`)
|
||||||
|
.cursor("pointer")
|
||||||
|
.onTouch((start) => {
|
||||||
|
if (!start) {
|
||||||
|
this.typeFilter = value
|
||||||
|
this.rerender()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
renderList() {
|
||||||
|
const txs = this.transactions
|
||||||
|
|
||||||
|
VStack(() => {
|
||||||
|
if (this.donations.length === 0 && this.subscriptions.length === 0) {
|
||||||
|
LoadingCircle()
|
||||||
|
} else if (txs.length === 0) {
|
||||||
|
VStack(() => {
|
||||||
|
p("No transactions match your filters")
|
||||||
|
.margin(0).fontSize(0.9, em)
|
||||||
|
.color("var(--headertext)").opacity(0.38)
|
||||||
|
.textAlign("center")
|
||||||
|
}).flex(1).justifyContent("center").alignItems("center")
|
||||||
|
} else {
|
||||||
|
txs.forEach(tx => this.renderCard(tx))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.flex(1)
|
||||||
|
.minHeight(0)
|
||||||
|
.overflowY("auto")
|
||||||
|
.paddingHorizontal(1.1, em)
|
||||||
|
.paddingBottom(1.5, em)
|
||||||
|
.gap(0.6, em)
|
||||||
|
}
|
||||||
|
|
||||||
|
renderCard(tx) {
|
||||||
|
VStack(() => {
|
||||||
|
HStack(() => {
|
||||||
|
// Name + type badge
|
||||||
|
VStack(() => {
|
||||||
|
p(tx.name)
|
||||||
|
.margin(0)
|
||||||
|
.fontSize(0.92, em)
|
||||||
|
.fontWeight("600")
|
||||||
|
.color("var(--headertext)")
|
||||||
|
.overflow("hidden")
|
||||||
|
.whiteSpace("nowrap")
|
||||||
|
.textOverflow("ellipsis")
|
||||||
|
.maxWidth(180, px)
|
||||||
|
|
||||||
|
p(tx.email)
|
||||||
|
.margin(0).marginTop(0.18, em)
|
||||||
|
.fontSize(0.72, em)
|
||||||
|
.color("var(--headertext)").opacity(0.45)
|
||||||
|
.overflow("hidden")
|
||||||
|
.whiteSpace("nowrap")
|
||||||
|
.textOverflow("ellipsis")
|
||||||
|
.maxWidth(180, px)
|
||||||
|
})
|
||||||
|
.flex(1).minWidth(0).alignItems("flex-start")
|
||||||
|
|
||||||
|
// Amount + status
|
||||||
|
VStack(() => {
|
||||||
|
p(this.fmt(tx.amount))
|
||||||
|
.margin(0)
|
||||||
|
.fontSize(1, em).fontWeight("700")
|
||||||
|
.color("var(--headertext)")
|
||||||
|
.textAlign("right")
|
||||||
|
|
||||||
|
p(tx.status === "active" ? "Active" : tx.status === "complete" ? "Complete" : "Inactive")
|
||||||
|
.margin(0).marginTop(0.2, em)
|
||||||
|
.fontSize(0.68, em).fontWeight("500")
|
||||||
|
.color(tx.status === "inactive" ? "var(--headertext)" : "#10b981")
|
||||||
|
.opacity(tx.status === "inactive" ? 0.35 : 1)
|
||||||
|
.textAlign("right")
|
||||||
|
})
|
||||||
|
.alignItems("flex-end").flexShrink(0)
|
||||||
|
})
|
||||||
|
.alignItems("flex-start")
|
||||||
|
|
||||||
|
HStack(() => {
|
||||||
|
// Type badge
|
||||||
|
p(tx.type === "donation" ? "Donation" : tx.product)
|
||||||
|
.margin(0)
|
||||||
|
.fontSize(0.68, em).fontWeight("600")
|
||||||
|
.color(tx.type === "donation" ? "var(--quillred)" : "#3b82f6")
|
||||||
|
.background(tx.type === "donation" ? "rgba(159,28,41,0.12)" : "rgba(59,130,246,0.12)")
|
||||||
|
.paddingHorizontal(0.55, em).paddingVertical(0.2, em)
|
||||||
|
.borderRadius(100, px)
|
||||||
|
.whiteSpace("nowrap")
|
||||||
|
|
||||||
|
p(this.formatDate(tx.created))
|
||||||
|
.margin(0)
|
||||||
|
.fontSize(0.72, em)
|
||||||
|
.color("var(--headertext)").opacity(0.38)
|
||||||
|
.flex(1).textAlign("right")
|
||||||
|
})
|
||||||
|
.marginTop(0.65, em)
|
||||||
|
.alignItems("center")
|
||||||
|
.gap(0.5, em)
|
||||||
|
})
|
||||||
|
.padding(0.95, em)
|
||||||
|
.background("var(--darkaccent)")
|
||||||
|
.border("1px solid var(--divider)")
|
||||||
|
.borderRadius(0.75, em)
|
||||||
|
}
|
||||||
|
|
||||||
|
formatDate(time) {
|
||||||
|
return new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", year: "numeric" }).format(new Date(time))
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt(amount) {
|
||||||
|
return "$" + Number(amount).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
register(Donations)
|
||||||
5
donations/icons/donations.svg
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<svg width="153" height="148" viewBox="0 0 153 148" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M150.67 65.9346L148.276 66.5188C139.897 68.5566 139.251 73.9716 138.5 80.2463C138.111 83.4763 137.674 87.1338 136.277 91.4801C134.795 96.1161 131.608 99.4316 128.273 101.75C130.202 99.3176 132.154 96.4723 133.788 93.2898C135.688 89.5896 135.835 86.7063 134.216 84.7256C131.518 81.4006 125.381 82.8968 124.184 83.2388L123.381 83.4668L122.892 84.1366C122.835 84.2173 116.712 92.4443 96.0115 106.96C73.145 122.987 84.8442 146.48 84.9725 146.718L85.6327 148H106.49L106.609 145.754C107.122 136.216 114.84 130.52 124.616 123.31C129.489 119.714 135.014 115.639 140.196 110.594C156.379 94.8383 151.377 69.4163 151.164 68.3428L150.67 65.9346ZM136.89 107.193C131.94 112.014 126.787 115.814 121.799 119.491C112.123 126.63 103.687 132.853 102.101 143.25H88.6205C86.7252 138.61 81.8802 122.659 98.7475 110.841C116.978 98.0636 124.236 90.0123 126.183 87.6373C128.188 87.2098 130.197 87.3048 130.544 87.7276C130.544 87.7276 130.905 88.5208 129.575 91.1191C124.744 100.519 116.588 106.965 116.503 107.027L108.532 113.259L118.441 111.231C119.158 111.083 136.154 107.445 140.809 92.9288C142.338 88.1503 142.827 84.0843 143.217 80.8116C143.811 75.8716 144.105 73.4253 146.908 72.0051C147.668 78.6361 148.356 96.0306 136.89 107.193Z" fill="black"/>
|
||||||
|
<path d="M27.4453 123.314C37.2208 130.525 44.9396 136.22 45.4526 145.758L45.5761 148H66.4333L67.0936 146.713C67.2171 146.475 78.921 122.982 56.0545 106.955C35.354 92.4394 29.2313 84.2124 29.1743 84.1317L28.6756 83.4524L27.8776 83.2339C26.6806 82.8967 20.5483 81.3957 17.8455 84.7207C16.2258 86.7014 16.3683 89.5799 18.273 93.2849C19.907 96.4674 21.8593 99.3174 23.7878 101.745C20.4581 99.4267 17.2708 96.1064 15.784 91.4752C14.3875 87.1289 13.9505 83.4667 13.561 80.2414C12.8153 73.9714 12.1646 68.5564 3.78555 66.5139L1.39155 65.9297L0.89755 68.3427C0.67905 69.4162 -4.3227 94.8382 11.8653 110.594C17.0475 115.643 22.5718 119.719 27.4453 123.314ZM5.16305 72.0002C7.96555 73.4204 8.26005 75.8667 8.84905 80.8067C9.23855 84.0747 9.7278 88.1454 11.2573 92.9239C15.9123 107.44 32.9078 111.074 33.6251 111.226L43.5763 113.263L35.5631 107.022C35.4776 106.955 27.3456 100.557 22.4911 91.1142C21.1611 88.5159 21.5221 87.7227 21.5221 87.7227C21.8688 87.2999 23.8781 87.2097 25.8826 87.6324C27.8301 90.0074 35.0881 98.0539 53.3186 110.836C70.1098 122.607 65.317 138.59 63.436 143.245H49.9651C48.3786 132.852 39.9425 126.625 30.2668 119.486C25.2793 115.814 20.1255 112.009 15.176 107.188C3.7428 96.0589 4.41255 78.6502 5.16305 72.0002Z" fill="black"/>
|
||||||
|
<path d="M75.5209 70.0237C69.6806 70.0237 64.2585 67.9849 60.5019 65.4083L58 72.6003C61.652 75.2815 67.9106 77.2158 73.9525 77.3204V88.1645H80.5247V76.9992C91.2643 75.1695 97 67.2231 97 58.739C97 49.1794 91.5779 43.5931 80.8384 39.0896C71.7643 35.221 67.9106 32.4353 67.9106 26.8489C67.9106 22.5546 70.9353 17.618 78.9638 17.618C85.6406 17.618 89.9125 19.978 92.1007 21.27L94.7072 14.1825C91.6825 12.3527 87.4031 10.6425 81.1521 10.4259V0H74.6844V10.8516C65.0875 12.4573 58.941 19.2237 58.941 28.1409C58.941 37.4839 65.5132 42.4205 76.1482 46.7149C83.8631 49.9337 87.9334 53.5858 87.9334 59.4933C87.9259 65.6174 82.9221 70.0237 75.5209 70.0237Z" fill="black"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.2 KiB |
5
donations/icons/donationslight.svg
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<svg width="153" height="148" viewBox="0 0 153 148" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M150.67 65.9346L148.276 66.5188C139.897 68.5566 139.251 73.9716 138.5 80.2463C138.111 83.4763 137.674 87.1338 136.277 91.4801C134.795 96.1161 131.608 99.4316 128.273 101.75C130.202 99.3176 132.154 96.4723 133.788 93.2898C135.688 89.5896 135.835 86.7063 134.216 84.7256C131.518 81.4006 125.381 82.8968 124.184 83.2388L123.381 83.4668L122.892 84.1366C122.835 84.2173 116.712 92.4443 96.0115 106.96C73.145 122.987 84.8442 146.48 84.9725 146.718L85.6327 148H106.49L106.609 145.754C107.122 136.216 114.84 130.52 124.616 123.31C129.489 119.714 135.014 115.639 140.196 110.594C156.379 94.8383 151.377 69.4163 151.164 68.3428L150.67 65.9346ZM136.89 107.193C131.94 112.014 126.787 115.814 121.799 119.491C112.123 126.63 103.687 132.853 102.101 143.25H88.6205C86.7252 138.61 81.8802 122.659 98.7475 110.841C116.978 98.0636 124.236 90.0123 126.183 87.6373C128.188 87.2098 130.197 87.3048 130.544 87.7276C130.544 87.7276 130.905 88.5208 129.575 91.1191C124.744 100.519 116.588 106.965 116.503 107.027L108.532 113.259L118.441 111.231C119.158 111.083 136.154 107.445 140.809 92.9288C142.338 88.1503 142.827 84.0843 143.217 80.8116C143.811 75.8716 144.105 73.4253 146.908 72.0051C147.668 78.6361 148.356 96.0306 136.89 107.193Z" fill="#FFE9C8"/>
|
||||||
|
<path d="M27.4453 123.314C37.2208 130.525 44.9396 136.22 45.4526 145.758L45.5761 148H66.4333L67.0936 146.713C67.2171 146.475 78.921 122.982 56.0545 106.955C35.354 92.4394 29.2313 84.2124 29.1743 84.1317L28.6756 83.4524L27.8776 83.2339C26.6806 82.8967 20.5483 81.3957 17.8455 84.7207C16.2258 86.7014 16.3683 89.5799 18.273 93.2849C19.907 96.4674 21.8593 99.3174 23.7878 101.745C20.4581 99.4267 17.2708 96.1064 15.784 91.4752C14.3875 87.1289 13.9505 83.4667 13.561 80.2414C12.8153 73.9714 12.1646 68.5564 3.78555 66.5139L1.39155 65.9297L0.89755 68.3427C0.67905 69.4162 -4.3227 94.8382 11.8653 110.594C17.0475 115.643 22.5718 119.719 27.4453 123.314ZM5.16305 72.0002C7.96555 73.4204 8.26005 75.8667 8.84905 80.8067C9.23855 84.0747 9.7278 88.1454 11.2573 92.9239C15.9123 107.44 32.9078 111.074 33.6251 111.226L43.5763 113.263L35.5631 107.022C35.4776 106.955 27.3456 100.557 22.4911 91.1142C21.1611 88.5159 21.5221 87.7227 21.5221 87.7227C21.8688 87.2999 23.8781 87.2097 25.8826 87.6324C27.8301 90.0074 35.0881 98.0539 53.3186 110.836C70.1098 122.607 65.317 138.59 63.436 143.245H49.9651C48.3786 132.852 39.9425 126.625 30.2668 119.486C25.2793 115.814 20.1255 112.009 15.176 107.188C3.7428 96.0589 4.41255 78.6502 5.16305 72.0002Z" fill="#FFE9C8"/>
|
||||||
|
<path d="M75.5209 70.0237C69.6806 70.0237 64.2585 67.9849 60.5019 65.4083L58 72.6003C61.652 75.2815 67.9106 77.2158 73.9525 77.3204V88.1645H80.5247V76.9992C91.2643 75.1695 97 67.2231 97 58.739C97 49.1794 91.5779 43.5931 80.8384 39.0896C71.7643 35.221 67.9106 32.4353 67.9106 26.8489C67.9106 22.5546 70.9353 17.618 78.9638 17.618C85.6406 17.618 89.9125 19.978 92.1007 21.27L94.7072 14.1825C91.6825 12.3527 87.4031 10.6425 81.1521 10.4259V0H74.6844V10.8516C65.0875 12.4573 58.941 19.2237 58.941 28.1409C58.941 37.4839 65.5132 42.4205 76.1482 46.7149C83.8631 49.9337 87.9334 53.5858 87.9334 59.4933C87.9259 65.6174 82.9221 70.0237 75.5209 70.0237Z" fill="#FFE9C8"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.2 KiB |
21
donations/server/functions.js
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
export async function getMoneyData(networkId) {
|
||||||
|
const purchases = await this.sql`
|
||||||
|
SELECT * FROM purchases
|
||||||
|
WHERE network_id = ${networkId}
|
||||||
|
ORDER BY created DESC
|
||||||
|
`;
|
||||||
|
|
||||||
|
const subscriptions = await this.sql`
|
||||||
|
SELECT mn.id, mn.created, mn.active, mn.network_plan_id,
|
||||||
|
np.name AS plan_name, np.price AS plan_price,
|
||||||
|
m.first_name, m.last_name, m.email
|
||||||
|
FROM member_networks mn
|
||||||
|
JOIN network_plans np ON mn.network_plan_id = np.id
|
||||||
|
LEFT JOIN members m ON mn.member_id = m.id
|
||||||
|
WHERE mn.network_id = ${networkId}
|
||||||
|
AND mn.network_plan_id IS NOT NULL
|
||||||
|
ORDER BY mn.created DESC
|
||||||
|
`;
|
||||||
|
|
||||||
|
return { purchases, subscriptions }
|
||||||
|
}
|
||||||
408
files/desktop/DesktopFilesGrid.js
Normal file
@@ -0,0 +1,408 @@
|
|||||||
|
class DesktopFilesGrid extends Shadow {
|
||||||
|
constructor(files, groups, viewMode, onFileClick) {
|
||||||
|
super()
|
||||||
|
this.files = files
|
||||||
|
this.groups = groups
|
||||||
|
this.viewMode = viewMode
|
||||||
|
this.onFileClick = onFileClick
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.files.length === 0) {
|
||||||
|
VStack(() => {
|
||||||
|
p("🗂")
|
||||||
|
.margin(0)
|
||||||
|
.fontSize(2.5, em)
|
||||||
|
.opacity(0.18)
|
||||||
|
p("No files here")
|
||||||
|
.margin(0)
|
||||||
|
.marginTop(0.65, em)
|
||||||
|
.fontSize(0.9, em)
|
||||||
|
.color("var(--headertext)")
|
||||||
|
.opacity(0.32)
|
||||||
|
})
|
||||||
|
.flex(1)
|
||||||
|
.justifyContent("center")
|
||||||
|
.alignItems("center")
|
||||||
|
.height(100, pct)
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.viewMode === "list") {
|
||||||
|
this.renderListView()
|
||||||
|
} else {
|
||||||
|
this.renderGridView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderListView() {
|
||||||
|
VStack(() => {
|
||||||
|
// Header row
|
||||||
|
HStack(() => {
|
||||||
|
p("Name") .styles(this.colHeader).flex(1)
|
||||||
|
p("Shared with").styles(this.colHeader).width(160, px).flexShrink(0)
|
||||||
|
p("Modified") .styles(this.colHeader).width(130, px).flexShrink(0)
|
||||||
|
p("Size") .styles(this.colHeader).width(80, px).flexShrink(0)
|
||||||
|
p("Group") .styles(this.colHeader).width(120, px).flexShrink(0)
|
||||||
|
})
|
||||||
|
.paddingHorizontal(1.25, em)
|
||||||
|
.paddingVertical(0.45, em)
|
||||||
|
.borderBottom("1px solid var(--divider)")
|
||||||
|
.flexShrink(0)
|
||||||
|
|
||||||
|
VStack(() => {
|
||||||
|
this.files.forEach((file, i) => this.renderListRow(file, i))
|
||||||
|
})
|
||||||
|
.overflowY("auto")
|
||||||
|
.flex(1)
|
||||||
|
})
|
||||||
|
.width(100, pct)
|
||||||
|
.height(100, pct)
|
||||||
|
.overflow("hidden")
|
||||||
|
}
|
||||||
|
|
||||||
|
renderListRow(file, index) {
|
||||||
|
const self = this
|
||||||
|
HStack(() => {
|
||||||
|
// Icon + name
|
||||||
|
HStack(() => {
|
||||||
|
p(this.fileIcon(file))
|
||||||
|
.margin(0)
|
||||||
|
.fontSize(1.15, em)
|
||||||
|
.lineHeight("1")
|
||||||
|
.flexShrink(0)
|
||||||
|
|
||||||
|
VStack(() => {
|
||||||
|
p(file.name)
|
||||||
|
.margin(0)
|
||||||
|
.fontSize(0.88, em)
|
||||||
|
.fontWeight("500")
|
||||||
|
.color("var(--headertext)")
|
||||||
|
.overflow("hidden")
|
||||||
|
.whiteSpace("nowrap")
|
||||||
|
.textOverflow("ellipsis")
|
||||||
|
})
|
||||||
|
.flex(1)
|
||||||
|
.minWidth(0)
|
||||||
|
})
|
||||||
|
.gap(0.6, em)
|
||||||
|
.flex(1)
|
||||||
|
.minWidth(0)
|
||||||
|
.alignItems("center")
|
||||||
|
|
||||||
|
// Active editors badge
|
||||||
|
HStack(() => {
|
||||||
|
if (file.activeEditors && file.activeEditors.length > 0) {
|
||||||
|
this.renderActiveEditors(file.activeEditors, "list")
|
||||||
|
} else if (file.sharedWith && file.sharedWith.length > 0) {
|
||||||
|
this.renderSharedAvatars(file.sharedWith)
|
||||||
|
} else {
|
||||||
|
p("—").margin(0).fontSize(0.8, em).color("var(--headertext)").opacity(0.2)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.width(160, px)
|
||||||
|
.flexShrink(0)
|
||||||
|
.alignItems("center")
|
||||||
|
.gap(0.3, em)
|
||||||
|
|
||||||
|
// Modified
|
||||||
|
p(this.relativeDate(file.modifiedAt))
|
||||||
|
.margin(0)
|
||||||
|
.fontSize(0.78, em)
|
||||||
|
.color("var(--headertext)")
|
||||||
|
.opacity(0.45)
|
||||||
|
.width(130, px)
|
||||||
|
.flexShrink(0)
|
||||||
|
.overflow("hidden")
|
||||||
|
.whiteSpace("nowrap")
|
||||||
|
|
||||||
|
// Size
|
||||||
|
p(file.size || "—")
|
||||||
|
.margin(0)
|
||||||
|
.fontSize(0.78, em)
|
||||||
|
.color("var(--headertext)")
|
||||||
|
.opacity(0.45)
|
||||||
|
.width(80, px)
|
||||||
|
.flexShrink(0)
|
||||||
|
|
||||||
|
// Group tag
|
||||||
|
HStack(() => {
|
||||||
|
const group = this.groups.find(g => g.id === file.groupId);
|
||||||
|
if (group) {
|
||||||
|
VStack(() => {})
|
||||||
|
.width(0.5, em).height(0.5, em)
|
||||||
|
.borderRadius(50, pct)
|
||||||
|
.background(group.color)
|
||||||
|
.flexShrink(0)
|
||||||
|
p(group.name)
|
||||||
|
.margin(0)
|
||||||
|
.fontSize(0.72, em)
|
||||||
|
.color("var(--headertext)")
|
||||||
|
.opacity(0.55)
|
||||||
|
.overflow("hidden")
|
||||||
|
.whiteSpace("nowrap")
|
||||||
|
.textOverflow("ellipsis")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.gap(0.38, em)
|
||||||
|
.width(120, px)
|
||||||
|
.flexShrink(0)
|
||||||
|
.alignItems("center")
|
||||||
|
.minWidth(0)
|
||||||
|
})
|
||||||
|
.paddingHorizontal(1.25, em)
|
||||||
|
.paddingVertical(0.62, em)
|
||||||
|
.borderBottom("1px solid var(--divider)")
|
||||||
|
.cursor("pointer")
|
||||||
|
.alignItems("center")
|
||||||
|
.width(100, pct)
|
||||||
|
.boxSizing("border-box")
|
||||||
|
.onClick(function(done){ if(done){ self.onFileClick(file) } })
|
||||||
|
}
|
||||||
|
|
||||||
|
renderGridView() {
|
||||||
|
VStack(() => {
|
||||||
|
ZStack(() => {
|
||||||
|
this.files.forEach(file => this.renderGridCard(file))
|
||||||
|
})
|
||||||
|
.display("grid")
|
||||||
|
.attr({ style: "display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 1em;" })
|
||||||
|
})
|
||||||
|
.padding(1.5, em)
|
||||||
|
.overflowY("auto")
|
||||||
|
.height(100, pct)
|
||||||
|
.width(100, pct)
|
||||||
|
.boxSizing("border-box")
|
||||||
|
}
|
||||||
|
|
||||||
|
renderGridCard(file) {
|
||||||
|
VStack(() => {
|
||||||
|
// Thumbnail area
|
||||||
|
ZStack(() => {
|
||||||
|
// File type large icon
|
||||||
|
VStack(() => {
|
||||||
|
p(this.fileIcon(file))
|
||||||
|
.margin(0)
|
||||||
|
.fontSize(2.4, em)
|
||||||
|
.lineHeight("1")
|
||||||
|
})
|
||||||
|
.width(100, pct)
|
||||||
|
.height(100, pct)
|
||||||
|
.justifyContent("center")
|
||||||
|
.alignItems("center")
|
||||||
|
.background(this.fileTint(file))
|
||||||
|
|
||||||
|
// Active editor badge (top-right)
|
||||||
|
if (file.activeEditors && file.activeEditors.length > 0) {
|
||||||
|
VStack(() => {
|
||||||
|
this.renderActiveEditors(file.activeEditors, "grid")
|
||||||
|
})
|
||||||
|
.position("absolute")
|
||||||
|
.top(0.45, em)
|
||||||
|
.right(0.45, em)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group color stripe (bottom of thumbnail)
|
||||||
|
VStack(() => {
|
||||||
|
const group = this.groups.find(g => g.id === file.groupId);
|
||||||
|
if (group) {
|
||||||
|
VStack(() => {})
|
||||||
|
.width(100, pct)
|
||||||
|
.height(3, px)
|
||||||
|
.background(group.color)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.position("absolute")
|
||||||
|
.bottom(0).left(0).right(0)
|
||||||
|
})
|
||||||
|
.position("relative")
|
||||||
|
.width(100, pct)
|
||||||
|
.height(6.5, em)
|
||||||
|
.borderRadius("0.5em 0.5em 0 0")
|
||||||
|
.overflow("hidden")
|
||||||
|
.flexShrink(0)
|
||||||
|
|
||||||
|
// Info area
|
||||||
|
VStack(() => {
|
||||||
|
p(file.name)
|
||||||
|
.margin(0)
|
||||||
|
.fontSize(0.8, em)
|
||||||
|
.fontWeight("600")
|
||||||
|
.color("var(--headertext)")
|
||||||
|
.overflow("hidden")
|
||||||
|
.whiteSpace("nowrap")
|
||||||
|
.textOverflow("ellipsis")
|
||||||
|
.width(100, pct)
|
||||||
|
|
||||||
|
p(this.relativeDate(file.modifiedAt))
|
||||||
|
.margin(0)
|
||||||
|
.marginTop(0.18, em)
|
||||||
|
.fontSize(0.68, em)
|
||||||
|
.color("var(--headertext)")
|
||||||
|
.opacity(0.38)
|
||||||
|
})
|
||||||
|
.padding(0.65, em)
|
||||||
|
.background("var(--darkaccent)")
|
||||||
|
.borderRadius("0 0 0.5em 0.5em")
|
||||||
|
.width(100, pct)
|
||||||
|
.boxSizing("border-box")
|
||||||
|
})
|
||||||
|
.border("1px solid var(--divider)")
|
||||||
|
.borderRadius(0.5, em)
|
||||||
|
.overflow("hidden")
|
||||||
|
.cursor("pointer")
|
||||||
|
.onClick((done) => {if(!done) return; this.onFileClick(file)})
|
||||||
|
}
|
||||||
|
|
||||||
|
renderActiveEditors(editors, context) {
|
||||||
|
const isGrid = context === "grid";
|
||||||
|
|
||||||
|
HStack(() => {
|
||||||
|
// Pulsing green dot
|
||||||
|
ZStack(() => {
|
||||||
|
VStack(() => {})
|
||||||
|
.width(isGrid ? 0.55 : 0.48, em)
|
||||||
|
.height(isGrid ? 0.55 : 0.48, em)
|
||||||
|
.borderRadius(50, pct)
|
||||||
|
.background("#22c55e")
|
||||||
|
.opacity(0.4)
|
||||||
|
.attr({ style: "animation: pulse-ring 1.5s ease-out infinite;" })
|
||||||
|
|
||||||
|
VStack(() => {})
|
||||||
|
.width(isGrid ? 0.4 : 0.35, em)
|
||||||
|
.height(isGrid ? 0.4 : 0.35, em)
|
||||||
|
.borderRadius(50, pct)
|
||||||
|
.background("#22c55e")
|
||||||
|
.position("absolute")
|
||||||
|
})
|
||||||
|
.position("relative")
|
||||||
|
.width(isGrid ? 0.55 : 0.48, em)
|
||||||
|
.height(isGrid ? 0.55 : 0.48, em)
|
||||||
|
.flexShrink(0)
|
||||||
|
|
||||||
|
// Editor avatar stack
|
||||||
|
HStack(() => {
|
||||||
|
editors.slice(0, 3).forEach((editor, i) => {
|
||||||
|
VStack(() => {
|
||||||
|
p(editor[0].toUpperCase())
|
||||||
|
.margin(0)
|
||||||
|
.fontSize(isGrid ? 0.5 : 0.45, em)
|
||||||
|
.fontWeight("700")
|
||||||
|
.color("white")
|
||||||
|
.lineHeight("1")
|
||||||
|
})
|
||||||
|
.width(isGrid ? 1.4 : 1.25, em)
|
||||||
|
.height(isGrid ? 1.4 : 1.25, em)
|
||||||
|
.borderRadius(50, pct)
|
||||||
|
.background(this.avatarColor(editor))
|
||||||
|
.justifyContent("center")
|
||||||
|
.alignItems("center")
|
||||||
|
.boxSizing("border-box")
|
||||||
|
.flexShrink(0)
|
||||||
|
.marginLeft(i > 0 ? -0.4 : 0, em)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.alignItems("center")
|
||||||
|
|
||||||
|
if (!isGrid) {
|
||||||
|
p(editors.length === 1
|
||||||
|
? `${editors[0]} is editing`
|
||||||
|
: `${editors.length} editing`)
|
||||||
|
.margin(0)
|
||||||
|
.fontSize(0.72, em)
|
||||||
|
.color("#22c55e")
|
||||||
|
.fontWeight("500")
|
||||||
|
.whiteSpace("nowrap")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.gap(isGrid ? 0.25 : 0.45, em)
|
||||||
|
.alignItems("center")
|
||||||
|
.paddingHorizontal(isGrid ? 0.4 : 0)
|
||||||
|
.paddingVertical(isGrid ? 0.22 : 0)
|
||||||
|
.background(isGrid ? "rgba(0,0,0,0.55)" : "transparent")
|
||||||
|
.borderRadius(isGrid ? 100 : 0, px)
|
||||||
|
}
|
||||||
|
|
||||||
|
renderSharedAvatars(people) {
|
||||||
|
HStack(() => {
|
||||||
|
people.slice(0, 4).forEach((name, i) => {
|
||||||
|
VStack(() => {
|
||||||
|
p(name[0].toUpperCase())
|
||||||
|
.margin(0)
|
||||||
|
.fontSize(0.45, em)
|
||||||
|
.fontWeight("700")
|
||||||
|
.color("white")
|
||||||
|
.lineHeight("1")
|
||||||
|
})
|
||||||
|
.width(1.25, em)
|
||||||
|
.height(1.25, em)
|
||||||
|
.borderRadius(50, pct)
|
||||||
|
.background(this.avatarColor(name))
|
||||||
|
.justifyContent("center")
|
||||||
|
.alignItems("center")
|
||||||
|
.boxSizing("border-box")
|
||||||
|
.flexShrink(0)
|
||||||
|
.marginLeft(i > 0 ? -0.38 : 0, em)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.alignItems("center")
|
||||||
|
}
|
||||||
|
|
||||||
|
colHeader(el) {
|
||||||
|
return el
|
||||||
|
.margin(0)
|
||||||
|
.fontSize(0.72, em)
|
||||||
|
.fontWeight("600")
|
||||||
|
.letterSpacing("0.03em")
|
||||||
|
.color("var(--headertext)")
|
||||||
|
.opacity(0.4)
|
||||||
|
}
|
||||||
|
|
||||||
|
fileIcon(file) {
|
||||||
|
const ext = file.name.split(".").pop().toLowerCase();
|
||||||
|
const map = {
|
||||||
|
pdf: "📄", doc: "📝", docx: "📝", txt: "📃", md: "📃",
|
||||||
|
xls: "📊", xlsx: "📊", csv: "📊",
|
||||||
|
ppt: "📋", pptx: "📋",
|
||||||
|
jpg: "🖼", jpeg: "🖼", png: "🖼", gif: "🖼", svg: "🖼", webp: "🖼",
|
||||||
|
mp4: "🎬", mov: "🎬", avi: "🎬",
|
||||||
|
mp3: "🎵", wav: "🎵",
|
||||||
|
zip: "🗜", rar: "🗜",
|
||||||
|
js: "⚙️", ts: "⚙️", py: "⚙️", json: "⚙️",
|
||||||
|
folder: "📁",
|
||||||
|
};
|
||||||
|
if (file.type === "folder") return "📁";
|
||||||
|
return map[ext] || "📄";
|
||||||
|
}
|
||||||
|
|
||||||
|
fileTint(file) {
|
||||||
|
const ext = file.name.split(".").pop().toLowerCase();
|
||||||
|
const tints = {
|
||||||
|
pdf: "rgba(239,68,68,0.08)", doc: "rgba(59,130,246,0.08)", docx: "rgba(59,130,246,0.08)",
|
||||||
|
xls: "rgba(16,185,129,0.08)", xlsx: "rgba(16,185,129,0.08)", csv: "rgba(16,185,129,0.08)",
|
||||||
|
jpg: "rgba(245,158,11,0.08)", jpeg: "rgba(245,158,11,0.08)", png: "rgba(245,158,11,0.08)",
|
||||||
|
mp4: "rgba(139,92,246,0.08)", mov: "rgba(139,92,246,0.08)",
|
||||||
|
zip: "rgba(107,114,128,0.08)",
|
||||||
|
};
|
||||||
|
if (file.type === "folder") return "rgba(59,130,246,0.06)";
|
||||||
|
return tints[ext] || "var(--darkaccent)";
|
||||||
|
}
|
||||||
|
|
||||||
|
relativeDate(date) {
|
||||||
|
const d = new Date(date);
|
||||||
|
const days = Math.floor((Date.now() - d) / 86400000);
|
||||||
|
if (days === 0) return "Today";
|
||||||
|
if (days === 1) return "Yesterday";
|
||||||
|
if (days < 7) return `${days} days ago`;
|
||||||
|
return d.toLocaleDateString([], { month: "short", day: "numeric", year: "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(DesktopFilesGrid)
|
||||||
175
files/desktop/DesktopFilesSidebar.js
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
class DesktopFilesSidebar extends Shadow {
|
||||||
|
constructor(groups, activeGroupIds, onToggleGroup, locations, activeLocation, onSelectLocation) {
|
||||||
|
super()
|
||||||
|
this.groups = groups
|
||||||
|
this.activeGroupIds = activeGroupIds
|
||||||
|
this.onToggleGroup = onToggleGroup
|
||||||
|
this.locations = locations
|
||||||
|
this.activeLocation = activeLocation
|
||||||
|
this.onSelectLocation = onSelectLocation
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const self = this
|
||||||
|
VStack(() => {
|
||||||
|
// ── Locations ─────────────────────────────────────────────
|
||||||
|
VStack(() => {
|
||||||
|
this.sectionLabel("BROWSE")
|
||||||
|
this.locations.forEach(loc => {
|
||||||
|
const isActive = this.activeLocation === loc.id;
|
||||||
|
HStack(() => {
|
||||||
|
p(loc.icon)
|
||||||
|
.margin(0)
|
||||||
|
.fontSize(0.88, em)
|
||||||
|
.lineHeight("1")
|
||||||
|
.flexShrink(0)
|
||||||
|
p(loc.label)
|
||||||
|
.margin(0)
|
||||||
|
.fontSize(0.88, em)
|
||||||
|
.fontWeight(isActive ? "600" : "400")
|
||||||
|
.color("var(--headertext)")
|
||||||
|
.opacity(isActive ? 1 : 0.75)
|
||||||
|
})
|
||||||
|
.gap(0.6, em)
|
||||||
|
.paddingHorizontal(0.85, em)
|
||||||
|
.paddingVertical(0.42, em)
|
||||||
|
.marginHorizontal(0.4, em)
|
||||||
|
.borderRadius(0.45, em)
|
||||||
|
.background(isActive ? "var(--app)" : "transparent")
|
||||||
|
.cursor("pointer")
|
||||||
|
.alignItems("center")
|
||||||
|
.width("calc(100% - 0.8em)")
|
||||||
|
.boxSizing("border-box")
|
||||||
|
.onClick(function(done){ if(done){ self.onSelectLocation(loc.id) } })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.paddingTop(0.9, em)
|
||||||
|
|
||||||
|
// ── Divider ───────────────────────────────────────────────
|
||||||
|
VStack(() => {})
|
||||||
|
.height(1, px)
|
||||||
|
.background("var(--divider)")
|
||||||
|
.marginVertical(0.65, em)
|
||||||
|
.marginHorizontal(1, em)
|
||||||
|
|
||||||
|
// ── Permission groups ─────────────────────────────────────
|
||||||
|
VStack(() => {
|
||||||
|
this.sectionLabel("PERMISSION GROUPS")
|
||||||
|
|
||||||
|
this.groups.forEach(group => {
|
||||||
|
const isOn = this.activeGroupIds.has(group.id);
|
||||||
|
HStack(() => {
|
||||||
|
// Colored dot / checkbox area
|
||||||
|
HStack(() => {
|
||||||
|
VStack(() => {
|
||||||
|
if (isOn) {
|
||||||
|
p("✓")
|
||||||
|
.margin(0)
|
||||||
|
.fontSize(0.6, em)
|
||||||
|
.fontWeight("800")
|
||||||
|
.color("white")
|
||||||
|
.lineHeight("1")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.width(0.95, em)
|
||||||
|
.height(0.95, em)
|
||||||
|
.borderRadius(0.22, em)
|
||||||
|
.background(isOn ? group.color : "transparent")
|
||||||
|
.border(`1.5px solid ${group.color}`)
|
||||||
|
.justifyContent("center")
|
||||||
|
.alignItems("center")
|
||||||
|
.boxSizing("border-box")
|
||||||
|
.flexShrink(0)
|
||||||
|
})
|
||||||
|
.cursor("pointer")
|
||||||
|
|
||||||
|
p(group.name)
|
||||||
|
.margin(0)
|
||||||
|
.fontSize(0.85, em)
|
||||||
|
.fontWeight("400")
|
||||||
|
.color("var(--headertext)")
|
||||||
|
.opacity(isOn ? 1 : 0.45)
|
||||||
|
.flex(1)
|
||||||
|
.minWidth(0)
|
||||||
|
.overflow("hidden")
|
||||||
|
.whiteSpace("nowrap")
|
||||||
|
.textOverflow("ellipsis")
|
||||||
|
|
||||||
|
p(`${group.fileCount}`)
|
||||||
|
.margin(0)
|
||||||
|
.fontSize(0.68, em)
|
||||||
|
.color("var(--headertext)")
|
||||||
|
.opacity(0.3)
|
||||||
|
.flexShrink(0)
|
||||||
|
})
|
||||||
|
.gap(0.6, em)
|
||||||
|
.paddingHorizontal(0.85, em)
|
||||||
|
.paddingVertical(0.38, em)
|
||||||
|
.marginHorizontal(0.4, em)
|
||||||
|
.borderRadius(0.45, em)
|
||||||
|
.cursor("pointer")
|
||||||
|
.alignItems("center")
|
||||||
|
.width("calc(100% - 0.8em)")
|
||||||
|
.boxSizing("border-box")
|
||||||
|
.onClick(function(done){ if(done){ self.onToggleGroup(group.id) } })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
VStack(() => {}).flex(1)
|
||||||
|
|
||||||
|
// ── Storage usage ─────────────────────────────────────────
|
||||||
|
VStack(() => {
|
||||||
|
HStack(() => {
|
||||||
|
p("Storage")
|
||||||
|
.margin(0)
|
||||||
|
.fontSize(0.75, em)
|
||||||
|
.color("var(--headertext)")
|
||||||
|
.opacity(0.45)
|
||||||
|
.flex(1)
|
||||||
|
p("4.2 GB / 10 GB")
|
||||||
|
.margin(0)
|
||||||
|
.fontSize(0.7, em)
|
||||||
|
.color("var(--headertext)")
|
||||||
|
.opacity(0.3)
|
||||||
|
})
|
||||||
|
.alignItems("center")
|
||||||
|
.marginBottom(0.45, em)
|
||||||
|
|
||||||
|
// Bar
|
||||||
|
VStack(() => {
|
||||||
|
VStack(() => {})
|
||||||
|
.width(42, pct)
|
||||||
|
.height(100, pct)
|
||||||
|
.background("var(--quillred)")
|
||||||
|
.borderRadius(100, px)
|
||||||
|
})
|
||||||
|
.width(100, pct)
|
||||||
|
.height(4, px)
|
||||||
|
.background("var(--darkaccent)")
|
||||||
|
.borderRadius(100, px)
|
||||||
|
.overflow("hidden")
|
||||||
|
})
|
||||||
|
.paddingHorizontal(1.1, em)
|
||||||
|
.paddingBottom(1.1, em)
|
||||||
|
.flexShrink(0)
|
||||||
|
})
|
||||||
|
.height(100, pct)
|
||||||
|
.width(100, pct)
|
||||||
|
.boxSizing("border-box")
|
||||||
|
.overflowY("auto")
|
||||||
|
}
|
||||||
|
|
||||||
|
sectionLabel(text) {
|
||||||
|
p(text)
|
||||||
|
.margin(0)
|
||||||
|
.marginBottom(0.25, em)
|
||||||
|
.paddingHorizontal(1.1, em)
|
||||||
|
.fontSize(0.62, em)
|
||||||
|
.fontWeight("700")
|
||||||
|
.letterSpacing("0.07em")
|
||||||
|
.color("var(--headertext)")
|
||||||
|
.opacity(0.35)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
register(DesktopFilesSidebar)
|
||||||
98
files/desktop/DesktopFilesToolbar.js
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
class DesktopFilesToolbar extends Shadow {
|
||||||
|
constructor(title, viewMode, searchText, onViewChange, onSearch, onUpload) {
|
||||||
|
super()
|
||||||
|
this.title = title
|
||||||
|
this.viewMode = viewMode
|
||||||
|
this.searchText = searchText
|
||||||
|
this.onViewChange = onViewChange
|
||||||
|
this.onSearch = onSearch
|
||||||
|
this.onUpload = onUpload
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const self = this
|
||||||
|
HStack(() => {
|
||||||
|
// Title
|
||||||
|
p(this.title)
|
||||||
|
.margin(0)
|
||||||
|
.fontSize(1.05, em)
|
||||||
|
.fontWeight("700")
|
||||||
|
.color("var(--headertext)")
|
||||||
|
.flex(1)
|
||||||
|
.minWidth(0)
|
||||||
|
|
||||||
|
// Search
|
||||||
|
HStack(() => {
|
||||||
|
p("🔍")
|
||||||
|
.margin(0)
|
||||||
|
.fontSize(0.8, em)
|
||||||
|
.opacity(0.38)
|
||||||
|
.flexShrink(0)
|
||||||
|
input("", "200px")
|
||||||
|
.attr({ type: "text", placeholder: "Search files…", value: this.searchText })
|
||||||
|
.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.8, em)
|
||||||
|
.paddingVertical(0.52, em)
|
||||||
|
.background("var(--darkaccent)")
|
||||||
|
.border("1px solid var(--divider)")
|
||||||
|
.borderRadius(0.5, em)
|
||||||
|
.alignItems("center")
|
||||||
|
|
||||||
|
// View toggle
|
||||||
|
HStack(() => {
|
||||||
|
this.viewBtn("list", "☰")
|
||||||
|
this.viewBtn("grid", "⊞")
|
||||||
|
})
|
||||||
|
.background("var(--darkaccent)")
|
||||||
|
.border("1px solid var(--divider)")
|
||||||
|
.borderRadius(0.5, em)
|
||||||
|
.overflow("hidden")
|
||||||
|
|
||||||
|
// Upload
|
||||||
|
button("+ Upload")
|
||||||
|
.paddingHorizontal(1, em)
|
||||||
|
.paddingVertical(0.52, em)
|
||||||
|
.background("var(--quillred)")
|
||||||
|
.color("white")
|
||||||
|
.border("none")
|
||||||
|
.borderRadius(0.5, em)
|
||||||
|
.fontSize(0.85, em)
|
||||||
|
.fontWeight("600")
|
||||||
|
.cursor("pointer")
|
||||||
|
.flexShrink(0)
|
||||||
|
.onClick(function(done){ if(done){ self.onUpload() } })
|
||||||
|
})
|
||||||
|
.gap(0.65, em)
|
||||||
|
.paddingHorizontal(1.5, em)
|
||||||
|
.paddingVertical(0.85, em)
|
||||||
|
.borderBottom("1px solid var(--divider)")
|
||||||
|
.alignItems("center")
|
||||||
|
.width(100, pct)
|
||||||
|
.boxSizing("border-box")
|
||||||
|
.flexShrink(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
viewBtn(mode, icon) {
|
||||||
|
const self = this
|
||||||
|
const isActive = this.viewMode === mode;
|
||||||
|
button(icon)
|
||||||
|
.paddingHorizontal(0.65, em)
|
||||||
|
.paddingVertical(0.45, em)
|
||||||
|
.background(isActive ? "var(--app)" : "transparent")
|
||||||
|
.color("var(--headertext)")
|
||||||
|
.border("none")
|
||||||
|
.cursor("pointer")
|
||||||
|
.fontSize(0.95, em)
|
||||||
|
.opacity(isActive ? 1 : 0.45)
|
||||||
|
.onClick(function(done){ if(done){ self.onViewChange(mode) } })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
register(DesktopFilesToolbar)
|
||||||
159
files/desktop/files.js
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
import "./DesktopFilesSidebar.js"
|
||||||
|
import "./DesktopFilesToolbar.js"
|
||||||
|
import "./DesktopFilesGrid.js"
|
||||||
|
|
||||||
|
css(`
|
||||||
|
files- {
|
||||||
|
font-family: 'Arial';
|
||||||
|
scrollbar-width: none;
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
}
|
||||||
|
@keyframes pulse-ring {
|
||||||
|
0% { transform: scale(1); opacity: 0.5; }
|
||||||
|
100% { transform: scale(2.2); opacity: 0; }
|
||||||
|
}
|
||||||
|
`)
|
||||||
|
|
||||||
|
class Files extends Shadow {
|
||||||
|
constructor() {
|
||||||
|
super()
|
||||||
|
this.viewMode = "list"
|
||||||
|
this.searchText = ""
|
||||||
|
this.activeLocation = "all"
|
||||||
|
|
||||||
|
this.groups = [
|
||||||
|
{ id: 1, name: "All Members", color: "#3b82f6", fileCount: 24 },
|
||||||
|
{ id: 2, name: "Admins", color: "#ef4444", fileCount: 8 },
|
||||||
|
{ id: 3, name: "Editors", color: "#10b981", fileCount: 15 },
|
||||||
|
{ id: 4, name: "Viewers", color: "#f59e0b", fileCount: 19 },
|
||||||
|
{ id: 5, name: "External", color: "#8b5cf6", fileCount: 4 },
|
||||||
|
]
|
||||||
|
|
||||||
|
this.activeGroupIds = new Set(this.groups.map(g => g.id))
|
||||||
|
|
||||||
|
this.locations = [
|
||||||
|
{ id: "all", label: "All Files", icon: "🗂" },
|
||||||
|
{ id: "mine", label: "My Files", icon: "👤" },
|
||||||
|
{ id: "shared", label: "Shared with Me", icon: "👥" },
|
||||||
|
{ id: "recent", label: "Recent", icon: "🕐" },
|
||||||
|
{ id: "starred", label: "Starred", icon: "⭐" },
|
||||||
|
{ id: "trash", label: "Trash", icon: "🗑" },
|
||||||
|
]
|
||||||
|
|
||||||
|
const ago = (d) => new Date(Date.now() - d * 86400000);
|
||||||
|
|
||||||
|
this.files = [
|
||||||
|
// Folders
|
||||||
|
{ id: 1, type: "folder", name: "Design Assets", groupId: 3, modifiedAt: ago(0), size: "—", sharedWith: ["Sarah McIntyre", "Jordan Kim", "Marcus Webb"], activeEditors: [], ownerId: 1, starred: true },
|
||||||
|
{ id: 2, type: "folder", name: "Engineering Docs", groupId: 2, modifiedAt: ago(1), size: "—", sharedWith: ["Marcus Webb"], activeEditors: [], ownerId: 1, starred: false },
|
||||||
|
// PDFs
|
||||||
|
{ id: 3, type: "file", name: "Q2 Board Presentation.pdf", groupId: 2, modifiedAt: ago(0), size: "4.2 MB", sharedWith: ["Priya Anand", "Marcus Webb"], activeEditors: ["Sarah McIntyre"], ownerId: 1, starred: true },
|
||||||
|
{ id: 4, type: "file", name: "Brand Guidelines.pdf", groupId: 1, modifiedAt: ago(3), size: "12.8 MB", sharedWith: ["Sarah McIntyre", "Jordan Kim"], activeEditors: [], ownerId: 2, starred: false },
|
||||||
|
{ id: 5, type: "file", name: "Legal NDA Template.pdf", groupId: 2, modifiedAt: ago(14), size: "0.9 MB", sharedWith: ["Priya Anand"], activeEditors: [], ownerId: 1, starred: false },
|
||||||
|
// Docs
|
||||||
|
{ id: 6, type: "file", name: "Product Roadmap.docx", groupId: 3, modifiedAt: ago(0), size: "1.1 MB", sharedWith: ["Sarah McIntyre", "Priya Anand", "Jordan Kim"], activeEditors: ["Marcus Webb", "Priya Anand"], ownerId: 1, starred: true },
|
||||||
|
{ id: 7, type: "file", name: "Sprint 22 Notes.md", groupId: 3, modifiedAt: ago(1), size: "18 KB", sharedWith: ["Marcus Webb"], activeEditors: [], ownerId: 3, starred: false },
|
||||||
|
{ id: 8, type: "file", name: "Onboarding Checklist.docx", groupId: 1, modifiedAt: ago(5), size: "245 KB", sharedWith: ["Sarah McIntyre", "Priya Anand"], activeEditors: [], ownerId: 1, starred: false },
|
||||||
|
// Spreadsheets
|
||||||
|
{ id: 9, type: "file", name: "Budget 2026.xlsx", groupId: 2, modifiedAt: ago(2), size: "3.3 MB", sharedWith: ["Priya Anand"], activeEditors: ["Jordan Kim"], ownerId: 1, starred: true },
|
||||||
|
{ id: 10, type: "file", name: "Member Directory.csv", groupId: 4, modifiedAt: ago(7), size: "88 KB", sharedWith: ["Priya Anand", "Marcus Webb", "Sarah McIntyre"], activeEditors: [], ownerId: 2, starred: false },
|
||||||
|
{ id: 11, type: "file", name: "Event Attendance.xlsx", groupId: 3, modifiedAt: ago(10), size: "1.4 MB", sharedWith: [], activeEditors: [], ownerId: 3, starred: false },
|
||||||
|
// Images
|
||||||
|
{ id: 12, type: "file", name: "Logo Final.png", groupId: 1, modifiedAt: ago(21), size: "2.1 MB", sharedWith: ["Jordan Kim", "Sarah McIntyre"], activeEditors: [], ownerId: 2, starred: false },
|
||||||
|
{ id: 13, type: "file", name: "Homepage Hero.jpg", groupId: 3, modifiedAt: ago(4), size: "5.6 MB", sharedWith: ["Sarah McIntyre"], activeEditors: ["Sarah McIntyre"], ownerId: 1, starred: false },
|
||||||
|
{ id: 14, type: "file", name: "Team Photo 2026.jpg", groupId: 1, modifiedAt: ago(30), size: "8.9 MB", sharedWith: ["Priya Anand", "Marcus Webb", "Jordan Kim"], activeEditors: [], ownerId: 1, starred: true },
|
||||||
|
// Code / config
|
||||||
|
{ id: 15, type: "file", name: "api-config.json", groupId: 2, modifiedAt: ago(0), size: "4 KB", sharedWith: ["Marcus Webb"], activeEditors: ["Marcus Webb"], ownerId: 3, starred: false },
|
||||||
|
// Presentations
|
||||||
|
{ id: 16, type: "file", name: "Investor Deck May.pptx", groupId: 2, modifiedAt: ago(6), size: "18.4 MB", sharedWith: ["Priya Anand"], activeEditors: [], ownerId: 1, starred: true },
|
||||||
|
// External shared
|
||||||
|
{ id: 17, type: "file", name: "Vendor Contract.pdf", groupId: 5, modifiedAt: ago(45), size: "2.2 MB", sharedWith: ["Priya Anand"], activeEditors: [], ownerId: 1, starred: false },
|
||||||
|
{ id: 18, type: "file", name: "External Proposal.docx", groupId: 5, modifiedAt: ago(8), size: "0.7 MB", sharedWith: [], activeEditors: [], ownerId: 1, starred: false },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
get locationLabel() {
|
||||||
|
return this.locations.find(l => l.id === this.activeLocation)?.label || "All Files"
|
||||||
|
}
|
||||||
|
|
||||||
|
get visibleFiles() {
|
||||||
|
let files = this.files;
|
||||||
|
|
||||||
|
// Filter by active groups
|
||||||
|
files = files.filter(f => this.activeGroupIds.has(f.groupId));
|
||||||
|
|
||||||
|
// Filter by location
|
||||||
|
if (this.activeLocation === "mine") files = files.filter(f => f.ownerId === 1);
|
||||||
|
if (this.activeLocation === "shared") files = files.filter(f => f.sharedWith?.length > 0);
|
||||||
|
if (this.activeLocation === "starred") files = files.filter(f => f.starred);
|
||||||
|
if (this.activeLocation === "recent") files = files.sort((a, b) => new Date(b.modifiedAt) - new Date(a.modifiedAt)).slice(0, 10);
|
||||||
|
|
||||||
|
// Search
|
||||||
|
if (this.searchText) {
|
||||||
|
const q = this.searchText.toLowerCase();
|
||||||
|
files = files.filter(f => f.name.toLowerCase().includes(q));
|
||||||
|
}
|
||||||
|
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
HStack(() => {
|
||||||
|
// Left sidebar
|
||||||
|
VStack(() => {
|
||||||
|
DesktopFilesSidebar(
|
||||||
|
this.groups,
|
||||||
|
this.activeGroupIds,
|
||||||
|
(groupId) => {
|
||||||
|
if (this.activeGroupIds.has(groupId)) {
|
||||||
|
if (this.activeGroupIds.size > 1) this.activeGroupIds.delete(groupId);
|
||||||
|
} else {
|
||||||
|
this.activeGroupIds.add(groupId);
|
||||||
|
}
|
||||||
|
this.rerender();
|
||||||
|
},
|
||||||
|
this.locations,
|
||||||
|
this.activeLocation,
|
||||||
|
(locId) => { this.activeLocation = locId; this.rerender(); }
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.width(220, px)
|
||||||
|
.minWidth(200, px)
|
||||||
|
.height(100, pct)
|
||||||
|
.borderRight("1px solid var(--divider)")
|
||||||
|
.flexShrink(0)
|
||||||
|
.overflow("hidden")
|
||||||
|
|
||||||
|
// Main area
|
||||||
|
VStack(() => {
|
||||||
|
DesktopFilesToolbar(
|
||||||
|
this.locationLabel,
|
||||||
|
this.viewMode,
|
||||||
|
this.searchText,
|
||||||
|
(mode) => { this.viewMode = mode; this.rerender(); },
|
||||||
|
(text) => { this.searchText = text; this.rerender(); },
|
||||||
|
() => {}
|
||||||
|
)
|
||||||
|
|
||||||
|
DesktopFilesGrid(
|
||||||
|
this.visibleFiles,
|
||||||
|
this.groups,
|
||||||
|
this.viewMode,
|
||||||
|
() => {}
|
||||||
|
)
|
||||||
|
.flex(1)
|
||||||
|
.minHeight(0)
|
||||||
|
.width(100, pct)
|
||||||
|
.overflow("hidden")
|
||||||
|
})
|
||||||
|
.flex(1)
|
||||||
|
.height(100, pct)
|
||||||
|
.overflow("hidden")
|
||||||
|
})
|
||||||
|
.height(100, pct)
|
||||||
|
.width(100, pct)
|
||||||
|
.overflow("hidden")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
register(Files)
|
||||||
3
files/icons/files.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg width="152" height="128" viewBox="0 0 152 128" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M135.189 18.0274H70.1495L56.0594 1.23569C55.7346 0.848813 55.3292 0.537769 54.8719 0.324324C54.4145 0.110879 53.9162 0.000189693 53.4117 1.17244e-07H3.45833C3.00415 -0.000118302 2.55438 0.0894701 2.13475 0.263644C1.71511 0.437819 1.33382 0.693166 1.01266 1.0151C0.6915 1.33703 0.436765 1.71924 0.26301 2.13989C0.0892542 2.56054 -0.000118018 3.01138 1.16963e-07 3.46667V111.766C0.00492427 115.986 1.67921 120.031 4.65558 123.014C7.63195 125.998 11.6674 127.676 15.8766 127.681H135.189C139.398 127.676 143.434 125.998 146.41 123.014C149.386 120.031 151.061 115.986 151.066 111.766V33.9405C151.061 29.7213 149.386 25.6764 146.41 22.6931C143.434 19.7099 139.398 18.0319 135.189 18.0274ZM6.91667 6.93333H51.8007L61.1119 18.0274H6.91667V6.93333ZM144.149 111.766C144.146 114.147 143.202 116.43 141.522 118.114C139.842 119.798 137.565 120.745 135.189 120.748H15.8766C13.501 120.745 11.2235 119.798 9.54377 118.114C7.864 116.43 6.9192 114.147 6.91667 111.766V24.9608H135.189C137.564 24.9633 139.842 25.9101 141.521 27.5935C143.201 29.2769 144.146 31.5595 144.149 33.9405V111.766Z" fill="black"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.2 KiB |
3
files/icons/fileslight.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg width="152" height="128" viewBox="0 0 152 128" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M135.189 18.0274H70.1495L56.0594 1.23569C55.7346 0.848813 55.3292 0.537769 54.8719 0.324324C54.4145 0.110879 53.9162 0.000189693 53.4117 1.17244e-07H3.45833C3.00415 -0.000118302 2.55438 0.0894701 2.13475 0.263644C1.71511 0.437819 1.33382 0.693166 1.01266 1.0151C0.6915 1.33703 0.436765 1.71924 0.26301 2.13989C0.0892542 2.56054 -0.000118018 3.01138 1.16963e-07 3.46667V111.766C0.00492427 115.986 1.67921 120.031 4.65558 123.014C7.63195 125.998 11.6674 127.676 15.8766 127.681H135.189C139.398 127.676 143.434 125.998 146.41 123.014C149.386 120.031 151.061 115.986 151.066 111.766V33.9405C151.061 29.7213 149.386 25.6764 146.41 22.6931C143.434 19.7099 139.398 18.0319 135.189 18.0274ZM6 5.5H53.4117L64.0654 18.0274H6V5.5ZM145.5 111.766C145.5 113 143.745 118.316 142.065 120C140.386 121.684 134.876 121.997 132.5 122L15.8766 122.5C13.501 122.497 10.2452 121.184 8.56543 119.5C6.88567 117.816 5.50254 114.147 5.5 111.766L6 23L70.1495 22.6931H135.189C137.564 22.6956 141.386 25.3166 143.065 27C144.745 28.6834 145.497 32.619 145.5 35V74V111.766Z" fill="#FEE8C8"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
65
jobs/JobCard.js
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import server from "/@server/server.js"
|
||||||
|
|
||||||
|
css(`
|
||||||
|
jobcard- p {
|
||||||
|
font-size: 0.85em;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
`)
|
||||||
|
|
||||||
|
class JobCard extends Shadow {
|
||||||
|
constructor(job) {
|
||||||
|
super()
|
||||||
|
this.job = job
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
VStack(() => {
|
||||||
|
HStack(() => {
|
||||||
|
h3(this.job.title)
|
||||||
|
.color("var(--headertext)")
|
||||||
|
.fontSize(1.3, em)
|
||||||
|
.fontWeight("normal")
|
||||||
|
.margin(0, em)
|
||||||
|
})
|
||||||
|
.justifyContent("space-between")
|
||||||
|
.verticalAlign("center")
|
||||||
|
|
||||||
|
p(this.job.company ?? "No company added")
|
||||||
|
.marginTop(0.75, em)
|
||||||
|
p(this.job.location ?? "No location added")
|
||||||
|
.marginTop(0.25, em)
|
||||||
|
p(this.job.salary_number ? this.salaryLabel(this.job.salary_number, this.job.salary_period) : "No salary added")
|
||||||
|
.marginTop(0.75, em)
|
||||||
|
})
|
||||||
|
.paddingVertical(1.5, em)
|
||||||
|
.paddingHorizontal(3.5, em)
|
||||||
|
.marginHorizontal(1, em)
|
||||||
|
.borderRadius(10, px)
|
||||||
|
.background("var(--desktop-item-background)")
|
||||||
|
.border("1px solid var(--desktop-item-border)")
|
||||||
|
.boxSizing("border-box")
|
||||||
|
}
|
||||||
|
|
||||||
|
salaryLabel(number, period) {
|
||||||
|
const formattedNumber = new Intl.NumberFormat('en-US', {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2
|
||||||
|
}).format(Number(number));
|
||||||
|
|
||||||
|
if (period === "one-time") {
|
||||||
|
return `One-time payment of $${formattedNumber}`
|
||||||
|
} else {
|
||||||
|
return `$${formattedNumber}/${period}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteJob(job) {
|
||||||
|
const result = await server.deleteJob(job.id, job.network_id, global.profile.id)
|
||||||
|
if (result === null) {
|
||||||
|
console.log("Failed to delete job")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
register(JobCard)
|
||||||
204
jobs/JobForm.js
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
import server from "/@server/server.js"
|
||||||
|
|
||||||
|
class JobForm extends Shadow {
|
||||||
|
inputStyles(el) {
|
||||||
|
return el
|
||||||
|
.background("var(--main)")
|
||||||
|
.color("var(--text)")
|
||||||
|
.border("1px solid var(--accent)")
|
||||||
|
.fontSize(0.9, rem)
|
||||||
|
.backgroundColor("var(--darkaccent)")
|
||||||
|
.borderRadius(12, px)
|
||||||
|
.outline("none")
|
||||||
|
.onTouch((start) => {
|
||||||
|
if (start) {
|
||||||
|
this.style.backgroundColor = "var(--accent)"
|
||||||
|
} else {
|
||||||
|
this.style.backgroundColor = "var(--darkaccent)"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
ZStack(() => {
|
||||||
|
p("X")
|
||||||
|
.color("var(--darkred)")
|
||||||
|
.fontSize(2, em)
|
||||||
|
.position("absolute")
|
||||||
|
.fontFamily("Arial")
|
||||||
|
.marginTop(1, rem)
|
||||||
|
.marginLeft(1, rem)
|
||||||
|
.onTap(() => {
|
||||||
|
this.toggle()
|
||||||
|
})
|
||||||
|
|
||||||
|
form(() => {
|
||||||
|
VStack(() => {
|
||||||
|
h1("Create a Job")
|
||||||
|
.color("var(--text)")
|
||||||
|
.textAlign("center")
|
||||||
|
.fontFamily("Arial")
|
||||||
|
.marginTop(1.5, em)
|
||||||
|
|
||||||
|
input("Title", "70%")
|
||||||
|
.attr({ name: "title", type: "text" })
|
||||||
|
.margin(1, em)
|
||||||
|
.padding(1, em)
|
||||||
|
.styles(this.inputStyles)
|
||||||
|
input("Location", "70%")
|
||||||
|
.attr({ name: "location", type: "text" })
|
||||||
|
.margin(1, em)
|
||||||
|
.padding(1, em)
|
||||||
|
.styles(this.inputStyles)
|
||||||
|
input("Company", "70%")
|
||||||
|
.attr({ name: "company", type: "text" })
|
||||||
|
.margin(1, em)
|
||||||
|
.padding(1, em)
|
||||||
|
.styles(this.inputStyles)
|
||||||
|
HStack(() => {
|
||||||
|
input("Salary", "30%")
|
||||||
|
.attr({ name: "salary_number", type: "number", min: "0", step: "0.01" })
|
||||||
|
.padding(1, em)
|
||||||
|
.marginHorizontal(1, em)
|
||||||
|
.styles(this.inputStyles)
|
||||||
|
select(() => {
|
||||||
|
option("One-time")
|
||||||
|
.attr({ value: "one-time"})
|
||||||
|
option("Hourly")
|
||||||
|
.attr({ value: "hour"})
|
||||||
|
option("Monthly")
|
||||||
|
.attr({ value: "month"})
|
||||||
|
option("Yearly")
|
||||||
|
.attr({ value: "year"})
|
||||||
|
})
|
||||||
|
.attr({ name: "salary_period" })
|
||||||
|
.width(40, pct)
|
||||||
|
.padding(1, em)
|
||||||
|
.marginHorizontal(1, em)
|
||||||
|
.styles(this.inputStyles)
|
||||||
|
})
|
||||||
|
.margin(1, em)
|
||||||
|
.boxSizing("border-box")
|
||||||
|
.verticalAlign("center")
|
||||||
|
.horizontalAlign("center")
|
||||||
|
|
||||||
|
input("Description", "70%")
|
||||||
|
.attr({ name: "description", type: "text" })
|
||||||
|
.margin(1, em)
|
||||||
|
.padding(1, em)
|
||||||
|
.styles(this.inputStyles)
|
||||||
|
HStack(() => {
|
||||||
|
button("==>")
|
||||||
|
.padding(1, em)
|
||||||
|
.fontSize(0.9, rem)
|
||||||
|
.borderRadius(12, px)
|
||||||
|
.background("var(--searchbackground)")
|
||||||
|
.color("var(--text)")
|
||||||
|
.border("1px solid var(--accent)")
|
||||||
|
.boxSizing("border-box")
|
||||||
|
.onTouch(function (start) {
|
||||||
|
if (start) {
|
||||||
|
this.style.backgroundColor = "var(--accent)"
|
||||||
|
} else {
|
||||||
|
this.style.backgroundColor = "var(--searchbackground)"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.width(70, vw)
|
||||||
|
.margin("auto")
|
||||||
|
.fontSize(0.9, rem)
|
||||||
|
.paddingLeft(0, em)
|
||||||
|
.paddingRight(2, em)
|
||||||
|
.marginVertical(1, em)
|
||||||
|
.border("1px solid transparent")
|
||||||
|
|
||||||
|
p("")
|
||||||
|
.state("errormessage", function (msg) {
|
||||||
|
this.innerText = msg
|
||||||
|
})
|
||||||
|
.margin("auto")
|
||||||
|
.marginTop(1, em)
|
||||||
|
.color("var(--text)")
|
||||||
|
.fontFamily("Arial")
|
||||||
|
.opacity(.7)
|
||||||
|
.padding(0.5, em)
|
||||||
|
.backgroundColor("var(--darkred)")
|
||||||
|
.width(100, pct)
|
||||||
|
.textAlign("center")
|
||||||
|
.boxSizing("border-box")
|
||||||
|
})
|
||||||
|
.horizontalAlign("center")
|
||||||
|
})
|
||||||
|
.onSubmit((e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
const data = {
|
||||||
|
title: e.target.$('[name="title"]').value,
|
||||||
|
location: e.target.$('[name="location"]').value,
|
||||||
|
company: e.target.$('[name="company"]').value,
|
||||||
|
salary_number: e.target.$('[name="salary_number"]').value,
|
||||||
|
salary_period: e.target.$('[name="salary_period"]').value,
|
||||||
|
description: e.target.$('[name="description"]').value,
|
||||||
|
};
|
||||||
|
this.handleSend(data)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.position("fixed")
|
||||||
|
.height(window.visualViewport.height - 20, px)
|
||||||
|
.width(100, pct)
|
||||||
|
.top(100, vh)
|
||||||
|
.background("var(--main)")
|
||||||
|
.zIndex(4)
|
||||||
|
.borderTopLeftRadius("10px")
|
||||||
|
.borderTopRightRadius("10px")
|
||||||
|
.boxSizing("border-box")
|
||||||
|
.border("1px solid var(--accent)")
|
||||||
|
.transition("top .3s")
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleSend(jobData) {
|
||||||
|
if (!jobData.title) {
|
||||||
|
this.$(".VStack > p")
|
||||||
|
.attr({ errormessage: 'Jobs must include a title.' })
|
||||||
|
.display("")
|
||||||
|
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
this.$(".VStack > p").style.display = "none"
|
||||||
|
}
|
||||||
|
|
||||||
|
const newJob = {
|
||||||
|
title: jobData.title,
|
||||||
|
location: jobData.location.trim() === '' ? null : jobData.location.trim(),
|
||||||
|
company: jobData.company.trim() === '' ? null : jobData.company.trim(),
|
||||||
|
salary_number: jobData.salary_number.trim() === '' ? null : jobData.salary_number,
|
||||||
|
salary_period: jobData.salary_number.trim() === '' ? null : jobData.salary_period,
|
||||||
|
description: jobData.description.trim() === '' ? null : jobData.description.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await server.addJob(newJob, global.currentNetwork.id, global.profile.id)
|
||||||
|
if (!result.error) {
|
||||||
|
console.log("Added new job: ", result)
|
||||||
|
this.toggle()
|
||||||
|
window.dispatchEvent(new CustomEvent('new-job', {
|
||||||
|
detail: { job: result }
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
console.log("Failed to add new event: ", data)
|
||||||
|
this.$(".VStack > p")
|
||||||
|
.attr({ errormessage: data.error })
|
||||||
|
.display("")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toggle() {
|
||||||
|
if(this.style.top === "15vh") {
|
||||||
|
this.style.top = "100vh"
|
||||||
|
this.pointerEvents = "none"
|
||||||
|
} else {
|
||||||
|
this.style.top = "15vh"
|
||||||
|
this.pointerEvents = "auto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
register(JobForm)
|
||||||
60
jobs/JobsGrid.js
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
class JobsGrid extends Shadow {
|
||||||
|
jobs;
|
||||||
|
|
||||||
|
constructor(jobs) {
|
||||||
|
super()
|
||||||
|
this.jobs = jobs
|
||||||
|
}
|
||||||
|
|
||||||
|
boldUntilFirstSpace(text) {
|
||||||
|
const index = text.indexOf(' ');
|
||||||
|
if (index === -1) {
|
||||||
|
// No spaces — bold the whole thing
|
||||||
|
return `<b>${text}</b>`;
|
||||||
|
}
|
||||||
|
return `<b>${text.slice(0, index)}</b>${text.slice(index)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
VStack(() => {
|
||||||
|
h3("Results")
|
||||||
|
.marginTop(0.1, em)
|
||||||
|
.marginBottom(1, em)
|
||||||
|
.marginLeft(0.4, em)
|
||||||
|
.color("var(--accent2)")
|
||||||
|
|
||||||
|
if (this.jobs.length > 0) {
|
||||||
|
ZStack(() => {
|
||||||
|
for (let i = 0; i < this.jobs.length; i++) {
|
||||||
|
VStack(() => {
|
||||||
|
p(this.jobs[i].title)
|
||||||
|
.fontSize(1.2, em)
|
||||||
|
.fontWeight("bold")
|
||||||
|
.marginBottom(0.5, em)
|
||||||
|
p(this.jobs[i].company)
|
||||||
|
p(this.jobs[i].city + ", " + this.jobs[i].state)
|
||||||
|
.marginBottom(0.5, em)
|
||||||
|
p(this.boldUntilFirstSpace(this.jobs[i].salary))
|
||||||
|
})
|
||||||
|
.padding(1, em)
|
||||||
|
.borderRadius(5, "px")
|
||||||
|
.background("var(--darkbrown)")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.display("grid")
|
||||||
|
.gridTemplateColumns("repeat(auto-fill, minmax(250px, 1fr))")
|
||||||
|
.gap(1, em)
|
||||||
|
} else {
|
||||||
|
p("No Jobs!")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.height(100, vh)
|
||||||
|
.paddingLeft(2, em)
|
||||||
|
.paddingRight(2, em)
|
||||||
|
.paddingTop(2, em)
|
||||||
|
.gap(0, em)
|
||||||
|
.width(100, "%")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
register(JobsGrid)
|
||||||
26
jobs/JobsSidebar.js
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
class JobsSidebar extends Shadow {
|
||||||
|
render() {
|
||||||
|
VStack(() => {
|
||||||
|
h3("Location")
|
||||||
|
.color("var(--accent2)")
|
||||||
|
.marginBottom(0, em)
|
||||||
|
|
||||||
|
HStack(() => {
|
||||||
|
input("Location", "100%")
|
||||||
|
.paddingLeft(3, em)
|
||||||
|
.paddingVertical(0.75, em)
|
||||||
|
.backgroundImage("/_/icons/locationPin.svg")
|
||||||
|
.backgroundRepeat("no-repeat")
|
||||||
|
.backgroundSize("18px 18px")
|
||||||
|
.backgroundPosition("10px center")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.paddingTop(1, em)
|
||||||
|
.paddingLeft(3, em)
|
||||||
|
.paddingRight(3, em)
|
||||||
|
.gap(1, em)
|
||||||
|
.minWidth(10, vw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
register(JobsSidebar)
|
||||||
293
jobs/desktop/DesktopJobDetail.js
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
class DesktopJobDetail extends Shadow {
|
||||||
|
constructor(job, calendars) {
|
||||||
|
super()
|
||||||
|
this.job = job
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (!this.job) {
|
||||||
|
VStack(() => {
|
||||||
|
p("💼")
|
||||||
|
.margin(0)
|
||||||
|
.fontSize(2.5, em)
|
||||||
|
.opacity(0.2)
|
||||||
|
p("Select a job to view details")
|
||||||
|
.margin(0)
|
||||||
|
.marginTop(0.75, em)
|
||||||
|
.fontSize(0.92, em)
|
||||||
|
.color("var(--headertext)")
|
||||||
|
.opacity(0.35)
|
||||||
|
.textAlign("center")
|
||||||
|
})
|
||||||
|
.flex(1)
|
||||||
|
.height(100, pct)
|
||||||
|
.justifyContent("center")
|
||||||
|
.alignItems("center")
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const job = this.job;
|
||||||
|
|
||||||
|
VStack(() => {
|
||||||
|
// ── Header ────────────────────────────────────────────────
|
||||||
|
VStack(() => {
|
||||||
|
// Logo + title
|
||||||
|
HStack(() => {
|
||||||
|
VStack(() => {
|
||||||
|
p(job.company ? job.company[0].toUpperCase() : "?")
|
||||||
|
.margin(0)
|
||||||
|
.fontSize(1.6, em)
|
||||||
|
.fontWeight("700")
|
||||||
|
.color("white")
|
||||||
|
})
|
||||||
|
.width(3.8, em)
|
||||||
|
.height(3.8, em)
|
||||||
|
.borderRadius(0.65, em)
|
||||||
|
.background(this.companyColor(job.company))
|
||||||
|
.justifyContent("center")
|
||||||
|
.alignItems("center")
|
||||||
|
.flexShrink(0)
|
||||||
|
|
||||||
|
VStack(() => {
|
||||||
|
h2(job.title)
|
||||||
|
.margin(0)
|
||||||
|
.fontSize(1.18, em)
|
||||||
|
.fontWeight("700")
|
||||||
|
.color("var(--headertext)")
|
||||||
|
.lineHeight("1.25")
|
||||||
|
|
||||||
|
p(job.company || "Unknown Company")
|
||||||
|
.margin(0)
|
||||||
|
.marginTop(0.18, em)
|
||||||
|
.fontSize(0.9, em)
|
||||||
|
.fontWeight("500")
|
||||||
|
.color("var(--headertext)")
|
||||||
|
.opacity(0.6)
|
||||||
|
})
|
||||||
|
.flex(1)
|
||||||
|
.minWidth(0)
|
||||||
|
})
|
||||||
|
.gap(0.9, em)
|
||||||
|
.alignItems("center")
|
||||||
|
.marginBottom(1, em)
|
||||||
|
|
||||||
|
// Meta chips row
|
||||||
|
HStack(() => {
|
||||||
|
if (job.location) this.chip("📍 " + job.location)
|
||||||
|
if (job.employment_type) this.chip(this.formatType(job.employment_type))
|
||||||
|
if (job.experience_level) this.chip(this.formatLevel(job.experience_level))
|
||||||
|
if (job.department) this.chip(job.department)
|
||||||
|
if (job.salary_number) this.chip(this.salaryLabel(job.salary_number, job.salary_period))
|
||||||
|
})
|
||||||
|
.gap(0.45, em)
|
||||||
|
.flexWrap("wrap")
|
||||||
|
.marginBottom(0.85, em)
|
||||||
|
|
||||||
|
// Stats row
|
||||||
|
HStack(() => {
|
||||||
|
if (job.applicants !== undefined) {
|
||||||
|
this.stat(job.applicants + " applicants")
|
||||||
|
}
|
||||||
|
if (job.posted_at) {
|
||||||
|
this.stat(this.relativeDate(job.posted_at))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.gap(1.2, em)
|
||||||
|
.marginBottom(1.1, em)
|
||||||
|
|
||||||
|
// CTA buttons
|
||||||
|
HStack(() => {
|
||||||
|
button("Apply now")
|
||||||
|
.paddingHorizontal(1.4, em)
|
||||||
|
.paddingVertical(0.58, em)
|
||||||
|
.background("var(--quillred)")
|
||||||
|
.color("white")
|
||||||
|
.border("none")
|
||||||
|
.borderRadius(0.45, em)
|
||||||
|
.fontWeight("600")
|
||||||
|
.fontSize(0.88, em)
|
||||||
|
.cursor("pointer")
|
||||||
|
|
||||||
|
button("Save job")
|
||||||
|
.paddingHorizontal(1.2, em)
|
||||||
|
.paddingVertical(0.58, em)
|
||||||
|
.background("transparent")
|
||||||
|
.color("var(--headertext)")
|
||||||
|
.border("1px solid var(--divider)")
|
||||||
|
.borderRadius(0.45, em)
|
||||||
|
.fontWeight("500")
|
||||||
|
.fontSize(0.88, em)
|
||||||
|
.cursor("pointer")
|
||||||
|
})
|
||||||
|
.gap(0.65, em)
|
||||||
|
})
|
||||||
|
.paddingHorizontal(1.75, em)
|
||||||
|
.paddingTop(1.6, em)
|
||||||
|
.paddingBottom(1.25, em)
|
||||||
|
.borderBottom("1px solid var(--divider)")
|
||||||
|
.flexShrink(0)
|
||||||
|
|
||||||
|
// ── Body ──────────────────────────────────────────────────
|
||||||
|
VStack(() => {
|
||||||
|
// About the role
|
||||||
|
if (job.description) {
|
||||||
|
this.section("About the role", () => {
|
||||||
|
p(job.description)
|
||||||
|
.margin(0)
|
||||||
|
.fontSize(0.88, em)
|
||||||
|
.color("var(--headertext)")
|
||||||
|
.opacity(0.8)
|
||||||
|
.lineHeight("1.65")
|
||||||
|
.whiteSpace("pre-wrap")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skills / requirements
|
||||||
|
if (job.skills && job.skills.length > 0) {
|
||||||
|
this.section("Skills & requirements", () => {
|
||||||
|
HStack(() => {
|
||||||
|
job.skills.forEach(skill => {
|
||||||
|
p(skill)
|
||||||
|
.margin(0)
|
||||||
|
.paddingHorizontal(0.75, em)
|
||||||
|
.paddingVertical(0.3, em)
|
||||||
|
.background("var(--darkaccent)")
|
||||||
|
.border("1px solid var(--divider)")
|
||||||
|
.borderRadius(100, px)
|
||||||
|
.fontSize(0.78, em)
|
||||||
|
.color("var(--headertext)")
|
||||||
|
.opacity(0.8)
|
||||||
|
.whiteSpace("nowrap")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.gap(0.45, em)
|
||||||
|
.flexWrap("wrap")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Job details table
|
||||||
|
this.section("Job details", () => {
|
||||||
|
VStack(() => {
|
||||||
|
this.detailRow("Employment type", this.formatType(job.employment_type) || "—")
|
||||||
|
this.detailRow("Experience level", this.formatLevel(job.experience_level) || "—")
|
||||||
|
this.detailRow("Location", job.location || "—")
|
||||||
|
this.detailRow("Compensation", job.salary_number ? this.salaryLabel(job.salary_number, job.salary_period) : "—")
|
||||||
|
if (job.department) this.detailRow("Department", job.department)
|
||||||
|
})
|
||||||
|
.gap(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.paddingHorizontal(1.75, em)
|
||||||
|
.paddingTop(0.25, em)
|
||||||
|
.paddingBottom(2, em)
|
||||||
|
.gap(0)
|
||||||
|
.overflowY("auto")
|
||||||
|
.flex(1)
|
||||||
|
})
|
||||||
|
.height(100, pct)
|
||||||
|
.width(100, pct)
|
||||||
|
.overflowY("hidden")
|
||||||
|
.boxSizing("border-box")
|
||||||
|
}
|
||||||
|
|
||||||
|
section(title, contentFn) {
|
||||||
|
VStack(() => {
|
||||||
|
p(title)
|
||||||
|
.margin(0)
|
||||||
|
.marginBottom(0.7, em)
|
||||||
|
.fontSize(0.82, em)
|
||||||
|
.fontWeight("700")
|
||||||
|
.letterSpacing("0.04em")
|
||||||
|
.color("var(--headertext)")
|
||||||
|
.opacity(0.38)
|
||||||
|
.textTransform("uppercase")
|
||||||
|
|
||||||
|
contentFn()
|
||||||
|
})
|
||||||
|
.paddingTop(1.35, em)
|
||||||
|
.paddingBottom(0.35, em)
|
||||||
|
.borderBottom("1px solid var(--divider)")
|
||||||
|
.width(100, pct)
|
||||||
|
.boxSizing("border-box")
|
||||||
|
}
|
||||||
|
|
||||||
|
detailRow(label, value) {
|
||||||
|
HStack(() => {
|
||||||
|
p(label)
|
||||||
|
.margin(0)
|
||||||
|
.fontSize(0.85, em)
|
||||||
|
.color("var(--headertext)")
|
||||||
|
.opacity(0.45)
|
||||||
|
.width(9, em)
|
||||||
|
.flexShrink(0)
|
||||||
|
p(value)
|
||||||
|
.margin(0)
|
||||||
|
.fontSize(0.85, em)
|
||||||
|
.color("var(--headertext)")
|
||||||
|
.fontWeight("500")
|
||||||
|
})
|
||||||
|
.paddingVertical(0.55, em)
|
||||||
|
.alignItems("flex-start")
|
||||||
|
.borderBottom("1px solid var(--divider)")
|
||||||
|
.width(100, pct)
|
||||||
|
}
|
||||||
|
|
||||||
|
chip(text) {
|
||||||
|
p(text)
|
||||||
|
.margin(0)
|
||||||
|
.paddingHorizontal(0.65, em)
|
||||||
|
.paddingVertical(0.25, em)
|
||||||
|
.background("var(--darkaccent)")
|
||||||
|
.border("1px solid var(--divider)")
|
||||||
|
.borderRadius(100, px)
|
||||||
|
.fontSize(0.78, em)
|
||||||
|
.color("var(--headertext)")
|
||||||
|
.opacity(0.75)
|
||||||
|
.whiteSpace("nowrap")
|
||||||
|
}
|
||||||
|
|
||||||
|
stat(text) {
|
||||||
|
p(text)
|
||||||
|
.margin(0)
|
||||||
|
.fontSize(0.78, em)
|
||||||
|
.color("var(--headertext)")
|
||||||
|
.opacity(0.4)
|
||||||
|
}
|
||||||
|
|
||||||
|
formatType(type) {
|
||||||
|
return { "full-time": "Full-time", "part-time": "Part-time", "contract": "Contract", "internship": "Internship" }[type] || type
|
||||||
|
}
|
||||||
|
|
||||||
|
formatLevel(level) {
|
||||||
|
return { "entry": "Entry level", "mid": "Mid level", "senior": "Senior" }[level] || level
|
||||||
|
}
|
||||||
|
|
||||||
|
salaryLabel(number, period) {
|
||||||
|
const n = new Intl.NumberFormat("en-US", { maximumFractionDigits: 0 }).format(Number(number));
|
||||||
|
if (period === "one-time") return `$${n} one-time`;
|
||||||
|
if (period === "year") return `$${n}/yr`;
|
||||||
|
if (period === "month") return `$${n}/mo`;
|
||||||
|
if (period === "hour") return `$${n}/hr`;
|
||||||
|
return `$${n}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
relativeDate(date) {
|
||||||
|
const d = new Date(date);
|
||||||
|
const days = Math.floor((Date.now() - d) / 86400000);
|
||||||
|
if (days === 0) return "Posted today";
|
||||||
|
if (days === 1) return "Posted yesterday";
|
||||||
|
if (days < 7) return `Posted ${days} days ago`;
|
||||||
|
if (days < 30) return `Posted ${Math.floor(days / 7)}w ago`;
|
||||||
|
return `Posted ${Math.floor(days / 30)}mo ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
companyColor(company) {
|
||||||
|
const colors = ["#3b82f6", "#ef4444", "#10b981", "#f59e0b", "#8b5cf6", "#ec4899", "#06b6d4", "#84cc16"];
|
||||||
|
if (!company) return colors[0];
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < company.length; i++) hash = company.charCodeAt(i) + ((hash << 5) - hash);
|
||||||
|
return colors[Math.abs(hash) % colors.length];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
register(DesktopJobDetail)
|
||||||
169
jobs/desktop/DesktopJobsList.js
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
class DesktopJobsList extends Shadow {
|
||||||
|
constructor(jobs, selectedId, onSelect) {
|
||||||
|
super()
|
||||||
|
this.jobs = jobs
|
||||||
|
this.selectedId = selectedId
|
||||||
|
this.onSelect = onSelect
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
VStack(() => {
|
||||||
|
p(this.jobs.length === 1 ? "1 job" : `${this.jobs.length} jobs`)
|
||||||
|
.margin(0)
|
||||||
|
.marginBottom(0.75, em)
|
||||||
|
.fontSize(0.78, em)
|
||||||
|
.fontWeight("600")
|
||||||
|
.color("var(--headertext)")
|
||||||
|
.opacity(0.45)
|
||||||
|
.flexShrink(0)
|
||||||
|
|
||||||
|
if (this.jobs.length === 0) {
|
||||||
|
VStack(() => {
|
||||||
|
p("No jobs match your filters")
|
||||||
|
.margin(0)
|
||||||
|
.fontSize(0.9, em)
|
||||||
|
.color("var(--headertext)")
|
||||||
|
.opacity(0.4)
|
||||||
|
.textAlign("center")
|
||||||
|
})
|
||||||
|
.flex(1)
|
||||||
|
.justifyContent("center")
|
||||||
|
.alignItems("center")
|
||||||
|
} else {
|
||||||
|
this.jobs.forEach(job => this.renderCard(job))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.paddingHorizontal(0.75, em)
|
||||||
|
.paddingTop(1, em)
|
||||||
|
.paddingBottom(1, em)
|
||||||
|
.gap(0.5, em)
|
||||||
|
.overflowY("auto")
|
||||||
|
.height(100, pct)
|
||||||
|
.boxSizing("border-box")
|
||||||
|
}
|
||||||
|
|
||||||
|
renderCard(job) {
|
||||||
|
const self = this
|
||||||
|
const isSelected = job.id === this.selectedId;
|
||||||
|
|
||||||
|
VStack(() => {
|
||||||
|
// Logo placeholder + title row
|
||||||
|
HStack(() => {
|
||||||
|
// Company logo circle
|
||||||
|
VStack(() => {
|
||||||
|
p(job.company ? job.company[0].toUpperCase() : "?")
|
||||||
|
.margin(0)
|
||||||
|
.fontSize(1.1, em)
|
||||||
|
.fontWeight("700")
|
||||||
|
.color("white")
|
||||||
|
})
|
||||||
|
.width(2.6, em)
|
||||||
|
.height(2.6, em)
|
||||||
|
.borderRadius(0.45, em)
|
||||||
|
.background(this.companyColor(job.company))
|
||||||
|
.justifyContent("center")
|
||||||
|
.alignItems("center")
|
||||||
|
.flexShrink(0)
|
||||||
|
|
||||||
|
VStack(() => {
|
||||||
|
p(job.title)
|
||||||
|
.margin(0)
|
||||||
|
.fontSize(0.92, em)
|
||||||
|
.fontWeight("600")
|
||||||
|
.color("var(--headertext)")
|
||||||
|
.lineHeight("1.3")
|
||||||
|
|
||||||
|
p(job.company || "Unknown Company")
|
||||||
|
.margin(0)
|
||||||
|
.marginTop(0.1, em)
|
||||||
|
.fontSize(0.78, em)
|
||||||
|
.color("var(--headertext)")
|
||||||
|
.opacity(0.55)
|
||||||
|
})
|
||||||
|
.gap(0)
|
||||||
|
.flex(1)
|
||||||
|
.minWidth(0)
|
||||||
|
})
|
||||||
|
.gap(0.65, em)
|
||||||
|
.alignItems("flex-start")
|
||||||
|
|
||||||
|
// Meta row
|
||||||
|
HStack(() => {
|
||||||
|
if (job.location) {
|
||||||
|
this.metaChip("📍 " + job.location)
|
||||||
|
}
|
||||||
|
if (job.employment_type) {
|
||||||
|
this.metaChip(this.formatType(job.employment_type))
|
||||||
|
}
|
||||||
|
if (job.salary_number) {
|
||||||
|
this.metaChip(this.salaryLabel(job.salary_number, job.salary_period))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.gap(0.35, em)
|
||||||
|
.flexWrap("wrap")
|
||||||
|
|
||||||
|
// Posted date
|
||||||
|
if (job.posted_at) {
|
||||||
|
p(this.relativeDate(job.posted_at))
|
||||||
|
.margin(0)
|
||||||
|
.fontSize(0.7, em)
|
||||||
|
.color("var(--headertext)")
|
||||||
|
.opacity(0.35)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.gap(0.55, em)
|
||||||
|
.padding(0.9, em)
|
||||||
|
.borderRadius(0.65, em)
|
||||||
|
.background(isSelected ? "var(--app)" : "var(--darkaccent)")
|
||||||
|
.border(`1px solid ${isSelected ? "var(--quillred)" : "var(--divider)"}`)
|
||||||
|
.cursor("pointer")
|
||||||
|
.boxSizing("border-box")
|
||||||
|
.width(100, pct)
|
||||||
|
.onClick(function(done){ if(done){ self.onSelect(job.id) } })
|
||||||
|
}
|
||||||
|
|
||||||
|
metaChip(text) {
|
||||||
|
p(text)
|
||||||
|
.margin(0)
|
||||||
|
.fontSize(0.7, em)
|
||||||
|
.color("var(--headertext)")
|
||||||
|
.opacity(0.65)
|
||||||
|
.paddingHorizontal(0.5, em)
|
||||||
|
.paddingVertical(0.18, em)
|
||||||
|
.borderRadius(100, px)
|
||||||
|
.whiteSpace("nowrap")
|
||||||
|
}
|
||||||
|
|
||||||
|
formatType(type) {
|
||||||
|
return { "full-time": "Full-time", "part-time": "Part-time", "contract": "Contract", "internship": "Internship" }[type] || type
|
||||||
|
}
|
||||||
|
|
||||||
|
salaryLabel(number, period) {
|
||||||
|
const n = new Intl.NumberFormat("en-US", { maximumFractionDigits: 0 }).format(Number(number));
|
||||||
|
if (period === "one-time") return `$${n} one-time`;
|
||||||
|
if (period === "year") return `$${n}/yr`;
|
||||||
|
if (period === "month") return `$${n}/mo`;
|
||||||
|
if (period === "hour") return `$${n}/hr`;
|
||||||
|
return `$${n}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
relativeDate(date) {
|
||||||
|
const d = new Date(date);
|
||||||
|
const days = Math.floor((Date.now() - d) / 86400000);
|
||||||
|
if (days === 0) return "Posted today";
|
||||||
|
if (days === 1) return "Posted yesterday";
|
||||||
|
if (days < 7) return `Posted ${days} days ago`;
|
||||||
|
if (days < 30) return `Posted ${Math.floor(days / 7)}w ago`;
|
||||||
|
return `Posted ${Math.floor(days / 30)}mo ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
companyColor(company) {
|
||||||
|
const colors = ["#3b82f6", "#ef4444", "#10b981", "#f59e0b", "#8b5cf6", "#ec4899", "#06b6d4", "#84cc16"];
|
||||||
|
if (!company) return colors[0];
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < company.length; i++) hash = company.charCodeAt(i) + ((hash << 5) - hash);
|
||||||
|
return colors[Math.abs(hash) % colors.length];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
register(DesktopJobsList)
|
||||||
111
jobs/desktop/DesktopJobsToolbar.js
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
class DesktopJobsToolbar extends Shadow {
|
||||||
|
constructor(filters, onFiltersChange) {
|
||||||
|
super()
|
||||||
|
this.filters = filters
|
||||||
|
this.onFiltersChange = onFiltersChange
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
HStack(() => {
|
||||||
|
// Keyword search
|
||||||
|
HStack(() => {
|
||||||
|
p("🔍")
|
||||||
|
.margin(0)
|
||||||
|
.fontSize(0.85, em)
|
||||||
|
.opacity(0.45)
|
||||||
|
.flexShrink(0)
|
||||||
|
input()
|
||||||
|
.attr({ type: "text", placeholder: "Search jobs, titles, companies…", value: this.filters.keyword })
|
||||||
|
.flex(1)
|
||||||
|
.border("none")
|
||||||
|
.outline("none")
|
||||||
|
.background("transparent")
|
||||||
|
.color("var(--headertext)")
|
||||||
|
.fontSize(0.9, em)
|
||||||
|
.onInput((e) => this.onFiltersChange({ ...this.filters, keyword: e.target.value }))
|
||||||
|
})
|
||||||
|
.gap(0.55, em)
|
||||||
|
.paddingHorizontal(0.9, em)
|
||||||
|
.paddingVertical(0.6, em)
|
||||||
|
.background("var(--darkaccent)")
|
||||||
|
.border("1px solid var(--divider)")
|
||||||
|
.borderRadius(0.5, em)
|
||||||
|
.alignItems("center")
|
||||||
|
.flex(1)
|
||||||
|
|
||||||
|
// Location
|
||||||
|
HStack(() => {
|
||||||
|
p("📍")
|
||||||
|
.margin(0)
|
||||||
|
.fontSize(0.85, em)
|
||||||
|
.opacity(0.45)
|
||||||
|
.flexShrink(0)
|
||||||
|
input()
|
||||||
|
.attr({ type: "text", placeholder: "Location", value: this.filters.location })
|
||||||
|
.flex(1)
|
||||||
|
.border("none")
|
||||||
|
.outline("none")
|
||||||
|
.background("transparent")
|
||||||
|
.color("var(--headertext)")
|
||||||
|
.fontSize(0.9, em)
|
||||||
|
.onInput((e) => this.onFiltersChange({ ...this.filters, location: e.target.value }))
|
||||||
|
})
|
||||||
|
.gap(0.55, em)
|
||||||
|
.paddingHorizontal(0.9, em)
|
||||||
|
.paddingVertical(0.6, em)
|
||||||
|
.background("var(--darkaccent)")
|
||||||
|
.border("1px solid var(--divider)")
|
||||||
|
.borderRadius(0.5, em)
|
||||||
|
.alignItems("center")
|
||||||
|
.width(200, px)
|
||||||
|
.flexShrink(0)
|
||||||
|
|
||||||
|
// Job type filter
|
||||||
|
this.filterSelect("Type", [
|
||||||
|
{ label: "All Types", value: "" },
|
||||||
|
{ label: "Full-time", value: "full-time" },
|
||||||
|
{ label: "Part-time", value: "part-time" },
|
||||||
|
{ label: "Contract", value: "contract" },
|
||||||
|
{ label: "Internship", value: "internship" },
|
||||||
|
], this.filters.type, (v) => this.onFiltersChange({ ...this.filters, type: v }))
|
||||||
|
|
||||||
|
// Experience level
|
||||||
|
this.filterSelect("Level", [
|
||||||
|
{ label: "All Levels", value: "" },
|
||||||
|
{ label: "Entry", value: "entry" },
|
||||||
|
{ label: "Mid", value: "mid" },
|
||||||
|
{ label: "Senior", value: "senior" },
|
||||||
|
], this.filters.level, (v) => this.onFiltersChange({ ...this.filters, level: v }))
|
||||||
|
})
|
||||||
|
.gap(0.65, em)
|
||||||
|
.paddingHorizontal(1.5, em)
|
||||||
|
.paddingVertical(0.85, em)
|
||||||
|
.borderBottom("1px solid var(--divider)")
|
||||||
|
.alignItems("center")
|
||||||
|
.width(100, pct)
|
||||||
|
.boxSizing("border-box")
|
||||||
|
.flexShrink(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
filterSelect(placeholder, options, value, onChange) {
|
||||||
|
select(() => {
|
||||||
|
options.forEach(opt => {
|
||||||
|
option(opt.label)
|
||||||
|
.attr({ value: opt.value, selected: opt.value === value ? "" : null })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.paddingVertical(0.55, em)
|
||||||
|
.paddingHorizontal(0.75, em)
|
||||||
|
.background("var(--darkaccent)")
|
||||||
|
.border("1px solid var(--divider)")
|
||||||
|
.borderRadius(0.5, em)
|
||||||
|
.color("var(--headertext)")
|
||||||
|
.fontSize(0.85, em)
|
||||||
|
.outline("none")
|
||||||
|
.cursor("pointer")
|
||||||
|
.flexShrink(0)
|
||||||
|
.onEvent("change", (e) => onChange(e.target.value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
register(DesktopJobsToolbar)
|
||||||
338
jobs/desktop/jobs.js
Normal file
@@ -0,0 +1,338 @@
|
|||||||
|
import "./DesktopJobsList.js"
|
||||||
|
import "./DesktopJobDetail.js"
|
||||||
|
import server from "/@server/server.js"
|
||||||
|
import "/_/code/components/LoadingCircle.js"
|
||||||
|
|
||||||
|
css(`
|
||||||
|
jobs- {
|
||||||
|
font-family: 'Arial';
|
||||||
|
scrollbar-width: none;
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
}
|
||||||
|
jobs- select {
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%23888'/%3E%3C/svg%3E");
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: right 0.65em center;
|
||||||
|
padding-right: 1.8em !important;
|
||||||
|
}
|
||||||
|
`)
|
||||||
|
|
||||||
|
class Jobs extends Shadow {
|
||||||
|
loaded = false
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super()
|
||||||
|
this.filters = { keyword: "", location: "", type: "", level: "" }
|
||||||
|
this.selectedJobId = null
|
||||||
|
|
||||||
|
this.jobs = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
title: "Senior Frontend Engineer",
|
||||||
|
company: "Acme Corp",
|
||||||
|
location: "San Francisco, CA",
|
||||||
|
employment_type: "full-time",
|
||||||
|
experience_level: "senior",
|
||||||
|
department: "Engineering",
|
||||||
|
salary_number: 165000,
|
||||||
|
salary_period: "year",
|
||||||
|
applicants: 47,
|
||||||
|
posted_at: new Date(Date.now() - 2 * 86400000),
|
||||||
|
skills: ["React", "TypeScript", "CSS", "GraphQL", "Node.js"],
|
||||||
|
description: "We're looking for a Senior Frontend Engineer to join our growing product team. You'll work closely with designers, backend engineers, and product managers to build fast, accessible, and beautiful web experiences.\n\nYou'll have ownership over large features and be expected to make architectural decisions. We ship every week and care deeply about code quality and UX."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
title: "Product Designer",
|
||||||
|
company: "Blue River",
|
||||||
|
location: "New York, NY",
|
||||||
|
employment_type: "full-time",
|
||||||
|
experience_level: "mid",
|
||||||
|
department: "Design",
|
||||||
|
salary_number: 120000,
|
||||||
|
salary_period: "year",
|
||||||
|
applicants: 112,
|
||||||
|
posted_at: new Date(Date.now() - 5 * 86400000),
|
||||||
|
skills: ["Figma", "Design Systems", "Prototyping", "User Research"],
|
||||||
|
description: "Blue River is hiring a Product Designer to lead design across our core consumer product. You'll partner with PMs and engineers to take features from zero to one, establish design patterns, and advocate for users in every decision.\n\nWe're a small, fast-moving team and designers have a huge impact here."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
title: "Backend Engineer",
|
||||||
|
company: "Orbit Systems",
|
||||||
|
location: "Remote",
|
||||||
|
employment_type: "contract",
|
||||||
|
experience_level: "mid",
|
||||||
|
department: "Platform",
|
||||||
|
salary_number: 95,
|
||||||
|
salary_period: "hour",
|
||||||
|
applicants: 29,
|
||||||
|
posted_at: new Date(Date.now() - 1 * 86400000),
|
||||||
|
skills: ["Go", "PostgreSQL", "Kubernetes", "gRPC", "Redis"],
|
||||||
|
description: "6-month contract (potential to extend) for a backend engineer to help scale our platform infrastructure. You'll be responsible for building and maintaining APIs used by millions of users, optimizing database performance, and improving system reliability."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
title: "Marketing Manager",
|
||||||
|
company: "Groundwork",
|
||||||
|
location: "Austin, TX",
|
||||||
|
employment_type: "full-time",
|
||||||
|
experience_level: "mid",
|
||||||
|
department: "Marketing",
|
||||||
|
salary_number: 98000,
|
||||||
|
salary_period: "year",
|
||||||
|
applicants: 88,
|
||||||
|
posted_at: new Date(Date.now() - 10 * 86400000),
|
||||||
|
skills: ["SEO", "Content Strategy", "Analytics", "Paid Acquisition", "Email Marketing"],
|
||||||
|
description: "We need a Marketing Manager to own our top-of-funnel growth. You'll manage content, paid channels, and email campaigns — and be the person who keeps the brand consistent across every touchpoint. You'll report directly to our Head of Growth."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 5,
|
||||||
|
title: "Data Analyst",
|
||||||
|
company: "Compass Data",
|
||||||
|
location: "Chicago, IL",
|
||||||
|
employment_type: "full-time",
|
||||||
|
experience_level: "entry",
|
||||||
|
department: "Analytics",
|
||||||
|
salary_number: 75000,
|
||||||
|
salary_period: "year",
|
||||||
|
applicants: 203,
|
||||||
|
posted_at: new Date(Date.now() - 3 * 86400000),
|
||||||
|
skills: ["SQL", "Python", "Tableau", "dbt", "Excel"],
|
||||||
|
description: "A great entry-level opportunity for someone who loves data. You'll work with our analytics team to build dashboards, run ad-hoc analysis, and help business teams make data-driven decisions. We'll invest in your growth and give you plenty of mentorship."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 6,
|
||||||
|
title: "iOS Engineer",
|
||||||
|
company: "Fieldwork",
|
||||||
|
location: "Seattle, WA",
|
||||||
|
employment_type: "full-time",
|
||||||
|
experience_level: "senior",
|
||||||
|
department: "Mobile",
|
||||||
|
salary_number: 175000,
|
||||||
|
salary_period: "year",
|
||||||
|
applicants: 34,
|
||||||
|
posted_at: new Date(Date.now() - 7 * 86400000),
|
||||||
|
skills: ["Swift", "SwiftUI", "Combine", "CoreData", "Xcode"],
|
||||||
|
description: "Fieldwork is building next-generation tools for field service teams. Our iOS app is the most important surface we have — technicians use it every day on job sites. We need a senior iOS engineer who cares about performance, offline reliability, and a great UX."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 7,
|
||||||
|
title: "Operations Coordinator",
|
||||||
|
company: "Maple & Co",
|
||||||
|
location: "Boston, MA",
|
||||||
|
employment_type: "part-time",
|
||||||
|
experience_level: "entry",
|
||||||
|
department: "Operations",
|
||||||
|
salary_number: 28,
|
||||||
|
salary_period: "hour",
|
||||||
|
applicants: 61,
|
||||||
|
posted_at: new Date(Date.now() - 14 * 86400000),
|
||||||
|
skills: ["Project Management", "Excel", "Communication", "Scheduling"],
|
||||||
|
description: "Part-time role (20 hrs/week) helping our operations team stay organized. You'll coordinate schedules, manage vendor relationships, and help improve internal workflows. Ideal for someone who's highly organized and excited to grow into a full-time ops role."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 8,
|
||||||
|
title: "Machine Learning Intern",
|
||||||
|
company: "NeuralPath",
|
||||||
|
location: "Remote",
|
||||||
|
employment_type: "internship",
|
||||||
|
experience_level: "entry",
|
||||||
|
department: "AI Research",
|
||||||
|
salary_number: 8000,
|
||||||
|
salary_period: "month",
|
||||||
|
applicants: 394,
|
||||||
|
posted_at: new Date(Date.now() - 0),
|
||||||
|
skills: ["Python", "PyTorch", "Linear Algebra", "Git"],
|
||||||
|
description: "Summer internship (12 weeks) on our ML research team. You'll work alongside researchers on real problems in NLP and recommendation systems. We expect you to ship something you're proud of by the end of the summer. Strong preference for candidates who can start June 2."
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
this.selectedJobId = this.jobs[0]?.id || null
|
||||||
|
this.loadJobs()
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadJobs() {
|
||||||
|
const fetched = await server.getJobs(global.currentNetwork.id)
|
||||||
|
this.loaded = true
|
||||||
|
if (fetched?.length) {
|
||||||
|
this.jobs = fetched
|
||||||
|
if (!this.selectedJobId) this.selectedJobId = fetched[0]?.id || null
|
||||||
|
}
|
||||||
|
this.rerender()
|
||||||
|
}
|
||||||
|
|
||||||
|
get filteredJobs() {
|
||||||
|
const { keyword, location, type, level } = this.filters;
|
||||||
|
return this.jobs.filter(job => {
|
||||||
|
if (keyword) {
|
||||||
|
const kw = keyword.toLowerCase();
|
||||||
|
const hay = [job.title, job.company, job.description, job.department].join(" ").toLowerCase();
|
||||||
|
if (!hay.includes(kw)) return false;
|
||||||
|
}
|
||||||
|
if (location) {
|
||||||
|
if (!job.location?.toLowerCase().includes(location.toLowerCase())) return false;
|
||||||
|
}
|
||||||
|
if (type && job.employment_type !== type) return false;
|
||||||
|
if (level && job.experience_level !== level) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
get selectedJob() {
|
||||||
|
return this.jobs.find(j => j.id === this.selectedJobId) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
filterSelect(options, currentValue, onChange) {
|
||||||
|
select(() => {
|
||||||
|
options.forEach(opt => {
|
||||||
|
option(opt.label)
|
||||||
|
.attr({ value: opt.value, selected: opt.value === currentValue ? "" : null })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.paddingVertical(0.55, em)
|
||||||
|
.paddingHorizontal(0.75, em)
|
||||||
|
.background("var(--darkaccent)")
|
||||||
|
.border("1px solid var(--divider)")
|
||||||
|
.borderRadius(0.5, em)
|
||||||
|
.color("var(--headertext)")
|
||||||
|
.fontSize(0.85, em)
|
||||||
|
.outline("none")
|
||||||
|
.cursor("pointer")
|
||||||
|
.flexShrink(0)
|
||||||
|
.onEvent("change", (e) => onChange(e.target.value))
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const filtered = this.filteredJobs;
|
||||||
|
|
||||||
|
VStack(() => {
|
||||||
|
// Toolbar — rendered inline so text inputs stay in this component
|
||||||
|
// and don't lose focus on rerender
|
||||||
|
HStack(() => {
|
||||||
|
HStack(() => {
|
||||||
|
p("🔍")
|
||||||
|
.margin(0).fontSize(0.85, em).opacity(0.45).flexShrink(0)
|
||||||
|
input()
|
||||||
|
.attr({ type: "text", placeholder: "Search jobs, titles, companies…", value: this.filters.keyword })
|
||||||
|
.flex(1).border("none").outline("none")
|
||||||
|
.background("transparent").color("var(--headertext)")
|
||||||
|
.fontSize(0.9, em)
|
||||||
|
.onInput((e) => {
|
||||||
|
this.filters.keyword = e.target.value
|
||||||
|
const newFiltered = this.filteredJobs
|
||||||
|
if (this.selectedJobId && !newFiltered.find(j => j.id === this.selectedJobId)) {
|
||||||
|
this.selectedJobId = newFiltered[0]?.id || null
|
||||||
|
}
|
||||||
|
this.rerender()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.gap(0.55, em)
|
||||||
|
.marginLeft(60, px)
|
||||||
|
.paddingHorizontal(0.9, em)
|
||||||
|
.paddingVertical(0.6, em)
|
||||||
|
.background("var(--darkaccent)")
|
||||||
|
.border("1px solid var(--divider)")
|
||||||
|
.borderRadius(0.5, em)
|
||||||
|
.alignItems("center")
|
||||||
|
.flex(1)
|
||||||
|
|
||||||
|
HStack(() => {
|
||||||
|
p("📍")
|
||||||
|
.margin(0).fontSize(0.85, em).opacity(0.45).flexShrink(0)
|
||||||
|
input()
|
||||||
|
.attr({ type: "text", placeholder: "Location", value: this.filters.location })
|
||||||
|
.flex(1).border("none").outline("none")
|
||||||
|
.background("transparent").color("var(--headertext)")
|
||||||
|
.fontSize(0.9, em)
|
||||||
|
.onInput((e) => {
|
||||||
|
this.filters.location = e.target.value
|
||||||
|
const newFiltered = this.filteredJobs
|
||||||
|
if (this.selectedJobId && !newFiltered.find(j => j.id === this.selectedJobId)) {
|
||||||
|
this.selectedJobId = newFiltered[0]?.id || null
|
||||||
|
}
|
||||||
|
this.rerender()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.gap(0.55, em).paddingHorizontal(0.9, em).paddingVertical(0.6, em)
|
||||||
|
.background("var(--darkaccent)").border("1px solid var(--divider)")
|
||||||
|
.borderRadius(0.5, em).alignItems("center").width(200, px).flexShrink(0)
|
||||||
|
|
||||||
|
this.filterSelect([
|
||||||
|
{ label: "All Types", value: "" },
|
||||||
|
{ label: "Full-time", value: "full-time" },
|
||||||
|
{ label: "Part-time", value: "part-time" },
|
||||||
|
{ label: "Contract", value: "contract" },
|
||||||
|
{ label: "Internship", value: "internship" },
|
||||||
|
], this.filters.type, (v) => {
|
||||||
|
this.filters.type = v
|
||||||
|
const newFiltered = this.filteredJobs
|
||||||
|
if (this.selectedJobId && !newFiltered.find(j => j.id === this.selectedJobId)) {
|
||||||
|
this.selectedJobId = newFiltered[0]?.id || null
|
||||||
|
}
|
||||||
|
this.rerender()
|
||||||
|
})
|
||||||
|
|
||||||
|
this.filterSelect([
|
||||||
|
{ label: "All Levels", value: "" },
|
||||||
|
{ label: "Entry", value: "entry" },
|
||||||
|
{ label: "Mid", value: "mid" },
|
||||||
|
{ label: "Senior", value: "senior" },
|
||||||
|
], this.filters.level, (v) => {
|
||||||
|
this.filters.level = v
|
||||||
|
const newFiltered = this.filteredJobs
|
||||||
|
if (this.selectedJobId && !newFiltered.find(j => j.id === this.selectedJobId)) {
|
||||||
|
this.selectedJobId = newFiltered[0]?.id || null
|
||||||
|
}
|
||||||
|
this.rerender()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.gap(0.65, em).paddingHorizontal(1.5, em).paddingVertical(0.85, em)
|
||||||
|
.borderBottom("1px solid var(--divider)").alignItems("center")
|
||||||
|
.width(100, pct).boxSizing("border-box").flexShrink(0)
|
||||||
|
|
||||||
|
if (!this.loaded) {
|
||||||
|
VStack(() => LoadingCircle())
|
||||||
|
.flex(1)
|
||||||
|
.justifyContent("center")
|
||||||
|
.alignItems("center")
|
||||||
|
} else {
|
||||||
|
HStack(() => {
|
||||||
|
// Left: job list
|
||||||
|
VStack(() => {
|
||||||
|
DesktopJobsList(filtered, this.selectedJobId, (id) => {
|
||||||
|
this.selectedJobId = id;
|
||||||
|
this.rerender();
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.width(360, px)
|
||||||
|
.minWidth(320, px)
|
||||||
|
.maxWidth(400, px)
|
||||||
|
.height(100, pct)
|
||||||
|
.borderRight("1px solid var(--divider)")
|
||||||
|
.flexShrink(0)
|
||||||
|
.overflow("hidden")
|
||||||
|
|
||||||
|
// Right: job detail
|
||||||
|
VStack(() => {
|
||||||
|
DesktopJobDetail(this.selectedJob)
|
||||||
|
})
|
||||||
|
.flex(1)
|
||||||
|
.height(100, pct)
|
||||||
|
.overflow("hidden")
|
||||||
|
})
|
||||||
|
.flex(1)
|
||||||
|
.minHeight(0)
|
||||||
|
.width(100, pct)
|
||||||
|
.overflow("hidden")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.height(100, pct)
|
||||||
|
.width(100, pct)
|
||||||
|
.overflow("hidden")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
register(Jobs)
|
||||||
3
jobs/icons/jobs.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg width="100" height="90" viewBox="0 0 100 90" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M93.3636 15.8877H73.7455V11.2591C73.7455 5.05435 68.7 0 62.5 0H37.2909C31.0909 0 26.0455 5.04529 26.0455 11.2591V15.8877H6.63636C2.98182 15.8877 0 18.8678 0 22.5272V41.2772C0 43.4783 1.46364 45.3895 3.57273 46.0054V83.3696C3.57273 87.029 6.54545 90 10.1909 90H89.8091C93.4636 90 96.4273 87.029 96.4273 83.3696V46.0054C98.5364 45.3895 100 43.4783 100 41.2772V22.5272C100 18.8678 97.0273 15.8877 93.3636 15.8877ZM93.7 83.3696C93.7 85.5254 91.9545 87.2826 89.8091 87.2826H10.1909C8.04545 87.2826 6.3 85.5254 6.3 83.3696V46.712L43.7364 56.2138V60.933C43.7364 63.1522 45.5455 64.9547 47.7636 64.9547H52.0364C54.2545 64.9547 56.0546 63.1522 56.0546 60.933V56.2772L93.7 46.712V83.3696ZM52.0273 50.1993C52.7455 50.1993 53.3182 50.788 53.3182 51.5036V60.933C53.3182 61.6576 52.7364 62.2373 52.0273 62.2373H47.7545C47.0364 62.2373 46.4545 61.6485 46.4545 60.933V51.5036C46.4545 50.779 47.0364 50.1993 47.7545 50.1993H52.0273ZM97.2636 41.2772C97.2636 42.2917 96.5727 43.1793 95.5818 43.433C55.2455 53.6775 56.0455 53.5688 56.0455 53.4783V51.5127C56.0455 49.2935 54.2364 47.4909 52.0273 47.4909H47.7545C45.5636 47.4909 43.7273 49.2482 43.7273 51.5127V53.4149L4.40909 43.433C3.41818 43.1793 2.72727 42.3007 2.72727 41.2772V22.5272C2.72727 20.3623 4.48182 18.6051 6.63636 18.6051H93.3636C95.5182 18.6051 97.2727 20.3623 97.2727 22.5272V41.2772H97.2636ZM28.7727 11.25C28.7727 6.53986 32.5909 2.70833 37.2909 2.70833H62.5C67.2 2.70833 71.0182 6.53986 71.0182 11.25V15.8786H66.3182V11.25C66.3182 9.13949 64.6 7.41848 62.5 7.41848H37.2909C35.1818 7.41848 33.4727 9.13949 33.4727 11.25V15.8786H28.7727V11.25ZM36.2 15.8786V11.25C36.2 10.6341 36.6909 10.1359 37.2909 10.1359H62.5C63.1 10.1359 63.5909 10.6341 63.5909 11.25V15.8786H36.1909H36.2Z" fill="black"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.8 KiB |
3
jobs/icons/jobslight.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg width="110" height="100" viewBox="0 0 110 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M102.7 17.54H81.12V12.43C81.12 5.58 75.57 0 68.75 0H41.02C34.2 0 28.65 5.57 28.65 12.43V17.54H7.3C3.28 17.54 0 20.83 0 24.87V45.57C0 48 1.61 50.11 3.93 50.79V92.04C3.93 96.08 7.2 99.36 11.21 99.36H98.79C102.81 99.36 106.07 96.08 106.07 92.04V50.79C108.39 50.11 110 48 110 45.57V24.87C110 20.83 106.73 17.54 102.7 17.54ZM103.07 92.04C103.07 94.42 101.15 96.36 98.79 96.36H11.21C8.85 96.36 6.93 94.42 6.93 92.04V51.57L48.11 62.06V67.27C48.11 69.72 50.1 71.71 52.54 71.71H57.24C59.68 71.71 61.66 69.72 61.66 67.27V62.13L103.07 51.57V92.04ZM57.23 55.42C58.02 55.42 58.65 56.07 58.65 56.86V67.27C58.65 68.07 58.01 68.71 57.23 68.71H52.53C51.74 68.71 51.1 68.06 51.1 67.27V56.86C51.1 56.06 51.74 55.42 52.53 55.42H57.23ZM106.99 45.57C106.99 46.69 106.23 47.67 105.14 47.95C60.77 59.26 61.65 59.14 61.65 59.04V56.87C61.65 54.42 59.66 52.43 57.23 52.43H52.53C50.12 52.43 48.1 54.37 48.1 56.87V58.97L4.85 47.95C3.76 47.67 3 46.7 3 45.57V24.87C3 22.48 4.93 20.54 7.3 20.54H102.7C105.07 20.54 107 22.48 107 24.87V45.57H106.99ZM31.65 12.42C31.65 7.22 35.85 2.99 41.02 2.99H68.75C73.92 2.99 78.12 7.22 78.12 12.42V17.53H72.95V12.42C72.95 10.09 71.06 8.19 68.75 8.19H41.02C38.7 8.19 36.82 10.09 36.82 12.42V17.53H31.65V12.42ZM39.82 17.53V12.42C39.82 11.74 40.36 11.19 41.02 11.19H68.75C69.41 11.19 69.95 11.74 69.95 12.42V17.53H39.81H39.82Z" fill="#FFE9C8"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.4 KiB |
3
jobs/icons/jobslightselected.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg width="102" height="92" viewBox="0 0 102 92" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M63.5 0.5C69.9767 0.5 75.245 5.77871 75.2451 12.2588V16.3877H94.3633C98.3036 16.3877 101.5 19.5923 101.5 23.5273V42.2773C101.5 44.5771 100.051 46.5844 97.9277 47.3652V84.3691C97.9277 88.3036 94.741 91.4997 90.8096 91.5H11.1904C7.26853 91.4997 4.07227 88.304 4.07227 84.3691V47.3652C1.94941 46.5844 0.500071 44.5771 0.5 42.2773V23.5273C0.5 19.5918 3.70596 16.3877 7.63672 16.3877H26.5459V12.2588C26.546 5.76939 31.8146 0.5 38.291 0.5H63.5ZM7.7998 84.3691C7.7998 86.2515 9.32416 87.782 11.1904 87.7822H90.8096C92.6758 87.782 94.2002 86.2515 94.2002 84.3691V48.3545L57.5547 57.665V61.9326C57.5547 64.4275 55.5309 66.4551 53.0361 66.4551H48.7637C46.2703 66.4551 44.2363 64.4289 44.2363 61.9326V57.6025L7.7998 48.3535V84.3691ZM48.7549 51.6992C48.3134 51.6992 47.9541 52.0548 47.9541 52.5039V61.9326C47.9541 62.3749 48.3157 62.7373 48.7549 62.7373H53.0273C53.4574 62.7373 53.8184 62.3839 53.8184 61.9326V52.5039C53.8184 52.0571 53.462 51.6993 53.0273 51.6992H48.7549ZM7.63672 20.1055C5.75962 20.1055 4.22754 21.6373 4.22754 23.5273V42.2773C4.22761 43.0695 4.76073 43.7505 5.5332 43.9482H5.53223L44.2275 53.7725V52.5127C44.2275 49.9652 46.2947 47.9912 48.7549 47.9912H53.0273C55.5123 47.9912 57.5459 50.0171 57.5459 52.5127V53.8721C58.1385 53.7243 59.2979 53.4278 61.5635 52.8467C66.4059 51.6045 76.2902 49.0704 96.458 43.9482L96.5996 43.9062C97.2939 43.665 97.7636 43.0127 97.7637 42.2773V41.7773H97.7725V23.5273C97.7725 21.6373 96.2404 20.1055 94.3633 20.1055H7.63672ZM38.291 4.20801C33.8686 4.20801 30.2725 7.81459 30.2725 12.25V16.3789H33.9727V12.25C33.9727 9.8655 35.9036 7.91895 38.291 7.91895H63.5C65.8775 7.91895 67.8184 9.86473 67.8184 12.25V16.3789H71.5186V12.25C71.5186 7.81459 67.9225 4.20801 63.5 4.20801H38.291ZM38.291 11.6357C37.9761 11.6357 37.7002 11.9012 37.7002 12.25V16.3789H64.0908V12.25C64.0908 11.9012 63.8149 11.6357 63.5 11.6357H38.291Z" fill="#BD2D2D" stroke="#FFE9C8"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.0 KiB |
441
jobs/jobs.js
Normal file
@@ -0,0 +1,441 @@
|
|||||||
|
import server from "/@server/server.js"
|
||||||
|
import "/_/code/components/LoadingCircle.js"
|
||||||
|
|
||||||
|
css(`
|
||||||
|
jobs-, jobs- * { box-sizing: border-box; }
|
||||||
|
jobs- {
|
||||||
|
font-family: 'Arial';
|
||||||
|
scrollbar-width: none;
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
}
|
||||||
|
jobs-::-webkit-scrollbar { display: none; }
|
||||||
|
jobs- input::placeholder, jobs- select::placeholder {
|
||||||
|
color: var(--headertext);
|
||||||
|
opacity: 0.35;
|
||||||
|
}
|
||||||
|
jobs- select {
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
}
|
||||||
|
`)
|
||||||
|
|
||||||
|
class Jobs extends Shadow {
|
||||||
|
selectedJobId = null
|
||||||
|
searchText = ""
|
||||||
|
searchOpen = false
|
||||||
|
filtersOpen = false
|
||||||
|
filters = { type: "", level: "" }
|
||||||
|
loaded = false
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super()
|
||||||
|
const cached = global.currentNetwork?.data?.jobs
|
||||||
|
if (cached?.length) {
|
||||||
|
this.jobs = cached
|
||||||
|
this.loaded = true
|
||||||
|
} else {
|
||||||
|
this.jobs = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadJobs() {
|
||||||
|
const fetched = await server.getJobs(global.currentNetwork.id)
|
||||||
|
this.jobs = fetched || []
|
||||||
|
this.loaded = true
|
||||||
|
this.rerender()
|
||||||
|
}
|
||||||
|
|
||||||
|
get selectedJob() { return this.jobs.find(j => j.id === this.selectedJobId) || null }
|
||||||
|
|
||||||
|
get filtered() {
|
||||||
|
return this.jobs.filter(job => {
|
||||||
|
if (this.searchText) {
|
||||||
|
const q = this.searchText.toLowerCase()
|
||||||
|
const hay = [job.title, job.company, job.location, job.department].join(" ").toLowerCase()
|
||||||
|
if (!hay.includes(q)) return false
|
||||||
|
}
|
||||||
|
if (this.filters.type && job.employment_type !== this.filters.type) return false
|
||||||
|
if (this.filters.level && job.experience_level !== this.filters.level) return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
VStack(() => {
|
||||||
|
if (this.selectedJobId === null) {
|
||||||
|
this.renderList()
|
||||||
|
} else {
|
||||||
|
this.renderDetail()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.height(100, pct).width(100, vw).overflow("hidden")
|
||||||
|
.onAppear(async () => {
|
||||||
|
if (!this.loaded) await this.loadJobs()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── List ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
renderList() {
|
||||||
|
VStack(() => {
|
||||||
|
this.renderListHeader()
|
||||||
|
|
||||||
|
if (this.filtersOpen && !global.appRefreshing) this.renderFilters()
|
||||||
|
|
||||||
|
const jobs = this.filtered
|
||||||
|
VStack(() => {
|
||||||
|
if (global.appRefreshing || !this.loaded) {
|
||||||
|
LoadingCircle()
|
||||||
|
} else if (!jobs.length) {
|
||||||
|
VStack(() => {
|
||||||
|
p("No jobs match your filters").margin(0).fontSize(0.9, em)
|
||||||
|
.color("var(--headertext)").opacity(0.38).textAlign("center")
|
||||||
|
}).flex(1).justifyContent("center").alignItems("center")
|
||||||
|
} else {
|
||||||
|
p(jobs.length === 1 ? "1 job" : `${jobs.length} jobs`)
|
||||||
|
.margin(0).paddingHorizontal(1.1, em).paddingTop(0.65, em)
|
||||||
|
.fontSize(0.72, em).fontWeight("600")
|
||||||
|
.color("var(--headertext)").opacity(0.4).flexShrink(0)
|
||||||
|
|
||||||
|
jobs.forEach(job => this.renderCard(job))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.flex(1).overflowY("auto").paddingBottom(2, em)
|
||||||
|
})
|
||||||
|
.height(100, pct).width(100, pct).overflow("hidden")
|
||||||
|
}
|
||||||
|
|
||||||
|
renderListHeader() {
|
||||||
|
VStack(() => {
|
||||||
|
HStack(() => {
|
||||||
|
p("Jobs")
|
||||||
|
.margin(0).fontSize(1.35, em).fontWeight("700")
|
||||||
|
.color("var(--headertext)").flex(1)
|
||||||
|
|
||||||
|
HStack(() => {
|
||||||
|
p("🔍")
|
||||||
|
.margin(0).fontSize(1.05, em).padding(0.4, em).cursor("pointer")
|
||||||
|
.onTouch((start) => {
|
||||||
|
if (!start) {
|
||||||
|
this.searchOpen = !this.searchOpen
|
||||||
|
if (!this.searchOpen) this.searchText = ""
|
||||||
|
this.rerender()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
p("⚙️")
|
||||||
|
.margin(0).fontSize(1.05, em).padding(0.4, em).cursor("pointer")
|
||||||
|
.onTouch((start) => {
|
||||||
|
if (!start) { this.filtersOpen = !this.filtersOpen; this.rerender() }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.gap(0)
|
||||||
|
})
|
||||||
|
.alignItems("center")
|
||||||
|
.paddingHorizontal(1.1, em).paddingTop(1, em).paddingBottom(0.5, em)
|
||||||
|
|
||||||
|
if (this.searchOpen) {
|
||||||
|
HStack(() => {
|
||||||
|
p("🔍").margin(0).fontSize(0.78, em).opacity(0.4).flexShrink(0)
|
||||||
|
input()
|
||||||
|
.attr({ type: "text", placeholder: "Search jobs…", autofocus: "true" })
|
||||||
|
.flex(1).border("none").outline("none")
|
||||||
|
.background("transparent")
|
||||||
|
.color("var(--headertext)").fontSize(0.9, em)
|
||||||
|
.onInput((e) => { this.searchText = e.target.value; this.rerender() })
|
||||||
|
})
|
||||||
|
.gap(0.5, em)
|
||||||
|
.paddingHorizontal(0.85, em).paddingVertical(0.55, em)
|
||||||
|
.background("var(--darkaccent)")
|
||||||
|
.border("1px solid var(--divider)")
|
||||||
|
.borderRadius(0.6, em)
|
||||||
|
.marginHorizontal(1.1, em).marginBottom(0.5, em)
|
||||||
|
.alignItems("center")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.flexShrink(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
renderFilters() {
|
||||||
|
HStack(() => {
|
||||||
|
select(() => {
|
||||||
|
option("Any type", "")
|
||||||
|
option("Full-time", "full-time")
|
||||||
|
option("Part-time", "part-time")
|
||||||
|
option("Contract", "contract")
|
||||||
|
option("Internship","internship")
|
||||||
|
})
|
||||||
|
.flex(1)
|
||||||
|
.padding(0.6, em)
|
||||||
|
.background("var(--darkaccent)")
|
||||||
|
.border("1px solid var(--divider)")
|
||||||
|
.borderRadius(0.55, em)
|
||||||
|
.color("var(--headertext)").fontSize(0.85, em)
|
||||||
|
.onInput((e) => { this.filters.type = e.target.value; this.rerender() })
|
||||||
|
|
||||||
|
select(() => {
|
||||||
|
option("Any level", "")
|
||||||
|
option("Entry", "entry")
|
||||||
|
option("Mid", "mid")
|
||||||
|
option("Senior", "senior")
|
||||||
|
})
|
||||||
|
.flex(1)
|
||||||
|
.padding(0.6, em)
|
||||||
|
.background("var(--darkaccent)")
|
||||||
|
.border("1px solid var(--divider)")
|
||||||
|
.borderRadius(0.55, em)
|
||||||
|
.color("var(--headertext)").fontSize(0.85, em)
|
||||||
|
.onInput((e) => { this.filters.level = e.target.value; this.rerender() })
|
||||||
|
})
|
||||||
|
.gap(0.65, em)
|
||||||
|
.paddingHorizontal(1.1, em).paddingBottom(0.65, em)
|
||||||
|
.flexShrink(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
renderCard(job) {
|
||||||
|
VStack(() => {
|
||||||
|
HStack(() => {
|
||||||
|
VStack(() => {
|
||||||
|
p((job.company || "?")[0].toUpperCase())
|
||||||
|
.margin(0).fontSize(1.1, em).fontWeight("700").color("white")
|
||||||
|
})
|
||||||
|
.width(2.8, em).height(2.8, em).borderRadius(0.55, em)
|
||||||
|
.background(this.companyColor(job.company))
|
||||||
|
.justifyContent("center").alignItems("center").flexShrink(0)
|
||||||
|
|
||||||
|
VStack(() => {
|
||||||
|
p(job.title)
|
||||||
|
.margin(0).fontSize(0.95, em).fontWeight("600")
|
||||||
|
.color("var(--headertext)").lineHeight("1.3")
|
||||||
|
|
||||||
|
p(job.company || "Unknown")
|
||||||
|
.margin(0).marginTop(0.1, em).fontSize(0.78, em)
|
||||||
|
.color("var(--headertext)").opacity(0.55)
|
||||||
|
})
|
||||||
|
.flex(1).minWidth(0)
|
||||||
|
|
||||||
|
VStack(() => {
|
||||||
|
p(this.salaryLabel(job.salary_number, job.salary_period))
|
||||||
|
.margin(0).fontSize(0.78, em).fontWeight("600")
|
||||||
|
.color("var(--headertext)").textAlign("right")
|
||||||
|
|
||||||
|
p(this.relativeDate(job.posted_at))
|
||||||
|
.margin(0).marginTop(0.25, em).fontSize(0.68, em)
|
||||||
|
.color("var(--headertext)").opacity(0.38).textAlign("right")
|
||||||
|
})
|
||||||
|
.alignItems("flex-end").flexShrink(0)
|
||||||
|
})
|
||||||
|
.gap(0.75, em).alignItems("flex-start")
|
||||||
|
|
||||||
|
HStack(() => {
|
||||||
|
if (job.location) this.chip("📍 " + job.location)
|
||||||
|
if (job.employment_type) this.chip(this.formatType(job.employment_type))
|
||||||
|
if (job.experience_level) this.chip(this.formatLevel(job.experience_level))
|
||||||
|
})
|
||||||
|
.gap(0.4, em).flexWrap("wrap").marginTop(0.65, em)
|
||||||
|
})
|
||||||
|
.padding(1, em)
|
||||||
|
.marginHorizontal(1.1, em).marginTop(0.5, em)
|
||||||
|
.background("var(--darkaccent)")
|
||||||
|
.border("1px solid var(--divider)")
|
||||||
|
.borderRadius(0.75, em)
|
||||||
|
.onTouch((start) => {
|
||||||
|
if (!start) { this.selectedJobId = job.id; this.rerender() }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
chip(text) {
|
||||||
|
p(text)
|
||||||
|
.margin(0).paddingHorizontal(0.55, em).paddingVertical(0.2, em)
|
||||||
|
.background("var(--darkaccent)").border("1px solid var(--divider)")
|
||||||
|
.borderRadius(100, px).fontSize(0.7, em)
|
||||||
|
.color("var(--headertext)").opacity(0.7).whiteSpace("nowrap")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Detail ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
renderDetail() {
|
||||||
|
const job = this.selectedJob
|
||||||
|
if (!job) return
|
||||||
|
|
||||||
|
VStack(() => {
|
||||||
|
// Header bar
|
||||||
|
HStack(() => {
|
||||||
|
p("‹")
|
||||||
|
.margin(0).fontSize(1.8, em).lineHeight("1")
|
||||||
|
.color("var(--headertext)").paddingRight(0.5, em).cursor("pointer")
|
||||||
|
.onTouch((start) => {
|
||||||
|
if (!start) { this.selectedJobId = null; this.rerender() }
|
||||||
|
})
|
||||||
|
p(job.company || "Job")
|
||||||
|
.margin(0).fontSize(0.95, em).fontWeight("600")
|
||||||
|
.color("var(--headertext)").opacity(0.7)
|
||||||
|
.flex(1).overflow("hidden").whiteSpace("nowrap").textOverflow("ellipsis")
|
||||||
|
})
|
||||||
|
.gap(0.25, em).paddingHorizontal(1.1, em).paddingVertical(0.85, em)
|
||||||
|
.borderBottom("1px solid var(--divider)")
|
||||||
|
.alignItems("center").flexShrink(0)
|
||||||
|
|
||||||
|
// Scrollable body
|
||||||
|
VStack(() => {
|
||||||
|
// Company + title
|
||||||
|
HStack(() => {
|
||||||
|
VStack(() => {
|
||||||
|
p((job.company || "?")[0].toUpperCase())
|
||||||
|
.margin(0).fontSize(1.6, em).fontWeight("700").color("white")
|
||||||
|
})
|
||||||
|
.width(3.5, em).height(3.5, em).borderRadius(0.65, em)
|
||||||
|
.background(this.companyColor(job.company))
|
||||||
|
.justifyContent("center").alignItems("center").flexShrink(0)
|
||||||
|
|
||||||
|
VStack(() => {
|
||||||
|
p(job.title)
|
||||||
|
.margin(0).fontSize(1.1, em).fontWeight("700")
|
||||||
|
.color("var(--headertext)").lineHeight("1.25")
|
||||||
|
p(job.company || "Unknown Company")
|
||||||
|
.margin(0).marginTop(0.15, em).fontSize(0.88, em)
|
||||||
|
.color("var(--headertext)").opacity(0.55)
|
||||||
|
})
|
||||||
|
.flex(1).minWidth(0)
|
||||||
|
})
|
||||||
|
.gap(0.9, em).alignItems("center")
|
||||||
|
.paddingHorizontal(1.1, em).paddingTop(1.25, em).paddingBottom(0.9, em)
|
||||||
|
|
||||||
|
// Meta chips
|
||||||
|
HStack(() => {
|
||||||
|
if (job.location) this.chip("📍 " + job.location)
|
||||||
|
if (job.employment_type) this.chip(this.formatType(job.employment_type))
|
||||||
|
if (job.experience_level) this.chip(this.formatLevel(job.experience_level))
|
||||||
|
if (job.department) this.chip(job.department)
|
||||||
|
if (job.salary_number) this.chip(this.salaryLabel(job.salary_number, job.salary_period))
|
||||||
|
})
|
||||||
|
.gap(0.45, em).flexWrap("wrap")
|
||||||
|
.paddingHorizontal(1.1, em).paddingBottom(0.85, em)
|
||||||
|
.borderBottom("1px solid var(--divider)")
|
||||||
|
|
||||||
|
// Stats
|
||||||
|
HStack(() => {
|
||||||
|
if (job.applicants !== undefined) {
|
||||||
|
p(job.applicants + " applicants")
|
||||||
|
.margin(0).fontSize(0.78, em).color("var(--headertext)").opacity(0.4)
|
||||||
|
}
|
||||||
|
if (job.posted_at) {
|
||||||
|
p(this.relativeDate(job.posted_at))
|
||||||
|
.margin(0).fontSize(0.78, em).color("var(--headertext)").opacity(0.4)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.gap(1.25, em).paddingHorizontal(1.1, em).paddingVertical(0.75, em)
|
||||||
|
.borderBottom("1px solid var(--divider)")
|
||||||
|
|
||||||
|
// CTA buttons
|
||||||
|
HStack(() => {
|
||||||
|
button("Apply now")
|
||||||
|
.flex(1).paddingVertical(0.72, em)
|
||||||
|
.background("var(--quillred)").color("white")
|
||||||
|
.border("none").borderRadius(0.55, em)
|
||||||
|
.fontWeight("600").fontSize(0.92, em).cursor("pointer")
|
||||||
|
|
||||||
|
button("Save job")
|
||||||
|
.flex(1).paddingVertical(0.72, em)
|
||||||
|
.background("transparent").color("var(--headertext)")
|
||||||
|
.border("1px solid var(--divider)").borderRadius(0.55, em)
|
||||||
|
.fontWeight("500").fontSize(0.92, em).cursor("pointer")
|
||||||
|
})
|
||||||
|
.gap(0.65, em).paddingHorizontal(1.1, em).paddingVertical(0.85, em)
|
||||||
|
.borderBottom("1px solid var(--divider)")
|
||||||
|
|
||||||
|
// Description
|
||||||
|
if (job.description) {
|
||||||
|
this.section("About the role", () => {
|
||||||
|
p(job.description)
|
||||||
|
.margin(0).fontSize(0.88, em).lineHeight("1.65")
|
||||||
|
.color("var(--headertext)").opacity(0.8)
|
||||||
|
.whiteSpace("pre-wrap")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skills
|
||||||
|
if (job.skills?.length) {
|
||||||
|
this.section("Skills & requirements", () => {
|
||||||
|
HStack(() => {
|
||||||
|
job.skills.forEach(s => {
|
||||||
|
p(s)
|
||||||
|
.margin(0).paddingHorizontal(0.75, em).paddingVertical(0.3, em)
|
||||||
|
.background("var(--darkaccent)").border("1px solid var(--divider)")
|
||||||
|
.borderRadius(100, px).fontSize(0.78, em)
|
||||||
|
.color("var(--headertext)").opacity(0.8).whiteSpace("nowrap")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.gap(0.45, em).flexWrap("wrap")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Details table
|
||||||
|
this.section("Job details", () => {
|
||||||
|
VStack(() => {
|
||||||
|
this.detailRow("Type", this.formatType(job.employment_type) || "—")
|
||||||
|
this.detailRow("Level", this.formatLevel(job.experience_level) || "—")
|
||||||
|
this.detailRow("Location", job.location || "—")
|
||||||
|
this.detailRow("Pay", job.salary_number ? this.salaryLabel(job.salary_number, job.salary_period) : "—")
|
||||||
|
if (job.department) this.detailRow("Dept.", job.department)
|
||||||
|
}).gap(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
div().height(2, em)
|
||||||
|
})
|
||||||
|
.flex(1).overflowY("auto")
|
||||||
|
})
|
||||||
|
.height(100, pct).width(100, pct).overflow("hidden")
|
||||||
|
}
|
||||||
|
|
||||||
|
section(title, fn) {
|
||||||
|
VStack(() => {
|
||||||
|
p(title)
|
||||||
|
.margin(0).marginBottom(0.65, em)
|
||||||
|
.fontSize(0.72, em).fontWeight("700").letterSpacing("0.05em")
|
||||||
|
.color("var(--headertext)").opacity(0.38).textTransform("uppercase")
|
||||||
|
fn()
|
||||||
|
})
|
||||||
|
.paddingHorizontal(1.1, em).paddingTop(1.1, em).paddingBottom(0.9, em)
|
||||||
|
.borderBottom("1px solid var(--divider)")
|
||||||
|
}
|
||||||
|
|
||||||
|
detailRow(label, value) {
|
||||||
|
HStack(() => {
|
||||||
|
p(label).margin(0).fontSize(0.85, em).color("var(--headertext)").opacity(0.45).width(5.5, em).flexShrink(0)
|
||||||
|
p(value).margin(0).fontSize(0.85, em).color("var(--headertext)").fontWeight("500")
|
||||||
|
})
|
||||||
|
.paddingVertical(0.55, em).alignItems("flex-start")
|
||||||
|
.borderBottom("1px solid var(--divider)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
formatType(t) { return { "full-time": "Full-time", "part-time": "Part-time", "contract": "Contract", "internship": "Internship" }[t] || t }
|
||||||
|
formatLevel(l) { return { "entry": "Entry level", "mid": "Mid level", "senior": "Senior" }[l] || l }
|
||||||
|
|
||||||
|
salaryLabel(n, p) {
|
||||||
|
if (!n) return "—"
|
||||||
|
const fmt = new Intl.NumberFormat("en-US", { maximumFractionDigits: 0 }).format(Number(n))
|
||||||
|
return { year: `$${fmt}/yr`, month: `$${fmt}/mo`, hour: `$${fmt}/hr`, "one-time": `$${fmt}` }[p] || `$${fmt}`
|
||||||
|
}
|
||||||
|
|
||||||
|
relativeDate(date) {
|
||||||
|
if (!date) return ""
|
||||||
|
const days = Math.floor((Date.now() - new Date(date)) / 86400000)
|
||||||
|
if (days === 0) return "Today"
|
||||||
|
if (days === 1) return "Yesterday"
|
||||||
|
if (days < 7) return `${days}d ago`
|
||||||
|
if (days < 30) return `${Math.floor(days / 7)}w ago`
|
||||||
|
return `${Math.floor(days / 30)}mo ago`
|
||||||
|
}
|
||||||
|
|
||||||
|
companyColor(company) {
|
||||||
|
const colors = ["#3b82f6", "#ef4444", "#10b981", "#f59e0b", "#8b5cf6", "#ec4899", "#06b6d4", "#84cc16"]
|
||||||
|
if (!company) return colors[0]
|
||||||
|
let hash = 0
|
||||||
|
for (let i = 0; i < company.length; i++) hash = company.charCodeAt(i) + ((hash << 5) - hash)
|
||||||
|
return colors[Math.abs(hash) % colors.length]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
register(Jobs)
|
||||||
3
package.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"type": "module"
|
||||||
|
}
|
||||||
132
people/PeopleCard.js
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
class PeopleCard extends Shadow {
|
||||||
|
constructor(person) {
|
||||||
|
super()
|
||||||
|
this.person = person
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const p_ = this.person
|
||||||
|
const initials = ((p_.first_name?.[0] ?? "") + (p_.last_name?.[0] ?? "")).toUpperCase()
|
||||||
|
const roles = Array.isArray(p_.roles) ? p_.roles : []
|
||||||
|
const isOnline = p_._online
|
||||||
|
|
||||||
|
HStack(() => {
|
||||||
|
// Avatar + online dot
|
||||||
|
ZStack(() => {
|
||||||
|
VStack(() => {
|
||||||
|
if (p_.image_path) {
|
||||||
|
img(`${config.SERVER}${p_.image_path}`, "2.75em", "2.75em")
|
||||||
|
.borderRadius(50, pct)
|
||||||
|
.objectFit("cover")
|
||||||
|
} else {
|
||||||
|
p(initials)
|
||||||
|
.margin(0)
|
||||||
|
.fontSize(0.78, em)
|
||||||
|
.fontWeight("700")
|
||||||
|
.color("white")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.width(2.75, em)
|
||||||
|
.height(2.75, em)
|
||||||
|
.borderRadius(50, pct)
|
||||||
|
.background(p_.image_path ? "transparent" : this.avatarColor(p_.email || p_.first_name))
|
||||||
|
.justifyContent("center")
|
||||||
|
.alignItems("center")
|
||||||
|
.overflow("hidden")
|
||||||
|
.flexShrink(0)
|
||||||
|
|
||||||
|
// Online indicator
|
||||||
|
if (isOnline) {
|
||||||
|
VStack(() => {})
|
||||||
|
.width(0.68, em)
|
||||||
|
.height(0.68, em)
|
||||||
|
.borderRadius(50, pct)
|
||||||
|
.background("#10b981")
|
||||||
|
.boxSizing("border-box")
|
||||||
|
.position("absolute")
|
||||||
|
.bottom(0).right(0)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.position("relative")
|
||||||
|
.width(2.75, em)
|
||||||
|
.height(2.75, em)
|
||||||
|
.flexShrink(0)
|
||||||
|
|
||||||
|
// Info
|
||||||
|
VStack(() => {
|
||||||
|
HStack(() => {
|
||||||
|
p(`${p_.first_name} ${p_.last_name}`)
|
||||||
|
.margin(0)
|
||||||
|
.fontSize(0.92, em)
|
||||||
|
.fontWeight("600")
|
||||||
|
.color("var(--headertext)")
|
||||||
|
.flex(1)
|
||||||
|
.minWidth(0)
|
||||||
|
.overflow("hidden")
|
||||||
|
.whiteSpace("nowrap")
|
||||||
|
.textOverflow("ellipsis")
|
||||||
|
|
||||||
|
if (isOnline) {
|
||||||
|
p("online")
|
||||||
|
.margin(0)
|
||||||
|
.fontSize(0.65, em)
|
||||||
|
.fontWeight("600")
|
||||||
|
.color("#10b981")
|
||||||
|
.flexShrink(0)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.alignItems("center")
|
||||||
|
.gap(0.5, em)
|
||||||
|
|
||||||
|
HStack(() => {
|
||||||
|
p(p_.email ?? "")
|
||||||
|
.margin(0)
|
||||||
|
.fontSize(0.72, em)
|
||||||
|
.color("var(--headertext)")
|
||||||
|
.opacity(0.42)
|
||||||
|
.overflow("hidden")
|
||||||
|
.whiteSpace("nowrap")
|
||||||
|
.textOverflow("ellipsis")
|
||||||
|
.flex(1)
|
||||||
|
.minWidth(0)
|
||||||
|
|
||||||
|
roles.slice(0, 2).forEach(role => {
|
||||||
|
p(role)
|
||||||
|
.margin(0)
|
||||||
|
.fontSize(0.62, em)
|
||||||
|
.fontWeight("600")
|
||||||
|
.color("var(--quillred)")
|
||||||
|
.background("rgba(159,28,41,0.1)")
|
||||||
|
.paddingHorizontal(0.45, em)
|
||||||
|
.paddingVertical(0.12, em)
|
||||||
|
.borderRadius(100, px)
|
||||||
|
.whiteSpace("nowrap")
|
||||||
|
.flexShrink(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.gap(0.35, em)
|
||||||
|
.alignItems("center")
|
||||||
|
.marginTop(0.2, em)
|
||||||
|
})
|
||||||
|
.flex(1)
|
||||||
|
.minWidth(0)
|
||||||
|
.gap(0)
|
||||||
|
})
|
||||||
|
.gap(0.75, em)
|
||||||
|
.paddingHorizontal(1, em)
|
||||||
|
.paddingVertical(0.85, em)
|
||||||
|
.alignItems("center")
|
||||||
|
.width(100, pct)
|
||||||
|
.boxSizing("border-box")
|
||||||
|
}
|
||||||
|
|
||||||
|
avatarColor(seed) {
|
||||||
|
const colors = ["#3b82f6", "#9E1C29", "#10b981", "#f59e0b", "#8b5cf6", "#ec4899", "#06b6d4", "#84cc16"]
|
||||||
|
if (!seed) return colors[0]
|
||||||
|
let hash = 0
|
||||||
|
for (let i = 0; i < seed.length; i++) hash = seed.charCodeAt(i) + ((hash << 5) - hash)
|
||||||
|
return colors[Math.abs(hash) % colors.length]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
register(PeopleCard)
|
||||||
61
people/PeopleList.js
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import "./PeopleCard.js"
|
||||||
|
import "/_/code/components/LoadingCircle.js"
|
||||||
|
|
||||||
|
css(`
|
||||||
|
people- {
|
||||||
|
font-family: 'Arial';
|
||||||
|
scrollbar-width: none;
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
}
|
||||||
|
people- input::placeholder {
|
||||||
|
color: var(--headertext);
|
||||||
|
opacity: 0.35;
|
||||||
|
}
|
||||||
|
peoplelist- {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
`)
|
||||||
|
|
||||||
|
class PeopleList extends Shadow {
|
||||||
|
constructor(members, isLoading) {
|
||||||
|
super()
|
||||||
|
this._members = members
|
||||||
|
this._isLoading = isLoading
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
VStack(() => {
|
||||||
|
if (this._isLoading) {
|
||||||
|
LoadingCircle()
|
||||||
|
} else if (this._members.length === 0) {
|
||||||
|
VStack(() => {
|
||||||
|
p("No members match your search")
|
||||||
|
.margin(0)
|
||||||
|
.fontSize(0.9, em)
|
||||||
|
.color("var(--headertext)")
|
||||||
|
.opacity(0.35)
|
||||||
|
.textAlign("center")
|
||||||
|
})
|
||||||
|
.flex(1)
|
||||||
|
.justifyContent("center")
|
||||||
|
.alignItems("center")
|
||||||
|
} else {
|
||||||
|
this._members.forEach((person, i) => {
|
||||||
|
PeopleCard(person)
|
||||||
|
if (i < this._members.length - 1) {
|
||||||
|
VStack(() => {}).height(1, px).background("var(--divider)").marginLeft(4.6, em)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.flex(1)
|
||||||
|
.overflowY("auto")
|
||||||
|
.paddingBottom(1, em)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
register(PeopleList)
|
||||||
310
people/desktop/DesktopPeopleDetail.js
Normal file
@@ -0,0 +1,310 @@
|
|||||||
|
import server from "/people/@server/index.js"
|
||||||
|
|
||||||
|
class DesktopPeopleDetail extends Shadow {
|
||||||
|
constructor(person, onSaved) {
|
||||||
|
super()
|
||||||
|
this.person = person
|
||||||
|
this.onSaved = onSaved
|
||||||
|
this.editingNotes = false
|
||||||
|
this.notesDraft = ""
|
||||||
|
this.notesSaving = false
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (!this.person) {
|
||||||
|
VStack(() => {
|
||||||
|
p("👤")
|
||||||
|
.margin(0).fontSize(2.8, em).opacity(0.15)
|
||||||
|
p("Select a member to view their profile")
|
||||||
|
.margin(0).marginTop(0.75, em).fontSize(0.9, em)
|
||||||
|
.color("var(--headertext)").opacity(0.32).textAlign("center")
|
||||||
|
})
|
||||||
|
.flex(1).height(100, pct).justifyContent("center").alignItems("center")
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const p_ = this.person;
|
||||||
|
const isOnline = p_._online;
|
||||||
|
const isAdmin = (p_.roles || []).includes("admin");
|
||||||
|
|
||||||
|
VStack(() => {
|
||||||
|
// ── Profile header ────────────────────────────────────────
|
||||||
|
VStack(() => {
|
||||||
|
HStack(() => {
|
||||||
|
// Large avatar
|
||||||
|
ZStack(() => {
|
||||||
|
this.renderAvatar(p_, 5)
|
||||||
|
VStack(() => {})
|
||||||
|
.width(0.9, em).height(0.9, em).borderRadius(50, pct)
|
||||||
|
.background(isOnline ? "#22c55e" : "var(--divider)")
|
||||||
|
.boxSizing("border-box")
|
||||||
|
.position("absolute").bottom(0.1, em).right(0.1, em)
|
||||||
|
})
|
||||||
|
.position("relative").flexShrink(0)
|
||||||
|
|
||||||
|
VStack(() => {
|
||||||
|
HStack(() => {
|
||||||
|
h2(`${p_.first_name} ${p_.last_name}`)
|
||||||
|
.margin(0).fontSize(1.3, em).fontWeight("700")
|
||||||
|
.color("var(--headertext)").lineHeight("1.2")
|
||||||
|
|
||||||
|
HStack(() => {
|
||||||
|
if (isOnline) {
|
||||||
|
p("● Online")
|
||||||
|
.margin(0).fontSize(0.72, em).fontWeight("600").color("#22c55e")
|
||||||
|
} else {
|
||||||
|
p("○ Offline")
|
||||||
|
.margin(0).fontSize(0.72, em).color("var(--headertext)").opacity(0.35)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.gap(0.75, em).alignItems("center")
|
||||||
|
|
||||||
|
if (p_.title) {
|
||||||
|
p(p_.title)
|
||||||
|
.margin(0).marginTop(0.22, em).fontSize(0.9, em)
|
||||||
|
.color("var(--headertext)").opacity(0.55).fontWeight("400")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Role + tier badges
|
||||||
|
HStack(() => {
|
||||||
|
(p_.roles || []).forEach(role => {
|
||||||
|
p(this.capitalize(role))
|
||||||
|
.margin(0).paddingHorizontal(0.6, em).paddingVertical(0.2, em)
|
||||||
|
.background(role === "admin" ? "rgba(239,68,68,0.12)" : "var(--darkaccent)")
|
||||||
|
.border(`1px solid ${role === "admin" ? "rgba(239,68,68,0.25)" : "var(--divider)"}`)
|
||||||
|
.borderRadius(100, px)
|
||||||
|
.fontSize(0.72, em).fontWeight("600")
|
||||||
|
.color(role === "admin" ? "#ef4444" : "var(--headertext)")
|
||||||
|
.opacity(role === "admin" ? 1 : 0.65)
|
||||||
|
})
|
||||||
|
if (p_.plan_name?.includes("Patron")) {
|
||||||
|
p("⭐ Patron")
|
||||||
|
.margin(0).paddingHorizontal(0.6, em).paddingVertical(0.2, em)
|
||||||
|
.background("rgba(245,158,11,0.1)")
|
||||||
|
.border("1px solid rgba(245,158,11,0.25)")
|
||||||
|
.borderRadius(100, px)
|
||||||
|
.fontSize(0.72, em).fontWeight("600").color("#d97706")
|
||||||
|
} else if (p_.plan_name?.includes("Annual")) {
|
||||||
|
p("Regular")
|
||||||
|
.margin(0).paddingHorizontal(0.6, em).paddingVertical(0.2, em)
|
||||||
|
.background("var(--darkaccent)").border("1px solid var(--divider)")
|
||||||
|
.borderRadius(100, px)
|
||||||
|
.fontSize(0.72, em).fontWeight("500")
|
||||||
|
.color("var(--headertext)").opacity(0.55)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.gap(0.4, em).flexWrap("wrap").marginTop(0.65, em)
|
||||||
|
})
|
||||||
|
.flex(1).minWidth(0)
|
||||||
|
})
|
||||||
|
.gap(1.25, em).alignItems("flex-start")
|
||||||
|
})
|
||||||
|
.paddingHorizontal(1.75, em).paddingTop(1.6, em).paddingBottom(1.35, em)
|
||||||
|
.borderBottom("1px solid var(--divider)").flexShrink(0)
|
||||||
|
|
||||||
|
// ── Body ──────────────────────────────────────────────────
|
||||||
|
VStack(() => {
|
||||||
|
// Contact
|
||||||
|
this.section("Contact", () => {
|
||||||
|
this.infoRow("✉", "Email", p_.email)
|
||||||
|
if (p_.phone) this.infoRow("📞", "Phone", this.formatPhone(p_.phone))
|
||||||
|
})
|
||||||
|
|
||||||
|
// Location
|
||||||
|
if (p_.city || p_.state || p_.county) {
|
||||||
|
this.section("Location", () => {
|
||||||
|
if (p_.city || p_.state) this.infoRow("📍", "City / State", [p_.city, p_.state].filter(Boolean).join(", "))
|
||||||
|
if (p_.county) this.infoRow("🗺", "County", p_.county)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bio
|
||||||
|
if (p_.bio) {
|
||||||
|
this.section("Bio", () => {
|
||||||
|
p(p_.bio)
|
||||||
|
.margin(0).fontSize(0.88, em)
|
||||||
|
.color("var(--headertext)").opacity(0.75)
|
||||||
|
.lineHeight("1.6").whiteSpace("pre-wrap")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Membership
|
||||||
|
this.section("Membership", () => {
|
||||||
|
this.infoRow("📅", "Joined", this.formatDate(p_.joined_network || p_.created))
|
||||||
|
if (p_.subscription_status) this.infoRow("💳", "Status", this.capitalize(p_.subscription_status))
|
||||||
|
})
|
||||||
|
|
||||||
|
// Notes (editable)
|
||||||
|
this.section("Notes", () => {
|
||||||
|
if (this.editingNotes) {
|
||||||
|
VStack(() => {
|
||||||
|
textarea(p_.notes || "")
|
||||||
|
.attr({ rows: 5, id: "notes-textarea" })
|
||||||
|
.width(100, pct)
|
||||||
|
.padding(0.65, em)
|
||||||
|
.background("var(--darkaccent)")
|
||||||
|
.border("1px solid var(--divider)")
|
||||||
|
.borderRadius(0.45, em)
|
||||||
|
.color("var(--headertext)")
|
||||||
|
.fontSize(0.88, em)
|
||||||
|
.lineHeight("1.6")
|
||||||
|
.outline("none")
|
||||||
|
.resize("vertical")
|
||||||
|
.boxSizing("border-box")
|
||||||
|
.onInput((e) => { this.notesDraft = e.target.value; })
|
||||||
|
.onAppear(function() { this.value = this.placeholder; } )
|
||||||
|
|
||||||
|
HStack(() => {
|
||||||
|
button("Cancel")
|
||||||
|
.paddingHorizontal(0.9, em).paddingVertical(0.4, em)
|
||||||
|
.background("transparent")
|
||||||
|
.border("1px solid var(--divider)")
|
||||||
|
.borderRadius(0.4, em)
|
||||||
|
.color("var(--headertext)").fontSize(0.82, em)
|
||||||
|
.cursor("pointer").opacity(0.65)
|
||||||
|
.onClick((done) => {if(!done) return; this.editingNotes = false; this.notesDraft = ""; this.rerender(); })
|
||||||
|
|
||||||
|
button(this.notesSaving ? "Saving…" : "Save")
|
||||||
|
.attr({ type: "button" })
|
||||||
|
.paddingHorizontal(0.9, em).paddingVertical(0.4, em)
|
||||||
|
.background("var(--quillred)").border("none")
|
||||||
|
.borderRadius(0.4, em)
|
||||||
|
.color("white").fontSize(0.82, em).fontWeight("600")
|
||||||
|
.cursor("pointer")
|
||||||
|
.onClick((done) => { if (!done) return; this.saveNotes() })
|
||||||
|
})
|
||||||
|
.gap(0.5, em).marginTop(0.65, em).justifyContent("flex-end")
|
||||||
|
})
|
||||||
|
.width(100, pct)
|
||||||
|
} else {
|
||||||
|
VStack(() => {
|
||||||
|
if (p_.notes) {
|
||||||
|
p(p_.notes)
|
||||||
|
.margin(0).fontSize(0.88, em)
|
||||||
|
.color("var(--headertext)").opacity(0.75)
|
||||||
|
.lineHeight("1.6").whiteSpace("pre-wrap")
|
||||||
|
} else {
|
||||||
|
p("No notes yet. Click Edit to add one.")
|
||||||
|
.margin(0).fontSize(0.85, em)
|
||||||
|
.color("var(--headertext)").opacity(0.3)
|
||||||
|
.fontStyle("italic")
|
||||||
|
}
|
||||||
|
|
||||||
|
button("Edit notes")
|
||||||
|
.marginTop(0.65, em)
|
||||||
|
.paddingHorizontal(0.9, em).paddingVertical(0.38, em)
|
||||||
|
.background("transparent").border("1px solid var(--divider)")
|
||||||
|
.borderRadius(0.4, em).color("var(--headertext)")
|
||||||
|
.fontSize(0.8, em).cursor("pointer").opacity(0.6)
|
||||||
|
.onClick((done) => {if(!done) return;
|
||||||
|
this.notesDraft = p_.notes || "";
|
||||||
|
this.editingNotes = true;
|
||||||
|
this.rerender();
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.width(100, pct)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.paddingHorizontal(1.75, em).paddingTop(0).paddingBottom(2, em)
|
||||||
|
.overflowY("auto").flex(1).gap(0)
|
||||||
|
})
|
||||||
|
.height(100, pct).width(100, pct).overflow("hidden").boxSizing("border-box")
|
||||||
|
}
|
||||||
|
|
||||||
|
section(title, contentFn) {
|
||||||
|
VStack(() => {
|
||||||
|
p(title.toUpperCase())
|
||||||
|
.margin(0).marginBottom(0.7, em)
|
||||||
|
.fontSize(0.62, em).fontWeight("700").letterSpacing("0.07em")
|
||||||
|
.color("var(--headertext)").opacity(0.35)
|
||||||
|
contentFn()
|
||||||
|
})
|
||||||
|
.paddingTop(1.25, em).paddingBottom(0.5, em)
|
||||||
|
.borderBottom("1px solid var(--divider)")
|
||||||
|
.width(100, pct).boxSizing("border-box")
|
||||||
|
}
|
||||||
|
|
||||||
|
infoRow(icon, label, value) {
|
||||||
|
HStack(() => {
|
||||||
|
p(icon)
|
||||||
|
.margin(0).width(1.1, em).fontSize(0.85, em)
|
||||||
|
.textAlign("center").flexShrink(0).opacity(0.6)
|
||||||
|
p(label)
|
||||||
|
.margin(0).fontSize(0.82, em)
|
||||||
|
.color("var(--headertext)").opacity(0.42)
|
||||||
|
.width(6.5, em).flexShrink(0)
|
||||||
|
p(value)
|
||||||
|
.margin(0).fontSize(0.85, em)
|
||||||
|
.color("var(--headertext)").fontWeight("500")
|
||||||
|
.flex(1).minWidth(0)
|
||||||
|
.overflow("hidden").whiteSpace("nowrap").textOverflow("ellipsis")
|
||||||
|
})
|
||||||
|
.gap(0.55, em).paddingVertical(0.42, em)
|
||||||
|
.borderBottom("1px solid var(--divider)")
|
||||||
|
.alignItems("center").width(100, pct)
|
||||||
|
}
|
||||||
|
|
||||||
|
renderAvatar(person, size) {
|
||||||
|
if (person.image_path) {
|
||||||
|
img(`${config.UI}${person.image_path}`, `${size}em`, `${size}em`)
|
||||||
|
.borderRadius(50, pct).objectFit("cover").flexShrink(0)
|
||||||
|
} else {
|
||||||
|
const initials = [person.first_name?.[0], person.last_name?.[0]].filter(Boolean).join("").toUpperCase();
|
||||||
|
VStack(() => {
|
||||||
|
p(initials)
|
||||||
|
.margin(0).fontSize(size * 0.32, em).fontWeight("700")
|
||||||
|
.color("white").lineHeight("1")
|
||||||
|
})
|
||||||
|
.width(size, em).height(size, em).borderRadius(50, pct)
|
||||||
|
.background(this.avatarColor(`${person.first_name} ${person.last_name}`))
|
||||||
|
.justifyContent("center").alignItems("center").flexShrink(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveNotes() {
|
||||||
|
if (this.notesSaving) return;
|
||||||
|
this.notesSaving = true;
|
||||||
|
|
||||||
|
const result = await server.saveMemberNote(this.person.email, this.notesDraft);
|
||||||
|
if (result?.success) {
|
||||||
|
this.person.notes = this.notesDraft;
|
||||||
|
this.notesSaving = false;
|
||||||
|
this.editingNotes = false;
|
||||||
|
this.notesDraft = "";
|
||||||
|
this.onSaved(this.person);
|
||||||
|
} else {
|
||||||
|
this.notesSaving = false;
|
||||||
|
this.editingNotes = false;
|
||||||
|
this.notesDraft = "";
|
||||||
|
this.rerender();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
formatPhone(phone) {
|
||||||
|
const d = phone.replace(/\D/g, "");
|
||||||
|
if (d.length === 10) return `${d.slice(0,3)}-${d.slice(3,6)}-${d.slice(6)}`;
|
||||||
|
if (d.length === 11) return `${d.slice(0,1)}-${d.slice(1,4)}-${d.slice(4,7)}-${d.slice(7)}`;
|
||||||
|
return phone;
|
||||||
|
}
|
||||||
|
|
||||||
|
formatDate(raw) {
|
||||||
|
if (!raw) return "—";
|
||||||
|
const d = new Date(raw);
|
||||||
|
return d.toLocaleDateString(undefined, { month: "long", day: "numeric", year: "numeric" });
|
||||||
|
}
|
||||||
|
|
||||||
|
capitalize(s) {
|
||||||
|
return s ? s[0].toUpperCase() + s.slice(1) : s;
|
||||||
|
}
|
||||||
|
|
||||||
|
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(DesktopPeopleDetail)
|
||||||
101
people/desktop/DesktopPeopleList.js
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import "../../components/Avatar.js"
|
||||||
|
|
||||||
|
class DesktopPeopleList extends Shadow {
|
||||||
|
constructor(people, selectedId, onSelect) {
|
||||||
|
super()
|
||||||
|
this.people = people
|
||||||
|
this.selectedId = selectedId
|
||||||
|
this.onSelect = onSelect
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
VStack(() => {
|
||||||
|
if (this.people.length === 0) {
|
||||||
|
VStack(() => {
|
||||||
|
p("No members match your search")
|
||||||
|
.margin(0).fontSize(0.88, em)
|
||||||
|
.color("var(--headertext)").opacity(0.35).textAlign("center")
|
||||||
|
})
|
||||||
|
.flex(1).justifyContent("center").alignItems("center")
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.people.forEach(person => this.renderRow(person))
|
||||||
|
})
|
||||||
|
.gap(0).overflowY("auto").height(100, pct)
|
||||||
|
.paddingVertical(0.5, em).boxSizing("border-box")
|
||||||
|
}
|
||||||
|
|
||||||
|
renderRow(person) {
|
||||||
|
const isSelected = person.id === this.selectedId;
|
||||||
|
const isOnline = person._online;
|
||||||
|
const isAdmin = (person.roles || []).includes("admin");
|
||||||
|
|
||||||
|
HStack(() => {
|
||||||
|
// Avatar
|
||||||
|
ZStack(() => {
|
||||||
|
Avatar(person, 2.6)
|
||||||
|
|
||||||
|
// Online indicator
|
||||||
|
VStack(() => {})
|
||||||
|
.width(0.62, em).height(0.62, em)
|
||||||
|
.borderRadius(50, pct)
|
||||||
|
.background(isOnline ? "#22c55e" : "var(--divider)")
|
||||||
|
.boxSizing("border-box")
|
||||||
|
.position("absolute").bottom(0).right(0)
|
||||||
|
})
|
||||||
|
.position("relative")
|
||||||
|
.width(2.6, em).height(2.6, em).flexShrink(0)
|
||||||
|
|
||||||
|
// Info
|
||||||
|
VStack(() => {
|
||||||
|
HStack(() => {
|
||||||
|
p(`${person.first_name} ${person.last_name}`)
|
||||||
|
.margin(0).fontSize(0.88, em).fontWeight("600")
|
||||||
|
.color("var(--headertext)").flex(1).minWidth(0)
|
||||||
|
.overflow("hidden").whiteSpace("nowrap").textOverflow("ellipsis")
|
||||||
|
|
||||||
|
if (isAdmin) {
|
||||||
|
p("Admin")
|
||||||
|
.margin(0).paddingHorizontal(0.42, em).paddingVertical(0.1, em)
|
||||||
|
.background("rgba(239,68,68,0.12)").borderRadius(100, px)
|
||||||
|
.fontSize(0.62, em).fontWeight("700").color("#ef4444")
|
||||||
|
.flexShrink(0)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.alignItems("center").gap(0.4, em).width(100, pct)
|
||||||
|
|
||||||
|
HStack(() => {
|
||||||
|
p(person.title || person.email)
|
||||||
|
.margin(0).fontSize(0.75, em)
|
||||||
|
.color("var(--headertext)").opacity(0.45)
|
||||||
|
.flex(1).minWidth(0)
|
||||||
|
.overflow("hidden").whiteSpace("nowrap").textOverflow("ellipsis")
|
||||||
|
|
||||||
|
if (person.plan_name?.includes("Patron")) {
|
||||||
|
p("Patron")
|
||||||
|
.margin(0).paddingHorizontal(0.42, em).paddingVertical(0.1, em)
|
||||||
|
.background("rgba(245,158,11,0.12)").borderRadius(100, px)
|
||||||
|
.fontSize(0.62, em).fontWeight("700").color("#d97706")
|
||||||
|
.flexShrink(0)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.alignItems("center").gap(0.4, em).width(100, pct).marginTop(0.15, em)
|
||||||
|
})
|
||||||
|
.flex(1).minWidth(0)
|
||||||
|
})
|
||||||
|
.gap(0.7, em).paddingHorizontal(0.9, em).paddingVertical(0.62, em)
|
||||||
|
.marginHorizontal(0.4, em)
|
||||||
|
.borderRadius(0.55, em)
|
||||||
|
.background(isSelected ? "var(--accent)" : "transparent")
|
||||||
|
.cursor("pointer").alignItems("center")
|
||||||
|
.width("calc(100% - 0.8em)").boxSizing("border-box")
|
||||||
|
.onClick((done) => {
|
||||||
|
if(done)
|
||||||
|
this.onSelect(person.id)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
register(DesktopPeopleList)
|
||||||
263
people/desktop/DesktopPeopleTable.js
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
class DesktopPeopleTable extends Shadow {
|
||||||
|
constructor(people, selectedId, sortKey, sortDir, onSelect, onSort) {
|
||||||
|
super()
|
||||||
|
this.people = people
|
||||||
|
this.selectedId = selectedId
|
||||||
|
this.sortKey = sortKey
|
||||||
|
this.sortDir = sortDir
|
||||||
|
this.onSelect = onSelect
|
||||||
|
this.onSort = onSort
|
||||||
|
}
|
||||||
|
|
||||||
|
get columns() {
|
||||||
|
return [
|
||||||
|
{ key: "status", label: "", width: "44px", sortable: false },
|
||||||
|
{ key: "name", label: "Name", width: "200px", sortable: true },
|
||||||
|
{ key: "email", label: "Email", width: "220px", sortable: true },
|
||||||
|
{ key: "phone", label: "Phone", width: "140px", sortable: false },
|
||||||
|
{ key: "title", label: "Title", width: "250px", sortable: true },
|
||||||
|
{ key: "county", label: "County", width: "120px", sortable: true },
|
||||||
|
{ key: "roles", label: "Role", width: "120px", sortable: true },
|
||||||
|
{ key: "tier", label: "Tier", width: "110px", sortable: true },
|
||||||
|
{ key: "joined", label: "Joined", width: "120px", sortable: true },
|
||||||
|
{ key: "notes", label: "Notes", width: "1fr", sortable: false },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
get gridTemplate() {
|
||||||
|
return this.columns.map(c => c.width).join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
VStack(() => {
|
||||||
|
VStack(() => {
|
||||||
|
// ── Sticky header ─────────────────────────────────────────
|
||||||
|
HStack(() => {
|
||||||
|
this.columns.forEach(col => this.renderHeaderCell(col))
|
||||||
|
})
|
||||||
|
.attr({ style: `display: grid; grid-template-columns: ${this.gridTemplate};` })
|
||||||
|
.paddingHorizontal(0.75, em)
|
||||||
|
.paddingVertical(0.52, em)
|
||||||
|
.borderBottom("1px solid var(--divider)")
|
||||||
|
.flexShrink(0)
|
||||||
|
.boxSizing("border-box")
|
||||||
|
|
||||||
|
// ── Rows ──────────────────────────────────────────────────
|
||||||
|
VStack(() => {
|
||||||
|
if (this.people.length === 0) {
|
||||||
|
VStack(() => {
|
||||||
|
p("No members match your filters")
|
||||||
|
.margin(0).fontSize(0.88, em)
|
||||||
|
.color("var(--headertext)").opacity(0.35)
|
||||||
|
})
|
||||||
|
.position("absolute")
|
||||||
|
.top(0).right(0).bottom(0).left(0)
|
||||||
|
.textAlign("center")
|
||||||
|
.justifyContent("center")
|
||||||
|
} else {
|
||||||
|
this.people.forEach((person, i) => this.renderRow(person, i))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.overflowY("auto")
|
||||||
|
.overflowX("hidden")
|
||||||
|
.flex(1)
|
||||||
|
.minHeight(0)
|
||||||
|
})
|
||||||
|
.width("max-content")
|
||||||
|
.minWidth(100, pct)
|
||||||
|
.height(100, pct)
|
||||||
|
})
|
||||||
|
.height(100, pct).width(100, pct).overflowX("auto").overflowY("hidden")
|
||||||
|
.position("relative")
|
||||||
|
}
|
||||||
|
|
||||||
|
renderHeaderCell(col) {
|
||||||
|
const isActive = this.sortKey === col.key;
|
||||||
|
const arrow = isActive ? (this.sortDir === "asc" ? " ↑" : " ↓") : "";
|
||||||
|
|
||||||
|
HStack(() => {
|
||||||
|
p(col.label + arrow)
|
||||||
|
.margin(0)
|
||||||
|
.fontSize(0.7, em)
|
||||||
|
.fontWeight("700")
|
||||||
|
.letterSpacing("0.04em")
|
||||||
|
.color("var(--headertext)")
|
||||||
|
.opacity(isActive ? 0.75 : 0.38)
|
||||||
|
.userSelect("none")
|
||||||
|
.whiteSpace("nowrap")
|
||||||
|
})
|
||||||
|
.alignItems("center")
|
||||||
|
.paddingHorizontal(col.key === "status" ? 0 : 0.5, em)
|
||||||
|
.justifyContent(col.key === "status" ? "center" : "flex-start")
|
||||||
|
.cursor(col.sortable ? "pointer" : "default")
|
||||||
|
.onClick((done) => { if(!done) return; if (col.sortable) this.onSort(col.key) })
|
||||||
|
}
|
||||||
|
|
||||||
|
renderRow(person, index) {
|
||||||
|
const isSelected = person.id === this.selectedId;
|
||||||
|
const isOnline = person._online;
|
||||||
|
|
||||||
|
HStack(() => {
|
||||||
|
// Status dot
|
||||||
|
HStack(() => {
|
||||||
|
VStack(() => {})
|
||||||
|
.width(0.5, em).height(0.5, em)
|
||||||
|
.borderRadius(50, pct)
|
||||||
|
.background(isOnline ? "#22c55e" : "var(--divider)")
|
||||||
|
.flexShrink(0)
|
||||||
|
})
|
||||||
|
.justifyContent("center").alignItems("center")
|
||||||
|
|
||||||
|
// Name + avatar
|
||||||
|
HStack(() => {
|
||||||
|
this.renderAvatar(person, 1.85)
|
||||||
|
VStack(() => {
|
||||||
|
p(`${person.first_name || ""} ${person.last_name || ""}`.trim())
|
||||||
|
.margin(0).fontSize(0.85, em).fontWeight("600")
|
||||||
|
.color("var(--headertext)")
|
||||||
|
.overflow("hidden").whiteSpace("nowrap").textOverflow("ellipsis")
|
||||||
|
})
|
||||||
|
.flex(1).minWidth(0)
|
||||||
|
})
|
||||||
|
.gap(0.55, em).alignItems("center").overflow("hidden").paddingHorizontal(0.5, em)
|
||||||
|
|
||||||
|
// Email
|
||||||
|
this.cell(person.email, true)
|
||||||
|
|
||||||
|
// Phone
|
||||||
|
this.cell(this.formatPhone(person.phone))
|
||||||
|
|
||||||
|
// Title
|
||||||
|
this.cell(person.title)
|
||||||
|
|
||||||
|
// County
|
||||||
|
this.cell(person.county || [person.city, person.state].filter(Boolean).join(", "))
|
||||||
|
|
||||||
|
// Role(s)
|
||||||
|
HStack(() => {
|
||||||
|
const roles = person.roles || [];
|
||||||
|
if (roles.length === 0) {
|
||||||
|
p("—").margin(0).fontSize(0.78, em).color("var(--headertext)").opacity(0.22)
|
||||||
|
} else {
|
||||||
|
roles.slice(0, 2).forEach(role => {
|
||||||
|
p(this.capitalize(role))
|
||||||
|
.margin(0)
|
||||||
|
.paddingHorizontal(0.45, em).paddingVertical(0.12, em)
|
||||||
|
.background((role === "admin" || role === "executive") ? "rgba(239,68,68,0.1)" : "var(--darkaccent)")
|
||||||
|
.border(`1px solid ${(role === "admin" || role === "executive") ? "rgba(239,68,68,0.22)" : "var(--divider)"}`)
|
||||||
|
.borderRadius(100, px)
|
||||||
|
.fontSize(0.68, em).fontWeight("600")
|
||||||
|
.color((role === "admin" || role === "executive") ? "#ef4444" : "var(--headertext)")
|
||||||
|
.opacity((role === "admin" || role === "executive") ? 1 : 0.6)
|
||||||
|
.whiteSpace("nowrap")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.gap(0.3, em).alignItems("center").paddingHorizontal(0.5, em).overflow("hidden")
|
||||||
|
|
||||||
|
// Tier
|
||||||
|
HStack(() => {
|
||||||
|
if (person.plan_name?.toLowerCase().includes("patron")) {
|
||||||
|
p("⭐ Patron")
|
||||||
|
.margin(0).paddingHorizontal(0.48, em).paddingVertical(0.12, em)
|
||||||
|
.background("rgba(245,158,11,0.1)")
|
||||||
|
.border("1px solid rgba(245,158,11,0.22)")
|
||||||
|
.borderRadius(100, px)
|
||||||
|
.fontSize(0.68, em).fontWeight("600").color("#d97706")
|
||||||
|
} else if (person.plan_name?.toLowerCase().includes("annual")) {
|
||||||
|
p("Regular")
|
||||||
|
.margin(0).paddingHorizontal(0.48, em).paddingVertical(0.12, em)
|
||||||
|
.background("var(--darkaccent)").border("1px solid var(--divider)")
|
||||||
|
.borderRadius(100, px)
|
||||||
|
.fontSize(0.68, em).color("var(--headertext)").opacity(0.5)
|
||||||
|
} else {
|
||||||
|
p("—").margin(0).fontSize(0.78, em).color("var(--headertext)").opacity(0.22)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.alignItems("center").paddingHorizontal(0.5, em)
|
||||||
|
|
||||||
|
// Joined
|
||||||
|
HStack(() => {
|
||||||
|
p(this.formatDateShort(person.joined_network || person.created))
|
||||||
|
.margin(0).fontSize(0.78, em).color("var(--headertext)").opacity(0.45)
|
||||||
|
.whiteSpace("nowrap")
|
||||||
|
})
|
||||||
|
.alignItems("center").paddingHorizontal(0.5, em)
|
||||||
|
|
||||||
|
// Notes (truncated)
|
||||||
|
HStack(() => {
|
||||||
|
p(person.notes || "")
|
||||||
|
.margin(0).fontSize(0.78, em)
|
||||||
|
.color("var(--headertext)").opacity(person.notes ? 0.5 : 0.2)
|
||||||
|
.fontStyle(person.notes ? "normal" : "italic")
|
||||||
|
.overflow("hidden").whiteSpace("nowrap").textOverflow("ellipsis")
|
||||||
|
.flex(1).minWidth(0)
|
||||||
|
})
|
||||||
|
.alignItems("center").paddingHorizontal(0.5, em).overflow("hidden").flex(1)
|
||||||
|
})
|
||||||
|
.attr({ style: `display: grid; grid-template-columns: ${this.gridTemplate};` })
|
||||||
|
.paddingHorizontal(0.75, em)
|
||||||
|
.paddingVertical(0.58, em)
|
||||||
|
.background(isSelected
|
||||||
|
? "var(--app)"
|
||||||
|
: index % 2 !== 0 ? "var(--darkaccent)" : "transparent")
|
||||||
|
.borderBottom("1px solid var(--divider)")
|
||||||
|
.boxSizing("border-box")
|
||||||
|
.alignItems("center")
|
||||||
|
.onClick((done) => {if(!done) return; this.onSelect(person.id)})
|
||||||
|
}
|
||||||
|
|
||||||
|
cell(value, isEmail = false) {
|
||||||
|
HStack(() => {
|
||||||
|
p(value || "—")
|
||||||
|
.margin(0).fontSize(0.82, em)
|
||||||
|
.color("var(--headertext)")
|
||||||
|
.opacity(value ? (isEmail ? 0.6 : 0.75) : 0.22)
|
||||||
|
.overflow("hidden").whiteSpace("nowrap").textOverflow("ellipsis")
|
||||||
|
.flex(1).minWidth(0)
|
||||||
|
})
|
||||||
|
.alignItems("center").paddingHorizontal(0.5, em).overflow("hidden")
|
||||||
|
}
|
||||||
|
|
||||||
|
renderAvatar(person, size) {
|
||||||
|
if (person.image_path) {
|
||||||
|
img(`${config.SERVER}${person.image_path}`, `${size}em`, `${size}em`)
|
||||||
|
.borderRadius(50, pct).objectFit("cover").flexShrink(0)
|
||||||
|
} else {
|
||||||
|
const initials = [person.first_name?.[0], person.last_name?.[0]].filter(Boolean).join("").toUpperCase() || "?";
|
||||||
|
VStack(() => {
|
||||||
|
p(initials)
|
||||||
|
.margin(0).fontSize(size * 0.38, em).fontWeight("700")
|
||||||
|
.color("white").lineHeight("1")
|
||||||
|
})
|
||||||
|
.width(size, em).height(size, em).borderRadius(50, pct)
|
||||||
|
.background(this.avatarColor(`${person.first_name} ${person.last_name}`))
|
||||||
|
.justifyContent("center").alignItems("center").flexShrink(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
formatPhone(phone) {
|
||||||
|
if (!phone) return null;
|
||||||
|
const d = phone.replace(/\D/g, "");
|
||||||
|
if (d.length === 10) return `${d.slice(0,3)}-${d.slice(3,6)}-${d.slice(6)}`;
|
||||||
|
return phone;
|
||||||
|
}
|
||||||
|
|
||||||
|
formatDateShort(raw) {
|
||||||
|
if (!raw) return "—";
|
||||||
|
return new Date(raw).toLocaleDateString([], { month: "short", day: "numeric", year: "2-digit" });
|
||||||
|
}
|
||||||
|
|
||||||
|
capitalize(s) {
|
||||||
|
return s ? s[0].toUpperCase() + s.slice(1) : s;
|
||||||
|
}
|
||||||
|
|
||||||
|
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(DesktopPeopleTable)
|
||||||
100
people/desktop/DesktopPeopleToolbar.js
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
class DesktopPeopleToolbar extends Shadow {
|
||||||
|
constructor(people, searchText, filterRole, filterTier, onSearch, onFilterRole, onFilterTier) {
|
||||||
|
super()
|
||||||
|
this.people = people
|
||||||
|
this.searchText = searchText
|
||||||
|
this.filterRole = filterRole
|
||||||
|
this.filterTier = filterTier
|
||||||
|
this.onSearch = onSearch
|
||||||
|
this.onFilterRole = onFilterRole
|
||||||
|
this.onFilterTier = onFilterTier
|
||||||
|
}
|
||||||
|
|
||||||
|
get allRoles() {
|
||||||
|
const roles = new Set();
|
||||||
|
this.people.forEach(p => (p.roles || []).forEach(r => roles.add(r)));
|
||||||
|
return [...roles].sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const online = this.people.filter(p => p._online).length;
|
||||||
|
const total = this.people.length;
|
||||||
|
|
||||||
|
HStack(() => {
|
||||||
|
// Counts
|
||||||
|
HStack(() => {
|
||||||
|
p(`${total} member${total !== 1 ? "s" : ""}`)
|
||||||
|
.margin(0)
|
||||||
|
.fontSize(0.88, em)
|
||||||
|
.fontWeight("600")
|
||||||
|
.color("var(--headertext)")
|
||||||
|
|
||||||
|
if (online > 0) {
|
||||||
|
HStack(() => {
|
||||||
|
VStack(() => {})
|
||||||
|
.width(0.42, em).height(0.42, em).borderRadius(50, pct)
|
||||||
|
.background("#22c55e").flexShrink(0)
|
||||||
|
p(`${online} online`)
|
||||||
|
.margin(0).fontSize(0.78, em).color("#22c55e").fontWeight("500")
|
||||||
|
})
|
||||||
|
.gap(0.3, em).alignItems("center")
|
||||||
|
.paddingHorizontal(0.6, em).paddingVertical(0.18, em)
|
||||||
|
.background("rgba(34,197,94,0.08)")
|
||||||
|
.border("1px solid rgba(34,197,94,0.2)")
|
||||||
|
.borderRadius(100, px)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.marginTop(20, px)
|
||||||
|
.gap(0.75, em).alignItems("center").flex(1)
|
||||||
|
|
||||||
|
// Search
|
||||||
|
HStack(() => {
|
||||||
|
p("🔍")
|
||||||
|
.margin(0).fontSize(0.8, em).opacity(0.38).flexShrink(0)
|
||||||
|
input("", "200px")
|
||||||
|
.attr({ type: "text", placeholder: "Search members…", value: this.searchText })
|
||||||
|
.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.8, em)
|
||||||
|
.background("var(--darkaccent)").border("1px solid var(--divider)")
|
||||||
|
.borderRadius(0.5, em).alignItems("center")
|
||||||
|
|
||||||
|
// Role filter
|
||||||
|
this.filterSelect(
|
||||||
|
[{ label: "All Roles", value: "" }, ...this.allRoles.map(r => ({ label: this.capitalize(r), value: r }))],
|
||||||
|
this.filterRole, this.onFilterRole
|
||||||
|
)
|
||||||
|
|
||||||
|
// Tier filter
|
||||||
|
this.filterSelect(
|
||||||
|
[{ label: "All Tiers", value: "" }, { label: "Regular", value: "1" }, { label: "Patron", value: "2" }],
|
||||||
|
this.filterTier, this.onFilterTier
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.gap(0.65, em).paddingHorizontal(1.5, em).paddingVertical(0.85, em)
|
||||||
|
.borderBottom("1px solid var(--divider)")
|
||||||
|
.alignItems("center").width(100, pct).boxSizing("border-box").flexShrink(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
filterSelect(options, value, onChange) {
|
||||||
|
select(() => {
|
||||||
|
options.forEach(opt => {
|
||||||
|
option(opt.label)
|
||||||
|
.attr({ value: opt.value, ...(opt.value === value ? { selected: "" } : {}) })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.paddingVertical(0.52, em).paddingHorizontal(0.75, em)
|
||||||
|
.background("var(--darkaccent)").border("1px solid var(--divider)")
|
||||||
|
.borderRadius(0.5, em).color("var(--headertext)").fontSize(0.85, em)
|
||||||
|
.outline("none").cursor("pointer").flexShrink(0)
|
||||||
|
.onChange((e) => onChange(e.target.value))
|
||||||
|
}
|
||||||
|
|
||||||
|
capitalize(s) {
|
||||||
|
return s ? s[0].toUpperCase() + s.slice(1) : s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
register(DesktopPeopleToolbar)
|
||||||
203
people/desktop/people.js
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
import "./DesktopPeopleToolbar.js"
|
||||||
|
import "./DesktopPeopleTable.js"
|
||||||
|
import "./DesktopPeopleDetail.js"
|
||||||
|
import server from "/people/@server/index.js"
|
||||||
|
|
||||||
|
css(`
|
||||||
|
people- {
|
||||||
|
font-family: 'Arial';
|
||||||
|
scrollbar-width: none;
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
}
|
||||||
|
people- select {
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%23888'/%3E%3C/svg%3E");
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: right 0.65em center;
|
||||||
|
padding-right: 1.8em !important;
|
||||||
|
}
|
||||||
|
people- textarea {
|
||||||
|
font-family: 'Arial';
|
||||||
|
}
|
||||||
|
people- input::placeholder {
|
||||||
|
color: var(--headertext);
|
||||||
|
opacity: 0.35;
|
||||||
|
}
|
||||||
|
`)
|
||||||
|
|
||||||
|
class People extends Shadow {
|
||||||
|
|
||||||
|
_people = (global.currentNetwork.data.members || []).map(p => ({ ...p, _online: this.isOnline(p) }))
|
||||||
|
#lastUpdated;
|
||||||
|
|
||||||
|
selectedMemberId = null
|
||||||
|
searchText = ""
|
||||||
|
filterRole = ""
|
||||||
|
filterTier = ""
|
||||||
|
sortKey = "joined"
|
||||||
|
sortDir = "asc"
|
||||||
|
tableEl = null
|
||||||
|
|
||||||
|
get people() { return this._people }
|
||||||
|
set people(val) {
|
||||||
|
this._people = val
|
||||||
|
this.#lastUpdated = Date.now()
|
||||||
|
global.currentNetwork.data.members = val
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super()
|
||||||
|
}
|
||||||
|
|
||||||
|
isOnline(member) {
|
||||||
|
return global.socket.connectedUsers.includes(member.email)
|
||||||
|
}
|
||||||
|
|
||||||
|
get filteredSortedPeople() {
|
||||||
|
let list = this.people;
|
||||||
|
|
||||||
|
if (this.searchText) {
|
||||||
|
const q = this.searchText.toLowerCase();
|
||||||
|
list = list.filter(p =>
|
||||||
|
[p.first_name, p.last_name, p.email, p.title, p.county, p.city, p.state]
|
||||||
|
.some(v => v?.toLowerCase().includes(q))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.filterRole) {
|
||||||
|
list = list.filter(p => (p.roles || []).includes(this.filterRole));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.filterTier) {
|
||||||
|
list = list.filter(p => String(p.plan_name) === this.filterTier);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort
|
||||||
|
const key = this.sortKey;
|
||||||
|
const dir = this.sortDir === "asc" ? 1 : -1;
|
||||||
|
list = [...list].sort((a, b) => {
|
||||||
|
let av, bv;
|
||||||
|
if (key === "name") { av = `${a.first_name} ${a.last_name}`; bv = `${b.first_name} ${b.last_name}`; }
|
||||||
|
else if (key === "email") { av = a.email || ""; bv = b.email || ""; }
|
||||||
|
else if (key === "title") { av = a.title || ""; bv = b.title || ""; }
|
||||||
|
else if (key === "county") { av = a.county || ""; bv = b.county || ""; }
|
||||||
|
else if (key === "roles") { av = (a.roles || [])[0] || ""; bv = (b.roles || [])[0] || ""; }
|
||||||
|
else if (key === "tier") { av = a.plan_name || 0; bv = b.plan_name || 0; }
|
||||||
|
else if (key === "joined") {
|
||||||
|
av = new Date(a.joined_network || a.created || 0).getTime();
|
||||||
|
bv = new Date(b.joined_network || b.created || 0).getTime();
|
||||||
|
}
|
||||||
|
if (typeof av === "string") return av.localeCompare(bv) * dir;
|
||||||
|
return (av - bv) * dir;
|
||||||
|
});
|
||||||
|
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
get selectedMember() {
|
||||||
|
return this.people.find(p => p.id === this.selectedMemberId) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleSort(key) {
|
||||||
|
if (this.sortKey === key) {
|
||||||
|
this.sortDir = this.sortDir === "asc" ? "desc" : "asc";
|
||||||
|
} else {
|
||||||
|
this.sortKey = key;
|
||||||
|
this.sortDir = "asc";
|
||||||
|
}
|
||||||
|
this.rerender();
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const filtered = this.filteredSortedPeople;
|
||||||
|
const detailOpen = !!this.selectedMember;
|
||||||
|
|
||||||
|
VStack(() => {
|
||||||
|
DesktopPeopleToolbar(
|
||||||
|
filtered,
|
||||||
|
this.searchText,
|
||||||
|
this.filterRole,
|
||||||
|
this.filterTier,
|
||||||
|
(text) => {
|
||||||
|
this.searchText = text
|
||||||
|
this.tableEl.people = this.filteredSortedPeople
|
||||||
|
this.tableEl.rerender()
|
||||||
|
},
|
||||||
|
(role) => { this.filterRole = role; this.rerender(); },
|
||||||
|
(tier) => { this.filterTier = tier; this.rerender(); }
|
||||||
|
)
|
||||||
|
|
||||||
|
HStack(() => {
|
||||||
|
// Table — shrinks when drawer open
|
||||||
|
VStack(() => {
|
||||||
|
this.tableEl = DesktopPeopleTable(
|
||||||
|
filtered,
|
||||||
|
this.selectedMemberId,
|
||||||
|
this.sortKey,
|
||||||
|
this.sortDir,
|
||||||
|
(id) => {
|
||||||
|
this.selectedMemberId = this.selectedMemberId === id ? null : id;
|
||||||
|
this.rerender();
|
||||||
|
},
|
||||||
|
(key) => this.handleSort(key)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.flex(1).height(100, pct).overflow("hidden")
|
||||||
|
|
||||||
|
// Slide-out detail drawer
|
||||||
|
if (detailOpen) {
|
||||||
|
VStack(() => {
|
||||||
|
// Drawer header with close button
|
||||||
|
HStack(() => {
|
||||||
|
p(`${this.selectedMember.first_name} ${this.selectedMember.last_name}`)
|
||||||
|
.margin(0).fontSize(0.88, em).fontWeight("600")
|
||||||
|
.color("var(--headertext)").flex(1).minWidth(0)
|
||||||
|
.overflow("hidden").whiteSpace("nowrap").textOverflow("ellipsis")
|
||||||
|
|
||||||
|
button("✕")
|
||||||
|
.border("none").background("transparent")
|
||||||
|
.color("var(--headertext)").opacity(0.4)
|
||||||
|
.fontSize(0.82, em).cursor("pointer").padding(0.25, em)
|
||||||
|
.borderRadius(0.3, em)
|
||||||
|
.onClick((done) => {if(!done) return; this.selectedMemberId = null; this.rerender(); })
|
||||||
|
})
|
||||||
|
.paddingHorizontal(1.25, em).paddingVertical(0.78, em)
|
||||||
|
.borderBottom("1px solid var(--divider)")
|
||||||
|
.alignItems("center").flexShrink(0)
|
||||||
|
|
||||||
|
VStack(() => {
|
||||||
|
DesktopPeopleDetail(this.selectedMember,
|
||||||
|
(updatedPerson) => this.updatePeople(updatedPerson)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.flex(1).overflow("hidden")
|
||||||
|
})
|
||||||
|
.width(360, px)
|
||||||
|
.height(100, pct)
|
||||||
|
.borderLeft("1px solid var(--divider)")
|
||||||
|
.flexShrink(0)
|
||||||
|
.overflow("hidden")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.flex(1).minHeight(0).width(100, pct).overflow("hidden")
|
||||||
|
})
|
||||||
|
.height(100, pct).width(100, pct).overflow("hidden")
|
||||||
|
.onAppear(async () => {
|
||||||
|
const res = await server.getPeople(global.currentNetwork.id);
|
||||||
|
if (!res.error && res.length > 0) {
|
||||||
|
if((this.people.length !== res.length) || !this.#lastUpdated) {
|
||||||
|
this.people = res.map(p => ({ ...p, _online: this.isOnline(p) }));
|
||||||
|
this.rerender();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
updatePeople(person) {
|
||||||
|
this.people = this.people.map(p => p.id === person.id ? person : p)
|
||||||
|
this.rerender()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
register(People)
|
||||||
3
people/icons/people.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg width="34" height="32" viewBox="0 0 34 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M22.4912 0.5C24.6609 0.500107 26.4463 2.29218 26.4463 4.50195C26.4461 5.9638 25.6634 7.24788 24.502 7.94824L23.6514 8.46094L24.5693 8.83887C26.587 9.66866 28.0146 11.6745 28.0146 14.0195V14.5029L28.4541 14.5566C30.4025 14.7942 31.9307 16.4869 31.9307 18.5342C31.9306 19.9965 31.1509 21.2779 29.9912 21.9766L29.1406 22.4893L30.0596 22.8672C32.0762 23.6969 33.5 25.7015 33.5 28.0469V31.4609C33.4998 31.4881 33.4804 31.4983 33.4697 31.499C33.466 31.4988 33.4624 31.4985 33.459 31.4971C33.4553 31.4955 33.4524 31.4944 33.4512 31.4932L33.4482 31.4902L33.4365 31.458V28.0469C33.4365 24.9911 31.019 22.5352 27.9805 22.5352C24.9421 22.5352 22.5225 24.9924 22.5225 28.0469V31.4629C22.5225 31.4743 22.5176 31.4844 22.5117 31.4902C22.5051 31.4967 22.4976 31.4988 22.4902 31.499C22.485 31.4985 22.4784 31.4966 22.4727 31.4912L22.4609 31.4609V28.0469C22.4609 24.9905 20.0379 22.5352 17 22.5352C13.9614 22.5352 11.544 24.9925 11.5439 28.0469V31.4609C11.5439 31.4744 11.5382 31.4841 11.5312 31.4912C11.5246 31.498 11.5191 31.5 11.5137 31.5C11.5104 31.5 11.508 31.4997 11.5049 31.499C11.4948 31.4967 11.4805 31.4846 11.4805 31.4629V28.0488C11.4805 24.9931 9.0629 22.5373 6.02441 22.5371C2.98626 22.5371 0.563477 24.9939 0.563477 28.0488V31.4609C0.563297 31.4882 0.542917 31.4999 0.530273 31.5C0.524251 31.5 0.518186 31.4977 0.511719 31.4912C0.505432 31.4847 0.501504 31.4756 0.500977 31.4629V28.0488C0.500977 25.7039 1.92786 23.7001 3.94531 22.8701L4.86426 22.4912L4.0127 21.9795C2.85121 21.2805 2.06934 19.997 2.06934 18.5352C2.06949 16.4873 3.60107 14.7963 5.55176 14.5586L5.99121 14.5049V14.0205C5.99124 11.6755 7.41775 9.67071 9.43652 8.84082L10.3555 8.46289L9.50391 7.9502C8.34227 7.24961 7.55957 5.96483 7.55957 4.50293C7.55962 2.29286 9.34267 0.502076 11.5137 0.501953H11.5977C13.73 0.546093 15.4668 2.31921 15.4668 4.50195C15.4666 5.96451 14.6864 7.24824 13.5254 7.94824L12.6758 8.46094L13.5938 8.83887C14.9033 9.37789 15.9663 10.4144 16.5479 11.7168L17.0039 12.7393L17.4609 11.7168C18.0426 10.4142 19.1043 9.3778 20.415 8.83887L21.334 8.46094L20.4834 7.94824C19.322 7.24792 18.5402 5.96407 18.54 4.50195C18.54 2.29132 20.3205 0.5 22.4912 0.5ZM11.5127 8.50879C8.47454 8.50879 6.05176 10.9655 6.05176 14.0205V14.5039L6.49219 14.5566C8.44316 14.7921 9.97363 16.4858 9.97363 18.5342C9.9735 19.9963 9.19367 21.2769 8.03418 21.9756L7.18359 22.4883L8.10156 22.8662C9.41094 23.4051 10.4731 24.4415 11.0547 25.7441L11.1865 26.04H11.8369L11.9688 25.7441C12.552 24.4411 13.6128 23.405 14.9219 22.8662L15.8398 22.4883L14.9893 21.9756C13.8297 21.2769 13.0499 19.9963 13.0498 18.5342C13.0498 16.4857 14.5807 14.793 16.5322 14.5566L16.9717 14.5029V14.0205C16.9717 10.9645 14.5521 8.50879 11.5137 8.50879H11.5127ZM22.4902 8.50781C19.4517 8.50781 17.0344 10.9643 17.0342 14.0186V14.5029L17.4736 14.5566C19.4239 14.7945 20.9541 16.4876 20.9541 18.5342C20.954 19.9961 20.173 21.2768 19.0117 21.9756L18.1602 22.4883L19.0791 22.8662C20.3897 23.4051 21.452 24.4414 22.0352 25.7441L22.168 26.04H22.8174L22.9492 25.7441C23.5309 24.4415 24.5929 23.4051 25.9023 22.8662L26.8213 22.4883L25.9697 21.9756C24.8102 21.2769 24.0304 19.9962 24.0303 18.5342C24.0303 16.4856 25.5595 14.793 27.5107 14.5566L27.9512 14.5029V14.0205C27.9512 11.1335 25.7892 8.78398 22.9893 8.53223V8.50781H22.4902ZM6.02344 14.6074C3.8627 14.6074 2.13208 16.3662 2.13184 18.5332C2.13184 20.7012 3.86338 22.4561 6.02344 22.4561C8.18395 22.4559 9.91211 20.7006 9.91211 18.5332C9.91187 16.3669 8.18463 14.6075 6.02344 14.6074ZM16.999 14.6074C14.8379 14.6076 13.1106 16.3669 13.1104 18.5332C13.1104 20.7005 14.8386 22.4559 16.999 22.4561C19.1591 22.4561 20.8916 20.7012 20.8916 18.5332C20.8914 16.3662 19.1598 14.6074 16.999 14.6074ZM27.9795 14.6074C25.8182 14.6075 24.0911 16.3668 24.0908 18.5332C24.0908 20.7006 25.8189 22.456 27.9795 22.4561C30.1404 22.4561 31.8672 20.7004 31.8672 18.5332C31.8669 16.3671 30.1411 14.6074 27.9795 14.6074ZM11.5137 0.579102C9.35372 0.579223 7.62207 2.33401 7.62207 4.50195C7.62232 6.66883 9.35304 8.42761 11.5137 8.42773C13.675 8.42773 15.4031 6.66835 15.4033 4.50195C15.4033 2.3345 13.6743 0.579102 11.5137 0.579102ZM22.4902 0.579102C20.3293 0.579102 18.6025 2.33478 18.6025 4.50195C18.6028 6.66807 20.3287 8.42773 22.4902 8.42773C24.6506 8.42762 26.3835 6.66912 26.3838 4.50195C26.3838 2.33373 24.6499 0.579213 22.4902 0.579102Z" fill="black" stroke="#000000"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.3 KiB |
3
people/icons/peoplelight.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg width="27" height="25" viewBox="0 0 27 25" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M9.14339 0.00116273C7.19618 0.00116273 5.60647 1.57928 5.60647 3.51799C5.60647 4.80237 6.30553 5.93008 7.34256 6.5453C5.59377 7.25256 4.36053 8.95969 4.36053 10.9536V10.9858C2.61174 11.1955 1.24591 12.6837 1.24591 14.4808C1.24591 15.7652 1.94497 16.8918 2.982 17.5058C1.23436 18.213 0.00115341 19.919 0.00115341 21.9129V24.5807H0C0.00115348 24.8134 0.190335 25.0011 0.423355 25C0.65522 24.9988 0.843255 24.8122 0.844407 24.5807V21.9129C0.844407 19.7381 2.59435 17.9977 4.78383 17.9977C6.97331 17.9977 8.71971 19.737 8.71971 21.9129V24.5807C8.71971 24.7777 8.85698 24.947 9.04848 24.9896C9.07962 24.9965 9.11077 25 9.14307 25C9.37493 25 9.56297 24.8122 9.56412 24.5807V21.9117C9.56412 19.7369 11.3106 17.9965 13.5 17.9965C15.6894 17.9965 17.4394 19.7359 17.4394 21.9117V24.5807C17.4406 24.8122 17.6274 24.9988 17.8593 25C17.9712 25 18.0785 24.9562 18.1581 24.8779C18.2377 24.7996 18.2827 24.6924 18.2827 24.5807V21.9117C18.2827 19.7369 20.0303 17.9965 22.2197 17.9965C24.4092 17.9965 26.1556 19.7359 26.1556 21.9117V24.5807C26.1568 24.6924 26.2017 24.7996 26.2813 24.8779C26.3598 24.9562 26.4682 25 26.5801 25C26.8108 24.9988 26.9988 24.8122 27 24.5807V21.9117C27 19.9178 25.7692 18.2106 24.0215 17.5034C25.0574 16.8895 25.7542 15.7641 25.7542 14.4797C25.7542 12.6839 24.3918 11.1943 22.6443 10.9847V10.9524C22.6443 8.95847 21.4099 7.25128 19.6622 6.54414C20.6993 5.92903 21.3983 4.8013 21.3983 3.51683C21.3983 1.57818 19.8064 0 17.8605 0C15.9133 0 14.326 1.57812 14.326 3.51683C14.326 4.80121 15.0239 5.92892 16.0609 6.54414C14.9246 7.00375 14.0064 7.88611 13.5035 8.99424C13.0005 7.88611 12.0811 7.00375 10.946 6.54414C11.9831 5.92903 12.6798 4.8013 12.6798 3.51683C12.6798 1.57818 11.0914 0 9.14527 0L9.14339 0.00116273ZM9.14339 0.843213C10.6361 0.843213 11.8346 2.03544 11.8346 3.51678C11.8346 4.99812 10.6361 6.1938 9.14339 6.1938C7.65069 6.1938 6.44985 4.99812 6.44985 3.51678C6.44985 2.03544 7.65069 0.843213 9.14339 0.843213ZM17.8597 0.843213C19.3524 0.843213 20.5544 2.03544 20.5544 3.51678C20.5544 4.99812 19.3524 6.1938 17.8597 6.1938C16.367 6.1938 15.1696 4.99812 15.1696 3.51678C15.1696 2.03544 16.367 0.843213 17.8597 0.843213ZM9.14339 7.03821C11.3328 7.03821 13.0804 8.77757 13.0804 10.9534V10.9845C11.3305 11.193 9.96582 12.6824 9.96582 14.4795C9.96582 15.7639 10.6626 16.8893 11.6985 17.5033C10.5634 17.9629 9.6463 18.8453 9.14221 19.9534H9.14105C8.63811 18.8453 7.71988 17.9629 6.5848 17.5033C7.62069 16.8894 8.31744 15.7639 8.31744 14.4795C8.31744 12.6825 6.95278 11.1921 5.20281 10.9845V10.9534C5.20281 8.77863 6.95275 7.03821 9.14223 7.03821H9.14339ZM17.8597 7.03821C20.0492 7.03821 21.7991 8.77757 21.7991 10.9534V10.9845C20.0492 11.193 18.6857 12.6824 18.6857 14.4795C18.6857 15.7639 19.3825 16.8893 20.4183 17.5033C19.2832 17.9629 18.365 18.8453 17.8621 19.9534H17.8609C17.3568 18.8453 16.4386 17.9629 15.3024 17.5033C16.3394 16.8894 17.0373 15.7639 17.0373 14.4795C17.0373 12.6837 15.6726 11.1942 13.9238 10.9845V10.9523C13.9238 8.77748 15.6703 7.03705 17.8597 7.03705L17.8597 7.03821ZM4.78315 11.8024C6.27585 11.8024 7.47439 12.9981 7.47439 14.4794C7.47439 15.9608 6.27585 17.153 4.78315 17.153C3.29045 17.153 2.08961 15.9608 2.08961 14.4794C2.08961 12.9981 3.29045 11.8024 4.78315 11.8024ZM13.4995 11.8024C14.9922 11.8024 16.193 12.9981 16.193 14.4794C16.193 15.9608 14.9922 17.153 13.4995 17.153C12.0068 17.153 10.8083 15.9608 10.8083 14.4794C10.8083 12.9981 12.0068 11.8024 13.4995 11.8024ZM22.2191 11.8024C23.7118 11.8024 24.9092 12.9981 24.9092 14.4794C24.9092 15.9608 23.7118 17.153 22.2191 17.153C20.7264 17.153 19.5278 15.9608 19.5278 14.4794C19.5278 12.9981 20.7264 11.8024 22.2191 11.8024Z" fill="#FFE9C8"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.7 KiB |
13
people/icons/peoplelightselected.svg
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<svg width="27" height="25" viewBox="0 0 27 25" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<ellipse cx="9" cy="3.5" rx="3" ry="3.5" fill="#74000A"/>
|
||||||
|
<ellipse cx="18" cy="3.3" rx="3" ry="3.3" fill="#74000A"/>
|
||||||
|
<circle cx="13.5" cy="14.5" r="3.5" fill="#74000A"/>
|
||||||
|
<circle cx="5.5" cy="14.5" r="3.5" fill="#74000A"/>
|
||||||
|
<circle cx="21.5" cy="14.5" r="3.5" fill="#74000A"/>
|
||||||
|
<ellipse cx="9.25" cy="11" rx="4.25" ry="4" fill="#74000A"/>
|
||||||
|
<ellipse cx="17.75" cy="11.5" rx="4.75" ry="4.5" fill="#74000A"/>
|
||||||
|
<ellipse cx="13.5" cy="19" rx="7" ry="6" fill="#74000A"/>
|
||||||
|
<ellipse cx="13.5" cy="20.5" rx="12.5" ry="4.5" fill="#74000A"/>
|
||||||
|
<rect x="0.5" y="20" width="26" height="5" fill="#74000A"/>
|
||||||
|
<path d="M9.14339 0.00116273C7.19618 0.00116273 5.60647 1.57928 5.60647 3.51799C5.60647 4.80237 6.30553 5.93008 7.34256 6.5453C5.59377 7.25256 4.36053 8.95969 4.36053 10.9536V10.9858C2.61174 11.1955 1.24591 12.6837 1.24591 14.4808C1.24591 15.7652 1.94497 16.8918 2.982 17.5058C1.23436 18.213 0.00115341 19.919 0.00115341 21.9129V24.5807H0C0.00115348 24.8134 0.190335 25.0011 0.423355 25C0.65522 24.9988 0.843255 24.8122 0.844407 24.5807V21.9129C0.844407 19.7381 2.59435 17.9977 4.78383 17.9977C6.97331 17.9977 8.71971 19.737 8.71971 21.9129V24.5807C8.71971 24.7777 8.85698 24.947 9.04848 24.9896C9.07962 24.9965 9.11077 25 9.14307 25C9.37493 25 9.56297 24.8122 9.56412 24.5807V21.9117C9.56412 19.7369 11.3106 17.9965 13.5 17.9965C15.6894 17.9965 17.4394 19.7359 17.4394 21.9117V24.5807C17.4406 24.8122 17.6274 24.9988 17.8593 25C17.9712 25 18.0785 24.9562 18.1581 24.8779C18.2377 24.7996 18.2827 24.6924 18.2827 24.5807V21.9117C18.2827 19.7369 20.0303 17.9965 22.2197 17.9965C24.4092 17.9965 26.1556 19.7359 26.1556 21.9117V24.5807C26.1568 24.6924 26.2017 24.7996 26.2813 24.8779C26.3598 24.9562 26.4682 25 26.5801 25C26.8108 24.9988 26.9988 24.8122 27 24.5807V21.9117C27 19.9178 25.7692 18.2106 24.0215 17.5034C25.0574 16.8895 25.7542 15.7641 25.7542 14.4797C25.7542 12.6839 24.3918 11.1943 22.6443 10.9847V10.9524C22.6443 8.95847 21.4099 7.25128 19.6622 6.54414C20.6993 5.92903 21.3983 4.8013 21.3983 3.51683C21.3983 1.57818 19.8064 0 17.8605 0C15.9133 0 14.326 1.57812 14.326 3.51683C14.326 4.80121 15.0239 5.92892 16.0609 6.54414C14.9246 7.00375 14.0064 7.88611 13.5035 8.99424C13.0005 7.88611 12.0811 7.00375 10.946 6.54414C11.9831 5.92903 12.6798 4.8013 12.6798 3.51683C12.6798 1.57818 11.0914 0 9.14527 0L9.14339 0.00116273ZM9.14339 0.843213C10.6361 0.843213 11.8346 2.03544 11.8346 3.51678C11.8346 4.99812 10.6361 6.1938 9.14339 6.1938C7.65069 6.1938 6.44985 4.99812 6.44985 3.51678C6.44985 2.03544 7.65069 0.843213 9.14339 0.843213ZM17.8597 0.843213C19.3524 0.843213 20.5544 2.03544 20.5544 3.51678C20.5544 4.99812 19.3524 6.1938 17.8597 6.1938C16.367 6.1938 15.1696 4.99812 15.1696 3.51678C15.1696 2.03544 16.367 0.843213 17.8597 0.843213ZM9.14339 7.03821C11.3328 7.03821 13.0804 8.77757 13.0804 10.9534V10.9845C11.3305 11.193 9.96582 12.6824 9.96582 14.4795C9.96582 15.7639 10.6626 16.8893 11.6985 17.5033C10.5634 17.9629 9.6463 18.8453 9.14221 19.9534H9.14105C8.63811 18.8453 7.71988 17.9629 6.5848 17.5033C7.62069 16.8894 8.31744 15.7639 8.31744 14.4795C8.31744 12.6825 6.95278 11.1921 5.20281 10.9845V10.9534C5.20281 8.77863 6.95275 7.03821 9.14223 7.03821H9.14339ZM17.8597 7.03821C20.0492 7.03821 21.7991 8.77757 21.7991 10.9534V10.9845C20.0492 11.193 18.6857 12.6824 18.6857 14.4795C18.6857 15.7639 19.3825 16.8893 20.4183 17.5033C19.2832 17.9629 18.365 18.8453 17.8621 19.9534H17.8609C17.3568 18.8453 16.4386 17.9629 15.3024 17.5033C16.3394 16.8894 17.0373 15.7639 17.0373 14.4795C17.0373 12.6837 15.6726 11.1942 13.9238 10.9845V10.9523C13.9238 8.77748 15.6703 7.03705 17.8597 7.03705L17.8597 7.03821ZM4.78315 11.8024C6.27585 11.8024 7.47439 12.9981 7.47439 14.4794C7.47439 15.9608 6.27585 17.153 4.78315 17.153C3.29045 17.153 2.08961 15.9608 2.08961 14.4794C2.08961 12.9981 3.29045 11.8024 4.78315 11.8024ZM13.4995 11.8024C14.9922 11.8024 16.193 12.9981 16.193 14.4794C16.193 15.9608 14.9922 17.153 13.4995 17.153C12.0068 17.153 10.8083 15.9608 10.8083 14.4794C10.8083 12.9981 12.0068 11.8024 13.4995 11.8024ZM22.2191 11.8024C23.7118 11.8024 24.9092 12.9981 24.9092 14.4794C24.9092 15.9608 23.7118 17.153 22.2191 17.153C20.7264 17.153 19.5278 15.9608 19.5278 14.4794C19.5278 12.9981 20.7264 11.8024 22.2191 11.8024Z" fill="#FFE9C8"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.2 KiB |
165
people/people.js
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
import server from "/people/@server/index.js"
|
||||||
|
import "./PeopleList.js"
|
||||||
|
|
||||||
|
class People extends Shadow {
|
||||||
|
searchText = ""
|
||||||
|
filterOnline = false
|
||||||
|
listEl = null
|
||||||
|
|
||||||
|
get people() { return this._people }
|
||||||
|
set people(val) {
|
||||||
|
this._people = val
|
||||||
|
global.currentNetwork.data.members = val
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super()
|
||||||
|
this._people = (global.currentNetwork.data.members || []).map(p => ({ ...p, _online: this.isOnline(p) }))
|
||||||
|
}
|
||||||
|
|
||||||
|
isOnline(member) {
|
||||||
|
return global.socket.connectedUsers.includes(member.email)
|
||||||
|
}
|
||||||
|
|
||||||
|
get filtered() {
|
||||||
|
let list = this.people
|
||||||
|
if (this.filterOnline) list = list.filter(p => p._online)
|
||||||
|
if (this.searchText) {
|
||||||
|
const q = this.searchText.toLowerCase()
|
||||||
|
list = list.filter(p =>
|
||||||
|
[p.first_name, p.last_name, p.email]
|
||||||
|
.some(v => v?.toLowerCase().includes(q))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
|
||||||
|
get onlineCount() {
|
||||||
|
return this.people.filter(p => p._online).length
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
VStack(() => {
|
||||||
|
// ── Header ────────────────────────────────────────────────
|
||||||
|
VStack(() => {
|
||||||
|
HStack(() => {
|
||||||
|
VStack(() => {
|
||||||
|
|
||||||
|
HStack(() => {
|
||||||
|
p(`${this.people.length} total`)
|
||||||
|
.margin(0)
|
||||||
|
.fontSize(0.72, em)
|
||||||
|
.color("var(--headertext)")
|
||||||
|
.opacity(0.4)
|
||||||
|
|
||||||
|
if (this.onlineCount > 0) {
|
||||||
|
HStack(() => {
|
||||||
|
VStack(() => {})
|
||||||
|
.width(6, px)
|
||||||
|
.height(6, px)
|
||||||
|
.borderRadius(50, pct)
|
||||||
|
.background("#10b981")
|
||||||
|
.flexShrink(0)
|
||||||
|
p(`${this.onlineCount} online`)
|
||||||
|
.margin(0)
|
||||||
|
.fontSize(0.72, em)
|
||||||
|
.color("#10b981")
|
||||||
|
.fontWeight("600")
|
||||||
|
})
|
||||||
|
.gap(0.3, em)
|
||||||
|
.alignItems("center")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.gap(0.65, em)
|
||||||
|
.alignItems("center")
|
||||||
|
.marginTop(0.2, em)
|
||||||
|
})
|
||||||
|
.gap(0)
|
||||||
|
.flex(1)
|
||||||
|
|
||||||
|
// Online filter pill
|
||||||
|
if (this.people.length > 0) {
|
||||||
|
HStack(() => {
|
||||||
|
VStack(() => {})
|
||||||
|
.width(7, px)
|
||||||
|
.height(7, px)
|
||||||
|
.borderRadius(50, pct)
|
||||||
|
.background(this.filterOnline ? "#10b981" : "var(--headertext)")
|
||||||
|
.opacity(this.filterOnline ? 1 : 0.3)
|
||||||
|
.flexShrink(0)
|
||||||
|
p("Online")
|
||||||
|
.margin(0)
|
||||||
|
.fontSize(0.75, em)
|
||||||
|
.fontWeight(this.filterOnline ? "600" : "400")
|
||||||
|
.color("var(--headertext)")
|
||||||
|
.opacity(this.filterOnline ? 1 : 0.45)
|
||||||
|
})
|
||||||
|
.gap(0.35, em)
|
||||||
|
.alignItems("center")
|
||||||
|
.paddingHorizontal(0.75, em)
|
||||||
|
.paddingVertical(0.35, em)
|
||||||
|
.background(this.filterOnline ? "rgba(16,185,129,0.12)" : "var(--darkaccent)")
|
||||||
|
.border(`1px solid ${this.filterOnline ? "rgba(16,185,129,0.4)" : "var(--divider)"}`)
|
||||||
|
.borderRadius(100, px)
|
||||||
|
.cursor("pointer")
|
||||||
|
.onTap(() => { this.filterOnline = !this.filterOnline; this.rerender() })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.alignItems("flex-start")
|
||||||
|
.marginBottom(0.75, em)
|
||||||
|
|
||||||
|
// Search bar
|
||||||
|
HStack(() => {
|
||||||
|
p("🔍")
|
||||||
|
.margin(0)
|
||||||
|
.fontSize(0.75, em)
|
||||||
|
.opacity(0.4)
|
||||||
|
.flexShrink(0)
|
||||||
|
input("Search members…", "100%")
|
||||||
|
.border("none")
|
||||||
|
.background("transparent")
|
||||||
|
.color("var(--headertext)")
|
||||||
|
.fontSize(0.88, em)
|
||||||
|
.outline("none")
|
||||||
|
.flex(1)
|
||||||
|
.attr({ value: this.searchText })
|
||||||
|
.onInput(e => {
|
||||||
|
this.searchText = e.target.value
|
||||||
|
this.listEl._members = this.filtered
|
||||||
|
this.listEl.rerender()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.gap(0.5, em)
|
||||||
|
.paddingHorizontal(0.75, em)
|
||||||
|
.paddingVertical(0.55, em)
|
||||||
|
.background("var(--darkaccent)")
|
||||||
|
.border("1px solid var(--divider)")
|
||||||
|
.borderRadius(0.55, em)
|
||||||
|
.alignItems("center")
|
||||||
|
})
|
||||||
|
.paddingHorizontal(1, em)
|
||||||
|
.paddingTop(1.25, em)
|
||||||
|
.paddingBottom(0.85, em)
|
||||||
|
.flexShrink(0)
|
||||||
|
|
||||||
|
// ── Divider ───────────────────────────────────────────────
|
||||||
|
VStack(() => {}).height(1, px).background("var(--divider)").flexShrink(0)
|
||||||
|
|
||||||
|
// ── List ──────────────────────────────────────────────────
|
||||||
|
this.listEl = PeopleList(this.filtered, global.appRefreshing || this.people.length === 0)
|
||||||
|
})
|
||||||
|
.height(100, pct)
|
||||||
|
.width(100, pct)
|
||||||
|
.boxSizing("border-box")
|
||||||
|
.overflow("hidden")
|
||||||
|
.onAppear(async () => {
|
||||||
|
const res = await server.getPeople(global.currentNetwork.id)
|
||||||
|
if (!res.error && res.length > 0 && this.people.length !== res.length) {
|
||||||
|
this.people = res.map(p => ({ ...p, _online: this.isOnline(p) }))
|
||||||
|
this.rerender()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
register(People)
|
||||||
39
people/server/functions.js
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
export async function saveMemberNote(email, text) {
|
||||||
|
console.log("saving note: ", email, text)
|
||||||
|
try {
|
||||||
|
await this.sql`
|
||||||
|
UPDATE members
|
||||||
|
SET notes = ${text}
|
||||||
|
WHERE email = ${email}
|
||||||
|
`
|
||||||
|
return { success: true }
|
||||||
|
} catch(e) {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPeople(networkId) {
|
||||||
|
try {
|
||||||
|
const people = await this.sql`
|
||||||
|
SELECT
|
||||||
|
m.*,
|
||||||
|
mn.created AS joined_network,
|
||||||
|
np.name AS plan_name,
|
||||||
|
COALESCE(
|
||||||
|
json_agg(r.name ORDER BY r.name) FILTER (WHERE r.name IS NOT NULL),
|
||||||
|
'[]'
|
||||||
|
) AS roles
|
||||||
|
FROM members m
|
||||||
|
JOIN member_networks mn ON mn.member_id = m.id AND mn.network_id = ${networkId}
|
||||||
|
LEFT JOIN network_plans np ON np.id = mn.network_plan_id
|
||||||
|
LEFT JOIN member_roles mr ON mr.member_id = m.id
|
||||||
|
LEFT JOIN roles r ON r.id = mr.role_id AND r.network_id = ${networkId}
|
||||||
|
GROUP BY m.id, mn.created, np.name
|
||||||
|
ORDER BY mn.created ASC
|
||||||
|
`;
|
||||||
|
return people
|
||||||
|
} catch(e) {
|
||||||
|
console.error(e)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
414
politics/desktop/PoliticsElections.js
Normal file
@@ -0,0 +1,414 @@
|
|||||||
|
class PoliticsElections extends Shadow {
|
||||||
|
constructor(elections, levelFilter, onVote, onThumbsUp) {
|
||||||
|
super()
|
||||||
|
this.elections = elections
|
||||||
|
this.levelFilter = levelFilter
|
||||||
|
this.onVote = onVote
|
||||||
|
this.onThumbsUp = onThumbsUp
|
||||||
|
// which election card is "expanded" for voting
|
||||||
|
this.expandedId = null
|
||||||
|
}
|
||||||
|
|
||||||
|
get visible() {
|
||||||
|
let list = this.levelFilter === "all"
|
||||||
|
? this.elections
|
||||||
|
: this.elections.filter(e => e.level === this.levelFilter)
|
||||||
|
return [...list].sort((a, b) => new Date(a.date) - new Date(b.date))
|
||||||
|
}
|
||||||
|
|
||||||
|
get upcoming() { return this.visible.filter(e => new Date(e.date) >= new Date()) }
|
||||||
|
get past() { return this.visible.filter(e => new Date(e.date) < new Date()) }
|
||||||
|
|
||||||
|
render() {
|
||||||
|
VStack(() => {
|
||||||
|
// Header
|
||||||
|
VStack(() => {
|
||||||
|
h2("Elections")
|
||||||
|
.margin(0).fontSize(1.1, em).fontWeight("700").color("var(--headertext)")
|
||||||
|
p(`${this.upcoming.length} upcoming · ${this.past.length} recent`)
|
||||||
|
.margin(0).marginTop(0.2, em).fontSize(0.78, em)
|
||||||
|
.color("var(--headertext)").opacity(0.42)
|
||||||
|
})
|
||||||
|
.paddingHorizontal(1.75, em).paddingVertical(1.25, em)
|
||||||
|
.borderBottom("1px solid var(--divider)").flexShrink(0)
|
||||||
|
|
||||||
|
VStack(() => {
|
||||||
|
if (this.upcoming.length > 0) {
|
||||||
|
this.sectionLabel("UPCOMING")
|
||||||
|
this.upcoming.forEach(e => this.renderElectionCard(e, false))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.past.length > 0) {
|
||||||
|
this.sectionLabel("RECENT")
|
||||||
|
this.past.forEach(e => this.renderElectionCard(e, true))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.visible.length === 0) {
|
||||||
|
VStack(() => {
|
||||||
|
p("🗳")
|
||||||
|
.margin(0).fontSize(2.2, em).opacity(0.18)
|
||||||
|
p("No elections found for this level")
|
||||||
|
.margin(0).marginTop(0.6, em).fontSize(0.88, em)
|
||||||
|
.color("var(--headertext)").opacity(0.32)
|
||||||
|
})
|
||||||
|
.flex(1).justifyContent("center").alignItems("center").paddingTop(4, em)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.overflowY("auto").flex(1)
|
||||||
|
.paddingHorizontal(1.75, em).paddingBottom(2, em).paddingTop(0.5, em)
|
||||||
|
})
|
||||||
|
.height(100, pct).width(100, pct).overflow("hidden")
|
||||||
|
}
|
||||||
|
|
||||||
|
renderElectionCard(election, isPast) {
|
||||||
|
const isExpanded = this.expandedId === election.id
|
||||||
|
const hasVoted = !!election.userVoteId
|
||||||
|
const daysAway = Math.ceil((new Date(election.date) - new Date()) / 86400000)
|
||||||
|
|
||||||
|
VStack(() => {
|
||||||
|
// ── Card top row ──────────────────────────────────────────
|
||||||
|
HStack(() => {
|
||||||
|
// Level badge
|
||||||
|
VStack(() => {
|
||||||
|
p(this.levelIcon(election.level))
|
||||||
|
.margin(0).fontSize(0.8, em).lineHeight("1")
|
||||||
|
})
|
||||||
|
.width(2, em).height(2, em).borderRadius(0.38, em)
|
||||||
|
.background("var(--darkaccent)").border("1px solid var(--divider)")
|
||||||
|
.justifyContent("center").alignItems("center").flexShrink(0)
|
||||||
|
|
||||||
|
VStack(() => {
|
||||||
|
HStack(() => {
|
||||||
|
p(election.title)
|
||||||
|
.margin(0).fontSize(0.95, em).fontWeight("700")
|
||||||
|
.color("var(--headertext)").flex(1).minWidth(0)
|
||||||
|
|
||||||
|
// Date chip
|
||||||
|
HStack(() => {
|
||||||
|
if (!isPast && daysAway <= 30) {
|
||||||
|
p(`${daysAway}d away`)
|
||||||
|
.margin(0).fontSize(0.68, em).fontWeight("700")
|
||||||
|
.color(daysAway <= 7 ? "#ef4444" : "#f59e0b")
|
||||||
|
} else {
|
||||||
|
p(this.formatDate(election.date))
|
||||||
|
.margin(0).fontSize(0.72, em)
|
||||||
|
.color("var(--headertext)").opacity(0.4)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.paddingHorizontal(0.55, em).paddingVertical(0.18, em)
|
||||||
|
.background(
|
||||||
|
!isPast && daysAway <= 7 ? "rgba(239,68,68,0.08)" :
|
||||||
|
!isPast && daysAway <= 30 ? "rgba(245,158,11,0.08)" :
|
||||||
|
"var(--darkaccent)"
|
||||||
|
)
|
||||||
|
.border(`1px solid ${
|
||||||
|
!isPast && daysAway <= 7 ? "rgba(239,68,68,0.2)" :
|
||||||
|
!isPast && daysAway <= 30 ? "rgba(245,158,11,0.2)" :
|
||||||
|
"var(--divider)"
|
||||||
|
}`)
|
||||||
|
.borderRadius(100, px).flexShrink(0)
|
||||||
|
})
|
||||||
|
.alignItems("center").gap(0.5, em)
|
||||||
|
|
||||||
|
HStack(() => {
|
||||||
|
p(this.capitalize(election.type))
|
||||||
|
.margin(0).paddingHorizontal(0.45, em).paddingVertical(0.1, em)
|
||||||
|
.background("var(--darkaccent)").border("1px solid var(--divider)")
|
||||||
|
.borderRadius(100, px).fontSize(0.68, em)
|
||||||
|
.color("var(--headertext)").opacity(0.55)
|
||||||
|
|
||||||
|
p(this.capitalize(election.level))
|
||||||
|
.margin(0).paddingHorizontal(0.45, em).paddingVertical(0.1, em)
|
||||||
|
.background("var(--darkaccent)").border("1px solid var(--divider)")
|
||||||
|
.borderRadius(100, px).fontSize(0.68, em)
|
||||||
|
.color("var(--headertext)").opacity(0.55)
|
||||||
|
|
||||||
|
if (election.description) {
|
||||||
|
p(election.description)
|
||||||
|
.margin(0).fontSize(0.75, em)
|
||||||
|
.color("var(--headertext)").opacity(0.45)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.gap(0.35, em).alignItems("center").marginTop(0.38, em)
|
||||||
|
})
|
||||||
|
.flex(1).minWidth(0).gap(0)
|
||||||
|
})
|
||||||
|
.gap(0.75, em).alignItems("flex-start")
|
||||||
|
|
||||||
|
// ── Candidates ────────────────────────────────────────────
|
||||||
|
if (election.candidates.length > 0) {
|
||||||
|
VStack(() => {
|
||||||
|
// Candidate vs. row
|
||||||
|
if (election.candidates.length >= 2) {
|
||||||
|
this.renderCandidateMatchup(election, isPast)
|
||||||
|
} else {
|
||||||
|
// Single candidate / unopposed
|
||||||
|
this.renderSingleCandidate(election.candidates[0], election, isPast)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.marginTop(1, em)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Footer: thumbs up + weigh in ──────────────────────────
|
||||||
|
HStack(() => {
|
||||||
|
// Thumbs up / follow this race
|
||||||
|
HStack(() => {
|
||||||
|
button(election.userThumbsUp ? "👍" : "👍")
|
||||||
|
.padding(0).border("none").background("transparent")
|
||||||
|
.cursor("pointer").fontSize(0.95, em)
|
||||||
|
.onClick((done) => {if(!done) return; this.onThumbsUp(election.id)})
|
||||||
|
|
||||||
|
p(election.thumbsUp > 0 ? `${election.thumbsUp}` : "Follow")
|
||||||
|
.margin(0).fontSize(0.75, em)
|
||||||
|
.color("var(--headertext)")
|
||||||
|
.opacity(election.userThumbsUp ? 1 : 0.42)
|
||||||
|
.fontWeight(election.userThumbsUp ? "600" : "400")
|
||||||
|
})
|
||||||
|
.gap(0.3, em).alignItems("center")
|
||||||
|
.paddingHorizontal(0.65, em).paddingVertical(0.32, em)
|
||||||
|
.background(election.userThumbsUp ? "rgba(59,130,246,0.08)" : "transparent")
|
||||||
|
.border(`1px solid ${election.userThumbsUp ? "rgba(59,130,246,0.25)" : "var(--divider)"}`)
|
||||||
|
.borderRadius(100, px).cursor("pointer")
|
||||||
|
.onClick((done) => {if(!done) return; this.onThumbsUp(election.id)})
|
||||||
|
|
||||||
|
HStack(() => {}).flex(1)
|
||||||
|
|
||||||
|
if (!isPast && !hasVoted) {
|
||||||
|
button(isExpanded ? "Close" : "Weigh In →")
|
||||||
|
.paddingHorizontal(1, em).paddingVertical(0.38, em)
|
||||||
|
.background(isExpanded ? "transparent" : "var(--quillred)")
|
||||||
|
.border(isExpanded ? "1px solid var(--divider)" : "none")
|
||||||
|
.borderRadius(0.45, em)
|
||||||
|
.color(isExpanded ? "var(--headertext)" : "white")
|
||||||
|
.fontSize(0.82, em).fontWeight("600").cursor("pointer")
|
||||||
|
.opacity(isExpanded ? 0.6 : 1)
|
||||||
|
.onClick((done) => {if(!done) return;
|
||||||
|
this.expandedId = isExpanded ? null : election.id
|
||||||
|
this.rerender()
|
||||||
|
})
|
||||||
|
} else if (hasVoted) {
|
||||||
|
HStack(() => {
|
||||||
|
p("✓ You weighed in")
|
||||||
|
.margin(0).fontSize(0.75, em).fontWeight("600").color("#22c55e")
|
||||||
|
})
|
||||||
|
.paddingHorizontal(0.75, em).paddingVertical(0.32, em)
|
||||||
|
.background("rgba(34,197,94,0.08)").border("1px solid rgba(34,197,94,0.2)")
|
||||||
|
.borderRadius(100, px)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.alignItems("center").marginTop(0.9, em)
|
||||||
|
|
||||||
|
// ── Expanded vote panel ───────────────────────────────────
|
||||||
|
if (isExpanded && !hasVoted) {
|
||||||
|
VStack(() => {
|
||||||
|
p("Who do you support?")
|
||||||
|
.margin(0).marginBottom(0.75, em).fontSize(0.8, em).fontWeight("600")
|
||||||
|
.color("var(--headertext)").opacity(0.55)
|
||||||
|
.textAlign("center")
|
||||||
|
|
||||||
|
HStack(() => {
|
||||||
|
election.candidates.forEach((candidate, i) => {
|
||||||
|
const color = this.partyColor(candidate.party)
|
||||||
|
if (i > 0) {
|
||||||
|
p("vs")
|
||||||
|
.margin(0).paddingHorizontal(0.6, em)
|
||||||
|
.fontSize(0.72, em).fontWeight("700")
|
||||||
|
.color("var(--headertext)").opacity(0.3)
|
||||||
|
.alignSelf("center")
|
||||||
|
}
|
||||||
|
button(() => {
|
||||||
|
VStack(() => {
|
||||||
|
p(candidate.initials || candidate.name[0])
|
||||||
|
.margin(0).fontSize(0.9, em).fontWeight("800").color("white").lineHeight("1")
|
||||||
|
})
|
||||||
|
.width(2.2, em).height(2.2, em).borderRadius(50, pct)
|
||||||
|
.background(color).justifyContent("center").alignItems("center")
|
||||||
|
.marginBottom(0.5, em)
|
||||||
|
|
||||||
|
p(candidate.name)
|
||||||
|
.margin(0).fontSize(0.82, em).fontWeight("700")
|
||||||
|
.color("var(--headertext)").textAlign("center")
|
||||||
|
|
||||||
|
p(`${candidate.party}${candidate.incumbent ? " · Incumbent" : ""}`)
|
||||||
|
.margin(0).marginTop(0.15, em).fontSize(0.68, em)
|
||||||
|
.color(color).textAlign("center")
|
||||||
|
})
|
||||||
|
.flex(1)
|
||||||
|
.paddingVertical(0.9, em)
|
||||||
|
.paddingHorizontal(0.5, em)
|
||||||
|
.background(`${color}0d`)
|
||||||
|
.border(`2px solid ${color}33`)
|
||||||
|
.borderRadius(0.65, em)
|
||||||
|
.cursor("pointer")
|
||||||
|
.display("flex").flexDirection("column").alignItems("center")
|
||||||
|
.onClick((done) => {if(!done) return;
|
||||||
|
this.expandedId = null
|
||||||
|
this.onVote(election.id, candidate.id)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.gap(0.5, em).width(100, pct)
|
||||||
|
})
|
||||||
|
.marginTop(0.85, em)
|
||||||
|
.padding(1, em)
|
||||||
|
.background("var(--darkaccent)")
|
||||||
|
.border("1px solid var(--divider)")
|
||||||
|
.borderRadius(0.65, em)
|
||||||
|
.alignItems("center")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.padding(1.2, em)
|
||||||
|
.border("1px solid var(--divider)")
|
||||||
|
.borderRadius(0.75, em)
|
||||||
|
.marginBottom(0.85, em)
|
||||||
|
.boxSizing("border-box")
|
||||||
|
.opacity(isPast ? 0.72 : 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
renderCandidateMatchup(election, isPast) {
|
||||||
|
const totalVotes = election.candidates.reduce((s, c) => s + (c.votes || 0), 0)
|
||||||
|
|
||||||
|
HStack(() => {
|
||||||
|
election.candidates.slice(0, 4).forEach((candidate, i) => {
|
||||||
|
const pct = totalVotes > 0 ? Math.round((candidate.votes / totalVotes) * 100) : null
|
||||||
|
const color = this.partyColor(candidate.party)
|
||||||
|
const isUserVote = election.userVoteId === candidate.id
|
||||||
|
|
||||||
|
if (i > 0) {
|
||||||
|
VStack(() => {
|
||||||
|
p("vs").margin(0).fontSize(0.68, em).fontWeight("700")
|
||||||
|
.color("var(--headertext)").opacity(0.28)
|
||||||
|
})
|
||||||
|
.justifyContent("center").paddingHorizontal(0.5, em).flexShrink(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(() => {
|
||||||
|
// Party color bar top
|
||||||
|
VStack(() => {})
|
||||||
|
.height(3, px).width(100, pct).background(color).flexShrink(0)
|
||||||
|
|
||||||
|
VStack(() => {
|
||||||
|
// Name + incumbent
|
||||||
|
HStack(() => {
|
||||||
|
VStack(() => {
|
||||||
|
p(candidate.initials || candidate.name.split(" ").map(w => w[0]).join(""))
|
||||||
|
.margin(0).fontSize(0.72, em).fontWeight("800").color("white").lineHeight("1")
|
||||||
|
})
|
||||||
|
.width(1.9, em).height(1.9, em).borderRadius(50, pct)
|
||||||
|
.background(color).justifyContent("center").alignItems("center").flexShrink(0)
|
||||||
|
|
||||||
|
VStack(() => {
|
||||||
|
p(candidate.name)
|
||||||
|
.margin(0).fontSize(0.82, em).fontWeight("700").color("var(--headertext)")
|
||||||
|
.overflow("hidden").whiteSpace("nowrap").textOverflow("ellipsis")
|
||||||
|
HStack(() => {
|
||||||
|
p(candidate.party)
|
||||||
|
.margin(0).paddingHorizontal(0.35, em).paddingVertical(0.08, em)
|
||||||
|
.background(`${color}18`).border(`1px solid ${color}33`)
|
||||||
|
.borderRadius(100, px).fontSize(0.62, em).fontWeight("700").color(color)
|
||||||
|
if (candidate.incumbent) {
|
||||||
|
p("Incumbent")
|
||||||
|
.margin(0).paddingHorizontal(0.35, em).paddingVertical(0.08, em)
|
||||||
|
.background("var(--darkaccent)").border("1px solid var(--divider)")
|
||||||
|
.borderRadius(100, px).fontSize(0.62, em).color("var(--headertext)").opacity(0.45)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.gap(0.25, em).alignItems("center").marginTop(0.18, em)
|
||||||
|
})
|
||||||
|
.flex(1).minWidth(0)
|
||||||
|
})
|
||||||
|
.gap(0.55, em).alignItems("center")
|
||||||
|
|
||||||
|
// Support bar
|
||||||
|
if (pct !== null && totalVotes > 0) {
|
||||||
|
VStack(() => {
|
||||||
|
VStack(() => {
|
||||||
|
HStack(() => {
|
||||||
|
VStack(() => {})
|
||||||
|
.height(100, pct).width(pct, pct)
|
||||||
|
.background(color).borderRadius(100, px)
|
||||||
|
.transition("width 0.5s ease")
|
||||||
|
})
|
||||||
|
.height(100, pct).width(100, pct).alignItems("center")
|
||||||
|
})
|
||||||
|
.width(100, pct).height(6, px).background(`${color}22`).borderRadius(100, px).overflow("hidden")
|
||||||
|
|
||||||
|
HStack(() => {
|
||||||
|
p(`${pct}% community support`)
|
||||||
|
.margin(0).fontSize(0.68, em).color("var(--headertext)").opacity(0.42)
|
||||||
|
if (isUserVote) {
|
||||||
|
p("· Your pick ✓")
|
||||||
|
.margin(0).fontSize(0.68, em).color(color).fontWeight("600")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.gap(0.3, em).alignItems("center").marginTop(0.35, em)
|
||||||
|
})
|
||||||
|
.marginTop(0.65, em).width(100, pct)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.padding(0.75, em).flex(1)
|
||||||
|
})
|
||||||
|
.flex(1).minWidth(0)
|
||||||
|
.background("var(--darkaccent)")
|
||||||
|
.border(`1px solid ${isUserVote ? color + "55" : "var(--divider)"}`)
|
||||||
|
.borderRadius(0.55, em).overflow("hidden")
|
||||||
|
.boxSizing("border-box")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.gap(0).alignItems("stretch").width(100, pct)
|
||||||
|
}
|
||||||
|
|
||||||
|
renderSingleCandidate(candidate, election, isPast) {
|
||||||
|
const color = this.partyColor(candidate.party)
|
||||||
|
HStack(() => {
|
||||||
|
VStack(() => {
|
||||||
|
p(candidate.initials || candidate.name[0])
|
||||||
|
.margin(0).fontSize(0.85, em).fontWeight("800").color("white").lineHeight("1")
|
||||||
|
})
|
||||||
|
.width(2.4, em).height(2.4, em).borderRadius(50, pct)
|
||||||
|
.background(color).justifyContent("center").alignItems("center").flexShrink(0)
|
||||||
|
|
||||||
|
VStack(() => {
|
||||||
|
p(candidate.name)
|
||||||
|
.margin(0).fontSize(0.9, em).fontWeight("700").color("var(--headertext)")
|
||||||
|
HStack(() => {
|
||||||
|
p(candidate.party).margin(0).fontSize(0.7, em).fontWeight("600").color(color)
|
||||||
|
if (candidate.incumbent) {
|
||||||
|
p("· Incumbent").margin(0).fontSize(0.7, em).color("var(--headertext)").opacity(0.42)
|
||||||
|
}
|
||||||
|
p("· Unopposed").margin(0).fontSize(0.7, em).color("var(--headertext)").opacity(0.35)
|
||||||
|
})
|
||||||
|
.gap(0.35, em).alignItems("center").marginTop(0.2, em)
|
||||||
|
})
|
||||||
|
.flex(1)
|
||||||
|
})
|
||||||
|
.gap(0.75, em).alignItems("center")
|
||||||
|
.padding(0.85, em)
|
||||||
|
.background("var(--darkaccent)").border("1px solid var(--divider)").borderRadius(0.55, em)
|
||||||
|
}
|
||||||
|
|
||||||
|
sectionLabel(text) {
|
||||||
|
p(text)
|
||||||
|
.margin(0).marginTop(0.85, em).marginBottom(0.65, em)
|
||||||
|
.fontSize(0.62, em).fontWeight("700").letterSpacing("0.07em")
|
||||||
|
.color("var(--headertext)").opacity(0.35)
|
||||||
|
}
|
||||||
|
|
||||||
|
levelIcon(level) {
|
||||||
|
return { federal: "🇺🇸", state: "🏠", local: "📍" }[level] || "🗳"
|
||||||
|
}
|
||||||
|
|
||||||
|
formatDate(raw) {
|
||||||
|
return new Date(raw).toLocaleDateString([], { month: "long", day: "numeric", year: "numeric" })
|
||||||
|
}
|
||||||
|
|
||||||
|
capitalize(s) {
|
||||||
|
return s ? s[0].toUpperCase() + s.slice(1) : s
|
||||||
|
}
|
||||||
|
|
||||||
|
partyColor(party) {
|
||||||
|
return { R: "#ef4444", D: "#3b82f6", I: "#8b5cf6", L: "#f59e0b", G: "#22c55e" }[party] || "#6b7280"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
register(PoliticsElections)
|
||||||
214
politics/desktop/PoliticsRepresentatives.js
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
class PoliticsRepresentatives extends Shadow {
|
||||||
|
constructor(reps, levelFilter) {
|
||||||
|
super()
|
||||||
|
this.reps = reps
|
||||||
|
this.levelFilter = levelFilter
|
||||||
|
}
|
||||||
|
|
||||||
|
get visible() {
|
||||||
|
if (this.levelFilter === "all") return this.reps
|
||||||
|
return this.reps.filter(r => r.level === this.levelFilter)
|
||||||
|
}
|
||||||
|
|
||||||
|
get grouped() {
|
||||||
|
const order = ["federal", "state", "local"]
|
||||||
|
const map = {}
|
||||||
|
order.forEach(l => { map[l] = [] })
|
||||||
|
this.visible.forEach(r => {
|
||||||
|
if (map[r.level]) map[r.level].push(r)
|
||||||
|
})
|
||||||
|
return order.map(l => ({ level: l, reps: map[l] })).filter(g => g.reps.length)
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
VStack(() => {
|
||||||
|
// Header
|
||||||
|
VStack(() => {
|
||||||
|
HStack(() => {
|
||||||
|
VStack(() => {
|
||||||
|
h2("Your Representatives")
|
||||||
|
.margin(0).fontSize(1.1, em).fontWeight("700").color("var(--headertext)")
|
||||||
|
p(`${this.visible.length} officials across your districts`)
|
||||||
|
.margin(0).marginTop(0.2, em).fontSize(0.78, em)
|
||||||
|
.color("var(--headertext)").opacity(0.42)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Party breakdown pill
|
||||||
|
HStack(() => {
|
||||||
|
const counts = { R: 0, D: 0, I: 0 }
|
||||||
|
this.visible.forEach(r => { counts[r.party] = (counts[r.party] || 0) + 1 })
|
||||||
|
if (counts.R) this.partyCount(counts.R, "R")
|
||||||
|
if (counts.D) this.partyCount(counts.D, "D")
|
||||||
|
if (counts.I) this.partyCount(counts.I, "I")
|
||||||
|
})
|
||||||
|
.gap(0.5, em).alignItems("center")
|
||||||
|
})
|
||||||
|
.justifyContent("space-between").alignItems("flex-start")
|
||||||
|
})
|
||||||
|
.paddingHorizontal(1.75, em).paddingVertical(1.25, em)
|
||||||
|
.borderBottom("1px solid var(--divider)").flexShrink(0)
|
||||||
|
|
||||||
|
// Groups
|
||||||
|
VStack(() => {
|
||||||
|
this.grouped.forEach(group => {
|
||||||
|
VStack(() => {
|
||||||
|
// Level header
|
||||||
|
HStack(() => {
|
||||||
|
HStack(() => {})
|
||||||
|
.flex(1).height(1, px).background("var(--divider)")
|
||||||
|
p(this.levelLabel(group.level))
|
||||||
|
.margin(0).marginHorizontal(0.9, em)
|
||||||
|
.fontSize(0.68, em).fontWeight("700").letterSpacing("0.08em")
|
||||||
|
.color("var(--headertext)").opacity(0.35)
|
||||||
|
.whiteSpace("nowrap")
|
||||||
|
HStack(() => {})
|
||||||
|
.flex(1).height(1, px).background("var(--divider)")
|
||||||
|
})
|
||||||
|
.alignItems("center").marginBottom(1, em)
|
||||||
|
|
||||||
|
// Cards
|
||||||
|
HStack(() => {
|
||||||
|
group.reps.forEach(rep => this.renderCard(rep))
|
||||||
|
})
|
||||||
|
.gap(0.85, em).flexWrap("wrap")
|
||||||
|
})
|
||||||
|
.paddingTop(1.35, em).paddingBottom(0.5, em)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.paddingHorizontal(1.75, em)
|
||||||
|
.overflowY("auto").flex(1)
|
||||||
|
})
|
||||||
|
.height(100, pct).width(100, pct).overflow("hidden")
|
||||||
|
}
|
||||||
|
|
||||||
|
renderCard(rep) {
|
||||||
|
const partyColor = this.partyColor(rep.party)
|
||||||
|
const partyBg = this.partyBg(rep.party)
|
||||||
|
|
||||||
|
VStack(() => {
|
||||||
|
// Party accent bar
|
||||||
|
VStack(() => {})
|
||||||
|
.height(4, px).width(100, pct)
|
||||||
|
.background(partyColor).flexShrink(0)
|
||||||
|
|
||||||
|
VStack(() => {
|
||||||
|
// Avatar + status
|
||||||
|
ZStack(() => {
|
||||||
|
// Avatar circle
|
||||||
|
VStack(() => {
|
||||||
|
p(rep.initials)
|
||||||
|
.margin(0).fontSize(1.15, em).fontWeight("800")
|
||||||
|
.color("white").lineHeight("1")
|
||||||
|
})
|
||||||
|
.width(3.8, em).height(3.8, em).borderRadius(50, pct)
|
||||||
|
.background(partyColor)
|
||||||
|
.justifyContent("center").alignItems("center")
|
||||||
|
.flexShrink(0)
|
||||||
|
|
||||||
|
// Party badge bottom-right
|
||||||
|
VStack(() => {
|
||||||
|
p(rep.party)
|
||||||
|
.margin(0).fontSize(0.52, em).fontWeight("800")
|
||||||
|
.color("white").lineHeight("1")
|
||||||
|
})
|
||||||
|
.position("absolute").bottom(0).right(0)
|
||||||
|
.width(1.3, em).height(1.3, em).borderRadius(50, pct)
|
||||||
|
.background(partyColor)
|
||||||
|
.boxSizing("border-box")
|
||||||
|
.justifyContent("center").alignItems("center")
|
||||||
|
})
|
||||||
|
.position("relative")
|
||||||
|
.width(3.8, em).height(3.8, em).marginBottom(0.85, em)
|
||||||
|
|
||||||
|
// Name
|
||||||
|
p(rep.name)
|
||||||
|
.margin(0).fontSize(0.92, em).fontWeight("700")
|
||||||
|
.color("var(--headertext)").lineHeight("1.25").textAlign("center")
|
||||||
|
|
||||||
|
// Title
|
||||||
|
p(rep.title)
|
||||||
|
.margin(0).marginTop(0.22, em).fontSize(0.72, em)
|
||||||
|
.color("var(--headertext)").opacity(0.55)
|
||||||
|
.textAlign("center").lineHeight("1.35")
|
||||||
|
|
||||||
|
// District chip
|
||||||
|
if (rep.district) {
|
||||||
|
p(rep.district)
|
||||||
|
.margin(0).marginTop(0.55, em)
|
||||||
|
.paddingHorizontal(0.6, em).paddingVertical(0.18, em)
|
||||||
|
.background(partyBg).border(`1px solid ${partyColor}33`)
|
||||||
|
.borderRadius(100, px)
|
||||||
|
.fontSize(0.68, em).fontWeight("600")
|
||||||
|
.color(partyColor)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Since
|
||||||
|
if (rep.since) {
|
||||||
|
p(`Since ${rep.since}`)
|
||||||
|
.margin(0).marginTop(0.42, em)
|
||||||
|
.fontSize(0.68, em).color("var(--headertext)").opacity(0.32)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Contact row
|
||||||
|
HStack(() => {
|
||||||
|
if (rep.phone) {
|
||||||
|
this.contactBtn("📞", rep.phone)
|
||||||
|
}
|
||||||
|
if (rep.website) {
|
||||||
|
this.contactBtn("🌐", rep.website)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.gap(0.4, em).marginTop(0.75, em)
|
||||||
|
})
|
||||||
|
.padding(1.1, em).alignItems("center")
|
||||||
|
})
|
||||||
|
.width(200, px).minWidth(180, px)
|
||||||
|
.background("var(--main)")
|
||||||
|
.border("1px solid var(--divider)")
|
||||||
|
.borderRadius(0.65, em)
|
||||||
|
.overflow("hidden")
|
||||||
|
.boxSizing("border-box")
|
||||||
|
.flexShrink(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
contactBtn(icon, value) {
|
||||||
|
button(icon)
|
||||||
|
.padding(0.35, em)
|
||||||
|
.background("var(--darkaccent)")
|
||||||
|
.border("1px solid var(--divider)")
|
||||||
|
.borderRadius(0.4, em)
|
||||||
|
.fontSize(0.8, em)
|
||||||
|
.cursor("pointer")
|
||||||
|
.attr({ title: value })
|
||||||
|
}
|
||||||
|
|
||||||
|
partyCount(count, party) {
|
||||||
|
HStack(() => {
|
||||||
|
VStack(() => {})
|
||||||
|
.width(0.5, em).height(0.5, em).borderRadius(50, pct)
|
||||||
|
.background(this.partyColor(party)).flexShrink(0)
|
||||||
|
p(`${count} ${party}`)
|
||||||
|
.margin(0).fontSize(0.75, em).fontWeight("600")
|
||||||
|
.color(this.partyColor(party))
|
||||||
|
})
|
||||||
|
.gap(0.3, em).alignItems("center")
|
||||||
|
.paddingHorizontal(0.6, em).paddingVertical(0.22, em)
|
||||||
|
.background(this.partyBg(party))
|
||||||
|
.border(`1px solid ${this.partyColor(party)}33`)
|
||||||
|
.borderRadius(100, px)
|
||||||
|
}
|
||||||
|
|
||||||
|
levelLabel(level) {
|
||||||
|
return { federal: "FEDERAL DELEGATION", state: "STATE DELEGATION", local: "LOCAL OFFICIALS" }[level] || level.toUpperCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
partyColor(party) {
|
||||||
|
return { R: "#ef4444", D: "#3b82f6", I: "#8b5cf6", L: "#f59e0b", G: "#22c55e" }[party] || "#6b7280"
|
||||||
|
}
|
||||||
|
|
||||||
|
partyBg(party) {
|
||||||
|
return { R: "rgba(239,68,68,0.08)", D: "rgba(59,130,246,0.08)", I: "rgba(139,92,246,0.08)", L: "rgba(245,158,11,0.08)", G: "rgba(34,197,94,0.08)" }[party] || "var(--darkaccent)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
register(PoliticsRepresentatives)
|
||||||