From 0d6c7683ff2982b0b8fc53be27fa83d987b98198 Mon Sep 17 00:00:00 2001 From: metacryst Date: Tue, 28 Apr 2026 20:05:00 -0500 Subject: [PATCH] init --- announcements/AnnouncementCard.js | 21 + announcements/Old.js | 150 +++ announcements/Panel.js | 150 +++ announcements/announcements.js | 273 ++++ .../desktop/DesktopAnnouncementsFeed.js | 154 +++ .../desktop/DesktopAnnouncementsViewer.js | 420 ++++++ announcements/desktop/announcements.js | 178 +++ announcements/icons/announcements.svg | 6 + announcements/icons/announcementslight.svg | 6 + .../icons/announcementslightselected.svg | 1 + calendar/CalendarForm.js | 437 +++++++ calendar/Day/DayHeaderRow.js | 101 ++ calendar/Day/DayView.js | 124 ++ calendar/EventFileList.js | 157 +++ calendar/Events/EventDetails.js | 612 +++++++++ calendar/Events/EventForm.js | 1156 +++++++++++++++++ calendar/Events/FilePreview.js | 298 +++++ calendar/Month/MonthGrid.js | 219 ++++ calendar/Month/MonthHeaderRow.js | 31 + calendar/Month/MonthView.js | 137 ++ calendar/Toolbar/BottomBar.js | 126 ++ calendar/Toolbar/CalendarOptions.js | 256 ++++ calendar/Toolbar/CalendarToolbar.js | 123 ++ calendar/Toolbar/ToolbarPopout.js | 519 ++++++++ calendar/Week/SpacerCell.js | 29 + calendar/Week/TimedLabelsColumn.js | 37 + calendar/Week/TimedWeekGrid.js | 338 +++++ calendar/Week/WeekHeaderRow.js | 160 +++ calendar/Week/WeekView.js | 142 ++ calendar/calendar.js | 582 +++++++++ calendar/calendarUtil.js | 307 +++++ calendar/calendarUtil.test.js | 285 ++++ calendar/desktop/DesktopCalendarForm.js | 334 +++++ calendar/desktop/DesktopMonthGrid.js | 320 +++++ calendar/desktop/DesktopMonthView.js | 118 ++ calendar/desktop/DesktopSidebar.js | 294 +++++ calendar/desktop/DesktopToolbar.js | 74 ++ .../desktop/Events/DesktopEventDetails.js | 396 ++++++ calendar/desktop/Events/DesktopEventForm.js | 1011 ++++++++++++++ calendar/desktop/Events/FilePreview.js | 154 +++ calendar/desktop/calendar.js | 415 ++++++ calendar/icons/addevent.svg | 4 + calendar/icons/addeventlight.svg | 4 + calendar/icons/addeventlightselected.svg | 4 + calendar/icons/calbutton.svg | 3 + calendar/icons/calbuttonlight.svg | 3 + calendar/icons/calbuttonlightselected.svg | 3 + calendar/icons/calendar.svg | 3 + calendar/icons/calendarlight.svg | 3 + calendar/icons/calendarlightselected.svg | 3 + chat/chat.js | 619 +++++++++ chat/desktop/DesktopChatSidebar.js | 317 +++++ chat/desktop/DesktopChatThread.js | 387 ++++++ chat/desktop/chat.js | 213 +++ chat/icons/chat.svg | 3 + chat/icons/chatlight.svg | 3 + components/AddButton.js | 33 + components/AppTitle.js | 8 + components/Avatar.js | 36 + components/BackButton.js | 62 + components/BottomSheet.js | 193 +++ components/SearchBar.js | 72 + donations/desktop/donations.js | 425 ++++++ donations/donations.js | 305 +++++ donations/icons/donations.svg | 5 + donations/icons/donationslight.svg | 5 + donations/server/functions.js | 21 + files/desktop/DesktopFilesGrid.js | 408 ++++++ files/desktop/DesktopFilesSidebar.js | 175 +++ files/desktop/DesktopFilesToolbar.js | 98 ++ files/desktop/files.js | 159 +++ files/icons/files.svg | 3 + files/icons/fileslight.svg | 3 + jobs/JobCard.js | 65 + jobs/JobForm.js | 204 +++ jobs/JobsGrid.js | 60 + jobs/JobsSidebar.js | 26 + jobs/desktop/DesktopJobDetail.js | 293 +++++ jobs/desktop/DesktopJobsList.js | 169 +++ jobs/desktop/DesktopJobsToolbar.js | 111 ++ jobs/desktop/jobs.js | 338 +++++ jobs/icons/jobs.svg | 3 + jobs/icons/jobslight.svg | 3 + jobs/icons/jobslightselected.svg | 3 + jobs/jobs.js | 441 +++++++ package.json | 3 + people/PeopleCard.js | 132 ++ people/PeopleList.js | 61 + people/desktop/DesktopPeopleDetail.js | 310 +++++ people/desktop/DesktopPeopleList.js | 101 ++ people/desktop/DesktopPeopleTable.js | 263 ++++ people/desktop/DesktopPeopleToolbar.js | 100 ++ people/desktop/people.js | 203 +++ people/icons/people.svg | 3 + people/icons/peoplelight.svg | 3 + people/icons/peoplelightselected.svg | 13 + people/people.js | 165 +++ people/server/functions.js | 39 + politics/desktop/PoliticsElections.js | 414 ++++++ politics/desktop/PoliticsRepresentatives.js | 214 +++ politics/desktop/PoliticsSidebar.js | 163 +++ politics/desktop/politics.js | 266 ++++ politics/icons/politics.svg | 3 + politics/icons/politicslight.svg | 3 + settings/SettingsIntegrationsSection.js | 77 ++ settings/SettingsRolesSection.js | 250 ++++ .../desktop/DesktopIntegrationsSection.js | 71 + settings/desktop/DesktopRolesSection.js | 243 ++++ settings/desktop/DesktopSettingsSidebar.js | 95 ++ settings/desktop/settings.js | 149 +++ settings/icons/settings.svg | 3 + settings/icons/settingslight.svg | 3 + settings/icons/settingslightselected.svg | 3 + settings/settings.js | 216 +++ tasks/desktop/tasks.js | 144 ++ tasks/icons/tasks.svg | 4 + tasks/icons/taskslight.svg | 4 + tasks/server/functions.js | 47 + tasks/tasks.js | 152 +++ website/desktop/website.js | 581 +++++++++ website/icons/website.svg | 3 + website/icons/websitelight.svg | 3 + website/website.js | 465 +++++++ 123 files changed, 20922 insertions(+) create mode 100644 announcements/AnnouncementCard.js create mode 100644 announcements/Old.js create mode 100644 announcements/Panel.js create mode 100644 announcements/announcements.js create mode 100644 announcements/desktop/DesktopAnnouncementsFeed.js create mode 100644 announcements/desktop/DesktopAnnouncementsViewer.js create mode 100644 announcements/desktop/announcements.js create mode 100644 announcements/icons/announcements.svg create mode 100644 announcements/icons/announcementslight.svg create mode 100644 announcements/icons/announcementslightselected.svg create mode 100644 calendar/CalendarForm.js create mode 100644 calendar/Day/DayHeaderRow.js create mode 100644 calendar/Day/DayView.js create mode 100644 calendar/EventFileList.js create mode 100644 calendar/Events/EventDetails.js create mode 100644 calendar/Events/EventForm.js create mode 100644 calendar/Events/FilePreview.js create mode 100644 calendar/Month/MonthGrid.js create mode 100644 calendar/Month/MonthHeaderRow.js create mode 100644 calendar/Month/MonthView.js create mode 100644 calendar/Toolbar/BottomBar.js create mode 100644 calendar/Toolbar/CalendarOptions.js create mode 100644 calendar/Toolbar/CalendarToolbar.js create mode 100644 calendar/Toolbar/ToolbarPopout.js create mode 100644 calendar/Week/SpacerCell.js create mode 100644 calendar/Week/TimedLabelsColumn.js create mode 100644 calendar/Week/TimedWeekGrid.js create mode 100644 calendar/Week/WeekHeaderRow.js create mode 100644 calendar/Week/WeekView.js create mode 100644 calendar/calendar.js create mode 100644 calendar/calendarUtil.js create mode 100644 calendar/calendarUtil.test.js create mode 100644 calendar/desktop/DesktopCalendarForm.js create mode 100644 calendar/desktop/DesktopMonthGrid.js create mode 100644 calendar/desktop/DesktopMonthView.js create mode 100644 calendar/desktop/DesktopSidebar.js create mode 100644 calendar/desktop/DesktopToolbar.js create mode 100644 calendar/desktop/Events/DesktopEventDetails.js create mode 100644 calendar/desktop/Events/DesktopEventForm.js create mode 100644 calendar/desktop/Events/FilePreview.js create mode 100644 calendar/desktop/calendar.js create mode 100644 calendar/icons/addevent.svg create mode 100644 calendar/icons/addeventlight.svg create mode 100644 calendar/icons/addeventlightselected.svg create mode 100644 calendar/icons/calbutton.svg create mode 100644 calendar/icons/calbuttonlight.svg create mode 100644 calendar/icons/calbuttonlightselected.svg create mode 100644 calendar/icons/calendar.svg create mode 100644 calendar/icons/calendarlight.svg create mode 100644 calendar/icons/calendarlightselected.svg create mode 100644 chat/chat.js create mode 100644 chat/desktop/DesktopChatSidebar.js create mode 100644 chat/desktop/DesktopChatThread.js create mode 100644 chat/desktop/chat.js create mode 100644 chat/icons/chat.svg create mode 100644 chat/icons/chatlight.svg create mode 100644 components/AddButton.js create mode 100644 components/AppTitle.js create mode 100644 components/Avatar.js create mode 100644 components/BackButton.js create mode 100644 components/BottomSheet.js create mode 100644 components/SearchBar.js create mode 100644 donations/desktop/donations.js create mode 100644 donations/donations.js create mode 100644 donations/icons/donations.svg create mode 100644 donations/icons/donationslight.svg create mode 100644 donations/server/functions.js create mode 100644 files/desktop/DesktopFilesGrid.js create mode 100644 files/desktop/DesktopFilesSidebar.js create mode 100644 files/desktop/DesktopFilesToolbar.js create mode 100644 files/desktop/files.js create mode 100644 files/icons/files.svg create mode 100644 files/icons/fileslight.svg create mode 100644 jobs/JobCard.js create mode 100644 jobs/JobForm.js create mode 100644 jobs/JobsGrid.js create mode 100644 jobs/JobsSidebar.js create mode 100644 jobs/desktop/DesktopJobDetail.js create mode 100644 jobs/desktop/DesktopJobsList.js create mode 100644 jobs/desktop/DesktopJobsToolbar.js create mode 100644 jobs/desktop/jobs.js create mode 100644 jobs/icons/jobs.svg create mode 100644 jobs/icons/jobslight.svg create mode 100644 jobs/icons/jobslightselected.svg create mode 100644 jobs/jobs.js create mode 100644 package.json create mode 100644 people/PeopleCard.js create mode 100644 people/PeopleList.js create mode 100644 people/desktop/DesktopPeopleDetail.js create mode 100644 people/desktop/DesktopPeopleList.js create mode 100644 people/desktop/DesktopPeopleTable.js create mode 100644 people/desktop/DesktopPeopleToolbar.js create mode 100644 people/desktop/people.js create mode 100644 people/icons/people.svg create mode 100644 people/icons/peoplelight.svg create mode 100644 people/icons/peoplelightselected.svg create mode 100644 people/people.js create mode 100644 people/server/functions.js create mode 100644 politics/desktop/PoliticsElections.js create mode 100644 politics/desktop/PoliticsRepresentatives.js create mode 100644 politics/desktop/PoliticsSidebar.js create mode 100644 politics/desktop/politics.js create mode 100644 politics/icons/politics.svg create mode 100644 politics/icons/politicslight.svg create mode 100644 settings/SettingsIntegrationsSection.js create mode 100644 settings/SettingsRolesSection.js create mode 100644 settings/desktop/DesktopIntegrationsSection.js create mode 100644 settings/desktop/DesktopRolesSection.js create mode 100644 settings/desktop/DesktopSettingsSidebar.js create mode 100644 settings/desktop/settings.js create mode 100644 settings/icons/settings.svg create mode 100644 settings/icons/settingslight.svg create mode 100644 settings/icons/settingslightselected.svg create mode 100644 settings/settings.js create mode 100644 tasks/desktop/tasks.js create mode 100644 tasks/icons/tasks.svg create mode 100644 tasks/icons/taskslight.svg create mode 100644 tasks/server/functions.js create mode 100644 tasks/tasks.js create mode 100644 website/desktop/website.js create mode 100644 website/icons/website.svg create mode 100644 website/icons/websitelight.svg create mode 100644 website/website.js diff --git a/announcements/AnnouncementCard.js b/announcements/AnnouncementCard.js new file mode 100644 index 0000000..66fe900 --- /dev/null +++ b/announcements/AnnouncementCard.js @@ -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") \ No newline at end of file diff --git a/announcements/Old.js b/announcements/Old.js new file mode 100644 index 0000000..3b690cd --- /dev/null +++ b/announcements/Old.js @@ -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) \ No newline at end of file diff --git a/announcements/Panel.js b/announcements/Panel.js new file mode 100644 index 0000000..b1aadc0 --- /dev/null +++ b/announcements/Panel.js @@ -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 { + 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) \ No newline at end of file diff --git a/announcements/announcements.js b/announcements/announcements.js new file mode 100644 index 0000000..2030024 --- /dev/null +++ b/announcements/announcements.js @@ -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) \ No newline at end of file diff --git a/announcements/desktop/DesktopAnnouncementsFeed.js b/announcements/desktop/DesktopAnnouncementsFeed.js new file mode 100644 index 0000000..7b64267 --- /dev/null +++ b/announcements/desktop/DesktopAnnouncementsFeed.js @@ -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) diff --git a/announcements/desktop/DesktopAnnouncementsViewer.js b/announcements/desktop/DesktopAnnouncementsViewer.js new file mode 100644 index 0000000..7caa2f7 --- /dev/null +++ b/announcements/desktop/DesktopAnnouncementsViewer.js @@ -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) diff --git a/announcements/desktop/announcements.js b/announcements/desktop/announcements.js new file mode 100644 index 0000000..3ac1b79 --- /dev/null +++ b/announcements/desktop/announcements.js @@ -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) diff --git a/announcements/icons/announcements.svg b/announcements/icons/announcements.svg new file mode 100644 index 0000000..8d2de01 --- /dev/null +++ b/announcements/icons/announcements.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/announcements/icons/announcementslight.svg b/announcements/icons/announcementslight.svg new file mode 100644 index 0000000..243a167 --- /dev/null +++ b/announcements/icons/announcementslight.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/announcements/icons/announcementslightselected.svg b/announcements/icons/announcementslightselected.svg new file mode 100644 index 0000000..788ae2f --- /dev/null +++ b/announcements/icons/announcementslightselected.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/calendar/CalendarForm.js b/calendar/CalendarForm.js new file mode 100644 index 0000000..982e4d8 --- /dev/null +++ b/calendar/CalendarForm.js @@ -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) \ No newline at end of file diff --git a/calendar/Day/DayHeaderRow.js b/calendar/Day/DayHeaderRow.js new file mode 100644 index 0000000..7b516ae --- /dev/null +++ b/calendar/Day/DayHeaderRow.js @@ -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) diff --git a/calendar/Day/DayView.js b/calendar/Day/DayView.js new file mode 100644 index 0000000..a37698e --- /dev/null +++ b/calendar/Day/DayView.js @@ -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) diff --git a/calendar/EventFileList.js b/calendar/EventFileList.js new file mode 100644 index 0000000..8bc6da5 --- /dev/null +++ b/calendar/EventFileList.js @@ -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) diff --git a/calendar/Events/EventDetails.js b/calendar/Events/EventDetails.js new file mode 100644 index 0000000..6f841a0 --- /dev/null +++ b/calendar/Events/EventDetails.js @@ -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 => ` +
+
+

${cal.name}

