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)