+
+ `).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) diff --git a/calendar/Events/EventForm.js b/calendar/Events/EventForm.js new file mode 100644 index 0000000..59a498c --- /dev/null +++ b/calendar/Events/EventForm.js @@ -0,0 +1,1156 @@ +import server from "/@server/server.js" +import calendarUtil from "../calendarUtil.js" +import "../EventFileList.js" +import "../../components/Avatar.js" + +css(` + eventform- { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; + scrollbar-width: none; + -ms-overflow-style: none; + } + eventform- > form { + display: flex; + flex-direction: column; + flex: 1 1 auto; + height: 100%; + min-height: 0; + } + eventform- ::-webkit-scrollbar { display: none; width: 0; height: 0; } + eventform- ::-webkit-scrollbar-thumb { background: transparent; } + eventform- ::-webkit-scrollbar-track { background: transparent; } + eventform- input::placeholder, + eventform- textarea::placeholder { + color: var(--headertext); + opacity: 0.35; + } + eventform- input[type="date"], + eventform- input[type="time"] { + min-width: 0; + } + eventform- input[type="checkbox"] { + flex-shrink: 0; + } + #eventform-toast-wrap { + transition: max-height 0.25s ease, opacity 0.22s ease, padding-top 0.25s ease; + } +`) + +class EventForm extends Shadow { + cardInputStyles(el) { + return el + .border("none").outline("none").background("transparent") + .color("var(--text)").fontSize(0.9, em).fontFamily("Arial") + .padding(0).boxSizing("border-box") + } + + selectedCalendars = []; + pendingSaveEventData = null; // stashed before the recurring scope prompt, consumed by performSave + uploadedFiles = []; + existingAttachments = []; + filesOpen = false; + attachmentsDeleted = false; + pendingDeletions = []; + initialAllDay = true; + startDate = calendarUtil.toDateInput(new Date()); + endDate = calendarUtil.toDateInput(new Date()); + startTime = "13:00"; + endTime = "14:00"; + recurrence = null; + + constructor(calendars, updateEvents, event = null, onBack = null, onDelete = null, initialDate = null, onSaveError = null) { + super() + this.calendars = calendars; + this.editEvent = event; + this.updateEvents = updateEvents; + this.onBack = onBack; + this.onDelete = onDelete; + this.onSaveError = onSaveError; + + if (event) { + const start = new Date(event.time_start); + const end = new Date(event.time_end); + this.startDate = calendarUtil.toDateInput(start); + this.endDate = calendarUtil.toDateInput(end); + this.startTime = event.all_day ? "13:00" : calendarUtil.toTimeInput(start); + this.endTime = event.all_day ? "14:00" : calendarUtil.toTimeInput(end); + this.selectedCalendars = calendars.filter(c => event.calendars?.includes(c.id)); + this.existingAttachments = event.attachments ?? []; + this.recurrence = event.recurrence + ? { frequency: event.recurrence.frequency, interval: event.recurrence.interval ?? 1, days_of_week: event.recurrence.days_of_week ? [...event.recurrence.days_of_week] : null } + : null; + + // Snapshot original state in getFormData() shape for dirty checking + this.originalFormData = { + title: event.title ?? "", + location: event.location ?? "", + description: event.description ?? "", + all_day: event.all_day, + time_start: event.all_day ? this.startDate : `${this.startDate}T${this.startTime}`, + time_end: event.all_day ? this.endDate : `${this.endDate}T${this.endTime}`, + calendars: [...(event.calendars ?? [])].sort().join(","), + recurrence: this.recurrence ? { ...this.recurrence, days_of_week: this.recurrence.days_of_week ? [...this.recurrence.days_of_week] : null } : null + }; + } else { + if (calendars.length > 0) { + this.selectedCalendars = [calendars[0]]; + } + if (initialDate) { + this.startDate = calendarUtil.toDateInput(initialDate); + const h = initialDate.getHours(), m = initialDate.getMinutes(); + if (h !== 0 || m !== 0) { + this.initialAllDay = false; + this.startTime = calendarUtil.toTimeInput(initialDate); + const endDate = new Date(initialDate.getTime() + 30 * 60 * 1000); + this.endTime = calendarUtil.toTimeInput(endDate); + this.endDate = calendarUtil.toDateInput(endDate); + } else { + this.endDate = calendarUtil.toDateInput(initialDate); + } + } + this.originalFormData = { + title: "", + location: "", + description: "", + all_day: this.initialAllDay, + time_start: this.initialAllDay ? this.startDate : `${this.startDate}T${this.startTime}`, + time_end: this.initialAllDay ? this.endDate : `${this.endDate}T${this.endTime}`, + calendars: (calendars.length > 0 ? [calendars[0].id] : []).sort().join(","), + recurrence: null + }; + } + } + + enforceEndAfterStart() { + const isAllDay = this.$('[name="all_day"]')?.checked ?? true; + const start = new Date(`${this.startDate}T${isAllDay ? "00:00" : this.startTime}`); + const end = new Date(`${this.endDate}T${isAllDay ? "00:00" : this.endTime}`); + + if (end < start) { + const newEnd = new Date(start.getTime() + (isAllDay ? 0 : 60 * 60 * 1000)); + this.endDate = calendarUtil.toDateInput(newEnd); + this.endTime = calendarUtil.toTimeInput(newEnd); + const endDateEl = this.$('[name="time_end_date"]'); + const endTimeEl = this.$('[name="time_end_time"]'); + if (endDateEl) endDateEl.value = this.endDate; + if (endTimeEl) endTimeEl.value = this.endTime; + } + } + + recurrenceOptionKey() { + const r = this.recurrence; + if (!r) return "never"; + if (r.frequency === 'daily') return "daily"; + if (r.frequency === 'weekly' && r.interval === 2) return "biweekly"; + if (r.frequency === 'weekly') return "weekly"; + if (r.frequency === 'monthly') return "monthly"; + if (r.frequency === 'yearly') return "yearly"; + return "never"; + } + + recurrenceLabel() { + switch (this.recurrenceOptionKey()) { + case "daily": return "Daily"; + case "weekly": return "Weekly"; + case "biweekly": return "Every 2 weeks"; + case "monthly": return "Monthly"; + case "yearly": return "Yearly"; + default: return "Never"; + } + } + + render() { + const isEditMode = !!this.editEvent; + const showTime = isEditMode ? !this.editEvent.all_day : !this.initialAllDay; + const initialAllDay = isEditMode ? this.editEvent.all_day : this.initialAllDay; + + 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 Event" : "New Event") + .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: "eventform-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: "eventform-toast-wrap" }) + .alignItems("center") + .overflow("hidden") + .maxHeight(0) + .opacity(0) + .flexShrink(0) + + // ── Scrollable body ─────────────────────────────────── + VStack(() => { + // ── Title card ──────────────────────────────────── + VStack(() => { + input("Title", "100%") + .attr({ name: "title", type: "text", value: this.editEvent?.title ?? "" }) + .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") + .flexShrink(0) + + // ── Date / time card ────────────────────────────── + VStack(() => { + // All day row + HStack(() => { + p("All day") + .margin(0).fontSize(0.92, em).color("var(--headertext)") + input("", "auto") + .attr({ name: "all_day", type: "checkbox", ...(initialAllDay ? { checked: true } : {}) }) + .accentColor("var(--quillred)").transform("scale(1.25)") + .onChange(() => { + const isAllDay = this.$('[name="all_day"]').checked + const display = isAllDay ? "none" : "" + this.$("#time_start_time").style.display = display + this.$("#time_end_time").style.display = display + }) + }) + .paddingHorizontal(1, em).paddingVertical(0.78, em) + .justifyContent("space-between").alignItems("center") + .borderBottom("1px solid var(--divider)") + + // Starts row + HStack(() => { + p("Starts") + .margin(0).fontSize(0.92, em).color("var(--headertext)").flexShrink(0) + HStack(() => { + input("", "auto", "1em") + .attr({ name: "time_start_date", type: "date", id: "time_start_date", value: this.startDate }) + .styles(this.cardInputStyles).fontSize(0.88, em) + .onChange((e) => { + const oldDay = new Date(this.startDate + 'T00:00:00').getDay(); + this.startDate = e.target.value; + this.enforceEndAfterStart(); + if (this.recurrence?.frequency === 'weekly' && this.recurrence.days_of_week?.length > 0) { + const newDay = new Date(this.startDate + 'T00:00:00').getDay(); + if (newDay !== oldDay && this.recurrence.days_of_week.includes(oldDay)) { + const updated = [...new Set(this.recurrence.days_of_week.map(d => d === oldDay ? newDay : d))].sort((a, b) => a - b); + this.recurrence.days_of_week = updated; + const oldEl = this.$(`#recur-day-${oldDay}`); + const newEl = this.$(`#recur-day-${newDay}`); + if (oldEl) { oldEl.style.background = 'transparent'; oldEl.style.color = 'var(--text)'; oldEl.style.opacity = '0.4'; } + if (newEl) { newEl.style.background = 'var(--quillred)'; newEl.style.color = 'white'; newEl.style.opacity = '1'; } + } + } + }) + input("", "auto", "1em") + .attr({ name: "time_start_time", type: "time", id: "time_start_time", value: this.startTime, step: "300" }) + .styles(this.cardInputStyles).fontSize(0.88, em) + .display(showTime ? "" : "none") + .onChange((e) => { this.startTime = e.target.value; this.enforceEndAfterStart() }) + }) + .gap(0.35, em).flex(1).justifyContent("flex-end") + }) + .paddingHorizontal(1, em).paddingVertical(0.78, em) + .alignItems("center").borderBottom("1px solid var(--divider)") + + // Ends row + HStack(() => { + p("Ends") + .margin(0).fontSize(0.92, em).color("var(--headertext)").flexShrink(0) + HStack(() => { + input("", "auto", "1em") + .attr({ name: "time_end_date", type: "date", id: "time_end_date", value: this.endDate }) + .styles(this.cardInputStyles).fontSize(0.88, em) + .onChange((e) => { this.endDate = e.target.value; this.enforceEndAfterStart() }) + input("", "auto", "1em") + .attr({ name: "time_end_time", type: "time", id: "time_end_time", value: this.endTime, step: "300" }) + .styles(this.cardInputStyles).fontSize(0.88, em) + .display(showTime ? "" : "none") + .onChange((e) => { this.endTime = e.target.value; this.enforceEndAfterStart() }) + }) + .gap(0.35, em).flex(1).justifyContent("flex-end") + }) + .paddingHorizontal(1, em).paddingVertical(0.78, em) + .alignItems("center").borderBottom("1px solid var(--divider)") + + // Repeat row + HStack(() => { + p("Repeat") + .margin(0).fontSize(0.92, em).color("var(--headertext)").flexShrink(0) + p(this.recurrenceLabel()) + .attr({ id: "recur-label" }) + .margin(0).fontSize(0.88, em).fontFamily("Arial") + .color(this.recurrence ? "var(--text)" : "var(--headertext)") + .opacity(this.recurrence ? 1 : 0.55) + .flex(1).textAlign("right") + p("β€Ί") + .attr({ id: "recur-chevron" }) + .margin(0).opacity(0.3).fontSize(1.1, em).flexShrink(0) + .transition("transform 0.25s ease") + }) + .paddingHorizontal(1, em).paddingVertical(0.78, em) + .alignItems("center").gap(0.5, em) + .cursor("pointer") + .onTap(() => { + const picker = this.$("#recur-picker") + const chevron = this.$("#recur-chevron") + if (!picker) return + const isOpen = picker.getAttribute("data-open") === "1" + if (isOpen) { + picker.setAttribute("data-open", "0") + picker.style.maxHeight = "0" + if (chevron) chevron.style.transform = "" + } else { + picker.setAttribute("data-open", "1") + picker.style.maxHeight = picker.scrollHeight + "px" + if (chevron) chevron.style.transform = "rotate(90deg)" + } + }) + + // Inline recurrence picker + VStack(() => { + const currentKey = this.recurrenceOptionKey() + const isWeekly = currentKey === 'weekly' || currentKey === 'biweekly' + const selectedDays = this.recurrence?.days_of_week ?? [] + + const recurrenceOptions = [ + { key: "never", label: "Never" }, + { key: "daily", label: "Daily" }, + { key: "weekly", label: "Weekly" }, + { key: "biweekly", label: "Every 2 weeks" }, + { key: "monthly", label: "Monthly" }, + { key: "yearly", label: "Yearly" }, + ] + + recurrenceOptions.forEach(opt => { + HStack(() => { + p(opt.label) + .margin(0).fontSize(0.9, em).color("var(--text)").fontFamily("Arial").flex(1) + p("βœ“") + .attr({ id: `recur-check-${opt.key}` }) + .margin(0).fontSize(0.88, em) + .color("var(--quillred)").fontWeight("700") + .display(currentKey === opt.key ? "" : "none") + }) + .paddingHorizontal(1.25, em).paddingVertical(0.72, em) + .alignItems("center") + .borderBottom("1px solid var(--divider)") + .cursor("pointer") + .onTap(() => { + let newRecurrence = null + if (opt.key !== 'never') { + if (opt.key === 'weekly' || opt.key === 'biweekly') { + const sd = new Date(this.startDate + 'T00:00:00').getDay() + const curDays = this.recurrence?.frequency === 'weekly' + ? (this.recurrence.days_of_week ?? [sd]) + : [sd] + newRecurrence = { + frequency: 'weekly', + interval: opt.key === 'biweekly' ? 2 : 1, + days_of_week: [...curDays] + } + } else { + const freqMap = { daily: 'daily', monthly: 'monthly', yearly: 'yearly' } + newRecurrence = { frequency: freqMap[opt.key], interval: 1 } + } + } + this.recurrence = newRecurrence + + const allKeys = ['never', 'daily', 'weekly', 'biweekly', 'monthly', 'yearly'] + allKeys.forEach(k => { + const el = this.$(`#recur-check-${k}`) + if (el) el.style.display = k === opt.key ? "" : "none" + }) + + const labelEl = this.$("#recur-label") + if (labelEl) { + labelEl.textContent = this.recurrenceLabel() + labelEl.style.color = newRecurrence ? "var(--text)" : "var(--headertext)" + labelEl.style.opacity = newRecurrence ? "1" : "0.55" + } + + const isNowWeekly = opt.key === 'weekly' || opt.key === 'biweekly' + const daysRow = this.$("#recur-days") + if (daysRow) daysRow.style.display = isNowWeekly ? "" : "none" + + if (isNowWeekly && newRecurrence?.days_of_week) { + const days = newRecurrence.days_of_week; + [0, 1, 2, 3, 4, 5, 6].forEach(d => { + const el = this.$(`#recur-day-${d}`) + if (!el) return + const sel = days.includes(d) + el.style.background = sel ? "var(--quillred)" : "transparent" + el.style.color = sel ? "white" : "var(--text)" + el.style.opacity = sel ? "1" : "0.4" + }) + } + + const picker = this.$("#recur-picker") + if (picker && picker.getAttribute("data-open") === "1") { + picker.style.maxHeight = picker.scrollHeight + "px" + } + }) + }) + + // Day-of-week selector (visible only for weekly/biweekly) + const DAY_LABELS = ['S', 'M', 'T', 'W', 'T', 'F', 'S'] + HStack(() => { + DAY_LABELS.forEach((label, i) => { + const sel = selectedDays.includes(i) + span(label) + .attr({ id: `recur-day-${i}` }) + .width(2, em).height(2, em) + .lineHeight("2em") + .textAlign("center") + .borderRadius(50, pct) + .fontSize(0.78, em).fontWeight("600") + .cursor("pointer").flexShrink(0) + .background(sel ? "var(--quillred)" : "transparent") + .color(sel ? "white" : "var(--text)") + .opacity(sel ? "1" : "0.4") + .onTap(() => { + if (!this.recurrence?.days_of_week) return + const days = this.recurrence.days_of_week + const idx = days.indexOf(i) + if (idx >= 0) { + if (days.length > 1) days.splice(idx, 1) + } else { + days.push(i) + } + const nowSel = days.includes(i) + const el = this.$(`#recur-day-${i}`) + if (el) { + el.style.background = nowSel ? "var(--quillred)" : "transparent" + el.style.color = nowSel ? "white" : "var(--text)" + el.style.opacity = nowSel ? "1" : "0.4" + } + const picker = this.$("#recur-picker") + if (picker && picker.getAttribute("data-open") === "1") { + picker.style.maxHeight = picker.scrollHeight + "px" + } + }) + }) + }) + .attr({ id: "recur-days" }) + .justifyContent("space-around") + .paddingHorizontal(0.5, em).paddingVertical(0.65, em) + .display(isWeekly ? "" : "none") + }) + .attr({ id: "recur-picker", "data-open": "0" }) + .overflow("hidden").maxHeight(0) + .transition("max-height 0.3s ease") + }) + .background("var(--darkaccent)").border("1px solid var(--divider)") + .borderRadius(12, px).marginHorizontal(1, em).overflow("hidden") + .flexShrink(0) + + // ── Calendar / Location / Notes card ────────────── + VStack(() => { + // Calendar row β€” tapping expands inline picker + 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) + p("β€Ί") + .attr({ id: "cal-chevron" }) + .margin(0).opacity(0.3).fontSize(1.1, em).flexShrink(0) + .transition("transform 0.25s ease") + }) + .paddingHorizontal(1, em).paddingVertical(0.78, em) + .alignItems("center").gap(0.5, em) + .borderBottom("1px solid var(--divider)").cursor("pointer") + .onTap(() => { + const picker = this.$("#cal-picker") + const chevron = this.$("#cal-chevron") + if (!picker) return + const isOpen = picker.getAttribute("data-open") === "1" + if (isOpen) { + picker.setAttribute("data-open", "0") + picker.style.maxHeight = "0" + if (chevron) chevron.style.transform = "" + } else { + picker.setAttribute("data-open", "1") + picker.style.maxHeight = picker.scrollHeight + "px" + if (chevron) chevron.style.transform = "rotate(90deg)" + } + }) + + // Inline calendar picker (collapsed by default) + 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 idx = this.selectedCalendars.findIndex(c => c.id === cal.id) + if (idx >= 0) { + if (this.selectedCalendars.length > 1) { + this.selectedCalendars.splice(idx, 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() + }) + }) + }) + .attr({ id: "cal-picker", "data-open": "0" }) + .overflow("hidden").maxHeight(0) + .transition("max-height 0.3s ease") + + // Location row + HStack(() => { + p("πŸ“").margin(0).fontSize(0.85, em).flexShrink(0) + input("Location", "100%") + .attr({ name: "location", type: "text", value: this.editEvent?.location ?? "" }) + .styles(this.cardInputStyles).flex(1) + }) + .paddingHorizontal(1, em).paddingVertical(0.78, em) + .alignItems("center").gap(0.65, em) + .borderBottom("1px solid var(--divider)") + + // Noes row + HStack(() => { + p("πŸ“") + .margin(0).fontSize(0.85, em).flexShrink(0).alignSelf("flex-start").paddingTop(0.08, em) + textarea(this.editEvent?.description ?? "") + .attr({ name: "description" }) + .styles(this.cardInputStyles).flex(1) + .minHeight(3, em).resize("none") + .fieldSizing("content").lineHeight("1.45") + .onAppear(function() { + this.value = this.placeholder + }) + }) + .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 ────────────────────────────── + input("", "100%") + .attr({ name: "attachments", type: "file", multiple: true }) + .display("none") + .onChange(async (e) => { + const files = Array.from(e.target.files) + e.target.value = "" + this.uploadedFiles.push(...files) + this.fileListComponent.update(files) + this.updateFilesChevron() + }) + + VStack(() => { + HStack(() => { + p("πŸ“Ž").margin(0).fontSize(0.85, em).flexShrink(0) + p("Attachments") + .margin(0).fontSize(0.92, em).color("var(--headertext)").flex(1) + HStack(() => { + span("Upload") + .color("var(--quillred)").cursor("pointer") + .fontFamily("Arial").fontSize(0.85, em) + .padding(0.5, em).margin(-0.5, em) + .onTap((e) => { e.stopPropagation(); this.$('[name="attachments"]').click() }) + span("β–Ό") + .attr({ id: "files-chevron" }) + .margin(0).fontSize(0.7, em).color("var(--text)").opacity(0.5) + .display(this.hasAnyFiles() ? "inline-block" : "none") + .transition("transform 0.3s ease") + .transform(this.filesOpen ? "rotate(180deg)" : "rotate(0deg)") + .padding(0.5, em).margin(-0.5, em) + }) + .gap(0.65, em).alignItems("center") + }) + .paddingHorizontal(1, em).paddingVertical(0.78, em).alignItems("center").gap(0.65, em) + .cursor(this.hasAnyFiles() ? "pointer" : "") + .onTap(() => { if (this.hasAnyFiles()) this.toggleFiles() }) + + VStack(() => { + this.fileListComponent = EventFileList( + this.existingAttachments, + isEditMode ? (fileId) => this.handleDeleteAttachment(fileId) : null, + (index) => this.handleDeleteNewFile(index), + (file, url) => $("filepreview-")?.open(file, url) + ) + this.fileListComponent + .padding("0 1em 0.75em").flexDirection("column") + .gap(0.5, em).border("none") + }) + .attr({ id: "files-content" }) + .overflow("hidden") + .maxHeight(this.hasAnyFiles() && this.filesOpen ? "600px" : "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) + + if (isEditMode) { + button("Delete Event") + .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") + .flexShrink(0) + .onTap(() => this.handleDelete()) + } + + }) + .overflowY("scroll").flex(1).minHeight(0).paddingTop(0.85, em).paddingBottom(1.5, em).gap(0.85, em) + + // ── Footer: creator avatar + timestamps ─────────────── + if (this.editEvent) { + const members = global.currentNetwork.data?.members || [] + const creator = members.find(m => m.id === this.editEvent.creator_id) + if (creator) { + HStack(() => { + Avatar(creator, 2) + VStack(() => { + p(`Created ${calendarUtil.timeAgo(this.editEvent.created)} by ${creator.first_name}`) + .margin(0).fontSize(0.9, em).color("var(--headertext)").opacity(0.5) + if (this.editEvent.updated_at && this.editEvent.updated_at !== this.editEvent.created) { + p(`Last updated ${calendarUtil.timeAgo(this.editEvent.updated_at)}`) + .margin(0).fontSize(0.9, em).color("var(--headertext)").opacity(0.4) + } + }) + .gap(0.15, em) + }) + // .position("absolute") + // .bottom(0) + // .left(0) + // .right(0) + .paddingHorizontal(1, em) + .paddingVertical(0.65, em) + .alignItems("center") + .gap(0.5, em) + .flexShrink(0) + } + } + + }) + .height(100, pct) + .position("relative") + .onSubmit((e) => { + e.preventDefault() + this.handleSend(this.getFormData()) + }) + .onKeyDown((e) => { + if (e.key === "Enter" && e.target.tagName !== "TEXTAREA" && e.target.tagName !== "BUTTON") e.preventDefault() + }) + }) + .height(100, pct) + } + + hasAnyFiles() { + return this.existingAttachments.length > 0 || this.uploadedFiles.length > 0; + } + + toggleFiles() { + this.filesOpen = !this.filesOpen; + const content = this.$("#files-content"); + const chevron = this.$("#files-chevron"); + if (content) content.style.maxHeight = this.filesOpen ? "600px" : "0"; + if (chevron) chevron.style.transform = this.filesOpen ? "rotate(180deg)" : "rotate(0deg)"; + } + + updateFilesChevron() { + const chevron = this.$("#files-chevron"); + const content = this.$("#files-content"); + const hasFiles = this.hasAnyFiles(); + if (chevron) chevron.style.display = hasFiles ? "inline-block" : "none"; + if (!hasFiles && this.filesOpen) { + this.filesOpen = false; + if (content) content.style.maxHeight = "0"; + } else if (hasFiles && !this.filesOpen) { + this.filesOpen = true; + if (content) content.style.maxHeight = "600px"; + if (chevron) chevron.style.transform = "rotate(180deg)"; + } + } + + updateCalendarDisplay() { + const el = this.$("#calendar-display"); + if (!el) return; + el.innerHTML = this.selectedCalendars.map(cal => ` +
+
+

${cal.name}

+
+ `).join(''); + } + + showError(msg) { + const wrap = this.$("#eventform-toast-wrap") + const toast = this.$("#eventform-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.$("#eventform-toast-wrap") + if (!wrap) return + clearTimeout(this._errorTimer) + wrap.style.maxHeight = "0" + wrap.style.opacity = "0" + wrap.style.paddingTop = "0" + } + + getFormData() { + const isAllDay = this.$('[name="all_day"]')?.checked ?? true; + return { + title: this.$('[name="title"]').value, + location: this.$('[name="location"]').value, + time_start: isAllDay + ? this.$('[name="time_start_date"]').value + : `${this.$('[name="time_start_date"]').value}T${this.$('[name="time_start_time"]').value}`, + time_end: isAllDay + ? this.$('[name="time_end_date"]').value + : `${this.$('[name="time_end_date"]').value}T${this.$('[name="time_end_time"]').value}`, + all_day: isAllDay, + description: this.$('[name="description"]').value, + recurrence: this.recurrence + }; + } + + isNewEventDirty() { + const data = this.getFormData() + const o = this.originalFormData + const newCalIds = this.selectedCalendars.map(c => c.id).sort().join(",") + return ( + data.title !== o.title || + (data.location || "") !== o.location || + (data.description || "") !== o.description || + data.all_day !== o.all_day || + data.time_start !== o.time_start || + data.time_end !== o.time_end || + newCalIds !== o.calendars || + this.uploadedFiles.length > 0 || + JSON.stringify(data.recurrence) !== JSON.stringify(o.recurrence) + ) + } + + handleBack() { + const isDirty = this.isNewEventDirty() || (!!this.editEvent && this.attachmentsDeleted); + if (isDirty) { + $('actionsheetpopup-').show( + "Discard Changes?", + [ + { + label: "Discard", + onTap: () => { + if (this.onBack) { + this.onBack() + } else { + $("bottomsheet-")._closeOverride = null + $("bottomsheet-").setSheet(false) + } + } + }, + { + label: "Keep Editing", + destructive: false, + onTap: () => {} + }, + ], + () => {} + ) + return; + } + if (this.onBack) { + this.onBack() + } else { + $("bottomsheet-")._closeOverride = null + $("bottomsheet-").setSheet(false) + } + } + + handleDelete() { + const event = this.editEvent; + 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.editEvent; + 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) { + const deleteResult = { scope: scope ?? 'all', templateId, occurrenceDate, overrideId: isOverride ? event.id : null }; + if (this.onDelete) this.onDelete(deleteResult); + } else { + this.showError(result.error ?? "Failed to delete event."); + } + } catch (err) { + console.error("Failed to delete event:", err); + this.showError("Failed to delete event."); + } + } + + async performSave(scope) { + const eventData = this.pendingSaveEventData; + this.pendingSaveEventData = null; + + const event = this.editEvent; + 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 toISO = (str, isEnd = false) => calendarUtil.toISO(str, eventData.all_day, isEnd) + + // scope='all': anchor dates to the template's start, not the tapped occurrence, to preserve earlier occurrences + let time_start = toISO(eventData.time_start); + let time_end = toISO(eventData.time_end, true); + if (scope === 'all' && event._templateStart) { + const tDateStr = calendarUtil.toDateInput(event._templateStart); + const tEndDateStr = calendarUtil.toDateInput(event._templateEnd ?? event._templateStart); + // Compute day offset between the tapped occurrence's date and the user's new date, + // then apply that same offset to the template's start/end dates. + // Zero offset (date unchanged) = previous behavior; non-zero = intentional date shift. + const shiftDate = (base, offset) => + calendarUtil.toDateInput(calendarUtil.addDays(new Date(base + 'T00:00:00'), offset)); + const daysBetween = (a, b) => + Math.round((new Date(a + 'T00:00:00') - new Date(b + 'T00:00:00')) / 86400000); + const occStartDate = calendarUtil.toDateInput(event.time_start instanceof Date ? event.time_start : new Date(event.time_start)); + + if (eventData.all_day) { + const startShift = daysBetween(eventData.time_start, occStartDate); + const span = daysBetween(eventData.time_end, eventData.time_start); + const newTStart = shiftDate(tDateStr, startShift); + const newTEnd = shiftDate(newTStart, span); + time_start = toISO(newTStart); + time_end = toISO(newTEnd, true); + } else { + const occEndDate = calendarUtil.toDateInput(event.time_end instanceof Date ? event.time_end : new Date(event.time_end)); + const formStart = eventData.time_start.includes('T') ? eventData.time_start.split('T')[0] : eventData.time_start; + const formEnd = eventData.time_end.includes('T') ? eventData.time_end.split('T')[0] : eventData.time_end; + const startT = eventData.time_start.includes('T') ? eventData.time_start.split('T')[1] : '00:00'; + const endT = eventData.time_end.includes('T') ? eventData.time_end.split('T')[1] : '00:00'; + const newTStart = shiftDate(tDateStr, daysBetween(formStart, occStartDate)); + const newTEnd = shiftDate(tEndDateStr, daysBetween(formEnd, occEndDate)); + time_start = toISO(`${newTStart}T${startT}`); + time_end = toISO(`${newTEnd}T${endT}`, true); + } + } + + // For weekly recurrence, if the user shifted the start to a different day of week, + // replace the old anchor day in days_of_week so the first occurrence isn't skipped. + let recurrence = eventData.recurrence ? { ...eventData.recurrence } : null; + if (scope !== 'single' && recurrence?.frequency === 'weekly' && recurrence.days_of_week?.length > 0) { + const newDay = new Date(time_start).getDay(); + const oldDayRef = scope === 'all' && event._templateStart + ? new Date(event._templateStart) + : occurrenceDate ? new Date(occurrenceDate) : null; + const originalDay = oldDayRef?.getDay() ?? null; + if (newDay !== originalDay && !recurrence.days_of_week.includes(newDay)) { + const days = [...recurrence.days_of_week]; + recurrence = { + ...recurrence, + days_of_week: (originalDay !== null && days.includes(originalDay)) + ? days.map(d => d === originalDay ? newDay : d) + : [...days, newDay].sort((a, b) => a - b) + }; + } + } + + const payload = { + title: eventData.title || "New event", + location: eventData.location || null, + description: eventData.description || null, + time_start, + time_end, + all_day: eventData.all_day, + calendars: this.selectedCalendars.map(c => c.id), + scope, + exception_date: occurrenceDate, + recurrence + } + + try { + // Override rows: pass event.id (direct UPDATE); virtual occurrences: pass templateId (INSERT override) + const serverEventId = (scope === 'single' && isOverride) ? event.id : templateId; + const result = await server.editEvent(serverEventId, payload, global.currentNetwork.id) + if (result.status === 200) { + // Run deletions against the final event ID so that for scope='single'/'future' (new event row) + // we remove from the new event (which inherited parent files) not from the original template + await this.performPendingDeletions(result.event.id) + const attachments = await this.uploadAndMergeFiles(result.event.id, [...this.existingAttachments]) + const editResult = { + scope, + event: { ...result.event, calendars: this.selectedCalendars.map(c => c.id), attachments, recurrence: eventData.recurrence ?? null }, + templateId, + occurrenceDate + }; + if (this.updateEvents) { + this.updateEvents(editResult); + } else { + $("bottomsheet-").toggle(); + } + } else { + this.rerender(); + this.showError(result.error ?? "Failed to save event.") + this.onSaveError?.() + } + } catch (err) { + console.error("Failed to save event:", err); + this.rerender(); + this.showError("Failed to save event.") + this.onSaveError?.() + } + } + + handleDeleteNewFile(index) { + this.uploadedFiles.splice(index, 1); + this.fileListComponent.removeNew(index); + this.updateFilesChevron(); + } + + handleDeleteAttachment(fileId) { + this.pendingDeletions.push(fileId) + this.existingAttachments = this.existingAttachments.filter(f => f.id !== fileId) + this.fileListComponent.removeExisting(fileId) + this.updateFilesChevron() + this.attachmentsDeleted = true + } + + async performPendingDeletions(eventId = null) { + const targetId = eventId ?? this.editEvent.id; + for (const fileId of this.pendingDeletions) { + try { + await mobileUtil.authFetch(`${config.SERVER}/events/${targetId}/attachments/${fileId}`, { + method: "DELETE", + credentials: "include" + }) + } catch (err) { + console.error("Failed to delete attachment:", err) + this.showError("Failed to delete attachment.") + } + } + this.pendingDeletions = [] + } + + async uploadAndMergeFiles(eventId, existing = []) { + if (this.uploadedFiles.length === 0) return existing; + const uploadResult = await this.handleUpload(eventId); + if (!uploadResult.success) { + this.uploadedFiles = []; + this.showError("Failed to upload attachment(s).") + return existing; + } + const existingIds = new Set(existing.map(a => a.id)); + const uniqueNew = uploadResult.insertedFiles.filter(a => !existingIds.has(a.id)); + return [...existing, ...uniqueNew]; + } + + async handleSend(eventData) { + this.hideError() + + const toISO = (str, isEnd = false) => calendarUtil.toISO(str, eventData.all_day, isEnd) + const startTimestamp = toISO(eventData.time_start); + const endTimestamp = toISO(eventData.time_end, true); + + const eventPayload = { + title: eventData.title || "New event", + location: eventData.location || null, + time_start: startTimestamp, + time_end: endTimestamp, + all_day: eventData.all_day, + calendars: this.selectedCalendars.map(({ id }) => id), + description: eventData.description || null, + recurrence: eventData.recurrence ?? null + } + + if (this.editEvent) { + const newCalIds = this.selectedCalendars.map(c => c.id).sort().join(","); + const unchanged = + eventData.title === this.originalFormData.title && + (eventData.location || "") === this.originalFormData.location && + (eventData.description || "") === this.originalFormData.description && + eventData.all_day === this.originalFormData.all_day && + eventData.time_start === this.originalFormData.time_start && + eventData.time_end === this.originalFormData.time_end && + newCalIds === this.originalFormData.calendars && + this.uploadedFiles.length === 0 && + !this.attachmentsDeleted && + JSON.stringify(eventData.recurrence) === JSON.stringify(this.originalFormData.recurrence); + + if (unchanged) { + this.onBack ? this.onBack() : $("bottomsheet-").toggle(); + return; + } + + const isRecurring = !!(this.editEvent._isOccurrence || this.editEvent.recurrence_parent_id || this.editEvent.recurrence_id); + if (isRecurring) { + this.pendingSaveEventData = eventData; + $('actionsheetpopup-').show( + "Edit Recurring Event", + [ + { label: "Edit just this event", onTap: () => this.performSave('single'), destructive: false }, + { label: "Edit this and future events", onTap: () => this.performSave('future'), destructive: false }, + { label: "Edit all events in series", onTap: () => this.performSave('all'), destructive: false }, + ], + () => { this.pendingSaveEventData = null; } + ) + return; + } + + await this.performPendingDeletions() + + const result = await server.editEvent(this.editEvent.id, eventPayload, global.currentNetwork.id) + if (result.status === 200) { + const attachments = await this.uploadAndMergeFiles(result.event.id, [...this.existingAttachments]); + if (this.updateEvents) { + this.updateEvents({ ...result.event, calendars: this.selectedCalendars.map(c => c.id), attachments, recurrence: eventData.recurrence ?? null }); + } else { + $("bottomsheet-").toggle(); + } + } else { + this.showError(result.error ?? "Failed to save event.") + this.onSaveError?.() + } + } else { + const result = await server.addEvent(eventPayload, global.currentNetwork.id) + if (result.status === 200) { + const attachments = await this.uploadAndMergeFiles(result.event.id); + // Clear override so save button doesn't re-trigger trySave + $("bottomsheet-")._closeOverride = null + $("bottomsheet-").toggle(); + setTimeout(() => { + this.updateEvents({ ...result.event, calendars: this.selectedCalendars.map(c => c.id), attachments }); + }, 300); + } else { + this.showError(result.error ?? "Failed to save event.") + this.onSaveError?.() + } + } + } + + async handleUpload(eventId) { + try { + const body = new FormData(); + Array.from(this.uploadedFiles).forEach(file => { + body.append('attachments', file); + }) + + const res = await mobileUtil.authFetch(`${config.SERVER}/events/${eventId}/upload-attachments`, { + method: "POST", + credentials: "include", + headers: { + "Accept": "application/json" + }, + body: body + }); + + const { insertedFiles } = await res.json(); + return { success: res.ok, insertedFiles: res.ok ? insertedFiles : [] }; + } catch (err) { + console.log("Failed to add attachment to event: ", eventId) + return { success: false, error: "Failed to add attachment(s)" }; + } + } + +} + +register(EventForm) diff --git a/calendar/Events/FilePreview.js b/calendar/Events/FilePreview.js new file mode 100644 index 0000000..3baac87 --- /dev/null +++ b/calendar/Events/FilePreview.js @@ -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 = `` + } + + _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) diff --git a/calendar/Month/MonthGrid.js b/calendar/Month/MonthGrid.js new file mode 100644 index 0000000..a25af81 --- /dev/null +++ b/calendar/Month/MonthGrid.js @@ -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) \ No newline at end of file diff --git a/calendar/Month/MonthHeaderRow.js b/calendar/Month/MonthHeaderRow.js new file mode 100644 index 0000000..70f0ce6 --- /dev/null +++ b/calendar/Month/MonthHeaderRow.js @@ -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) \ No newline at end of file diff --git a/calendar/Month/MonthView.js b/calendar/Month/MonthView.js new file mode 100644 index 0000000..8c46bcc --- /dev/null +++ b/calendar/Month/MonthView.js @@ -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) \ No newline at end of file diff --git a/calendar/Toolbar/BottomBar.js b/calendar/Toolbar/BottomBar.js new file mode 100644 index 0000000..becb267 --- /dev/null +++ b/calendar/Toolbar/BottomBar.js @@ -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) diff --git a/calendar/Toolbar/CalendarOptions.js b/calendar/Toolbar/CalendarOptions.js new file mode 100644 index 0000000..d23e8ae --- /dev/null +++ b/calendar/Toolbar/CalendarOptions.js @@ -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) diff --git a/calendar/Toolbar/CalendarToolbar.js b/calendar/Toolbar/CalendarToolbar.js new file mode 100644 index 0000000..cc77033 --- /dev/null +++ b/calendar/Toolbar/CalendarToolbar.js @@ -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) \ No newline at end of file diff --git a/calendar/Toolbar/ToolbarPopout.js b/calendar/Toolbar/ToolbarPopout.js new file mode 100644 index 0000000..752635d --- /dev/null +++ b/calendar/Toolbar/ToolbarPopout.js @@ -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) \ No newline at end of file diff --git a/calendar/Week/SpacerCell.js b/calendar/Week/SpacerCell.js new file mode 100644 index 0000000..bf3061d --- /dev/null +++ b/calendar/Week/SpacerCell.js @@ -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) \ No newline at end of file diff --git a/calendar/Week/TimedLabelsColumn.js b/calendar/Week/TimedLabelsColumn.js new file mode 100644 index 0000000..a98a3eb --- /dev/null +++ b/calendar/Week/TimedLabelsColumn.js @@ -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) \ No newline at end of file diff --git a/calendar/Week/TimedWeekGrid.js b/calendar/Week/TimedWeekGrid.js new file mode 100644 index 0000000..3d0c455 --- /dev/null +++ b/calendar/Week/TimedWeekGrid.js @@ -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) diff --git a/calendar/Week/WeekHeaderRow.js b/calendar/Week/WeekHeaderRow.js new file mode 100644 index 0000000..258b1cb --- /dev/null +++ b/calendar/Week/WeekHeaderRow.js @@ -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) \ No newline at end of file diff --git a/calendar/Week/WeekView.js b/calendar/Week/WeekView.js new file mode 100644 index 0000000..5f65938 --- /dev/null +++ b/calendar/Week/WeekView.js @@ -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) \ No newline at end of file diff --git a/calendar/calendar.js b/calendar/calendar.js new file mode 100644 index 0000000..d478b61 --- /dev/null +++ b/calendar/calendar.js @@ -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) \ No newline at end of file diff --git a/calendar/calendarUtil.js b/calendar/calendarUtil.js new file mode 100644 index 0000000..5667d24 --- /dev/null +++ b/calendar/calendarUtil.js @@ -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; + } +} \ No newline at end of file diff --git a/calendar/calendarUtil.test.js b/calendar/calendarUtil.test.js new file mode 100644 index 0000000..6b46282 --- /dev/null +++ b/calendar/calendarUtil.test.js @@ -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 + }) +}) diff --git a/calendar/desktop/DesktopCalendarForm.js b/calendar/desktop/DesktopCalendarForm.js new file mode 100644 index 0000000..08da376 --- /dev/null +++ b/calendar/desktop/DesktopCalendarForm.js @@ -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) diff --git a/calendar/desktop/DesktopMonthGrid.js b/calendar/desktop/DesktopMonthGrid.js new file mode 100644 index 0000000..3c837ad --- /dev/null +++ b/calendar/desktop/DesktopMonthGrid.js @@ -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) diff --git a/calendar/desktop/DesktopMonthView.js b/calendar/desktop/DesktopMonthView.js new file mode 100644 index 0000000..c280992 --- /dev/null +++ b/calendar/desktop/DesktopMonthView.js @@ -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) diff --git a/calendar/desktop/DesktopSidebar.js b/calendar/desktop/DesktopSidebar.js new file mode 100644 index 0000000..d0d9100 --- /dev/null +++ b/calendar/desktop/DesktopSidebar.js @@ -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) diff --git a/calendar/desktop/DesktopToolbar.js b/calendar/desktop/DesktopToolbar.js new file mode 100644 index 0000000..d683523 --- /dev/null +++ b/calendar/desktop/DesktopToolbar.js @@ -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) diff --git a/calendar/desktop/Events/DesktopEventDetails.js b/calendar/desktop/Events/DesktopEventDetails.js new file mode 100644 index 0000000..a1d803f --- /dev/null +++ b/calendar/desktop/Events/DesktopEventDetails.js @@ -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) diff --git a/calendar/desktop/Events/DesktopEventForm.js b/calendar/desktop/Events/DesktopEventForm.js new file mode 100644 index 0000000..cad8b40 --- /dev/null +++ b/calendar/desktop/Events/DesktopEventForm.js @@ -0,0 +1,1011 @@ +import server from "/@server/server.js" +import calendarUtil from "../../calendarUtil.js" +import "../../EventFileList.js" +import "../../../components/Avatar.js" + +css(`desktopeventform- { flex: 1; }`) + +class DesktopEventForm extends Shadow { + + startDate = calendarUtil.toDateInput(new Date()) + endDate = calendarUtil.toDateInput(new Date()) + startTime = "13:00" + endTime = "14:00" + recurrence = null + selectedCalendars = [] + uploadedFiles = [] + existingAttachments = [] + filesOpen = false + attachmentsDeleted = false + pendingDeletions = [] + pendingSaveEventData = null + + constructor(calendars, onSaved, editEvent = null, onDelete = null, onBack = null, initialDate = null) { + super() + this.calendars = calendars + this.onSaved = onSaved + this.editEvent = editEvent + this.onDelete = onDelete + this.onBack = onBack + + if (editEvent) { + const start = new Date(editEvent.time_start) + const end = new Date(editEvent.time_end) + this.startDate = calendarUtil.toDateInput(start) + this.endDate = calendarUtil.toDateInput(end) + this.startTime = editEvent.all_day ? "13:00" : calendarUtil.toTimeInput(start) + this.endTime = editEvent.all_day ? "14:00" : calendarUtil.toTimeInput(end) + this.selectedCalendars = calendars.filter(c => editEvent.calendars?.includes(c.id)) + this.existingAttachments = editEvent.attachments ?? [] + this.filesOpen = this.existingAttachments.length > 0 + this.recurrence = editEvent.recurrence + ? { frequency: editEvent.recurrence.frequency, interval: editEvent.recurrence.interval ?? 1, days_of_week: editEvent.recurrence.days_of_week ? [...editEvent.recurrence.days_of_week] : null } + : null + this.originalFormData = { + title: editEvent.title ?? "", + location: editEvent.location ?? "", + description: editEvent.description ?? "", + all_day: editEvent.all_day, + time_start: editEvent.all_day ? this.startDate : `${this.startDate}T${this.startTime}`, + time_end: editEvent.all_day ? this.endDate : `${this.endDate}T${this.endTime}`, + calendars: [...(editEvent.calendars ?? [])].sort().join(","), + recurrence: this.recurrence ? { ...this.recurrence, days_of_week: this.recurrence.days_of_week ? [...this.recurrence.days_of_week] : null } : null + } + } else { + if (calendars.length > 0) this.selectedCalendars = [calendars[0]] + if (initialDate) { + this.startDate = calendarUtil.toDateInput(initialDate) + this.endDate = calendarUtil.toDateInput(initialDate) + } + this.originalFormData = { + title: null, + location: null, + description: null, + all_day: true, + time_start: this.startDate, + time_end: this.endDate, + calendars: (calendars.length > 0 ? [calendars[0].id] : []).sort().join(","), + recurrence: null + } + } + } + + recurrenceOptionKey() { + const r = this.recurrence + if (!r) return "never" + if (r.frequency === 'daily') return "daily" + if (r.frequency === 'weekly' && r.interval === 2) return "biweekly" + if (r.frequency === 'weekly') return "weekly" + if (r.frequency === 'monthly') return "monthly" + if (r.frequency === 'yearly') return "yearly" + return "never" + } + + recurrenceLabel() { + switch (this.recurrenceOptionKey()) { + case "daily": return "Daily" + case "weekly": return "Weekly" + case "biweekly": return "Every 2 weeks" + case "monthly": return "Monthly" + case "yearly": return "Yearly" + default: return "Never" + } + } + + 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)"}` + }) + } + + dtInput(attrs, onChange) { + return input("", "auto", "2em") + .attr(attrs) + .styles(this.fieldStyles) + .onChange(onChange) + } + + dateTimeRow(label, dateName, dateVal, timeName, timeId, timeVal, showTime, onDateChange, onTimeChange) { + HStack(() => { + span(label) + .fontSize(0.8, em) + .color("var(--headertext)") + .opacity(0.5) + .width("3.5em") + .flexShrink(0) + this.dtInput({ name: dateName, type: "date", value: dateVal }, onDateChange) + this.dtInput({ name: timeName, id: timeId, type: "time", value: timeVal, step: "300" }, onTimeChange) + .display(showTime ? "" : "none") + }) + .gap(0.5, em) + .alignItems("center") + } + + setFilesPanel(open) { + this.filesOpen = open + const content = this.$("#files-content") + const chevron = this.$("#files-chevron") + if (content) content.style.maxHeight = open ? "600px" : "0" + if (chevron) chevron.style.transform = open ? "rotate(180deg)" : "rotate(0deg)" + } + + updateFilesChevron() { + const hasFiles = this.uploadedFiles.length > 0 || this.existingAttachments.length > 0 + const chevron = this.$("#files-chevron") + if (chevron) chevron.style.display = hasFiles ? "inline-block" : "none" + if (!hasFiles && this.filesOpen) this.setFilesPanel(false) + else if (hasFiles && !this.filesOpen) this.setFilesPanel(true) + } + + handleDeleteNewFile(index) { + this.uploadedFiles.splice(index, 1) + this.fileListComponent.removeNew(index) + this.updateFilesChevron() + } + + handleDeleteExistingFile(fileId) { + this.pendingDeletions.push(fileId) + this.existingAttachments = this.existingAttachments.filter(f => f.id !== fileId) + this.fileListComponent.removeExisting(fileId) + this.updateFilesChevron() + this.attachmentsDeleted = true + } + + async performPendingDeletions(eventId = null) { + const targetId = eventId ?? this.editEvent.id; + for (const fileId of this.pendingDeletions) { + try { + await fetch(`${config.SERVER}/events/${targetId}/attachments/${fileId}`, { + method: "DELETE", credentials: "include" + }) + } catch (err) { + console.error("Failed to delete attachment:", err) + } + } + this.pendingDeletions = [] + } + + 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) + } + + isCalendarSelected(calId) { + return this.selectedCalendars.some(c => c.id === calId); + } + + updateCalendarOpacity() { + this.$$("[data-cal-id]").forEach(el => { + const calId = Number(el.getAttribute("data-cal-id")); + el.style.opacity = this.isCalendarSelected(calId) ? "1" : "0.35"; + }); + } + + toggleCalendar(cal) { + const isSelected = this.isCalendarSelected(cal.id); + if (isSelected && this.selectedCalendars.length === 1) return; + + this.selectedCalendars = isSelected + ? this.selectedCalendars.filter(c => c.id !== cal.id) + : [...this.selectedCalendars, cal]; + + this.updateCalendarOpacity(); + } + + enforceEndAfterStart() { + const isAllDay = this.$('[name="all_day"]')?.checked ?? true + const start = new Date(`${this.startDate}T${isAllDay ? "00:00" : this.startTime}`) + const end = new Date(`${this.endDate}T${isAllDay ? "00:00" : this.endTime}`) + if (end < start) { + const newEnd = new Date(start.getTime() + (isAllDay ? 0 : 60 * 60 * 1000)) + this.endDate = calendarUtil.toDateInput(newEnd) + this.endTime = calendarUtil.toTimeInput(newEnd) + const endDateEl = this.$('[name="time_end_date"]') + const endTimeEl = this.$('[name="time_end_time"]') + if (endDateEl) endDateEl.value = this.endDate + if (endTimeEl) endTimeEl.value = this.endTime + } + } + + render() { + const isEdit = !!this.editEvent + const showTime = isEdit ? !this.editEvent.all_day : false + const allDay = isEdit ? this.editEvent.all_day : true + + form(() => { + VStack(() => { + this.renderHeader(isEdit) + this.renderBody(isEdit, showTime, allDay) + HStack(() => { + const members = global.currentNetwork.data?.members || [] + const creatorId = this.editEvent ? this.editEvent.creator_id : global.profile?.id + const creator = members.find(m => m.id === creatorId) + if (creator) { + Avatar(creator, 1.6) + VStack(() => { + const created = this.editEvent?.created + const updated = this.editEvent?.updated_at + if (created) { + p(`Created ${calendarUtil.timeAgo(created)} by ${creator.first_name}`) + .margin(0) + .fontSize(0.7, em) + .color("var(--headertext)") + .opacity(0.5) + if (updated && updated !== created) { + p(`Last updated ${calendarUtil.timeAgo(updated)}`) + .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") + }) + .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: "title", type: "text", placeholder: "Enter event title...", value: this.editEvent?.title ?? "" }) + .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" }) + .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) + .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("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) + .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(isEdit, showTime, allDay) { + const hasFiles = this.uploadedFiles.length > 0 || this.existingAttachments.length > 0 + + VStack(() => { + + this.prop("WHEN", () => { + VStack(() => { + HStack(() => { + span("All day") + .fontSize(0.88, em) + .color("var(--headertext)") + input("", "auto") + .attr({ name: "all_day", type: "checkbox", ...(allDay ? { checked: true } : {}) }) + .accentColor("var(--quillred)") + .onChange(() => { + const isAllDay = this.$('[name="all_day"]').checked + this.$("#time_start_time").style.display = isAllDay ? "none" : "" + this.$("#time_end_time").style.display = isAllDay ? "none" : "" + }) + }) + .gap(0.5, em) + .alignItems("center") + + this.dateTimeRow( + "Start", + "time_start_date", this.startDate, + "time_start_time", "time_start_time", this.startTime, showTime, + e => { + const oldDay = new Date(this.startDate + 'T00:00:00').getDay(); + this.startDate = e.target.value; + this.enforceEndAfterStart(); + if (this.recurrence?.frequency === 'weekly' && this.recurrence.days_of_week?.length > 0) { + const newDay = new Date(this.startDate + 'T00:00:00').getDay(); + if (newDay !== oldDay && this.recurrence.days_of_week.includes(oldDay)) { + const updated = [...new Set(this.recurrence.days_of_week.map(d => d === oldDay ? newDay : d))].sort((a, b) => a - b); + this.recurrence.days_of_week = updated; + const oldEl = this.$(`#recur-day-${oldDay}`); + const newEl = this.$(`#recur-day-${newDay}`); + if (oldEl) { oldEl.style.background = 'transparent'; oldEl.style.color = 'var(--headertext)'; oldEl.style.opacity = '0.4'; } + if (newEl) { newEl.style.background = 'var(--quillred)'; newEl.style.color = 'white'; newEl.style.opacity = '1'; } + } + } + }, + e => { this.startTime = e.target.value; this.enforceEndAfterStart() } + ) + this.dateTimeRow( + "End", + "time_end_date", this.endDate, + "time_end_time", "time_end_time", this.endTime, showTime, + e => { this.endDate = e.target.value; this.enforceEndAfterStart() }, + e => { this.endTime = e.target.value; this.enforceEndAfterStart() } + ) + }) + .gap(0.5, em) + .width(100, pct) + }) + + this.prop("REPEAT", () => { + const currentKey = this.recurrenceOptionKey() + const isWeekly = currentKey === 'weekly' || currentKey === 'biweekly' + const selectedDays = this.recurrence?.days_of_week ?? [] + + // Row showing current value β€” click to expand + HStack(() => { + p(this.recurrenceLabel()) + .attr({ id: "recur-label" }) + .margin(0) + .fontSize(0.88, em) + .color("var(--headertext)") + .opacity(this.recurrence ? 1 : 0.5) + .flex(1) + span("β–Ό") + .attr({ id: "recur-chevron" }) + .fontSize(0.65, em) + .color("var(--headertext)") + .opacity(0.4) + .transition("transform 0.25s ease") + .transform(isWeekly ? "" : "") + .userSelect("none") + }) + .cursor("pointer") + .alignItems("center") + .onClick((done) => { + if (!done) return + const picker = this.$("#recur-picker") + const chevron = this.$("#recur-chevron") + if (!picker) return + const open = picker.getAttribute("data-open") === "1" + if (open) { + picker.setAttribute("data-open", "0") + picker.style.maxHeight = "0" + if (chevron) chevron.style.transform = "" + } else { + picker.setAttribute("data-open", "1") + picker.style.maxHeight = picker.scrollHeight + "px" + if (chevron) chevron.style.transform = "rotate(180deg)" + } + }) + + // Expandable option list + VStack(() => { + const recurrenceOptions = [ + { key: "never", label: "Never" }, + { key: "daily", label: "Daily" }, + { key: "weekly", label: "Weekly" }, + { key: "biweekly", label: "Every 2 weeks" }, + { key: "monthly", label: "Monthly" }, + { key: "yearly", label: "Yearly" }, + ] + + recurrenceOptions.forEach(opt => { + HStack(() => { + p(opt.label) + .margin(0) + .fontSize(0.88, em) + .color("var(--headertext)") + .flex(1) + span("βœ“") + .attr({ id: `recur-check-${opt.key}` }) + .fontSize(0.82, em) + .color("var(--quillred)") + .fontWeight("700") + .display(currentKey === opt.key ? "" : "none") + }) + .paddingVertical(0.5, em) + .paddingHorizontal(1, em) + .alignItems("center") + .borderTop("1px solid var(--divider)") + .cursor("pointer") + .onClick((done) => { + if (!done) return + let newRecurrence = null + if (opt.key !== 'never') { + if (opt.key === 'weekly' || opt.key === 'biweekly') { + const sd = new Date(this.startDate + 'T00:00:00').getDay() + const curDays = this.recurrence?.frequency === 'weekly' + ? (this.recurrence.days_of_week ?? [sd]) + : [sd] + newRecurrence = { + frequency: 'weekly', + interval: opt.key === 'biweekly' ? 2 : 1, + days_of_week: [...curDays] + } + } else { + const freqMap = { daily: 'daily', monthly: 'monthly', yearly: 'yearly' } + newRecurrence = { frequency: freqMap[opt.key], interval: 1 } + } + } + this.recurrence = newRecurrence + + const allKeys = ['never', 'daily', 'weekly', 'biweekly', 'monthly', 'yearly'] + allKeys.forEach(k => { + const el = this.$(`#recur-check-${k}`) + if (el) el.style.display = k === opt.key ? "" : "none" + }) + + const labelEl = this.$("#recur-label") + if (labelEl) { + labelEl.textContent = this.recurrenceLabel() + labelEl.style.opacity = newRecurrence ? "1" : "0.5" + } + + const isNowWeekly = opt.key === 'weekly' || opt.key === 'biweekly' + const daysRow = this.$("#recur-days") + if (daysRow) daysRow.style.display = isNowWeekly ? "" : "none" + + if (isNowWeekly && newRecurrence?.days_of_week) { + const days = newRecurrence.days_of_week; + [0, 1, 2, 3, 4, 5, 6].forEach(d => { + const el = this.$(`#recur-day-${d}`) + if (!el) return + const sel = days.includes(d) + el.style.background = sel ? "var(--quillred)" : "transparent" + el.style.color = sel ? "white" : "var(--headertext)" + el.style.opacity = sel ? "1" : "0.4" + }) + } + + // Recalculate maxHeight after content change + const picker = this.$("#recur-picker") + if (picker && picker.getAttribute("data-open") === "1") { + picker.style.maxHeight = picker.scrollHeight + "px" + } + }) + }) + + // Day-of-week selector (weekly/biweekly only) + const DAY_LABELS = ['S', 'M', 'T', 'W', 'T', 'F', 'S'] + HStack(() => { + DAY_LABELS.forEach((label, i) => { + const sel = selectedDays.includes(i) + span(label) + .attr({ id: `recur-day-${i}` }) + .width(1.8, em).height(1.8, em) + .lineHeight("1.8em") + .textAlign("center") + .borderRadius(50, pct) + .fontSize(0.75, em).fontWeight("600") + .cursor("pointer").flexShrink(0) + .background(sel ? "var(--quillred)" : "transparent") + .color(sel ? "white" : "var(--headertext)") + .opacity(sel ? "1" : "0.4") + .onClick((done) => { + if (!done) return + if (!this.recurrence?.days_of_week) return + const days = this.recurrence.days_of_week + const idx = days.indexOf(i) + if (idx >= 0) { + if (days.length > 1) days.splice(idx, 1) + } else { + days.push(i) + } + const nowSel = days.includes(i) + const el = this.$(`#recur-day-${i}`) + if (el) { + el.style.background = nowSel ? "var(--quillred)" : "transparent" + el.style.color = nowSel ? "white" : "var(--headertext)" + el.style.opacity = nowSel ? "1" : "0.4" + } + const picker = this.$("#recur-picker") + if (picker && picker.getAttribute("data-open") === "1") { + picker.style.maxHeight = picker.scrollHeight + "px" + } + }) + }) + }) + .attr({ id: "recur-days" }) + .justifyContent("space-around") + .paddingHorizontal(0.5, em).paddingVertical(0.65, em) + .borderTop("1px solid var(--divider)") + .display(isWeekly ? "" : "none") + }) + .attr({ id: "recur-picker", "data-open": "0" }) + .overflow("hidden") + .maxHeight(0) + .transition("max-height 0.3s ease") + .border("1px solid var(--divider)") + .borderRadius(0.35, em) + .marginTop(0.4, em) + }) + + this.prop("CALENDARS", () => { + HStack(() => { + this.calendars.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) + .cursor("pointer") + .opacity(this.isCalendarSelected(cal.id) ? 1 : 0.35) + .attr({ "data-cal-id": cal.id }) + .onClick((done) => { if (done) this.toggleCalendar(cal) }) + .onHover(function(hovering) { + const isSelected = $("desktopeventform-").isCalendarSelected(cal.id) + this.style.opacity = hovering ? 0.8 : isSelected ? 1 : 0.35; + }) + }) + }) + .flexWrap("wrap") + .gap(0.45, em) + }) + + this.prop("LOCATION", () => { + input("", "100%") + .attr({ name: "location", type: "text", value: this.editEvent?.location ?? "" }) + .styles(this.fieldStyles) + }) + + this.prop("DESCRIPTION", () => { + textarea(this.editEvent?.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; + }) + }) + + this.prop("ATTACHMENTS", () => { + input("Attachments", "100%") + .attr({ name: "attachments", type: "file", multiple: true }) + .display("none") + .onChange(async e => { + const files = Array.from(e.target.files) + e.target.value = "" + this.uploadedFiles.push(...files) + this.fileListComponent.update(files) + this.updateFilesChevron() + }) + + HStack(() => { + span("Upload") + .color("var(--quillred)") + .cursor("pointer") + .fontSize(0.88, em) + .onClick((done) => { if (done) this.$('[name="attachments"]').click() }) + .onHover(function(hovering) { + this.style.opacity = hovering ? 0.82 : 1; + }) + span("β–Ό") + .attr({ id: "files-chevron" }) + .fontSize(0.7, em) + .color("var(--headertext)") + .opacity(0.5) + .display(hasFiles ? "inline-block" : "none") + .transition("transform 0.3s ease") + .transform(this.filesOpen ? "rotate(180deg)" : "rotate(0deg)") + .cursor("pointer") + .userSelect("none") + .onClick((done) => { if (done) this.setFilesPanel(!this.filesOpen) }) + }) + .gap(0.75, em) + .alignItems("center") + + VStack(() => { + this.fileListComponent = EventFileList( + this.existingAttachments, + isEdit ? (fileId) => this.handleDeleteExistingFile(fileId) : null, + (index) => this.handleDeleteNewFile(index), + (file, url) => $("filepreview-").open(file, url) + ) + this.fileListComponent + .border("1px solid var(--divider)") + .borderRadius(0.35, em) + .boxSizing("border-box") + }) + .attr({ id: "files-content" }) + .overflow("hidden") + .maxHeight(hasFiles && this.filesOpen ? "600px" : "0") + .transition("max-height 0.5s ease") + .marginTop(0.5, em) + }) + }) + .flex(1) + .overflowY("scroll") + .width(100, pct) + .boxSizing("border-box") + } + + showError(msg) { + $("modal-")?.showError(msg) + } + + getFormData() { + const isAllDay = this.$('[name="all_day"]')?.checked ?? true + const val = name => this.$(`[name="${name}"]`).value + return { + title: val("title") || null, + location: val("location") || null, + description: val("description") || null, + all_day: isAllDay, + time_start: isAllDay ? val("time_start_date") : `${val("time_start_date")}T${val("time_start_time")}`, + time_end: isAllDay ? val("time_end_date") : `${val("time_end_date")}T${val("time_end_time")}`, + recurrence: this.recurrence + } + } + + isFormDataUnchanged(data) { + const o = this.originalFormData + const newCalIds = this.selectedCalendars.map(c => c.id).sort().join(",") + return ( + (data.title ?? "") === (o.title ?? "") && + (data.location ?? "") === (o.location ?? "") && + (data.description ?? "") === (o.description ?? "") && + data.all_day === o.all_day && + data.time_start === o.time_start && + data.time_end === o.time_end && + newCalIds === o.calendars && + this.uploadedFiles.length === 0 && + JSON.stringify(data.recurrence) === JSON.stringify(o.recurrence) + ) + } + + isNewEventDirty() { + const data = this.getFormData() + return !this.isFormDataUnchanged(data) || this.attachmentsDeleted + } + + handleBack() { + const isDirty = this.isNewEventDirty() + if (isDirty) { + $('actionsheetpopup-').show( + "Discard Changes?", + [ + { + label: "Discard", + onTap: () => { + if (this.onBack) this.onBack() + else $("modal-").forceClose() + } + }, + { label: "Keep Editing", destructive: false, onTap: () => {} } + ], + () => {} + ) + return + } + if (this.onBack) this.onBack() + else $("modal-").forceClose() + } + + handleDelete() { + const event = this.editEvent + 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.editEvent + 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.onDelete) this.onDelete(deleteResult) + } else { + this.showError(result.error ?? "Failed to delete event.") + } + } catch (err) { + console.error("Failed to delete event:", err) + this.showError("Failed to delete event.") + } + } + + async handleUpload(eventId) { + try { + const body = new FormData() + this.uploadedFiles.forEach(file => body.append("attachments", file)) + const res = await fetch(`${config.SERVER}/events/${eventId}/upload-attachments`, { + method: "POST", credentials: "include", + headers: { "Accept": "application/json" }, body + }) + const { insertedFiles } = await res.json() + return { success: res.ok, insertedFiles: res.ok ? insertedFiles : [] } + } catch (err) { + console.error("Failed to upload attachments:", err) + return { success: false, insertedFiles: [] } + } + } + + async _uploadAndMerge(eventId, existing = []) { + if (this.uploadedFiles.length === 0) return existing + const upload = await this.handleUpload(eventId) + if (!upload.success) return existing + const existingIds = new Set(existing.map(a => a.id)) + return [...existing, ...upload.insertedFiles.filter(a => !existingIds.has(a.id))] + } + + async performSave(scope) { + const eventData = this.pendingSaveEventData + this.pendingSaveEventData = null + + const event = this.editEvent + 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 toISO = (str, isEnd = false) => calendarUtil.toISO(str, eventData.all_day, isEnd) + + // scope='all': anchor dates to the template's start, not the tapped occurrence + let time_start = toISO(eventData.time_start) + let time_end = toISO(eventData.time_end, true) + if (scope === 'all' && event._templateStart) { + const tDateStr = calendarUtil.toDateInput(event._templateStart) + const tEndDateStr = calendarUtil.toDateInput(event._templateEnd ?? event._templateStart) + const shiftDate = (base, offset) => + calendarUtil.toDateInput(calendarUtil.addDays(new Date(base + 'T00:00:00'), offset)) + const daysBetween = (a, b) => + Math.round((new Date(a + 'T00:00:00') - new Date(b + 'T00:00:00')) / 86400000) + const occStartDate = calendarUtil.toDateInput(event.time_start instanceof Date ? event.time_start : new Date(event.time_start)) + + if (eventData.all_day) { + const startShift = daysBetween(eventData.time_start, occStartDate) + const span = daysBetween(eventData.time_end, eventData.time_start) + const newTStart = shiftDate(tDateStr, startShift) + const newTEnd = shiftDate(newTStart, span) + time_start = toISO(newTStart) + time_end = toISO(newTEnd, true) + } else { + const occEndDate = calendarUtil.toDateInput(event.time_end instanceof Date ? event.time_end : new Date(event.time_end)) + const formStart = eventData.time_start.includes('T') ? eventData.time_start.split('T')[0] : eventData.time_start + const formEnd = eventData.time_end.includes('T') ? eventData.time_end.split('T')[0] : eventData.time_end + const startT = eventData.time_start.includes('T') ? eventData.time_start.split('T')[1] : '00:00' + const endT = eventData.time_end.includes('T') ? eventData.time_end.split('T')[1] : '00:00' + const newTStart = shiftDate(tDateStr, daysBetween(formStart, occStartDate)) + const newTEnd = shiftDate(tEndDateStr, daysBetween(formEnd, occEndDate)) + time_start = toISO(`${newTStart}T${startT}`) + time_end = toISO(`${newTEnd}T${endT}`, true) + } + } + + // For weekly recurrence, if the user shifted the start to a different day of week, + // replace the old anchor day in days_of_week so the first occurrence isn't skipped. + let recurrence = eventData.recurrence ? { ...eventData.recurrence } : null; + if (scope !== 'single' && recurrence?.frequency === 'weekly' && recurrence.days_of_week?.length > 0) { + const newDay = new Date(time_start).getDay(); + const oldDayRef = scope === 'all' && event._templateStart + ? new Date(event._templateStart) + : occurrenceDate ? new Date(occurrenceDate) : null; + const originalDay = oldDayRef?.getDay() ?? null; + if (newDay !== originalDay && !recurrence.days_of_week.includes(newDay)) { + const days = [...recurrence.days_of_week]; + recurrence = { + ...recurrence, + days_of_week: (originalDay !== null && days.includes(originalDay)) + ? days.map(d => d === originalDay ? newDay : d) + : [...days, newDay].sort((a, b) => a - b) + }; + } + } + + const payload = { + title: eventData.title || "New event", + location: eventData.location || null, + description: eventData.description || null, + time_start, + time_end, + all_day: eventData.all_day, + calendars: this.selectedCalendars.map(c => c.id), + scope, + exception_date: occurrenceDate, + recurrence + } + + try { + const serverEventId = (scope === 'single' && isOverride) ? event.id : templateId + const result = await server.editEvent(serverEventId, payload, global.currentNetwork.id) + if (result.status === 200) { + // Run deletions against the final event ID so that for scope='single'/'future' (new event row) + // we remove from the new event (which inherited parent files) not from the original template + await this.performPendingDeletions(result.event.id) + const attachments = await this._uploadAndMerge(result.event.id, [...this.existingAttachments]) + const editResult = { + scope, + event: { ...result.event, calendars: this.selectedCalendars.map(c => c.id), attachments, recurrence: eventData.recurrence ?? null }, + templateId, + occurrenceDate + } + if (this.onSaved) this.onSaved(editResult) + } else { + this.showError(result.error ?? "Failed to save event.") + } + } catch (err) { + console.error("Failed to save event:", err) + this.showError("Failed to save event.") + } + } + + async handleSave() { + $("modal-")?.showError("") + const data = this.getFormData() + + if (this.editEvent) { + const unchanged = this.isFormDataUnchanged(data) && !this.attachmentsDeleted + if (unchanged) { + if (this.onBack) this.onBack() + else $("modal-").forceClose() + return + } + + const isRecurring = !!(this.editEvent._isOccurrence || this.editEvent.recurrence_parent_id || this.editEvent.recurrence_id) + if (isRecurring) { + this.pendingSaveEventData = data + $('actionsheetpopup-').show( + "Edit Recurring Event", + [ + { label: "Edit just this event", onTap: () => this.performSave('single'), destructive: false }, + { label: "Edit this and future events", onTap: () => this.performSave('future'), destructive: false }, + { label: "Edit all events in series", onTap: () => this.performSave('all'), destructive: false }, + ], + () => { this.pendingSaveEventData = null } + ) + return + } + + await this.performPendingDeletions() + const toISO = (str, isEnd = false) => calendarUtil.toISO(str, data.all_day, isEnd) + const payload = { + title: data.title ?? "New event", + location: data.location, + description: data.description, + time_start: toISO(data.time_start), + time_end: toISO(data.time_end, true), + all_day: data.all_day, + calendars: this.selectedCalendars.map(c => c.id), + recurrence: data.recurrence ?? null + } + const result = await server.editEvent(this.editEvent.id, payload, global.currentNetwork.id) + if (result.status === 200) { + const attachments = await this._uploadAndMerge(result.event.id, [...this.existingAttachments]) + if (this.onSaved) this.onSaved({ ...result.event, attachments, recurrence: data.recurrence ?? null }) + } else { + this.showError(result.error ?? "Failed to save event.") + } + } else { + const toISO = (str, isEnd = false) => calendarUtil.toISO(str, data.all_day, isEnd) + const payload = { + title: data.title ?? "New event", + location: data.location, + description: data.description, + time_start: toISO(data.time_start), + time_end: toISO(data.time_end, true), + all_day: data.all_day, + calendars: this.selectedCalendars.map(c => c.id), + recurrence: data.recurrence ?? null + } + const result = await server.addEvent(payload, global.currentNetwork.id) + if (result.status === 200) { + const attachments = await this._uploadAndMerge(result.event.id) + $("modal-").forceClose() + if (this.onSaved) this.onSaved({ ...result.event, attachments, recurrence: data.recurrence ?? null }) + } else { + this.showError(result.error ?? "Failed to save event.") + } + } + } + +} + +register(DesktopEventForm) diff --git a/calendar/desktop/Events/FilePreview.js b/calendar/desktop/Events/FilePreview.js new file mode 100644 index 0000000..f19629a --- /dev/null +++ b/calendar/desktop/Events/FilePreview.js @@ -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 = `` + } 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) diff --git a/calendar/desktop/calendar.js b/calendar/desktop/calendar.js new file mode 100644 index 0000000..24c57d8 --- /dev/null +++ b/calendar/desktop/calendar.js @@ -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) diff --git a/calendar/icons/addevent.svg b/calendar/icons/addevent.svg new file mode 100644 index 0000000..2c29b40 --- /dev/null +++ b/calendar/icons/addevent.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/calendar/icons/addeventlight.svg b/calendar/icons/addeventlight.svg new file mode 100644 index 0000000..70162d9 --- /dev/null +++ b/calendar/icons/addeventlight.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/calendar/icons/addeventlightselected.svg b/calendar/icons/addeventlightselected.svg new file mode 100644 index 0000000..dc63d41 --- /dev/null +++ b/calendar/icons/addeventlightselected.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/calendar/icons/calbutton.svg b/calendar/icons/calbutton.svg new file mode 100644 index 0000000..37c8dc1 --- /dev/null +++ b/calendar/icons/calbutton.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/calendar/icons/calbuttonlight.svg b/calendar/icons/calbuttonlight.svg new file mode 100644 index 0000000..267ab2f --- /dev/null +++ b/calendar/icons/calbuttonlight.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/calendar/icons/calbuttonlightselected.svg b/calendar/icons/calbuttonlightselected.svg new file mode 100644 index 0000000..ee55317 --- /dev/null +++ b/calendar/icons/calbuttonlightselected.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/calendar/icons/calendar.svg b/calendar/icons/calendar.svg new file mode 100644 index 0000000..6c2d257 --- /dev/null +++ b/calendar/icons/calendar.svg @@ -0,0 +1,3 @@ + + + diff --git a/calendar/icons/calendarlight.svg b/calendar/icons/calendarlight.svg new file mode 100644 index 0000000..970a6c7 --- /dev/null +++ b/calendar/icons/calendarlight.svg @@ -0,0 +1,3 @@ + + + diff --git a/calendar/icons/calendarlightselected.svg b/calendar/icons/calendarlightselected.svg new file mode 100644 index 0000000..122d5c0 --- /dev/null +++ b/calendar/icons/calendarlightselected.svg @@ -0,0 +1,3 @@ + + + diff --git a/chat/chat.js b/chat/chat.js new file mode 100644 index 0000000..6b1d193 --- /dev/null +++ b/chat/chat.js @@ -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) diff --git a/chat/desktop/DesktopChatSidebar.js b/chat/desktop/DesktopChatSidebar.js new file mode 100644 index 0000000..4f8bac6 --- /dev/null +++ b/chat/desktop/DesktopChatSidebar.js @@ -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) diff --git a/chat/desktop/DesktopChatThread.js b/chat/desktop/DesktopChatThread.js new file mode 100644 index 0000000..4977712 --- /dev/null +++ b/chat/desktop/DesktopChatThread.js @@ -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) diff --git a/chat/desktop/chat.js b/chat/desktop/chat.js new file mode 100644 index 0000000..1c9a190 --- /dev/null +++ b/chat/desktop/chat.js @@ -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) diff --git a/chat/icons/chat.svg b/chat/icons/chat.svg new file mode 100644 index 0000000..ff14321 --- /dev/null +++ b/chat/icons/chat.svg @@ -0,0 +1,3 @@ + + + diff --git a/chat/icons/chatlight.svg b/chat/icons/chatlight.svg new file mode 100644 index 0000000..e26a9c7 --- /dev/null +++ b/chat/icons/chatlight.svg @@ -0,0 +1,3 @@ + + + diff --git a/components/AddButton.js b/components/AddButton.js new file mode 100644 index 0000000..7569240 --- /dev/null +++ b/components/AddButton.js @@ -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) \ No newline at end of file diff --git a/components/AppTitle.js b/components/AppTitle.js new file mode 100644 index 0000000..c9a77c3 --- /dev/null +++ b/components/AppTitle.js @@ -0,0 +1,8 @@ +function AppTitle(text) { + return h1(text) + .fontFamily("Laandbrau") + .letterSpacing(2, px) + .fontSize(2.3, em) +} + +window.AppTitle = AppTitle \ No newline at end of file diff --git a/components/Avatar.js b/components/Avatar.js new file mode 100644 index 0000000..e5e4801 --- /dev/null +++ b/components/Avatar.js @@ -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) diff --git a/components/BackButton.js b/components/BackButton.js new file mode 100644 index 0000000..92d5093 --- /dev/null +++ b/components/BackButton.js @@ -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) \ No newline at end of file diff --git a/components/BottomSheet.js b/components/BottomSheet.js new file mode 100644 index 0000000..754e388 --- /dev/null +++ b/components/BottomSheet.js @@ -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) diff --git a/components/SearchBar.js b/components/SearchBar.js new file mode 100644 index 0000000..bece96d --- /dev/null +++ b/components/SearchBar.js @@ -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) \ No newline at end of file diff --git a/donations/desktop/donations.js b/donations/desktop/donations.js new file mode 100644 index 0000000..5e7ddd5 --- /dev/null +++ b/donations/desktop/donations.js @@ -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) diff --git a/donations/donations.js b/donations/donations.js new file mode 100644 index 0000000..12882c3 --- /dev/null +++ b/donations/donations.js @@ -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) diff --git a/donations/icons/donations.svg b/donations/icons/donations.svg new file mode 100644 index 0000000..d4d255b --- /dev/null +++ b/donations/icons/donations.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/donations/icons/donationslight.svg b/donations/icons/donationslight.svg new file mode 100644 index 0000000..6b75f6e --- /dev/null +++ b/donations/icons/donationslight.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/donations/server/functions.js b/donations/server/functions.js new file mode 100644 index 0000000..e63f009 --- /dev/null +++ b/donations/server/functions.js @@ -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 } +} \ No newline at end of file diff --git a/files/desktop/DesktopFilesGrid.js b/files/desktop/DesktopFilesGrid.js new file mode 100644 index 0000000..26db288 --- /dev/null +++ b/files/desktop/DesktopFilesGrid.js @@ -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) diff --git a/files/desktop/DesktopFilesSidebar.js b/files/desktop/DesktopFilesSidebar.js new file mode 100644 index 0000000..997c977 --- /dev/null +++ b/files/desktop/DesktopFilesSidebar.js @@ -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) diff --git a/files/desktop/DesktopFilesToolbar.js b/files/desktop/DesktopFilesToolbar.js new file mode 100644 index 0000000..2e944ad --- /dev/null +++ b/files/desktop/DesktopFilesToolbar.js @@ -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) diff --git a/files/desktop/files.js b/files/desktop/files.js new file mode 100644 index 0000000..89f3a31 --- /dev/null +++ b/files/desktop/files.js @@ -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) diff --git a/files/icons/files.svg b/files/icons/files.svg new file mode 100644 index 0000000..be1d718 --- /dev/null +++ b/files/icons/files.svg @@ -0,0 +1,3 @@ + + + diff --git a/files/icons/fileslight.svg b/files/icons/fileslight.svg new file mode 100644 index 0000000..5bfc8dc --- /dev/null +++ b/files/icons/fileslight.svg @@ -0,0 +1,3 @@ + + + diff --git a/jobs/JobCard.js b/jobs/JobCard.js new file mode 100644 index 0000000..3a01457 --- /dev/null +++ b/jobs/JobCard.js @@ -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) \ No newline at end of file diff --git a/jobs/JobForm.js b/jobs/JobForm.js new file mode 100644 index 0000000..3a7794d --- /dev/null +++ b/jobs/JobForm.js @@ -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) \ No newline at end of file diff --git a/jobs/JobsGrid.js b/jobs/JobsGrid.js new file mode 100644 index 0000000..2af5d4f --- /dev/null +++ b/jobs/JobsGrid.js @@ -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 `${text}`; + } + return `${text.slice(0, index)}${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) diff --git a/jobs/JobsSidebar.js b/jobs/JobsSidebar.js new file mode 100644 index 0000000..1546cec --- /dev/null +++ b/jobs/JobsSidebar.js @@ -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) \ No newline at end of file diff --git a/jobs/desktop/DesktopJobDetail.js b/jobs/desktop/DesktopJobDetail.js new file mode 100644 index 0000000..3d29a20 --- /dev/null +++ b/jobs/desktop/DesktopJobDetail.js @@ -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) diff --git a/jobs/desktop/DesktopJobsList.js b/jobs/desktop/DesktopJobsList.js new file mode 100644 index 0000000..efe1265 --- /dev/null +++ b/jobs/desktop/DesktopJobsList.js @@ -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) diff --git a/jobs/desktop/DesktopJobsToolbar.js b/jobs/desktop/DesktopJobsToolbar.js new file mode 100644 index 0000000..a2f8c44 --- /dev/null +++ b/jobs/desktop/DesktopJobsToolbar.js @@ -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) diff --git a/jobs/desktop/jobs.js b/jobs/desktop/jobs.js new file mode 100644 index 0000000..0a46766 --- /dev/null +++ b/jobs/desktop/jobs.js @@ -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) diff --git a/jobs/icons/jobs.svg b/jobs/icons/jobs.svg new file mode 100644 index 0000000..5eccad1 --- /dev/null +++ b/jobs/icons/jobs.svg @@ -0,0 +1,3 @@ + + + diff --git a/jobs/icons/jobslight.svg b/jobs/icons/jobslight.svg new file mode 100644 index 0000000..5fce2c9 --- /dev/null +++ b/jobs/icons/jobslight.svg @@ -0,0 +1,3 @@ + + + diff --git a/jobs/icons/jobslightselected.svg b/jobs/icons/jobslightselected.svg new file mode 100644 index 0000000..0002ad1 --- /dev/null +++ b/jobs/icons/jobslightselected.svg @@ -0,0 +1,3 @@ + + + diff --git a/jobs/jobs.js b/jobs/jobs.js new file mode 100644 index 0000000..8796246 --- /dev/null +++ b/jobs/jobs.js @@ -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) diff --git a/package.json b/package.json new file mode 100644 index 0000000..96ae6e5 --- /dev/null +++ b/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} \ No newline at end of file diff --git a/people/PeopleCard.js b/people/PeopleCard.js new file mode 100644 index 0000000..d78a2aa --- /dev/null +++ b/people/PeopleCard.js @@ -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) diff --git a/people/PeopleList.js b/people/PeopleList.js new file mode 100644 index 0000000..bd1b41b --- /dev/null +++ b/people/PeopleList.js @@ -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) \ No newline at end of file diff --git a/people/desktop/DesktopPeopleDetail.js b/people/desktop/DesktopPeopleDetail.js new file mode 100644 index 0000000..3e30ec8 --- /dev/null +++ b/people/desktop/DesktopPeopleDetail.js @@ -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) diff --git a/people/desktop/DesktopPeopleList.js b/people/desktop/DesktopPeopleList.js new file mode 100644 index 0000000..2588408 --- /dev/null +++ b/people/desktop/DesktopPeopleList.js @@ -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) diff --git a/people/desktop/DesktopPeopleTable.js b/people/desktop/DesktopPeopleTable.js new file mode 100644 index 0000000..f153b01 --- /dev/null +++ b/people/desktop/DesktopPeopleTable.js @@ -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) diff --git a/people/desktop/DesktopPeopleToolbar.js b/people/desktop/DesktopPeopleToolbar.js new file mode 100644 index 0000000..c66fdef --- /dev/null +++ b/people/desktop/DesktopPeopleToolbar.js @@ -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) diff --git a/people/desktop/people.js b/people/desktop/people.js new file mode 100644 index 0000000..770952f --- /dev/null +++ b/people/desktop/people.js @@ -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) diff --git a/people/icons/people.svg b/people/icons/people.svg new file mode 100644 index 0000000..f2b266a --- /dev/null +++ b/people/icons/people.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/people/icons/peoplelight.svg b/people/icons/peoplelight.svg new file mode 100644 index 0000000..3e83d03 --- /dev/null +++ b/people/icons/peoplelight.svg @@ -0,0 +1,3 @@ + + + diff --git a/people/icons/peoplelightselected.svg b/people/icons/peoplelightselected.svg new file mode 100644 index 0000000..0f24e99 --- /dev/null +++ b/people/icons/peoplelightselected.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/people/people.js b/people/people.js new file mode 100644 index 0000000..218f071 --- /dev/null +++ b/people/people.js @@ -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) diff --git a/people/server/functions.js b/people/server/functions.js new file mode 100644 index 0000000..912315d --- /dev/null +++ b/people/server/functions.js @@ -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 [] + } +} \ No newline at end of file diff --git a/politics/desktop/PoliticsElections.js b/politics/desktop/PoliticsElections.js new file mode 100644 index 0000000..5c1acf6 --- /dev/null +++ b/politics/desktop/PoliticsElections.js @@ -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) diff --git a/politics/desktop/PoliticsRepresentatives.js b/politics/desktop/PoliticsRepresentatives.js new file mode 100644 index 0000000..239a44b --- /dev/null +++ b/politics/desktop/PoliticsRepresentatives.js @@ -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) diff --git a/politics/desktop/PoliticsSidebar.js b/politics/desktop/PoliticsSidebar.js new file mode 100644 index 0000000..b4d14b5 --- /dev/null +++ b/politics/desktop/PoliticsSidebar.js @@ -0,0 +1,163 @@ +class PoliticsSidebar extends Shadow { + constructor(view, levelFilter, onViewChange, onLevelChange, jurisdiction, elections) { + super() + this.view = view + this.levelFilter = levelFilter + this.onViewChange = onViewChange + this.onLevelChange = onLevelChange + this.jurisdiction = jurisdiction + this.elections = elections + } + + render() { + VStack(() => { + // ── App title ───────────────────────────────────────────── + VStack(() => { + p("βš–οΈ") + .margin(0).fontSize(1.6, em).lineHeight("1") + h2("Civics") + .margin(0).marginTop(0.35, em).fontSize(1.05, em).fontWeight("700") + .color("var(--headertext)") + p("Your Political Dashboard") + .margin(0).marginTop(0.12, em).fontSize(0.72, em) + .color("var(--headertext)").opacity(0.38) + }) + .paddingHorizontal(1.1, em).paddingTop(1.2, em).paddingBottom(0.9, em) + + // ── Divider ─────────────────────────────────────────────── + VStack(() => {}).height(1, px).background("var(--divider)").marginHorizontal(1, em) + + // ── Main nav ────────────────────────────────────────────── + VStack(() => { + this.sectionLabel("VIEWS") + this.navItem("πŸ›", "Representatives", "representatives") + this.navItem("πŸ—³", "Elections", "elections") + }) + .paddingTop(0.75, em) + + VStack(() => {}).height(1, px).background("var(--divider)").marginHorizontal(1, em).marginVertical(0.5, em) + + // ── Level filter ────────────────────────────────────────── + VStack(() => { + this.sectionLabel("GOVERNMENT LEVEL") + this.levelItem("all", "πŸ‡ΊπŸ‡Έ + 🏠 All") + this.levelItem("federal", "πŸ‡ΊπŸ‡Έ Federal") + this.levelItem("state", "🏠 State") + this.levelItem("local", "πŸ“ Local") + }) + + VStack(() => {}).height(1, px).background("var(--divider)").marginHorizontal(1, em).marginVertical(0.5, em) + + // ── Jurisdiction card ───────────────────────────────────── + VStack(() => { + this.sectionLabel("YOUR DISTRICT") + VStack(() => { + this.jurisdictionRow("Federal", this.jurisdiction.federal) + this.jurisdictionRow("State Senate", this.jurisdiction.stateSenate) + this.jurisdictionRow("State House", this.jurisdiction.stateHouse) + this.jurisdictionRow("County", this.jurisdiction.county) + }) + .padding(0.75, em) + .background("var(--darkaccent)") + .border("1px solid var(--divider)") + .borderRadius(0.55, em) + .marginHorizontal(0.75, em) + .gap(0) + }) + .paddingTop(0) + + VStack(() => {}).flex(1) + + // ── Next election countdown ─────────────────────────────── + VStack(() => { + const next = this.elections + .filter(e => new Date(e.date) > new Date()) + .sort((a, b) => new Date(a.date) - new Date(b.date))[0] + + if (next) { + const days = Math.ceil((new Date(next.date) - new Date()) / 86400000) + VStack(() => { + p("NEXT ELECTION") + .margin(0).fontSize(0.6, em).fontWeight("700").letterSpacing("0.07em") + .color("var(--headertext)").opacity(0.35) + p(next.title) + .margin(0).marginTop(0.35, em).fontSize(0.8, em).fontWeight("600") + .color("var(--headertext)").lineHeight("1.3") + HStack(() => { + p(`${days}`) + .margin(0).fontSize(1.6, em).fontWeight("800") + .color("var(--quillred)").lineHeight("1") + p("days away") + .margin(0).marginLeft(0.35, em).fontSize(0.72, em) + .color("var(--headertext)").opacity(0.45) + .alignSelf("flex-end").paddingBottom(0.1, em) + }) + .alignItems("flex-end").marginTop(0.5, em) + }) + .padding(0.85, em) + .background("var(--darkaccent)") + .border("1px solid var(--divider)") + .borderRadius(0.55, em) + .cursor("pointer") + .onClick((done) => {if(!done) return; this.onViewChange("elections")}) + } + }) + .paddingHorizontal(0.75, em).paddingBottom(1.1, em).flexShrink(0) + }) + .height(100, pct).width(100, pct).boxSizing("border-box").overflowY("auto") + } + + navItem(icon, label, viewId) { + const isActive = this.view === viewId + HStack(() => { + p(icon).margin(0).fontSize(0.95, em).lineHeight("1").flexShrink(0) + p(label).margin(0).fontSize(0.88, em).fontWeight(isActive ? "600" : "400") + .color("var(--headertext)").opacity(isActive ? 1 : 0.65) + }) + .gap(0.62, em).paddingHorizontal(0.85, em).paddingVertical(0.45, 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((done) => {if(!done) return; this.onViewChange(viewId)}) + } + + levelItem(level, label) { + const isActive = this.levelFilter === level + HStack(() => { + p(label).margin(0).fontSize(0.85, em).fontWeight(isActive ? "600" : "400") + .color("var(--headertext)").opacity(isActive ? 1 : 0.58) + }) + .paddingHorizontal(0.85, em).paddingVertical(0.38, em) + .marginHorizontal(0.4, em) + .borderRadius(0.45, em) + .background(isActive ? "var(--app)" : "transparent") + .cursor("pointer") + .width("calc(100% - 0.8em)").boxSizing("border-box") + .onClick((done) => {if(!done) return; this.onLevelChange(level)}) + } + + jurisdictionRow(label, value) { + HStack(() => { + p(label) + .margin(0).fontSize(0.68, em).color("var(--headertext)").opacity(0.4) + .width(5.5, em).flexShrink(0) + p(value) + .margin(0).fontSize(0.72, em).fontWeight("600").color("var(--headertext)") + .flex(1).minWidth(0).overflow("hidden").whiteSpace("nowrap").textOverflow("ellipsis") + }) + .paddingVertical(0.32, em) + .borderBottom("1px solid var(--divider)") + .alignItems("flex-start").gap(0.4, em) + } + + sectionLabel(text) { + p(text) + .margin(0).marginBottom(0.28, em).paddingHorizontal(1.1, em) + .fontSize(0.62, em).fontWeight("700").letterSpacing("0.07em") + .color("var(--headertext)").opacity(0.35) + } +} + +register(PoliticsSidebar) diff --git a/politics/desktop/politics.js b/politics/desktop/politics.js new file mode 100644 index 0000000..ba806ee --- /dev/null +++ b/politics/desktop/politics.js @@ -0,0 +1,266 @@ +import "./PoliticsSidebar.js" +import "./PoliticsRepresentatives.js" +import "./PoliticsElections.js" + +css(` + politics- { + font-family: 'Arial'; + scrollbar-width: none; + -ms-overflow-style: none; + } +`) + +class Politics extends Shadow { + constructor() { + super() + this.view = "representatives" + this.levelFilter = "all" + + this.jurisdiction = { + federal: "TX-21", + stateSenate: "SD-25", + stateHouse: "HD-20", + county: "Comal County", + } + + this.representatives = [ + // ── Federal ─────────────────────────────────────────────── + { + id: 1, level: "federal", + name: "John Cornyn", initials: "JC", + title: "U.S. Senator", party: "R", + district: "Texas (Class 2)", since: 2002, + phone: "(202) 224-2934", website: "cornyn.senate.gov", + }, + { + id: 2, level: "federal", + name: "Ted Cruz", initials: "TC", + title: "U.S. Senator", party: "R", + district: "Texas (Class 1)", since: 2013, + phone: "(202) 224-5922", website: "cruz.senate.gov", + }, + { + id: 3, level: "federal", + name: "Chip Roy", initials: "CR", + title: "U.S. Representative", party: "R", + district: "TX-21", since: 2019, + phone: "(202) 225-4236", website: "roy.house.gov", + }, + + // ── State ───────────────────────────────────────────────── + { + id: 4, level: "state", + name: "Donna Campbell", initials: "DC", + title: "Texas State Senator", party: "R", + district: "Senate District 25", since: 2013, + phone: "(512) 463-0125", website: "senate.texas.gov/member.php?d=25", + }, + { + id: 5, level: "state", + name: "Terry Wilson", initials: "TW", + title: "Texas State Representative", party: "R", + district: "House District 20", since: 2017, + phone: "(512) 463-0309", website: "house.texas.gov/members/member-page/?district=20", + }, + { + id: 6, level: "state", + name: "Ken Paxton", initials: "KP", + title: "Texas Attorney General", party: "R", + district: "Statewide", since: 2015, + phone: "(512) 463-2100", website: "texasattorneygeneral.gov", + }, + + // ── Local ───────────────────────────────────────────────── + { + id: 7, level: "local", + name: "Sherman Krause", initials: "SK", + title: "Comal County Judge", party: "R", + district: "Comal County", since: 2019, + phone: "(830) 221-1100", website: "co.comal.tx.us", + }, + { + id: 8, level: "local", + name: "Mark Reynolds", initials: "MR", + title: "County Sheriff", party: "R", + district: "Comal County", since: 2017, + phone: "(830) 620-3400", website: null, + }, + { + id: 9, level: "local", + name: "Jane Guerrero", initials: "JG", + title: "New Braunfels Mayor", party: "I", + district: "City of New Braunfels", since: 2021, + phone: "(830) 221-4000", website: "newbraunfels.gov", + }, + ] + + const future = (d) => new Date(Date.now() + d * 86400000) + const past = (d) => new Date(Date.now() - d * 86400000) + + this.elections = [ + // Upcoming + { + id: 1, + level: "federal", + title: "U.S. Senate β€” Texas", + type: "general", + date: future(209), + description: "Class 2 seat β€” John Cornyn defending", + userVoteId: null, + userThumbsUp: false, + thumbsUp: 14, + candidates: [ + { id: 101, name: "John Cornyn", initials: "JC", party: "R", incumbent: true, votes: 312 }, + { id: 102, name: "Colin Allred", initials: "CA", party: "D", incumbent: false, votes: 187 }, + ] + }, + { + id: 2, + level: "federal", + title: "U.S. House β€” TX-21", + type: "general", + date: future(209), + description: "Chip Roy vs. challenger", + userVoteId: null, + userThumbsUp: true, + thumbsUp: 31, + candidates: [ + { id: 201, name: "Chip Roy", initials: "CR", party: "R", incumbent: true, votes: 278 }, + { id: 202, name: "Marc Segers", initials: "MS", party: "D", incumbent: false, votes: 91 }, + ] + }, + { + id: 3, + level: "state", + title: "Texas House District 20", + type: "primary", + date: future(42), + description: "Republican primary β€” open seat", + userVoteId: null, + userThumbsUp: false, + thumbsUp: 8, + candidates: [ + { id: 301, name: "Terry Wilson", initials: "TW", party: "R", incumbent: true, votes: 203 }, + { id: 302, name: "Brad Fischer", initials: "BF", party: "R", incumbent: false, votes: 144 }, + ] + }, + { + id: 4, + level: "local", + title: "Comal County Commissioner Pct. 2", + type: "general", + date: future(12), + description: "Precinct 2 seat", + userVoteId: null, + userThumbsUp: false, + thumbsUp: 5, + candidates: [ + { id: 401, name: "Lisa Hartmann", initials: "LH", party: "R", incumbent: false, votes: 89 }, + { id: 402, name: "David Moreno", initials: "DM", party: "D", incumbent: false, votes: 53 }, + ] + }, + { + id: 5, + level: "local", + title: "New Braunfels City Council At-Large", + type: "special", + date: future(6), + description: "Filling vacant at-large seat", + userVoteId: null, + userThumbsUp: false, + thumbsUp: 22, + candidates: [ + { id: 501, name: "Karen Ellis", initials: "KE", party: "I", incumbent: false, votes: 112 }, + { id: 502, name: "Tom Riggs", initials: "TR", party: "I", incumbent: false, votes: 74 }, + { id: 503, name: "Anita Solis", initials: "AS", party: "I", incumbent: false, votes: 41 }, + ] + }, + + // Recent/past + { + id: 6, + level: "state", + title: "Texas Senate District 25", + type: "primary", + date: past(48), + description: "Donna Campbell won unopposed", + userVoteId: 601, + userThumbsUp: true, + thumbsUp: 41, + candidates: [ + { id: 601, name: "Donna Campbell", initials: "DC", party: "R", incumbent: true, votes: 487 }, + ] + }, + { + id: 7, + level: "local", + title: "Comal County Sheriff", + type: "general", + date: past(180), + description: "Mark Reynolds re-elected", + userVoteId: 701, + userThumbsUp: false, + thumbsUp: 19, + candidates: [ + { id: 701, name: "Mark Reynolds", initials: "MR", party: "R", incumbent: true, votes: 623 }, + { id: 702, name: "Phil Sanderson", initials: "PS", party: "D", incumbent: false, votes: 214 }, + ] + }, + ] + } + + handleVote(electionId, candidateId) { + const election = this.elections.find(e => e.id === electionId) + if (!election || election.userVoteId) return + election.userVoteId = candidateId + const candidate = election.candidates.find(c => c.id === candidateId) + if (candidate) candidate.votes++ + this.rerender() + } + + handleThumbsUp(electionId) { + const election = this.elections.find(e => e.id === electionId) + if (!election) return + election.userThumbsUp = !election.userThumbsUp + election.thumbsUp += election.userThumbsUp ? 1 : -1 + this.rerender() + } + + render() { + HStack(() => { + // Sidebar + VStack(() => { + PoliticsSidebar( + this.view, + this.levelFilter, + (v) => { this.view = v; this.rerender(); }, + (l) => { this.levelFilter = l; this.rerender(); }, + this.jurisdiction, + this.elections + ) + }) + .width(230, px).minWidth(210, px) + .height(100, pct) + .borderRight("1px solid var(--divider)") + .flexShrink(0).overflow("hidden") + + // Main + VStack(() => { + if (this.view === "representatives") { + PoliticsRepresentatives(this.representatives, this.levelFilter) + } else { + PoliticsElections( + this.elections, + this.levelFilter, + (electionId, candidateId) => this.handleVote(electionId, candidateId), + (electionId) => this.handleThumbsUp(electionId) + ) + } + }) + .flex(1).height(100, pct).overflow("hidden") + }) + .height(100, pct).width(100, pct).overflow("hidden") + } +} + +register(Politics) diff --git a/politics/icons/politics.svg b/politics/icons/politics.svg new file mode 100644 index 0000000..b2083fe --- /dev/null +++ b/politics/icons/politics.svg @@ -0,0 +1,3 @@ + + + diff --git a/politics/icons/politicslight.svg b/politics/icons/politicslight.svg new file mode 100644 index 0000000..6aa95b5 --- /dev/null +++ b/politics/icons/politicslight.svg @@ -0,0 +1,3 @@ + + + diff --git a/settings/SettingsIntegrationsSection.js b/settings/SettingsIntegrationsSection.js new file mode 100644 index 0000000..92d35ff --- /dev/null +++ b/settings/SettingsIntegrationsSection.js @@ -0,0 +1,77 @@ +class SettingsIntegrationsSection extends Shadow { + constructor(stripeDetails, basePath, callbacks, loading = false) { + super() + this.stripeDetails = stripeDetails + this.basePath = basePath + this.loading = loading + this.onConnectStripe = callbacks.onConnectStripe + } + + render() { + VStack(() => { + this.backHeader("Integrations", () => window.navigateTo(this.basePath)) + + VStack(() => {}).height(1, px).background("var(--divider)").flexShrink(0) + + if (this.loading) { LoadingCircle(); return } + + VStack(() => { + p("STRIPE") + .margin(0).marginBottom(0.55, em) + .fontSize(0.62, em).fontWeight("700").letterSpacing("0.07em") + .color("var(--headertext)").opacity(0.35) + + if (this.stripeDetails?.email) { + HStack(() => { + VStack(() => {}) + .width(8, px).height(8, px).borderRadius(50, pct) + .background("#10b981").flexShrink(0) + VStack(() => { + p("Connected").margin(0).fontSize(0.9, em).fontWeight("600").color("var(--headertext)") + p(this.stripeDetails.email) + .margin(0).marginTop(0.15, em).fontSize(0.72, em) + .color("var(--headertext)").opacity(0.42) + }) + .gap(0).flex(1) + }) + .gap(0.65, em).alignItems("center").paddingVertical(0.85, em) + } else { + VStack(() => { + p("Stripe is not connected to this network.") + .margin(0).fontSize(0.82, em).color("var(--headertext)").opacity(0.5) + .marginBottom(1, em) + button("Connect Stripe β†’") + .paddingHorizontal(1.2, em).paddingVertical(0.65, em) + .border("none").borderRadius(0.5, em) + .background("var(--quillred)").color("white") + .fontSize(0.85, em).fontWeight("600").cursor("pointer") + .onTap(() => this.onConnectStripe()) + }) + .paddingVertical(0.85, em).gap(0) + } + }) + .paddingHorizontal(1, em).paddingTop(1, em) + .flex(1).overflowY("auto") + }) + .height(100, pct).overflow("hidden") + } + + backHeader(title, onBack) { + HStack(() => { + button("β€Ή") + .border("none").background("transparent") + .color("var(--headertext)").fontSize(1.4, em) + .lineHeight("1").paddingVertical(0.2, em).paddingRight(0.3, em) + .cursor("pointer").flexShrink(0) + .onTap(onBack) + p(title) + .margin(0).fontSize(1.05, em).fontWeight("700").color("var(--headertext)") + .flex(1).overflow("hidden").whiteSpace("nowrap").textOverflow("ellipsis") + }) + .gap(0.25, em).alignItems("center") + .paddingHorizontal(0.85, em).paddingTop(1.25, em).paddingBottom(0.85, em) + .flexShrink(0) + } +} + +register(SettingsIntegrationsSection) diff --git a/settings/SettingsRolesSection.js b/settings/SettingsRolesSection.js new file mode 100644 index 0000000..0a4e391 --- /dev/null +++ b/settings/SettingsRolesSection.js @@ -0,0 +1,250 @@ +class SettingsRolesSection extends Shadow { + newRoleName = "" + confirmDeleteId = null + + constructor(roles, allApps, activeSection, activeRoleId, roleApps, basePath, callbacks, loading = false) { + super() + this.roles = roles + this.allApps = allApps + this.activeSection = activeSection // "roles" | "role-detail" + this.activeRoleId = activeRoleId + this.roleApps = roleApps + this.basePath = basePath + this.loading = loading + this.onDeleteRole = callbacks.onDeleteRole + this.onCreateRole = callbacks.onCreateRole + this.onToggleApp = callbacks.onToggleApp + } + + get activeRole() { + return this.roles.find(r => String(r.id) === String(this.activeRoleId)) ?? null + } + + get activeRoleAppIds() { + return this.roleApps[this.activeRoleId] ?? new Set() + } + + render() { + if (this.activeSection === "role-detail") { + this.renderRoleDetail() + } else { + this.renderRolesList() + } + } + + renderRolesList() { + VStack(() => { + this.backHeader("Roles & Apps", () => window.navigateTo(this.basePath)) + + VStack(() => {}).height(1, px).background("var(--divider)").flexShrink(0) + + if (this.loading) { LoadingCircle(); return } + + VStack(() => { + this.roles.forEach((role, i) => { + const appCount = (this.roleApps[role.id] ?? new Set()).size + const isConfirming = this.confirmDeleteId === role.id + + HStack(() => { + VStack(() => { + HStack(() => { + p(role.name) + .margin(0).fontSize(0.92, em).fontWeight("600").color("var(--headertext)") + if (role.is_default) { + p("default") + .margin(0).fontSize(0.62, em).fontWeight("600") + .color("var(--quillred)") + .background("rgba(159,28,41,0.1)") + .paddingHorizontal(0.45, em).paddingVertical(0.1, em) + .borderRadius(100, px) + } + }) + .gap(0.45, em).alignItems("center") + p(`${appCount} app${appCount !== 1 ? "s" : ""} assigned`) + .margin(0).marginTop(0.15, em).fontSize(0.72, em) + .color("var(--headertext)").opacity(0.4) + }) + .flex(1).gap(0) + + if (isConfirming) { + HStack(() => { + button("Delete") + .padding("0.25em 0.6em") + .border("none").borderRadius(0.35, em) + .background("var(--quillred)").color("white") + .fontSize(0.72, em).fontWeight("600").cursor("pointer") + .onTap(async (e) => { + e.stopPropagation() + await this.onDeleteRole(role.id) + }) + button("βœ•") + .padding("0.25em 0.45em") + .border("none").borderRadius(0.35, em) + .background("transparent").color("var(--headertext)") + .opacity(0.4).fontSize(0.78, em).cursor("pointer") + .onTap((e) => { + e.stopPropagation() + this.confirmDeleteId = null + this.rerender() + }) + }) + .gap(0.35, em).alignItems("center") + } else { + HStack(() => { + button("β‹―") + .border("none").background("transparent") + .color("var(--headertext)").opacity(0.35) + .fontSize(1.1, em).cursor("pointer").padding("0.1em 0.4em") + .onTap((e) => { + e.stopPropagation() + this.confirmDeleteId = role.id + this.rerender() + }) + p("β€Ί").margin(0).fontSize(1.1, em).color("var(--headertext)").opacity(0.3) + }) + .gap(0.1, em).alignItems("center") + } + }) + .gap(0.75, em).alignItems("center") + .paddingVertical(0.85, em) + .cursor("pointer") + .onTap(() => window.navigateTo(`${this.basePath}/roles/${role.id}`)) + + if (i < this.roles.length - 1) { + VStack(() => {}).height(1, px).background("var(--divider)").marginLeft(0) + } + }) + + // New role input + HStack(() => { + p("+").margin(0).fontSize(1.1, em).color("var(--headertext)").opacity(0.35).flexShrink(0) + input("New role name…", "100%") + .border("none").background("transparent") + .color("var(--headertext)").fontSize(0.88, em).outline("none").flex(1) + .attr({ value: this.newRoleName }) + .onInput(e => { this.newRoleName = e.target.value }) + .onKeyDown(async (e) => { + if (e.key === "Enter" && this.newRoleName.trim()) { + await this.onCreateRole(this.newRoleName.trim()) + this.newRoleName = "" + } + }) + }) + .gap(0.65, em).alignItems("center") + .paddingVertical(0.85, em) + .borderTop("1px dashed var(--divider)") + .marginTop(0.1, em) + }) + .paddingHorizontal(1, em) + .flex(1).overflowY("auto") + }) + .height(100, pct).overflow("hidden") + } + + renderRoleDetail() { + VStack(() => { + this.backHeader(this.activeRole?.name ?? "Role", () => window.navigateTo(`${this.basePath}/roles`)) + + VStack(() => {}).height(1, px).background("var(--divider)").flexShrink(0) + + if (this.loading) { LoadingCircle(); return } + + VStack(() => { + VStack(() => { + p("APP ACCESS") + .margin(0).marginBottom(0.55, em) + .fontSize(0.62, em).fontWeight("700").letterSpacing("0.07em") + .color("var(--headertext)").opacity(0.35) + + this.allApps.forEach((app, i) => { + const hasApp = this.activeRoleAppIds.has(app.id) + const comingSoonApps = ["jobs", "politics", "files"] + const isComingSoon = comingSoonApps.includes(app.name) + + HStack(() => { + p(app.name.charAt(0).toUpperCase() + app.name.slice(1)) + .margin(0).fontSize(0.9, em).color("var(--headertext)").flex(1) + + HStack(() => { + if (isComingSoon) { + p("Coming soon") + .margin(0).fontSize(0.9, em).color("var(--headertext)").opacity(0.45) + .background("var(--darkaccent)").border("1px solid var(--divider)") + .paddingHorizontal(0.5, em).paddingVertical(0.15, em).borderRadius(100, px) + } + + VStack(() => {}) + .width(1.15, em).height(1.15, em) + .borderRadius(0.25, em) + .background(hasApp ? "var(--quillred)" : "transparent") + .border(`2px solid ${hasApp ? "var(--quillred)" : "var(--divider)"}`) + .boxSizing("border-box") + .justifyContent("center").alignItems("center") + }) + .justifyContent("center").alignItems("center") + .gap(1, em) + }) + .paddingVertical(0.8, em) + .gap(1, em).alignItems("center") + .cursor(isComingSoon ? "default" : "pointer") + .opacity(isComingSoon ? 0.5 : 1) + .onTap(() => { if (!isComingSoon) this.onToggleApp(this.activeRoleId, app.id, !hasApp) }) + + if (i < this.allApps.length - 1) { + VStack(() => {}).height(1, px).background("var(--divider)") + } + }) + }) + .paddingHorizontal(1, em) + .paddingVertical(0.75, em) + .marginBottom(1, em) + + VStack(() => {}).height(1, px).background("var(--divider)").marginHorizontal(1, em) + + VStack(() => { + this.comingSoonRow("πŸ”", "Permissions") + VStack(() => {}).height(1, px).background("var(--divider)") + this.comingSoonRow("πŸ””", "Notifications") + VStack(() => {}).height(1, px).background("var(--divider)") + this.comingSoonRow("🎨", "Color & Badge") + }) + .paddingHorizontal(1, em) + .marginTop(1, em) + .opacity(0.4) + }) + .flex(1).overflowY("auto").paddingBottom(2, em) + }) + .height(100, pct).overflow("hidden") + } + + backHeader(title, onBack) { + HStack(() => { + button("β€Ή") + .border("none").background("transparent") + .color("var(--headertext)").fontSize(1.4, em) + .lineHeight("1").paddingVertical(0.2, em).paddingRight(0.3, em) + .cursor("pointer").flexShrink(0) + .onTap(onBack) + p(title) + .margin(0).fontSize(1.05, em).fontWeight("700").color("var(--headertext)") + .flex(1).overflow("hidden").whiteSpace("nowrap").textOverflow("ellipsis") + }) + .gap(0.25, em).alignItems("center") + .paddingHorizontal(0.85, em).paddingTop(1.25, em).paddingBottom(0.85, em) + .flexShrink(0) + } + + comingSoonRow(icon, label) { + HStack(() => { + p(icon).margin(0).fontSize(1, em).flexShrink(0) + p(label).margin(0).fontSize(0.9, em).color("var(--headertext)").flex(1) + p("Coming soon") + .margin(0).fontSize(0.9, em).color("var(--headertext)").opacity(0.45) + .background("var(--darkaccent)").border("1px solid var(--divider)") + .paddingHorizontal(0.5, em).paddingVertical(0.15, em).borderRadius(100, px) + }) + .gap(0.75, em).alignItems("center").paddingVertical(0.85, em) + } +} + +register(SettingsRolesSection) diff --git a/settings/desktop/DesktopIntegrationsSection.js b/settings/desktop/DesktopIntegrationsSection.js new file mode 100644 index 0000000..d39a345 --- /dev/null +++ b/settings/desktop/DesktopIntegrationsSection.js @@ -0,0 +1,71 @@ +import "/_/code/components/LoadingCircle.js" + +class DesktopIntegrationsSection extends Shadow { + constructor(stripeDetails, onConnectStripe) { + super() + this.stripeDetails = stripeDetails + this.onConnectStripe = onConnectStripe + } + + render() { + VStack(() => { + HStack(() => { + VStack(() => { + p("Integrations") + .margin(0).fontSize(1.35, em).fontWeight("700").color("var(--headertext)") + p("Connect third-party services to your network") + .margin(0).marginTop(0.2, em).fontSize(0.75, em).color("var(--headertext)").opacity(0.42) + }) + .gap(0) + }) + .paddingHorizontal(1.75, em).paddingTop(1.5, em).paddingBottom(1.25, em) + .alignItems("flex-start").flexShrink(0) + + VStack(() => { + this.settingSection("πŸ’³", "Stripe", "Accept payments and manage subscriptions", () => { + if (this.stripeDetails === null) { + LoadingCircle() + } else if (this.stripeDetails?.email) { + HStack(() => { + VStack(() => {}).width(8, px).height(8, px).borderRadius(50, pct).background("#10b981").flexShrink(0) + p("Connected").margin(0).fontSize(0.82, em).fontWeight("600").color("#10b981") + }) + .gap(0.35, em).alignItems("center").marginBottom(0.3, em) + p(this.stripeDetails.email) + .margin(0).fontSize(0.75, em).color("var(--headertext)").opacity(0.5) + } else { + p("Not connected") + .margin(0).fontSize(0.82, em).color("var(--headertext)").opacity(0.4).marginBottom(0.65, em) + button("Connect Stripe β†’") + .paddingHorizontal(1, em).paddingVertical(0.5, em) + .border("none").borderRadius(0.45, em) + .background("var(--quillred)").color("white") + .fontSize(0.82, em).fontWeight("600").cursor("pointer") + .onClick((done) => { if (done) this.onConnectStripe() }) + } + }) + }) + .paddingHorizontal(1.75, em).flex(1) + }) + .height(100, pct).overflow("hidden") + } + + settingSection(icon, title, subtitle, renderContent) { + VStack(() => { + HStack(() => { + p(icon).margin(0).fontSize(1, em).lineHeight("1").flexShrink(0) + VStack(() => { + p(title).margin(0).fontSize(0.92, em).fontWeight("700").color("var(--headertext)") + p(subtitle).margin(0).marginTop(0.1, em).fontSize(0.72, em).color("var(--headertext)").opacity(0.42) + }) + .gap(0) + }) + .gap(0.65, em).alignItems("flex-start").marginBottom(0.85, em) + renderContent() + }) + .padding(1.1, em).background("var(--darkaccent)").border("1px solid var(--divider)") + .borderRadius(0.65, em).marginBottom(0.85, em) + } +} + +register(DesktopIntegrationsSection) diff --git a/settings/desktop/DesktopRolesSection.js b/settings/desktop/DesktopRolesSection.js new file mode 100644 index 0000000..5a54d1d --- /dev/null +++ b/settings/desktop/DesktopRolesSection.js @@ -0,0 +1,243 @@ +class DesktopRolesSection extends Shadow { + constructor(roles, allApps, selectedRoleId, roleApps, confirmDeleteId, onSelectRole, onDeleteRole, onSetConfirmDelete, onCreateRole, onToggleApp) { + super() + this.roles = roles + this.allApps = allApps + this.selectedRoleId = selectedRoleId + this.roleApps = roleApps + this.confirmDeleteId = confirmDeleteId + this.onSelectRole = onSelectRole + this.onDeleteRole = onDeleteRole + this.onSetConfirmDelete = onSetConfirmDelete + this.onCreateRole = onCreateRole + this.onToggleApp = onToggleApp + this.newRoleName = "" + } + + get selectedRole() { + return this.roles.find(r => r.id === this.selectedRoleId) ?? null + } + + get selectedRoleAppIds() { + return this.roleApps[this.selectedRoleId] ?? new Set() + } + + render() { + VStack(() => { + HStack(() => { + VStack(() => { + p("Roles & Apps") + .margin(0).fontSize(1.35, em).fontWeight("700").color("var(--headertext)") + p("Control which apps each role can access") + .margin(0).marginTop(0.2, em).fontSize(0.75, em) + .color("var(--headertext)").opacity(0.42) + }) + .gap(0).flex(1) + }) + .paddingHorizontal(1.75, em).paddingTop(1.5, em).paddingBottom(1.1, em) + .alignItems("flex-start").flexShrink(0) + + HStack(() => { + // ── Role list ───────────────────────────────────────── + VStack(() => { + p("ROLES") + .margin(0).marginBottom(0.5, em) + .fontSize(0.62, em).fontWeight("700").letterSpacing("0.07em") + .color("var(--headertext)").opacity(0.35) + + this.roles.forEach(role => { + const isSelected = this.selectedRoleId === role.id + const isConfirming = this.confirmDeleteId === role.id + const appCount = (this.roleApps[role.id] ?? new Set()).size + + HStack(() => { + VStack(() => { + p(role.name) + .margin(0).fontSize(0.88, em) + .fontWeight(isSelected ? "700" : "500") + .color("var(--headertext)") + .overflow("hidden").whiteSpace("nowrap").textOverflow("ellipsis") + p(`${appCount} app${appCount !== 1 ? "s" : ""}`) + .margin(0).marginTop(0.12, em).fontSize(0.68, em) + .color("var(--headertext)").opacity(0.38) + }) + .flex(1).minWidth(0).gap(0) + + if (isConfirming) { + HStack(() => { + button("Delete") + .padding("0.2em 0.5em").border("none").borderRadius(0.3, em) + .background("var(--quillred)").color("white") + .fontSize(0.68, em).fontWeight("600").cursor("pointer") + .onClick((done) => { if (done) this.onDeleteRole(role.id) }) + button("βœ•") + .padding("0.2em 0.45em").border("none").borderRadius(0.3, em) + .background("transparent").color("var(--headertext)") + .opacity(0.4).fontSize(0.72, em).cursor("pointer") + .onClick((done) => { if (done) this.onSetConfirmDelete(null) }) + }) + .gap(0.3, em).alignItems("center") + } else { + button("β‹―") + .border("none").background("transparent") + .color("var(--headertext)").opacity(0.3) + .fontSize(1, em).cursor("pointer").padding("0.1em 0.35em") + .borderRadius(0.3, em) + .onClick((done) => { if (done) this.onSetConfirmDelete(role.id) }) + } + }) + .paddingHorizontal(0.75, em).paddingVertical(0.6, em) + .borderRadius(0.5, em) + .background(isSelected ? "var(--app)" : "transparent") + .border(`1px solid ${isSelected ? "var(--quillred)" : "transparent"}`) + .cursor("pointer").alignItems("center") + .onClick((done) => { if (done) this.onSelectRole(role.id) }) + }) + + // New role input + HStack(() => { + p("+").margin(0).fontSize(1, em).color("var(--headertext)").opacity(0.4).flexShrink(0) + input("New role name…", "100%") + .border("none").background("transparent") + .color("var(--headertext)").fontSize(0.85, em).outline("none").flex(1) + .onInput(e => { this.newRoleName = e.target.value }) + .onKeyDown(async (e) => { + if (e.key === "Enter" && this.newRoleName.trim()) { + await this.onCreateRole(this.newRoleName.trim()) + this.newRoleName = "" + } + }) + }) + .gap(0.5, em).alignItems("center") + .paddingHorizontal(0.75, em).paddingVertical(0.55, em) + .borderRadius(0.5, em).border("1px dashed var(--divider)") + .marginTop(0.35, em).cursor("text") + }) + .width(220, px).flexShrink(0).paddingHorizontal(1.75, em).gap(0.25, em) + + VStack(() => {}).width(1, px).background("var(--divider)").alignSelf("stretch").flexShrink(0) + + // ── Role detail ─────────────────────────────────────── + VStack(() => { + if (!this.selectedRole) { + VStack(() => { + p("Select a role to configure it") + .margin(0).fontSize(0.88, em) + .color("var(--headertext)").opacity(0.35).textAlign("center") + }) + .flex(1).justifyContent("center").alignItems("center") + } else { + VStack(() => { + HStack(() => { + p(this.selectedRole.name) + .margin(0).fontSize(1.1, em).fontWeight("700").color("var(--headertext)") + if (this.selectedRole.is_default) { + p("default") + .margin(0).fontSize(0.65, em).fontWeight("600") + .color("var(--quillred)") + .background("rgba(159,28,41,0.1)") + .paddingHorizontal(0.55, em).paddingVertical(0.15, em) + .borderRadius(100, px) + } + }) + .gap(0.65, em).alignItems("center").marginBottom(1.25, em) + + this.settingSection("πŸ“±", "App Access", "Choose which apps this role can access", () => { + VStack(() => { + this.allApps.forEach(app => { + const hasApp = this.selectedRoleAppIds.has(app.id) + const comingSoonApps = ["tasks", "jobs", "politics", "files"] + const isComingSoon = comingSoonApps.includes(app.name) + + HStack(() => { + VStack(() => { + if (hasApp) { + p("βœ“").margin(0).fontSize(0.6, em).fontWeight("800").color("white").lineHeight("1") + } + }) + .width(1.05, em).height(1.05, em).borderRadius(0.25, em) + .background(hasApp ? "var(--quillred)" : "transparent") + .border(`1.5px solid ${hasApp ? "var(--quillred)" : "var(--divider)"}`) + .justifyContent("center").alignItems("center") + .boxSizing("border-box").flexShrink(0) + + p(app.name.charAt(0).toUpperCase() + app.name.slice(1)) + .margin(0).fontSize(0.88, em).color("var(--headertext)") + + if (isComingSoon) { + p("coming soon") + .margin(0).fontSize(0.62, em).fontWeight("600") + .color("var(--headertext)").opacity(0.25) + .background("var(--divider)") + .paddingHorizontal(0.45, em).paddingVertical(0.12, em).borderRadius(100, px) + } + }) + .gap(0.75, em).alignItems("center") + .paddingVertical(0.5, em).paddingHorizontal(0.75, em) + .borderRadius(0.45, em) + .background("var(--darkaccent)").border("1px solid var(--divider)") + .cursor(isComingSoon ? "default" : "pointer") + .opacity(isComingSoon ? 0.5 : 1) + .onClick((done) => { if (!isComingSoon && done) this.onToggleApp(this.selectedRoleId, app.id, !hasApp) }) + }) + }) + .gap(0.45, em) + }) + + this.comingSoonSection("πŸ”", "Permissions", "Fine-grained action-level permissions per app") + this.comingSoonSection("πŸ””", "Notifications", "Configure default notification settings") + this.comingSoonSection("🎨", "Color & Badge", "Assign a display color and badge label") + }) + .paddingHorizontal(1.75, em).paddingBottom(1.75, em).paddingTop(0.5, em).gap(0) + } + }) + .flex(1).minWidth(0).overflowY("auto").height(100, pct) + }) + .flex(1).minHeight(0).alignItems("flex-start").overflow("hidden") + }) + .height(100, pct).overflow("hidden") + } + + settingSection(icon, title, subtitle, renderContent) { + VStack(() => { + HStack(() => { + p(icon).margin(0).fontSize(1, em).lineHeight("1").flexShrink(0) + VStack(() => { + p(title).margin(0).fontSize(0.92, em).fontWeight("700").color("var(--headertext)") + p(subtitle).margin(0).marginTop(0.1, em).fontSize(0.72, em).color("var(--headertext)").opacity(0.42) + }) + .gap(0) + }) + .gap(0.65, em).alignItems("flex-start").marginBottom(0.85, em) + renderContent() + }) + .padding(1.1, em).background("var(--darkaccent)").border("1px solid var(--divider)") + .borderRadius(0.65, em).marginBottom(0.85, em) + } + + comingSoonSection(icon, title, subtitle) { + VStack(() => { + HStack(() => { + p(icon).margin(0).fontSize(1, em).lineHeight("1").flexShrink(0).opacity(0.35) + VStack(() => { + HStack(() => { + p(title).margin(0).fontSize(0.92, em).fontWeight("700").color("var(--headertext)").opacity(0.35) + p("coming soon") + .margin(0).fontSize(0.62, em).fontWeight("600") + .color("var(--headertext)").opacity(0.25) + .background("var(--divider)") + .paddingHorizontal(0.45, em).paddingVertical(0.12, em).borderRadius(100, px) + }) + .gap(0.55, em).alignItems("center") + p(subtitle).margin(0).marginTop(0.1, em).fontSize(0.72, em).color("var(--headertext)").opacity(0.28) + }) + .gap(0) + }) + .gap(0.65, em).alignItems("flex-start") + }) + .padding(1.1, em).border("1px solid var(--divider)").borderRadius(0.65, em) + .marginBottom(0.85, em).opacity(0.6) + } +} + +register(DesktopRolesSection) diff --git a/settings/desktop/DesktopSettingsSidebar.js b/settings/desktop/DesktopSettingsSidebar.js new file mode 100644 index 0000000..fafbd7c --- /dev/null +++ b/settings/desktop/DesktopSettingsSidebar.js @@ -0,0 +1,95 @@ +class DesktopSettingsSidebar extends Shadow { + constructor(activeSection, onSectionChange) { + super() + this.activeSection = activeSection + this.onSectionChange = onSectionChange + } + + render() { + VStack(() => { + VStack(() => { + p("Settings") + .fontFamily("Laandbrau") + .margin(0).fontSize(1.8, em).fontWeight("700").color("var(--headertext)") + p(global.currentNetwork.name) + .margin(0).marginTop(0.2, em).fontSize(0.72, em) + .color("var(--headertext)").opacity(0.38) + }) + .marginTop(30, px) + .paddingHorizontal(1.1, em).paddingTop(1.2, em).paddingBottom(0.9, em) + + VStack(() => {}).height(1, px).background("var(--divider)").marginHorizontal(1, em) + + VStack(() => { + this.sectionLabel("MANAGE") + this.navItem("🎭", "Roles & Apps", "roles") + }) + .paddingTop(0.75, em) + + VStack(() => {}).height(1, px).background("var(--divider)").marginHorizontal(1, em).marginVertical(0.5, em) + + VStack(() => { + this.sectionLabel("INTEGRATIONS") + this.navItem("πŸ’³", "Stripe", "integrations") + }) + + VStack(() => {}).flex(1) + + VStack(() => { + p("NETWORK") + .margin(0).marginBottom(0.4, em) + .fontSize(0.6, em).fontWeight("700").letterSpacing("0.07em") + .color("var(--headertext)").opacity(0.35) + + VStack(() => { + this.infoRow("ID", String(global.currentNetwork.id)) + this.infoRow("Slug", global.currentNetwork.abbreviation) + }) + .background("var(--darkaccent)").border("1px solid var(--divider)") + .borderRadius(0.5, em).gap(0) + }) + .paddingHorizontal(0.75, em).paddingBottom(1.1, em).flexShrink(0) + }) + .width(230, px).height(100, pct) + .borderRight("1px solid var(--divider)") + .flexShrink(0).overflowY("auto").boxSizing("border-box") + } + + navItem(icon, label, sectionId) { + const isActive = this.activeSection === sectionId + HStack(() => { + p(icon).margin(0).fontSize(0.9, em).lineHeight("1").flexShrink(0) + p(label).margin(0).fontSize(0.88, em) + .fontWeight(isActive ? "600" : "400") + .color("var(--headertext)").opacity(isActive ? 1 : 0.65) + }) + .gap(0.62, em).paddingHorizontal(0.85, em).paddingVertical(0.45, 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((done) => { if (done) this.onSectionChange(sectionId) }) + } + + sectionLabel(text) { + p(text) + .margin(0).marginBottom(0.28, em).paddingHorizontal(1.1, em) + .fontSize(0.62, em).fontWeight("700").letterSpacing("0.07em") + .color("var(--headertext)").opacity(0.35) + } + + infoRow(label, value) { + HStack(() => { + p(label) + .margin(0).fontSize(0.68, em).color("var(--headertext)").opacity(0.4) + .width(4, em).flexShrink(0) + p(value) + .margin(0).fontSize(0.72, em).fontWeight("600").color("var(--headertext)") + }) + .paddingVertical(0.3, em).paddingHorizontal(0.65, em) + .borderBottom("1px solid var(--divider)") + .alignItems("center").gap(0.4, em) + } +} + +register(DesktopSettingsSidebar) diff --git a/settings/desktop/settings.js b/settings/desktop/settings.js new file mode 100644 index 0000000..be3c063 --- /dev/null +++ b/settings/desktop/settings.js @@ -0,0 +1,149 @@ +import server from "/@server/server.js" +import env from "/_/code/env.js" +import "/_/code/components/LoadingCircle.js" +import "./DesktopSettingsSidebar.js" +import "./DesktopRolesSection.js" +import "./DesktopIntegrationsSection.js" + +css(` + settings- { + font-family: 'Arial'; + scrollbar-width: none; + -ms-overflow-style: none; + } + settings- input::placeholder { + color: var(--headertext); + opacity: 0.35; + } +`) + +class Settings extends Shadow { + activeSection = "roles" + loaded = false + + roles = [] + allApps = [] + selectedRoleId = null + roleApps = {} + confirmDeleteId = null + stripeDetails = null + + render() { + HStack(() => { + DesktopSettingsSidebar(this.activeSection, (section) => { + this.activeSection = section + this.rerender() + }) + + VStack(() => { + if (!this.loaded) { + VStack(() => LoadingCircle()) + .flex(1) + .justifyContent("center") + .alignItems("center") + } else if (this.activeSection === "roles") + DesktopRolesSection( + this.roles, + this.allApps, + this.selectedRoleId, + this.roleApps, + this.confirmDeleteId, + (id) => { this.selectedRoleId = id; this.confirmDeleteId = null; this.rerender() }, + (id) => this.deleteRole(id), + (id) => { this.confirmDeleteId = id; this.rerender() }, + (name) => this.createRole(name), + (roleId, appId, add) => this.toggleRoleApp(roleId, appId, add) + ) + else if (this.activeSection === "integrations") + DesktopIntegrationsSection(this.stripeDetails, () => this.handleConnectStripe()) + }) + .flex(1).height(100, pct).overflowY("auto") + }) + .height(100, pct).width(100, pct).overflow("hidden") + .onAppear(async () => { + if (this.loaded) return + await this.loadRoles() + this.stripeDetails = await server.getStripeProfile(global.currentNetwork.id) + this.rerender() + }) + } + + // ── Server actions ──────────────────────────────────────────────── + + async loadRoles() { + const [roles, apps] = await Promise.all([ + server.getRoles(global.currentNetwork.id), + server.getAllApps() + ]) + + this.roles = Array.isArray(roles) ? roles : [] + this.allApps = Array.isArray(apps) ? apps : [] + this.selectedRoleId = this.roles[0]?.id ?? null + + await Promise.all(this.roles.map(async role => { + const roleApps = await server.getRoleApps(role.id) + this.roleApps[role.id] = new Set(Array.isArray(roleApps) ? roleApps.map(a => a.id) : []) + })) + + this.loaded = true + this.rerender() + } + + async createRole(name) { + const result = await server.createRole(name, global.currentNetwork.id) + if (!result?.error && result?.role) { + this.roles.push(result.role) + this.roleApps[result.role.id] = new Set() + this.selectedRoleId = result.role.id + this.rerender() + } + } + + async deleteRole(roleId) { + await server.deleteRole(roleId, global.currentNetwork.id) + this.roles = this.roles.filter(r => r.id !== roleId) + delete this.roleApps[roleId] + this.selectedRoleId = this.roles[0]?.id ?? null + this.confirmDeleteId = null + this.rerender() + } + + async toggleRoleApp(roleId, appId, add) { + if (add) { + await server.addRoleApp(roleId, appId) + if (!this.roleApps[roleId]) this.roleApps[roleId] = new Set() + this.roleApps[roleId].add(appId) + } else { + await server.removeRoleApp(roleId, appId) + this.roleApps[roleId]?.delete(appId) + } + + if (roleId == global.currentNetwork.role?.id) { + const appName = this.allApps.find(a => a.id === appId)?.name + if (appName) { + if (add) { + global.currentNetwork.apps.push(appName) + } else { + global.currentNetwork.apps = global.currentNetwork.apps.filter(a => a !== appName) + } + document.querySelector("app-menu")?.rerender() + } + } + + this.rerender() + } + + handleConnectStripe() { + const state = btoa(JSON.stringify({ returnTo: window.location.href, networkId: global.currentNetwork.id })) + const params = new URLSearchParams({ + response_type: "code", + client_id: env.client_id, + scope: "read_write", + redirect_uri: `${env.baseURL}/stripe/onboardingcomplete`, + state, + }) + window.location.href = `https://connect.stripe.com/oauth/authorize?${params}` + } +} + +register(Settings) diff --git a/settings/icons/settings.svg b/settings/icons/settings.svg new file mode 100644 index 0000000..c03c0f6 --- /dev/null +++ b/settings/icons/settings.svg @@ -0,0 +1,3 @@ + + + diff --git a/settings/icons/settingslight.svg b/settings/icons/settingslight.svg new file mode 100644 index 0000000..bb193bb --- /dev/null +++ b/settings/icons/settingslight.svg @@ -0,0 +1,3 @@ + + + diff --git a/settings/icons/settingslightselected.svg b/settings/icons/settingslightselected.svg new file mode 100644 index 0000000..0a845e1 --- /dev/null +++ b/settings/icons/settingslightselected.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/settings/settings.js b/settings/settings.js new file mode 100644 index 0000000..c76b2d8 --- /dev/null +++ b/settings/settings.js @@ -0,0 +1,216 @@ +import server from "/@server/server.js" +import env from "/_/code/env.js" +import "./SettingsRolesSection.js" +import "./SettingsIntegrationsSection.js" +import "/_/code/components/LoadingCircle.js" + +css(` + settings- { + font-family: 'Arial'; + scrollbar-width: none; + -ms-overflow-style: none; + } + settings- input::placeholder { + color: var(--headertext); + opacity: 0.35; + } +`) + +class Settings extends Shadow { + roles = [] + allApps = [] + roleApps = {} + stripeDetails = null + loaded = false + + // ── URL-derived routing ─────────────────────────────────────────── + + get basePath() { + return window.location.pathname + .replace(/\/roles(\/[^/]*)?$/, '') + .replace(/\/integrations$/, '') + .replace(/\/$/, '') || '/' + } + + get section() { + const path = window.location.pathname + if (/\/roles\/[^/]+/.test(path)) return 'role-detail' + if (/\/roles$/.test(path)) return 'roles' + if (/\/integrations$/.test(path)) return 'integrations' + return 'home' + } + + get activeRoleId() { + const match = window.location.pathname.match(/\/roles\/([^/]+)$/) + return match ? match[1] : null + } + + // ── Render ──────────────────────────────────────────────────────── + + render() { + const loading = !this.loaded || global.appRefreshing + + VStack(() => { + if (this.section === 'roles' || this.section === 'role-detail') { + SettingsRolesSection( + this.roles, + this.allApps, + this.section, + this.activeRoleId, + this.roleApps, + this.basePath, + { + onDeleteRole: (roleId) => this.deleteRole(roleId), + onCreateRole: (name) => this.createRole(name), + onToggleApp: (roleId, appId, add) => this.toggleRoleApp(roleId, appId, add), + }, + loading + ) + } else if (this.section === 'integrations') { + SettingsIntegrationsSection( + this.stripeDetails, + this.basePath, + { onConnectStripe: () => this.handleConnectStripe() }, + loading + ) + } else { + if (loading) LoadingCircle() + else this.renderHome() + } + }) + .height(100, pct) + .width(100, pct) + .boxSizing("border-box") + .overflow("hidden") + .onNavigate(() => { + this.rerender() + }) + .onAppear(async () => { + if (this.loaded) return + await this.loadData() + }) + } + + // ── Home ────────────────────────────────────────────────────────── + + renderHome() { + VStack(() => { + VStack(() => {}).height(1, px).background("var(--divider)").flexShrink(0) + + VStack(() => { + this.menuItem("🎭", "Roles & Apps", `${this.roles.length} roles`, "roles") + + VStack(() => {}) + .height(1, px) + .background("var(--divider)") + .marginLeft(3.5, em) + + this.menuItem("πŸ’³", "Stripe", this.stripeDetails?.email ? "Connected" : "Not connected", "integrations") + }) + .marginTop(0.5, em) + .paddingHorizontal(1, em) + .flexShrink(0) + }) + .height(100, pct) + .overflowY("auto") + } + + menuItem(icon, label, subtitle, section) { + HStack(() => { + VStack(() => { + p(icon).margin(0).fontSize(1.1, em).lineHeight("1").color("var(--headertext)") + }) + .width(2.2, em).height(2.2, em).borderRadius(0.45, em) + .background("var(--darkaccent)").border("1px solid var(--divider)") + .justifyContent("center").alignItems("center").flexShrink(0) + + VStack(() => { + p(label).margin(0).fontSize(0.92, em).fontWeight("600").color("var(--headertext)") + p(subtitle).margin(0).marginTop(0.15, em).fontSize(0.72, em).color("var(--headertext)").opacity(0.42) + }) + .flex(1).gap(0) + + p("β€Ί").margin(0).fontSize(1.1, em).color("var(--headertext)").opacity(0.3) + }) + .gap(0.75, em).alignItems("center") + .paddingVertical(0.85, em) + .cursor("pointer") + .onTap(() => window.navigateTo(`${this.basePath}/${section}`)) + } + + // ── Server actions ──────────────────────────────────────────────── + + async loadData() { + const [roles, apps, stripe] = await Promise.all([ + server.getRoles(global.currentNetwork.id), + server.getAllApps(), + server.getStripeProfile(global.currentNetwork.id) + ]) + this.roles = Array.isArray(roles) ? roles : [] + this.allApps = Array.isArray(apps) ? apps : [] + this.stripeDetails = stripe + + await Promise.all(this.roles.map(async role => { + const roleApps = await server.getRoleApps(role.id) + this.roleApps[role.id] = new Set(Array.isArray(roleApps) ? roleApps.map(a => a.id) : []) + })) + + this.loaded = true + this.rerender() + } + + async createRole(name) { + const result = await server.createRole(name, global.currentNetwork.id) + if (!result?.error && result?.role) { + this.roles.push(result.role) + this.roleApps[result.role.id] = new Set() + this.rerender() + } + } + + async deleteRole(roleId) { + await server.deleteRole(roleId, global.currentNetwork.id) + this.roles = this.roles.filter(r => r.id !== roleId) + delete this.roleApps[roleId] + window.navigateTo(`${this.basePath}/roles`) + } + + async toggleRoleApp(roleId, appId, add) { + if (add) { + await server.addRoleApp(roleId, appId) + if (!this.roleApps[roleId]) this.roleApps[roleId] = new Set() + this.roleApps[roleId].add(appId) + } else { + await server.removeRoleApp(roleId, appId) + this.roleApps[roleId]?.delete(appId) + } + + if (roleId == global.currentNetwork.role?.id) { + const appName = this.allApps.find(a => a.id === appId)?.name + if (appName) { + if (add) { + global.currentNetwork.apps.push(appName) + } else { + global.currentNetwork.apps = global.currentNetwork.apps.filter(a => a !== appName) + } + document.querySelector("appmenu-")?.rerender() + } + } + + this.rerender() + } + + handleConnectStripe() { + const state = btoa(JSON.stringify({ returnTo: window.location.href, networkId: global.currentNetwork.id })) + const params = new URLSearchParams({ + response_type: "code", + client_id: env.client_id, + scope: "read_write", + redirect_uri: `${env.baseURL}/stripe/onboardingcomplete`, + state, + }) + window.location.href = `https://connect.stripe.com/oauth/authorize?${params}` + } +} + +register(Settings) diff --git a/tasks/desktop/tasks.js b/tasks/desktop/tasks.js new file mode 100644 index 0000000..3533cd5 --- /dev/null +++ b/tasks/desktop/tasks.js @@ -0,0 +1,144 @@ +import "../../components/AppTitle.js" +import taskServer from "/tasks/@server/tasks.js" + +class Tasks extends Shadow { + tasks = [] + newTitle = "" + + render() { + VStack(() => { + AppTitle("Tasks") + .marginTop(1, em) + .marginBottom(2, em) + .marginLeft(7, vw) + + VStack(() => { + this.tasks.forEach((task) => { + HStack(() => { + // Circular checkbox + VStack(() => {}) + .width(1.1, em).height(1.1, em) + .minWidth(1.1, em) + .borderRadius(50, pct) + .border(task.done ? "none" : "1.5px solid var(--divider)") + .background(task.done ? "var(--accent)" : "transparent") + .cursor("pointer") + .flexShrink(0) + .onClick(async (end) => { + if(end) { + const updated = await taskServer.updateTaskDone(task.id, !task.done) + console.log(updated) + if (updated && updated.id) { + const idx = this.tasks.findIndex(t => t.id === task.id) + if (idx >= 0) this.tasks[idx] = updated + this.rerender() + } + } + }) + + p(`#${task.id}`) + .margin(0) + .fontSize(0.75, em) + .color("var(--headertext)") + .opacity(0.4) + .flexShrink(0) + + input("", "100%") + .attr({ value: task.title, placeholder: "Task title" }) + .background("transparent") + .border("none") + .outline("none") + .fontSize(0.9, em) + .color(task.done ? "var(--headertext)" : "var(--text)") + .textDecoration(task.done ? "line-through" : "none") + .opacity(task.done ? 0.5 : 1) + .padding(0) + .onBlur(async function () { + const newVal = this.value.trim() + if (newVal && newVal !== task.title) { + await taskServer.editTaskTitle(task.id, newVal) + task.title = newVal + } + }) + .onKeyDown(function (e) { + if (e.key === "Enter") this.blur() + }) + + // Delete button + p("Γ—") + .margin(0) + .fontSize(1.1, em) + .color("var(--headertext)") + .opacity(0.3) + .cursor("pointer") + .flexShrink(0) + .onHover(function (hovering) { + this.style.opacity = hovering ? "1" : "0.3" + this.style.color = hovering ? "var(--quillred)" : "var(--headertext)" + }) + .onClick(async () => { + await taskServer.deleteTask(task.id) + this.tasks = this.tasks.filter(t => t.id !== task.id) + this.rerender() + }) + }) + .gap(0.65, em) + .alignItems("center") + .paddingVertical(0.4, em) + .paddingHorizontal(0.5, em) + .marginRight(0) + }) + }) + .width(70, pct) + .marginLeft(7, vw) + .border("1px solid var(--divider)") + .borderRadius(8, px) + .overflow("hidden") + + // Add task input + input("", "70%") + .attr({ placeholder: "New task..." }) + .marginLeft(7, vw) + .marginTop(0.5, em) + .padding(0.5, em) + .paddingHorizontal(0.75, em) + .background("var(--darkaccent)") + .border("1px solid var(--divider)") + .borderRadius(8, px) + .outline("none") + .fontSize(0.9, em) + .boxSizing("border-box") + .color("var(--text)") + .onKeyDown(async (e) => { + if (e.key === "Enter") { + console.log("enter") + const val = e.target.value.trim() + if (!val) return + const task = await taskServer.addTask(global.currentNetwork.id, val) + if (task && task.id) { + this.tasks.push(task) + e.target.value = "" + this.rerender() + } else { + if(task.error) console.error("Error making task: ", task.error) + } + } + }) + }) + .onAppear(async () => { + const tasks = await taskServer.getTasks(global.currentNetwork.id) + if (tasks && !tasks.error && tasks.length !== this.tasks.length) { + this.tasks = tasks + this.rerender() + } + }) + .gap(0) + .paddingTop(2, pct) + .paddingBottom(4, pct) + .width(80, vw) + .height(100, pct) + .overflow("auto") + } +} + +register(Tasks) diff --git a/tasks/icons/tasks.svg b/tasks/icons/tasks.svg new file mode 100644 index 0000000..9439308 --- /dev/null +++ b/tasks/icons/tasks.svg @@ -0,0 +1,4 @@ + + + + diff --git a/tasks/icons/taskslight.svg b/tasks/icons/taskslight.svg new file mode 100644 index 0000000..7158157 --- /dev/null +++ b/tasks/icons/taskslight.svg @@ -0,0 +1,4 @@ + + + + diff --git a/tasks/server/functions.js b/tasks/server/functions.js new file mode 100644 index 0000000..49bfe24 --- /dev/null +++ b/tasks/server/functions.js @@ -0,0 +1,47 @@ +export async function getTasks(networkId) { + const tasks = await this.sql` + SELECT * FROM tasks.tasks + WHERE network_id = ${networkId} + AND is_active = true + ORDER BY created ASC + `; + return tasks +} + +export async function addTask(networkId, title) { + const [task] = await this.sql` + INSERT INTO tasks.tasks (title, network_id) + VALUES (${title}, ${networkId}) + RETURNING * + `; + return task +} + +export async function updateTaskDone(taskId, done) { + const [task] = await this.sql` + UPDATE tasks.tasks + SET done = ${done} + WHERE id = ${taskId} + RETURNING * + `; + return task +} + +export async function editTaskTitle(taskId, title) { + const [task] = await this.sql` + UPDATE tasks.tasks + SET title = ${title} + WHERE id = ${taskId} + RETURNING * + `; + return task +} + +export async function deleteTask(taskId) { + await this.sql` + UPDATE tasks.tasks + SET is_active = false + WHERE id = ${taskId} + `; + return { ok: true } +} diff --git a/tasks/tasks.js b/tasks/tasks.js new file mode 100644 index 0000000..ecfa0e8 --- /dev/null +++ b/tasks/tasks.js @@ -0,0 +1,152 @@ +import taskServer from "/tasks/@server/tasks.js" + +class Tasks extends Shadow { + tasks = [] + + render() { + VStack(() => { + + // Task list β€” full-bleed on mobile, no inset borders + VStack(() => { + if (this.tasks.length === 0) { + p("No tasks yet") + .margin(0) + .padding(2, em) + .textAlign("center") + .color("var(--headertext)") + .opacity(0.4) + .fontSize(0.95, em) + } else { + this.tasks.forEach((task, i) => { + HStack(() => { + // Larger circular checkbox β€” 1.6em for thumb-friendly target + VStack(() => {}) + .width(1.6, em).height(1.6, em) + .minWidth(1.6, em) + .borderRadius(50, pct) + .border(task.done ? "none" : "2px solid var(--divider)") + .background(task.done ? "var(--accent)" : "transparent") + .cursor("pointer") + .flexShrink(0) + .transition("all 0.15s ease") + .onClick(async (end) => { + if (end) { + const updated = await taskServer.updateTaskDone(task.id, !task.done) + if (updated && updated.id) { + const idx = this.tasks.findIndex(t => t.id === task.id) + if (idx >= 0) this.tasks[idx] = updated + this.rerender() + } + } + }) + + // Title input β€” takes remaining width + input("", "100%") + .attr({ value: task.title, placeholder: "Task title" }) + .background("transparent") + .border("none") + .outline("none") + .fontSize(1.05, em) + .color(task.done ? "var(--headertext)" : "var(--text)") + .textDecoration(task.done ? "line-through" : "none") + .opacity(task.done ? 0.5 : 1) + .padding(0) + .paddingVertical(0.25, em) + .onBlur(async function () { + const newVal = this.value.trim() + if (newVal && newVal !== task.title) { + await taskServer.editTaskTitle(task.id, newVal) + task.title = newVal + } + }) + .onKeyDown(function (e) { + if (e.key === "Enter") this.blur() + }) + + // Delete button β€” bigger hit area on mobile + VStack(() => { + p("Γ—") + .margin(0) + .fontSize(1.4, em) + .color("var(--headertext)") + .opacity(0.35) + .lineHeight(1) + }) + .width(2.2, em).height(2.2, em) + .minWidth(2.2, em) + .alignItems("center") + .justifyContent("center") + .cursor("pointer") + .flexShrink(0) + .onClick(async () => { + await taskServer.deleteTask(task.id) + this.tasks = this.tasks.filter(t => t.id !== task.id) + this.rerender() + }) + }) + .gap(0.85, em) + .alignItems("center") + .paddingVertical(0.85, em) + .paddingHorizontal(5, vw) + .borderBottom(i < this.tasks.length - 1 ? "1px solid var(--divider)" : "none") + }) + } + }) + .width(100, pct) + .borderTop("1px solid var(--divider)") + .borderBottom("1px solid var(--divider)") + }) + .onAppear(async () => { + const tasks = await taskServer.getTasks(global.currentNetwork.id) + if (tasks && !tasks.error && tasks.length !== this.tasks.length) { + this.tasks = tasks + this.rerender() + } + }) + .gap(0) + .paddingBottom(6, em) // leave room for sticky input below + .width(100, vw) + .height(100, pct) + .overflow("auto") + + // Sticky bottom add-task bar β€” anchored above the keyboard area, easy thumb reach + HStack(() => { + input("", "100%") + .attr({ placeholder: "New task..." }) + .padding(0.85, em) + .paddingHorizontal(1, em) + .background("var(--darkaccent)") + .border("1px solid var(--divider)") + .borderRadius(999, px) + .outline("none") + .fontSize(1, em) + .boxSizing("border-box") + .color("var(--text)") + .onKeyDown(async (e) => { + if (e.key === "Enter") { + const val = e.target.value.trim() + if (!val) return + const task = await taskServer.addTask(global.currentNetwork.id, val) + if (task && task.id) { + this.tasks.push(task) + e.target.value = "" + this.rerender() + } else if (task && task.error) { + console.error("Error making task:", task.error) + } + } + }) + }) + .position("fixed") + .bottom(0, px) + .left(0, px) + .right(0, px) + .padding(0.75, em) + .paddingHorizontal(5, vw) + .paddingBottom("calc(0.75em + env(safe-area-inset-bottom))") + .background("var(--background)") + .borderTop("1px solid var(--divider)") + } +} + +register(Tasks) \ No newline at end of file diff --git a/website/desktop/website.js b/website/desktop/website.js new file mode 100644 index 0000000..dcebbb6 --- /dev/null +++ b/website/desktop/website.js @@ -0,0 +1,581 @@ +import server from "/@server/server.js" + +css(` + website- { + font-family: 'Arial'; + scrollbar-width: none; + -ms-overflow-style: none; + } + website- input::placeholder { color: var(--headertext); opacity: 0.35; } + website- textarea { font-family: 'Arial'; } +`) + +class Website extends Shadow { + contact_form = [] + join_form = [] + + activeTab = "overview" // "overview" | "contact" | "join" + searchText = "" + sortKey = "time" + sortDir = "desc" + selectedId = null // selected row id for detail drawer + + // ── helpers ──────────────────────────────────────────────────────────── + + formatTimeCustom(time) { + if (!time) return "β€”" + // input: 01.28.2026-10:26:550910pm + const match = time.match(/(\d{2})\.(\d{2})\.(\d{4})-(\d{1,2}):(\d{2}):\d+(am|pm)/i) + if (!match) return time + const [, month, day, year, hour, minute, ampm] = match + return `${month}/${day}/${year.slice(2)} ${hour}:${minute}${ampm.toLowerCase()}` + } + + parseTime(str) { + const match = str.match(/(\d+)\.(\d+)\.(\d+)-(\d+):(\d+):(\d+)(\d{3})(am|pm)/i); + const [, month, day, year, hours, minutes, seconds, ms, ampm] = match; + + let hrs = parseInt(hours); + if (ampm.toLowerCase() === 'pm' && hrs !== 12) hrs += 12; + if (ampm.toLowerCase() === 'am' && hrs === 12) hrs = 0; + + return new Date(year, month - 1, day, hrs, minutes, seconds, ms); + } + + fmt(n) { + return Number(n).toLocaleString("en-US") + } + + // ── stats ────────────────────────────────────────────────────────────── + + get stats() { + const now = new Date() + const thisMonth = (entries) => entries.filter(e => { + const d = this.parseTime(e.time) + return d.getMonth() === now.getMonth() && d.getFullYear() === now.getFullYear() + }) + const counties = new Set([ + ...this.contact_form.map(e => e.county).filter(Boolean), + ...this.join_form.map(e => e.county).filter(Boolean) + ]) + return { + totalContact: this.contact_form.length, + totalJoin: this.join_form.length, + monthContact: thisMonth(this.contact_form).length, + monthJoin: thisMonth(this.join_form).length, + counties: counties.size, + } + } + + 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(), contact: 0, join: 0 }) + } + const bucket = (entries, key) => entries.forEach(e => { + const d = this.parseTime(e.time) + const m = months.find(m => m.year === d.getFullYear() && m.month === d.getMonth()) + if (m) m[key]++ + }) + bucket(this.contact_form, "contact") + bucket(this.join_form, "join") + return months + } + + // ── sorted/filtered rows ─────────────────────────────────────────────── + + get rows() { + const source = this.activeTab === "contact" ? this.contact_form : this.join_form + let list = source.map((e, i) => ({ ...e, _id: i })) + + if (this.searchText) { + const q = this.searchText.toLowerCase() + list = list.filter(e => + [e.fname, e.lname, e.email, e.phone, e.county, e.message] + .some(v => v?.toLowerCase().includes(q)) + ) + } + + list.sort((a, b) => { + let av, bv + if (this.sortKey === "time") { av = this.parseTime(a.time); bv = this.parseTime(b.time) } + else if (this.sortKey === "first") { av = a.fname || ""; bv = b.fname || "" } + else if (this.sortKey === "last") { av = a.lname || ""; bv = b.lname || "" } + else if (this.sortKey === "email") { av = a.email || ""; bv = b.email || "" } + else if (this.sortKey === "county") { av = a.county || ""; bv = b.county || "" } + else { av = a[this.sortKey] || ""; bv = b[this.sortKey] || "" } + const cmp = typeof av === "string" ? av.localeCompare(bv) : av - bv + return this.sortDir === "asc" ? cmp : -cmp + }) + + return list + } + + get selectedRow() { + const source = this.activeTab === "contact" ? this.contact_form : this.join_form + return this.selectedId !== null ? source[this.selectedId] : null + } + + // ── render ───────────────────────────────────────────────────────────── + + render() { + VStack(() => { + this.renderToolbar() + + HStack(() => { + // main content + VStack(() => { + if (this.activeTab === "overview") { + this.renderOverview() + } else { + this.renderTableView() + } + }) + .flex(1).minWidth(0).height(100, pct).overflow("hidden") + + // detail drawer (contact tab only) + if (this.activeTab === "contact" && this.selectedRow) { + this.renderDetailDrawer(this.selectedRow) + } + }) + .flex(1).minHeight(0).overflow("hidden") + }) + .height(100, pct).width(100, pct).overflow("hidden") + .onAppear(() => this.getData()) + } + + renderToolbar() { + HStack(() => { + // Tab strip + HStack(() => { + [["overview","Overview"], ["contact","Contact Inquiries"], ["join","Join Requests"]] + .forEach(([key, label]) => { + const isActive = this.activeTab === key + p(label) + .margin(0).fontSize(0.8, em) + .fontWeight(isActive ? "600" : "400") + .color("var(--headertext)").opacity(isActive ? 1 : 0.5) + .paddingHorizontal(0.75, em).paddingVertical(0.35, em) + .borderRadius(0.35, em) + .background(isActive ? "var(--app)" : "transparent") + .cursor("pointer") + .onClick((done) => { + if (!done) return + this.activeTab = key + this.searchText = "" + this.selectedId = null + this.sortKey = "time" + this.sortDir = "desc" + this.rerender() + }) + }) + }) + .background("var(--darkaccent)").border("1px solid var(--divider)") + .borderRadius(0.5, em).padding(0.2, em).gap(0.15, em) + }) + .marginTop(20, px) + .paddingHorizontal(1.25, em) + .paddingTop(1.1, em) + .paddingBottom(0.85, em) + .alignItems("center") + .gap(0.75, em) + .flexShrink(0) + } + + // ── overview ─────────────────────────────────────────────────────────── + + renderOverview() { + VStack(() => { + // Stats row + HStack(() => { + const s = this.stats + this.statCard("Contact Submissions", String(s.totalContact), "βœ‰οΈ") + this.statCard("Join Requests", String(s.totalJoin), "πŸ™‹") + this.statCard("This Month", String(s.monthContact + s.monthJoin), "πŸ“…") + this.statCard("Counties Reached", String(s.counties), "πŸ“") + this.statCard("Page Visits", "β€”", "πŸ‘οΈ") + this.statCard("Avg. Time on Site", "β€”", "⏱️") + }) + .paddingHorizontal(1.25, em).paddingBottom(1, em) + .gap(0.65, em).alignItems("stretch").flexShrink(0) + + VStack(() => {}).height(1, px).background("var(--divider)") + .marginHorizontal(1.25, em).flexShrink(0) + + // Chart + recent columns + HStack(() => { + this.renderChart() + + VStack(() => {}).width(1, px).background("var(--divider)").flexShrink(0) + + // Recent submissions split + HStack(() => { + this.renderRecentColumn("Recent Contact", this.contact_form, "contact") + VStack(() => {}).width(1, px).background("var(--divider)") + this.renderRecentColumn("Recent Join Requests", this.join_form, "join") + }) + .flex(1).minWidth(0).height(100, pct) + }) + .flex(1).minHeight(0).gap(0).overflow("hidden") + }) + .height(100, pct).width(100, pct).overflow("hidden") + .paddingTop(1, em) + } + + statCard(label, value, icon) { + VStack(() => { + p(icon).margin(0).fontSize(1.05, em).lineHeight("1") + p(value) + .margin(0).marginTop(0.5, em) + .fontSize(value === "β€”" ? 1.3 : 1.2, em).fontWeight("700") + .color(value === "β€”" ? "var(--headertext)" : "var(--headertext)") + .opacity(value === "β€”" ? 0.25 : 1) + .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.contact + m.join), 1) + + VStack(() => { + p("Monthly Submissions") + .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.contact + m.join + const barPct = Math.max((total / max) * 100, total > 0 ? 3 : 0) + + VStack(() => { + VStack(() => { + if (m.join > 0) VStack(() => {}).flex(m.join).background("#3b82f6") + if (m.contact > 0) VStack(() => {}).flex(m.contact).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 : 3, 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("Contact").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("Join").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).paddingLeft(1.25, em) + .background("var(--darkaccent)").border("1px solid var(--divider)") + .borderRadius(0.6, em).width(320, px).height(180, px) + .boxSizing("border-box").flexShrink(0).margin(1.25, em) + .marginRight(0) + } + + renderRecentColumn(title, entries, tab) { + const recent = [...entries] + .sort((a, b) => this.parseTime(b.time) - this.parseTime(a.time)) + .slice(0, 6) + + VStack(() => { + HStack(() => { + p(title) + .margin(0).fontSize(0.7, em).fontWeight("700") + .letterSpacing("0.05em").color("var(--headertext)").opacity(0.38) + .flex(1) + + if (entries.length > 0) { + p(`View all β†’`) + .margin(0).fontSize(0.72, em).color("var(--headertext)").opacity(0.4) + .cursor("pointer") + .onClick((done) => { + if (!done) return + this.activeTab = tab + this.searchText = "" + this.selectedId = null + this.rerender() + }) + } + }) + .alignItems("center").marginBottom(0.55, em) + + if (recent.length === 0) { + p("No submissions yet") + .margin(0).fontSize(0.82, em).color("var(--headertext)").opacity(0.28) + .fontStyle("italic") + } else { + recent.forEach(e => { + HStack(() => { + VStack(() => { + p(`${e.fname || ""} ${e.lname || ""}`.trim() || e.email || "β€”") + .margin(0).fontSize(0.82, em).fontWeight("600") + .color("var(--headertext)") + .overflow("hidden").whiteSpace("nowrap").textOverflow("ellipsis") + p(e.county || e.email || "β€”") + .margin(0).marginTop(0.12, em).fontSize(0.72, em) + .color("var(--headertext)").opacity(0.42) + .overflow("hidden").whiteSpace("nowrap").textOverflow("ellipsis") + }).flex(1).minWidth(0) + + p(this.formatTimeCustom(e.time)) + .margin(0).fontSize(0.7, em).color("var(--headertext)").opacity(0.35) + .whiteSpace("nowrap").flexShrink(0) + }) + .paddingVertical(0.55, em) + .borderBottom("1px solid var(--divider)") + .alignItems("center").gap(0.5, em) + }) + } + }) + .padding(1.25, em).flex(1).minWidth(0).overflow("hidden") + } + + // ── table view ───────────────────────────────────────────────────────── + + renderTableView() { + const isContact = this.activeTab === "contact" + const COLS = isContact + ? [ + { label: "Date", key: "time", width: "150px" }, + { label: "First", key: "first", width: "130px" }, + { label: "Last", key: "last", width: "130px" }, + { label: "Email", key: "email", width: "1fr" }, + { label: "Phone", key: "phone", width: "140px" }, + { label: "County", key: "county", width: "130px" }, + { label: "Message", key: "msg", width: "90px", sortable: false }, + ] + : [ + { label: "Date", key: "time", width: "150px" }, + { label: "First", key: "first", width: "130px" }, + { label: "Last", key: "last", width: "130px" }, + { label: "Email", key: "email", width: "1fr" }, + { label: "Phone", key: "phone", width: "140px" }, + { label: "County", key: "county", width: "130px" }, + ] + const gridCols = COLS.map(c => c.width).join(" ") + const rows = this.rows + + VStack(() => { + // Search bar + HStack(() => { + p("πŸ”").margin(0).fontSize(0.82, em).opacity(0.35).flexShrink(0) + input("", "240px") + .attr({ type: "text", placeholder: `Search ${isContact ? "contact inquiries" : "join requests"}…`, value: this.searchText }) + .border("none").outline("none").background("transparent") + .color("var(--headertext)").fontSize(0.85, em) + .onInput((e) => { this.searchText = e.target.value; this.selectedId = null; this.rerender() }) + }) + .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") + .marginHorizontal(1.25, em).marginBottom(0.75, em).flexShrink(0) + + // Count + p(`${rows.length} result${rows.length !== 1 ? "s" : ""}`) + .margin(0).marginBottom(0.5, em) + .paddingHorizontal(1.25, em) + .fontSize(0.78, em).color("var(--headertext)").opacity(0.38) + .flexShrink(0) + + // Header + HStack(() => { + COLS.forEach(col => { + const isSort = this.sortKey === col.key + HStack(() => { + p(col.label + (isSort ? (this.sortDir === "asc" ? " ↑" : " ↓") : "")) + .margin(0).fontSize(0.7, em).fontWeight("700").letterSpacing("0.04em") + .color("var(--headertext)").opacity(isSort ? 0.85 : 0.38) + .userSelect("none").whiteSpace("nowrap") + }) + .alignItems("center") + .cursor(col.sortable === false ? "default" : "pointer") + .onClick((done) => { + if (!done || col.sortable === false) return + if (this.sortKey === col.key) this.sortDir = this.sortDir === "asc" ? "desc" : "asc" + else { this.sortKey = col.key; this.sortDir = "asc" } + this.rerender() + }) + }) + }) + .attr({ style: `display: grid; grid-template-columns: ${gridCols};` }) + .paddingHorizontal(1.25, em).paddingVertical(0.6, em) + .borderTop("1px solid var(--divider)").borderBottom("1px solid var(--divider)") + .flexShrink(0) + + // Rows + VStack(() => { + if (rows.length === 0) { + VStack(() => { + p("No results match your search") + .margin(0).fontSize(0.9, em) + .color("var(--headertext)").opacity(0.35).textAlign("center") + }).flex(1).justifyContent("center").alignItems("center") + } else { + rows.forEach((row, i) => { + const isSelected = this.selectedId === row._id + HStack(() => { + p(this.formatTimeCustom(row.time)) + .margin(0).fontSize(0.78, em).color("var(--headertext)").opacity(0.6) + .whiteSpace("nowrap") + + p(row.fname || "β€”") + .margin(0).fontSize(0.82, em).color("var(--headertext)") + .overflow("hidden").whiteSpace("nowrap").textOverflow("ellipsis") + + p(row.lname || "β€”") + .margin(0).fontSize(0.82, em).color("var(--headertext)") + .overflow("hidden").whiteSpace("nowrap").textOverflow("ellipsis") + + p(row.email || "β€”") + .margin(0).fontSize(0.78, em).color("var(--headertext)").opacity(0.6) + .overflow("hidden").whiteSpace("nowrap").textOverflow("ellipsis") + .minWidth(0) + + p(this.formatPhone(row.phone)) + .margin(0).fontSize(0.78, em).color("var(--headertext)").opacity(0.55) + .whiteSpace("nowrap") + + p(row.county || "β€”") + .margin(0).fontSize(0.78, em).color("var(--headertext)").opacity(0.55) + .overflow("hidden").whiteSpace("nowrap").textOverflow("ellipsis") + + if (isContact) { + p(row.message ? "View β†’" : "β€”") + .margin(0).fontSize(0.78, em) + .color(row.message ? "var(--quillred)" : "var(--headertext)") + .opacity(row.message ? 1 : 0.22) + .fontWeight(row.message ? "600" : "400") + .cursor(row.message ? "pointer" : "default") + } + }) + .attr({ style: `display: grid; grid-template-columns: ${gridCols}; align-items: center;` }) + .paddingHorizontal(1.25, em).paddingVertical(0.6, em) + .background(isSelected ? "var(--app)" : i % 2 !== 0 ? "var(--darkaccent)" : "transparent") + .borderBottom("1px solid var(--divider)") + .cursor("pointer") + .onClick((done) => { + if (!done) return + this.selectedId = isSelected ? null : row._id + this.rerender() + }) + }) + } + }) + .flex(1).overflowY("auto") + }) + .height(100, pct).width(100, pct).overflow("hidden") + .paddingTop(1, em) + } + + renderDetailDrawer(row) { + VStack(() => { + // Header + HStack(() => { + p(`${row.fname || ""} ${row.lname || ""}`.trim() || row.email || "Contact") + .margin(0).fontSize(0.92, 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.selectedId = null; this.rerender() }) + }) + .paddingHorizontal(1.25, em).paddingVertical(0.82, em) + .borderBottom("1px solid var(--divider)") + .alignItems("center").flexShrink(0) + + // Fields + VStack(() => { + const fields = [ + ["Date", this.formatTimeCustom(row.time)], + ["First", row.fname], + ["Last", row.lname], + ["Email", row.email], + ["Phone", this.formatPhone(row.phone)], + ["County", row.county], + ] + fields.forEach(([label, value]) => { + VStack(() => { + p(label) + .margin(0).fontSize(0.68, em).fontWeight("700") + .letterSpacing("0.04em").color("var(--headertext)").opacity(0.38) + p(value || "β€”") + .margin(0).marginTop(0.2, em).fontSize(0.88, em) + .color("var(--headertext)").opacity(value ? 0.85 : 0.28) + .fontStyle(value ? "normal" : "italic") + }) + .paddingVertical(0.72, em) + .borderBottom("1px solid var(--divider)") + }) + + if (row.message) { + VStack(() => { + p("Message") + .margin(0).fontSize(0.68, em).fontWeight("700") + .letterSpacing("0.04em").color("var(--headertext)").opacity(0.38) + p(row.message) + .margin(0).marginTop(0.35, em).fontSize(0.88, em) + .color("var(--headertext)").opacity(0.82) + .lineHeight("1.55") + .whiteSpace("pre-wrap") + }) + .paddingVertical(0.72, em) + } + }) + .paddingHorizontal(1.25, em) + .flex(1).overflowY("auto") + }) + .width(320, px).height(100, pct) + .borderLeft("1px solid var(--divider)") + .flexShrink(0).overflow("hidden") + } + + // ── data ─────────────────────────────────────────────────────────────── + + formatPhone(phone) { + if (!phone) return "β€”" + const d = phone.replace(/\D/g, "") + if (d.length === 10) return `(${d.slice(0,3)}) ${d.slice(3,6)}-${d.slice(6)}` + return phone + } + + async getData() { + const data = await server.getSiteInfo(global.currentNetwork.abbreviation) + if ( + data.contact_form.length !== this.contact_form.length || + data.join_form.length !== this.join_form.length + ) { + this.contact_form = data.contact_form || [] + this.join_form = data.join_form || [] + this.rerender() + } + } +} + +register(Website) diff --git a/website/icons/website.svg b/website/icons/website.svg new file mode 100644 index 0000000..d7f4d76 --- /dev/null +++ b/website/icons/website.svg @@ -0,0 +1,3 @@ + + + diff --git a/website/icons/websitelight.svg b/website/icons/websitelight.svg new file mode 100644 index 0000000..867f4ff --- /dev/null +++ b/website/icons/websitelight.svg @@ -0,0 +1,3 @@ + + + diff --git a/website/website.js b/website/website.js new file mode 100644 index 0000000..79b7da1 --- /dev/null +++ b/website/website.js @@ -0,0 +1,465 @@ +import server from "/@server/server.js" + +css(` + website- { + font-family: 'Arial'; + scrollbar-width: none; + -ms-overflow-style: none; + } + website-::-webkit-scrollbar { display: none; } + website- .stats-scroll { + overflow-x: auto; + -webkit-overflow-scrolling: touch; + } + website- input::placeholder { color: var(--headertext); opacity: 0.35; } +`) + +class Website extends Shadow { + contact_form = [] + join_form = [] + + activeTab = "overview" // "overview" | "contact" | "join" + searchText = "" + searchOpen = false + selectedTx = null + + // ── helpers ──────────────────────────────────────────────────────────── + + formatTimeCustom(time) { + if (!time) return "β€”" + const match = time.match(/(\d{2})\.(\d{2})\.(\d{4})-(\d{1,2}):(\d{2}):\d+(am|pm)/i) + if (!match) return time + const [, month, day, year, hour, minute, ampm] = match + return `${month}/${day}/${year.slice(2)} ${hour}:${minute}${ampm.toLowerCase()}` + } + + parseTime(str) { + const match = str.match(/(\d+)\.(\d+)\.(\d+)-(\d+):(\d+):(\d+)(\d{3})(am|pm)/i); + const [, month, day, year, hours, minutes, seconds, ms, ampm] = match; + + let hrs = parseInt(hours); + if (ampm.toLowerCase() === 'pm' && hrs !== 12) hrs += 12; + if (ampm.toLowerCase() === 'am' && hrs === 12) hrs = 0; + + return new Date(year, month - 1, day, hrs, minutes, seconds, ms); + } + + // ── computed ─────────────────────────────────────────────────────────── + + get stats() { + const now = new Date() + const thisMonth = (entries) => entries.filter(e => { + const d = this.parseTime(e.time) + return d.getMonth() === now.getMonth() && d.getFullYear() === now.getFullYear() + }) + const counties = new Set([ + ...this.contact_form.map(e => e.county).filter(Boolean), + ...this.join_form.map(e => e.county).filter(Boolean), + ]) + return { + totalContact: this.contact_form.length, + totalJoin: this.join_form.length, + thisMonth: thisMonth(this.contact_form).length + thisMonth(this.join_form).length, + counties: counties.size, + } + } + + get currentList() { + const source = this.activeTab === "contact" ? this.contact_form : this.join_form + let list = source.map((e, i) => ({ ...e, _id: i })) + .sort((a, b) => this.parseTime(b.time) - this.parseTime(a.time)) + + if (this.searchText) { + const q = this.searchText.toLowerCase() + list = list.filter(e => + [e.fname, e.lname, e.email, e.phone, e.county, e.message] + .some(v => v?.toLowerCase().includes(q)) + ) + } + return list + } + + // ── render ───────────────────────────────────────────────────────────── + + render() { + ZStack(() => { + VStack(() => { + this.renderHeader() + this.renderTabs() + + if (this.activeTab === "overview") { + this.renderOverview() + } else { + this.renderList() + } + }) + .height(100, pct).width(100, pct) + .boxSizing("border-box").overflow("hidden") + + if (this.selectedTx) { + this.renderDetail(this.selectedTx) + } + }) + .display("block") + .height(100, pct).width(100, pct).overflow("hidden") + .maxWidth(100, vw) + .onAppear(() => this.getData()) + } + + renderHeader() { + if (this.searchOpen && this.activeTab !== "overview") { + 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) + .width(100, pct).boxSizing("border-box").flexShrink(0) + } + } + + renderTabs() { + HStack(() => { + [["overview","Overview"],["contact","Contact"],["join","Join"]].forEach(([key, label]) => { + const isActive = this.activeTab === key + 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"}`) + .onTouch((start) => { + if (!start) { + this.activeTab = key + this.searchText = "" + this.searchOpen = false + this.selectedTx = null + this.rerender() + } + }) + }) + }) + .paddingHorizontal(1.1, em).paddingBottom(0.65, em) + .gap(0.4, em).width(100, pct).boxSizing("border-box").flexShrink(0) + } + + // ── overview ─────────────────────────────────────────────────────────── + + renderOverview() { + VStack(() => { + this.renderStats() + + // Divider + div().height(1, px).background("var(--divider)") + .marginHorizontal(1.1, em).marginBottom(0.85, em).flexShrink(0) + + // Recent Contact + this.renderRecentSection("Recent Contact", this.contact_form, "contact") + + // Divider + div().height(1, px).background("var(--divider)") + .marginHorizontal(1.1, em).marginVertical(0.85, em).flexShrink(0) + + // Recent Join + this.renderRecentSection("Recent Join Requests", this.join_form, "join") + }) + .flex(1).minHeight(0).overflowY("auto") + .paddingBottom(1.5, em) + } + + renderStats() { + const s = this.stats + const cards = [ + { label: "Contact", value: String(s.totalContact), icon: "βœ‰οΈ" }, + { label: "Join", value: String(s.totalJoin), icon: "πŸ™‹" }, + { label: "This Month", value: String(s.thisMonth), icon: "πŸ“…" }, + { label: "Counties", value: String(s.counties), icon: "πŸ“" }, + { label: "Page Visits", value: "β€”", icon: "πŸ‘οΈ" }, + { label: "Avg. Time", value: "β€”", icon: "⏱️" }, + ] + + HStack(() => { + div().width(1.1, em).flexShrink(0) + cards.forEach(c => { + VStack(() => { + p(c.icon).margin(0).fontSize(1.1, em).lineHeight("1") + p(c.value) + .margin(0).marginTop(0.38, em) + .fontSize(1.0, em).fontWeight("700") + .color("var(--headertext)").lineHeight("1") + .opacity(c.value === "β€”" ? 0.22 : 1) + .whiteSpace("nowrap") + p(c.label) + .margin(0).marginTop(0.25, em) + .fontSize(0.62, em).color("var(--headertext)").opacity(0.42) + .whiteSpace("nowrap") + }) + .padding(0.85, em) + .background("var(--darkaccent)").border("1px solid var(--divider)") + .borderRadius(0.75, em).alignItems("center").flexShrink(0) + }) + div().width(1.1, em).flexShrink(0) + }) + .attr({ class: "stats-scroll" }) + .paddingBottom(0.85, em).gap(0.6, em) + .width(100, pct).boxSizing("border-box").flexShrink(0) + } + + renderRecentSection(title, entries, tab) { + const recent = [...entries] + .sort((a, b) => this.parseTime(b.time) - this.parseTime(a.time)) + .slice(0, 4) + + VStack(() => { + HStack(() => { + p(title) + .margin(0).fontSize(0.72, em).fontWeight("700") + .letterSpacing("0.04em").color("var(--headertext)").opacity(0.4) + .flex(1) + + p("See all β†’") + .margin(0).fontSize(0.72, em).color("var(--headertext)").opacity(0.38) + .onTouch((start) => { + if (!start) { + this.activeTab = tab + this.searchText = "" + this.searchOpen = false + this.rerender() + } + }) + }) + .alignItems("center").marginBottom(0.5, em) + + if (recent.length === 0) { + p("No submissions yet") + .margin(0).fontSize(0.82, em) + .color("var(--headertext)").opacity(0.28).fontStyle("italic") + } else { + recent.forEach((e, i) => { + HStack(() => { + VStack(() => { + p(`${e.fname || ""} ${e.lname || ""}`.trim() || e.email || "β€”") + .margin(0).fontSize(0.88, em).fontWeight("600") + .color("var(--headertext)") + .overflow("hidden").whiteSpace("nowrap").textOverflow("ellipsis") + p(e.county || e.email || "β€”") + .margin(0).marginTop(0.12, em).fontSize(0.72, em) + .color("var(--headertext)").opacity(0.42) + .overflow("hidden").whiteSpace("nowrap").textOverflow("ellipsis") + }).flex(1).minWidth(0) + + p(this.formatTimeCustom(e.time)) + .margin(0).fontSize(0.68, em) + .color("var(--headertext)").opacity(0.32) + .whiteSpace("nowrap").flexShrink(0) + }) + .paddingVertical(0.65, em) + .borderBottom(i < recent.length - 1 ? "1px solid var(--divider)" : "none") + .alignItems("center").gap(0.5, em) + .onTouch((start, event) => { + if(start) { + this._touchStartY = event.touches[0].clientY; + this._touchStartX = event.touches[0].clientX; + } else { + const dy = Math.abs(event.changedTouches[0].clientY - this._touchStartY); + const dx = Math.abs(event.changedTouches[0].clientX - this._touchStartX); + if (dy > 10 || dx > 10) return; // was a scroll, ignore + + this.selectedTx = { ...e, _tab: tab }; + this.rerender(); + } + }) + }) + } + }) + .paddingHorizontal(1.1, em) + .flexShrink(0) + } + + // ── list ─────────────────────────────────────────────────────────────── + + renderList() { + const rows = this.currentList + const isContact = this.activeTab === "contact" + + VStack(() => { + if (rows.length === 0) { + VStack(() => { + p(this.searchText ? "No results match your search" : "No submissions yet") + .margin(0).fontSize(0.9, em) + .color("var(--headertext)").opacity(0.35).textAlign("center") + }).flex(1).justifyContent("center").alignItems("center") + } else { + rows.forEach(row => this.renderCard(row, isContact)) + } + }) + .flex(1).minHeight(0).overflowY("auto") + .paddingHorizontal(1.1, em).paddingBottom(1.5, em) + .gap(0.55, em).width(100, pct).boxSizing("border-box") + } + + renderCard(row, isContact) { + HStack(() => { + VStack(() => { + p(`${row.fname || ""} ${row.lname || ""}`.trim() || row.email || "β€”") + .margin(0).fontSize(0.9, em).fontWeight("600") + .color("var(--headertext)") + .whiteSpace("nowrap").overflow("hidden").textOverflow("ellipsis") + + p(row.county || "β€”") + .margin(0).marginTop(0.28, em).fontSize(0.72, em) + .color("var(--headertext)").opacity(0.45) + .whiteSpace("nowrap") + }) + .flex(1).minWidth(0).alignItems("flex-start") + + VStack(() => { + p(this.formatTimeCustom(row.time)) + .margin(0).fontSize(0.7, em).color("var(--headertext)").opacity(0.35) + .whiteSpace("nowrap").textAlign("right") + + if (isContact && row.message) { + p("Msg β†’") + .margin(0).marginTop(0.28, em) + .fontSize(0.68, em).fontWeight("600") + .color("var(--quillred)") + } + }) + .alignItems("flex-end").flexShrink(0) + }) + .padding(0.9, em) + .background("var(--darkaccent)").border("1px solid var(--divider)") + .borderRadius(0.75, em).alignItems("center") + .width(100, pct).boxSizing("border-box") + .onTouch((start) => { + if (!start) { + this.selectedTx = { ...row, _tab: this.activeTab } + this.rerender() + } + }) + } + + // ── detail sheet ──────────────────────────────────────────────────────── + + renderDetail(entry) { + const name = `${entry.fname || ""} ${entry.lname || ""}`.trim() || entry.email || "Submission" + + VStack(() => { + // Handle bar + HStack(() => { + div().width(2.5, em).height(4, px).borderRadius(100, px).background("var(--divider)") + }) + .justifyContent("center").paddingTop(0.75, em).paddingBottom(0.5, em).flexShrink(0) + + // Header + HStack(() => { + VStack(() => { + p(name) + .margin(0).fontSize(1.1, em).fontWeight("700") + .color("var(--headertext)") + + p(entry._tab === "contact" ? "Contact Inquiry" : "Join Request") + .margin(0).marginTop(0.18, em) + .fontSize(0.72, em).fontWeight("600") + .color(entry._tab === "contact" ? "var(--quillred)" : "#3b82f6") + }) + .flex(1).minWidth(0) + + div("βœ•") + .fontSize(1, em).padding(0.4, em).borderRadius(50, pct) + .background("var(--darkaccent)").border("1px solid var(--divider)") + .color("var(--headertext)").opacity(0.6).flexShrink(0) + .onTouch((start) => { + if (!start) { this.selectedTx = null; this.rerender() } + }) + }) + .paddingHorizontal(1.3, em).paddingBottom(1, em) + .alignItems("center").gap(0.75, em).flexShrink(0) + + div().height(1, px).background("var(--divider)").marginHorizontal(1.3, em).flexShrink(0) + + // Detail rows + VStack(() => { + const fields = [ + ["Date", this.formatTimeCustom(entry.time)], + ["First", entry.fname], + ["Last", entry.lname], + ["Email", entry.email], + ["Phone", this.formatPhone(entry.phone)], + ["County", entry.county], + ] + fields.forEach(([label, value]) => this.detailRow(label, value)) + + if (entry.message) { + VStack(() => { + p("Message") + .margin(0).fontSize(0.72, em).fontWeight("700") + .letterSpacing("0.04em").color("var(--headertext)").opacity(0.38) + + p(entry.message) + .margin(0).marginTop(0.4, em).fontSize(0.9, em) + .color("var(--headertext)").opacity(0.82) + .lineHeight("1.6").whiteSpace("pre-wrap") + }) + .paddingVertical(0.85, em) + } + }) + .paddingHorizontal(1.3, em).paddingTop(0.5, em) + .flex(1).overflowY("auto") + }) + .position("absolute") + .bottom(0, px).left(0, px).right(0, px) + .background("var(--main)") + .borderTop("1px solid var(--divider)") + .borderRadius("1.2em 1.2em 0 0") + .boxShadow("0 -4px 24px rgba(0,0,0,0.12)") + .zIndex(10).width(100, pct).boxSizing("border-box") + .maxHeight(88, vh).overflowY("auto") + } + + detailRow(label, value) { + HStack(() => { + p(label) + .margin(0).fontSize(0.8, em) + .color("var(--headertext)").opacity(0.42) + .width(72, px).flexShrink(0) + + p(value || "β€”") + .margin(0).fontSize(0.88, em).fontWeight("500") + .color("var(--headertext)").opacity(value ? 0.88 : 0.28) + .fontStyle(value ? "normal" : "italic") + .flex(1).minWidth(0) + .overflow("hidden").textOverflow("ellipsis").whiteSpace("nowrap") + }) + .paddingVertical(0.82, em) + .borderBottom("1px solid var(--divider)") + .alignItems("center") + } + + 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 + } + + async getData() { + const data = await server.getSiteInfo(global.currentNetwork.abbreviation) + if ( + data.contact_form.length !== this.contact_form.length || + data.join_form.length !== this.join_form.length + ) { + this.contact_form = data.contact_form || [] + this.join_form = data.join_form || [] + this.rerender() + } + } +} + +register(Website)