max / goingson
10 files changed,
+564 insertions,
-5 deletions
| @@ -597,6 +597,13 @@ textarea, | |||
| 597 | 597 | color: var(--content); | |
| 598 | 598 | } | |
| 599 | 599 | ||
| 600 | + | /* Right-aligned cluster of header controls (e.g. a filter toggle + primary action). */ | |
| 601 | + | .page-header-actions { | |
| 602 | + | display: flex; | |
| 603 | + | align-items: center; | |
| 604 | + | gap: var(--space-3); | |
| 605 | + | } | |
| 606 | + | ||
| 600 | 607 | /* --- Merged pill + page-header toolbar --- */ | |
| 601 | 608 | /* Tab groups use pills as view labels; page-header buttons float right on the same row */ | |
| 602 | 609 | .tab-group { | |
| @@ -945,6 +952,23 @@ textarea, | |||
| 945 | 952 | background-color: color-mix(in srgb, var(--category-six) 20%, var(--surface-raised)); | |
| 946 | 953 | border-color: var(--category-six); | |
| 947 | 954 | } | |
| 955 | + | /* Groups: a project shared into a group. */ | |
| 956 | + | .tag.tag-shared { | |
| 957 | + | background-color: color-mix(in srgb, var(--info) 20%, var(--surface-raised)); | |
| 958 | + | border-color: var(--info); | |
| 959 | + | } | |
| 960 | + | /* Inline marker on a task row whose project is shared. */ | |
| 961 | + | .shared-badge { | |
| 962 | + | display: inline-block; | |
| 963 | + | margin-left: var(--space-2); | |
| 964 | + | padding: 0 var(--space-2); | |
| 965 | + | border-radius: var(--radius-sm); | |
| 966 | + | font-size: var(--font-size-xs); | |
| 967 | + | line-height: 1.4; | |
| 968 | + | color: var(--info); | |
| 969 | + | background-color: color-mix(in srgb, var(--info) 15%, transparent); | |
| 970 | + | vertical-align: middle; | |
| 971 | + | } | |
| 948 | 972 | ||
| 949 | 973 | /* =================================================================== | |
| 950 | 974 | 13. Data Table | |
| @@ -2205,6 +2229,52 @@ textarea, | |||
| 2205 | 2229 | .sync-account-username { | |
| 2206 | 2230 | color: var(--content-muted); | |
| 2207 | 2231 | } | |
| 2232 | + | /* Sharing (Groups) — group cards + member rows in the Sharing settings section. */ | |
| 2233 | + | .group-card { | |
| 2234 | + | border: var(--border-width) solid var(--border); | |
| 2235 | + | border-radius: var(--radius-md); | |
| 2236 | + | background: var(--surface-raised); | |
| 2237 | + | padding: var(--space-4); | |
| 2238 | + | margin-bottom: var(--space-4); | |
| 2239 | + | } | |
| 2240 | + | .group-card-header { | |
| 2241 | + | display: flex; | |
| 2242 | + | align-items: baseline; | |
| 2243 | + | justify-content: space-between; | |
| 2244 | + | gap: var(--space-3); | |
| 2245 | + | } | |
| 2246 | + | .group-card-name { | |
| 2247 | + | font-weight: 500; | |
| 2248 | + | color: var(--content); | |
| 2249 | + | } | |
| 2250 | + | .group-card-role { | |
| 2251 | + | font-size: var(--font-size-sm); | |
| 2252 | + | } | |
| 2253 | + | .group-member-list { | |
| 2254 | + | margin: var(--space-4) 0; | |
| 2255 | + | display: flex; | |
| 2256 | + | flex-direction: column; | |
| 2257 | + | gap: var(--space-2); | |
| 2258 | + | } | |
| 2259 | + | .group-member-row { | |
| 2260 | + | display: flex; | |
| 2261 | + | align-items: center; | |
| 2262 | + | gap: var(--space-3); | |
| 2263 | + | font-size: var(--font-size-sm); | |
| 2264 | + | } | |
| 2265 | + | .group-member-email { | |
| 2266 | + | color: var(--content); | |
| 2267 | + | flex: 1 1 auto; | |
| 2268 | + | overflow-wrap: anywhere; | |
| 2269 | + | } | |
| 2270 | + | .group-member-role { | |
| 2271 | + | font-size: var(--font-size-sm); | |
| 2272 | + | } | |
| 2273 | + | .group-add-member { | |
| 2274 | + | border-top: var(--border-width) solid var(--border); | |
| 2275 | + | padding-top: var(--space-4); | |
| 2276 | + | } | |
| 2277 | + | ||
| 2208 | 2278 | .sync-banner-title { | |
| 2209 | 2279 | font-weight: 500; | |
| 2210 | 2280 | margin: 0 0 var(--space-3) 0; |
| @@ -174,7 +174,10 @@ | |||
| 174 | 174 | <div id="projects-view" class="subview hidden" role="tabpanel" aria-labelledby="projects-tab"> | |
| 175 | 175 | <div class="page-header"> | |
| 176 | 176 | <h2 class="page-title">Projects</h2> | |
| 177 | - | <button class="btn btn-primary" data-act="projects.openNew" title="New project (n)">+ New Project <kbd class="kbd-hint">n</kbd></button> | |
| 177 | + | <div class="page-header-actions"> | |
| 178 | + | <span id="projects-filter-slot"></span> | |
| 179 | + | <button class="btn btn-primary" data-act="projects.openNew" title="New project (n)">+ New Project <kbd class="kbd-hint">n</kbd></button> | |
| 180 | + | </div> | |
| 178 | 181 | </div> | |
| 179 | 182 | <div class="cards-grid" id="projects-grid"> | |
| 180 | 183 | <div class="skeleton-shimmer" aria-label="Loading projects"> | |
| @@ -540,6 +543,7 @@ | |||
| 540 | 543 | <button class="settings-nav-item" data-section="email" data-act="settings.showSection" data-a1="email">Email Accounts</button> | |
| 541 | 544 | <button class="settings-nav-item" data-section="planning" data-act="settings.showSection" data-a1="planning">Planning & Review</button> | |
| 542 | 545 | <button class="settings-nav-item" data-section="sync" data-act="settings.showSection" data-a1="sync">Cloud Sync</button> | |
| 546 | + | <button class="settings-nav-item" data-section="sharing" data-act="settings.showSection" data-a1="sharing">Sharing</button> | |
| 543 | 547 | <button class="settings-nav-item" data-section="data" data-act="settings.showSection" data-a1="data">Import & Export</button> | |
| 544 | 548 | <button class="settings-nav-item" data-section="about" data-act="settings.showSection" data-a1="about">About</button> | |
| 545 | 549 | </div> | |
| @@ -637,6 +641,7 @@ | |||
| 637 | 641 | <script src="js/touch.js"></script> | |
| 638 | 642 | <!-- Domain render modules (load before their parent domains) --> | |
| 639 | 643 | <script src="js/projects-render.js"></script> | |
| 644 | + | <script src="js/groups.js"></script> | |
| 640 | 645 | <script src="js/tasks-render.js"></script> | |
| 641 | 646 | <script src="js/tasks-kanban.js"></script> | |
| 642 | 647 | <script src="js/contacts-render.js"></script> | |
| @@ -682,6 +687,7 @@ | |||
| 682 | 687 | <!-- UI --> | |
| 683 | 688 | <script src="js/settings.js"></script> | |
| 684 | 689 | <script src="js/settings-sync.js"></script> | |
| 690 | + | <script src="js/settings-sharing.js"></script> | |
| 685 | 691 | <script src="js/export.js"></script> | |
| 686 | 692 | <script src="js/import.js"></script> | |
| 687 | 693 | <script src="js/import-external.js"></script> |
| @@ -60,7 +60,7 @@ async function invoke(command, args = {}) { | |||
| 60 | 60 | /// Commands that write the local DB. Matching this marks a local write for the | |
| 61 | 61 | /// file-watcher echo-suppression (CHRONIC-E). Read commands (list_/get_/search/ | |
| 62 | 62 | /// validate) deliberately do not match. | |
| 63 | - | const MUTATION_COMMAND = /^(create|update|delete|bulk|complete|start|snooze|unsnooze|mark|clear|archive|unarchive|set|toggle|upsert|add|remove|move|promote|convert|reorder|save|restore|import|sync|log|schedule|reanchor|link|unlink)_/; | |
| 63 | + | const MUTATION_COMMAND = /^(create|update|delete|bulk|complete|start|snooze|unsnooze|mark|clear|archive|unarchive|set|toggle|upsert|add|remove|move|promote|convert|reorder|save|restore|import|sync|log|schedule|reanchor|link|unlink|share|unshare)_/; | |
| 64 | 64 | ||
| 65 | 65 | // ============ API Object ============ | |
| 66 | 66 | ||
| @@ -72,6 +72,20 @@ const api = { | |||
| 72 | 72 | create: (input) => invoke('create_project', { input }), | |
| 73 | 73 | update: (id, input) => invoke('update_project', { id, input }), | |
| 74 | 74 | delete: (id) => invoke('delete_project', { id }), | |
| 75 | + | // Sharing (Groups): stamp/clear the group scope across the whole subtree. | |
| 76 | + | share: (projectId, groupId) => invoke('share_project', { projectId, groupId }), | |
| 77 | + | unshare: (projectId) => invoke('unshare_project', { projectId }), | |
| 78 | + | }, | |
| 79 | + | ||
| 80 | + | // Groups — shared-workspace admin (SyncKit). Admin commands round-trip to the | |
| 81 | + | // server via the SyncKit client; the group key stays client-side. | |
| 82 | + | groups: { | |
| 83 | + | list: () => invoke('group_list'), // Groups I belong to | |
| 84 | + | create: (name) => invoke('group_create', { name }), // I become admin | |
| 85 | + | listMembers: (groupId) => invoke('group_list_members', { groupId }), | |
| 86 | + | myPubkey: () => invoke('my_group_pubkey'), // Base64 identity key to hand an admin | |
| 87 | + | addMember: (groupId, email, pubkey) => invoke('group_add_member', { groupId, email, pubkey }), | |
| 88 | + | removeMember: (groupId, userId) => invoke('group_remove_member', { groupId, userId }), | |
| 75 | 89 | }, | |
| 76 | 90 | ||
| 77 | 91 | // Tasks — CRUD + lifecycle (start/complete) + snooze/waiting status |
| @@ -461,6 +461,8 @@ function getEventContextMenuItems(eventId) { | |||
| 461 | 461 | * @returns {Array} - Menu items | |
| 462 | 462 | */ | |
| 463 | 463 | function getProjectContextMenuItems(projectId) { | |
| 464 | + | const project = (window.GoingsOn.state.projects || []).find(p => p.id === projectId); | |
| 465 | + | const isShared = !!(project && project.groupId); | |
| 464 | 466 | return [ | |
| 465 | 467 | { label: 'Open Project', action: () => GoingsOn.projects.open(projectId) }, | |
| 466 | 468 | { label: 'Edit Project', action: () => GoingsOn.projects.openEdit(projectId) }, | |
| @@ -468,6 +470,8 @@ function getProjectContextMenuItems(projectId) { | |||
| 468 | 470 | { label: 'Add Task', action: () => GoingsOn.tasks.openNewForProject(projectId) }, | |
| 469 | 471 | { label: 'Add Event', action: () => GoingsOn.events.openNewForProject(projectId) }, | |
| 470 | 472 | 'separator', | |
| 473 | + | { label: isShared ? 'Sharing…' : 'Share…', action: () => GoingsOn.projects.openShareDialog(projectId) }, | |
| 474 | + | 'separator', | |
| 471 | 475 | { label: 'Delete Project', danger: true, action: () => GoingsOn.projects.delete(projectId) }, | |
| 472 | 476 | ]; | |
| 473 | 477 | } |
| @@ -0,0 +1,87 @@ | |||
| 1 | + | /** | |
| 2 | + | * GoingsOn - Groups (shared-workspace) client cache + badge helpers. | |
| 3 | + | * | |
| 4 | + | * A thin, lazily-populated cache of the groups you belong to, keyed by id, so | |
| 5 | + | * the UI can resolve a project's `groupId` to a human name without a round-trip | |
| 6 | + | * per render. Sharing is whole-project, so a task's share state is its project's | |
| 7 | + | * share state — the badge helpers reflect that. | |
| 8 | + | * | |
| 9 | + | * The admin/list commands themselves live on `GoingsOn.api.groups`; this module | |
| 10 | + | * is only the render-side cache. Invalidate it whenever group membership changes | |
| 11 | + | * (a group is created, joined, or renamed). | |
| 12 | + | */ | |
| 13 | + | ||
| 14 | + | (function() { | |
| 15 | + | 'use strict'; | |
| 16 | + | const esc = GoingsOn.utils.escapeHtml; | |
| 17 | + | const escAttr = GoingsOn.utils.escapeAttrValue; | |
| 18 | + | ||
| 19 | + | let cache = null; // Array of GroupDto, or null when unloaded. | |
| 20 | + | let byId = {}; // id -> GroupDto. | |
| 21 | + | ||
| 22 | + | /** | |
| 23 | + | * Load the group list into the cache if it is not already loaded. Non-fatal: | |
| 24 | + | * if sync is not configured the cache stays null and names simply do not | |
| 25 | + | * resolve (badges still render as a bare "Shared"). | |
| 26 | + | * @returns {Promise<Array>} the cached groups (possibly empty) | |
| 27 | + | */ | |
| 28 | + | async function ensureLoaded() { | |
| 29 | + | if (cache) return cache; | |
| 30 | + | try { | |
| 31 | + | const list = await GoingsOn.api.groups.list(); | |
| 32 | + | cache = list; | |
| 33 | + | byId = {}; | |
| 34 | + | list.forEach(g => { byId[g.id] = g; }); | |
| 35 | + | } catch (_) { | |
| 36 | + | // Leave cache null so a later call retries once sync is ready. | |
| 37 | + | } | |
| 38 | + | return cache || []; | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | /** Group name for an id, or null when unknown / cache not loaded. */ | |
| 42 | + | function nameFor(groupId) { | |
| 43 | + | if (!groupId) return null; | |
| 44 | + | const g = byId[groupId]; | |
| 45 | + | return g ? g.name : null; | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | /** Drop the cache so the next ensureLoaded refetches. */ | |
| 49 | + | function invalidate() { | |
| 50 | + | cache = null; | |
| 51 | + | byId = {}; | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | /** | |
| 55 | + | * Badge HTML for a project's card meta, or '' when the project is personal. | |
| 56 | + | * Shows the group name when known, otherwise a bare "Shared". | |
| 57 | + | */ | |
| 58 | + | function projectCardBadge(project) { | |
| 59 | + | if (!project || !project.groupId) return ''; | |
| 60 | + | const name = nameFor(project.groupId); | |
| 61 | + | const label = name ? `Shared · ${name}` : 'Shared'; | |
| 62 | + | return `<span class="tag tag-shared" title="${escAttr(label)}">${esc(label)}</span>`; | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | /** | |
| 66 | + | * Small inline marker for a task row whose project is shared, resolved from | |
| 67 | + | * the projects cache (best-effort — no badge if projects are not loaded). | |
| 68 | + | */ | |
| 69 | + | function taskSharedBadge(projectId) { | |
| 70 | + | if (!projectId) return ''; | |
| 71 | + | const project = GoingsOn.getProjectsCache().find(p => p.id === projectId); | |
| 72 | + | if (!project || !project.groupId) return ''; | |
| 73 | + | const name = nameFor(project.groupId); | |
| 74 | + | const label = name ? `Shared · ${name}` : 'Shared'; | |
| 75 | + | return `<span class="shared-badge" title="${escAttr(label)}">Shared</span>`; | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | GoingsOn.groups = { | |
| 79 | + | ensureLoaded, | |
| 80 | + | nameFor, | |
| 81 | + | invalidate, | |
| 82 | + | projectCardBadge, | |
| 83 | + | taskSharedBadge, | |
| 84 | + | list: () => cache || [], | |
| 85 | + | }; | |
| 86 | + | ||
| 87 | + | })(); |
| @@ -84,6 +84,7 @@ | |||
| 84 | 84 | ||
| 85 | 85 | // Track retired-project reveal state (ephemeral UI state) | |
| 86 | 86 | let showRetired = false; | |
| 87 | + | let showSharedOnly = false; | |
| 87 | 88 | ||
| 88 | 89 | function isRetired(project) { | |
| 89 | 90 | return RETIRED_STATUSES.includes(project.status); | |
| @@ -98,6 +99,16 @@ | |||
| 98 | 99 | } | |
| 99 | 100 | ||
| 100 | 101 | /** | |
| 102 | + | * Toggle the shared-only filter (group-scoped projects), re-rendering from | |
| 103 | + | * the cached list. Mirrors the calendar-subscription model: the same project | |
| 104 | + | * grid, narrowed to what is shared with a group. | |
| 105 | + | */ | |
| 106 | + | function toggleSharedOnly() { | |
| 107 | + | showSharedOnly = !showSharedOnly; | |
| 108 | + | render(GoingsOn.state.get('projects') || []); | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | /** | |
| 101 | 112 | * Render a single project card. | |
| 102 | 113 | * @param {Object} p - Project from the backend | |
| 103 | 114 | * @returns {string} Card markup | |
| @@ -115,6 +126,7 @@ | |||
| 115 | 126 | <div class="card-meta"> | |
| 116 | 127 | <span class="tag type-${(p.projectType || 'other').toLowerCase()}">${esc(p.projectTypeDisplay || p.projectType || 'Other')}</span> | |
| 117 | 128 | <span class="tag status-${(p.status || 'active').toLowerCase()}">${esc(p.statusDisplay || p.status || 'Active')}</span> | |
| 129 | + | ${GoingsOn.groups.projectCardBadge(p)} | |
| 118 | 130 | </div> | |
| 119 | 131 | </div> | |
| 120 | 132 | `; | |
| @@ -128,13 +140,30 @@ | |||
| 128 | 140 | const grid = document.getElementById('projects-grid'); | |
| 129 | 141 | if (!grid) return; | |
| 130 | 142 | ||
| 143 | + | // The shared-only filter surfaces in the header only when sharing is in | |
| 144 | + | // play (at least one shared project, or the filter is already on). | |
| 145 | + | const sharedCount = projects.filter(p => p.groupId).length; | |
| 146 | + | const slot = document.getElementById('projects-filter-slot'); | |
| 147 | + | if (slot) { | |
| 148 | + | slot.innerHTML = (sharedCount > 0 || showSharedOnly) | |
| 149 | + | ? `<button class="btn btn-small ${showSharedOnly ? 'btn-primary' : 'btn-secondary'}" | |
| 150 | + | data-act="projects.toggleSharedOnly" aria-pressed="${showSharedOnly}">Shared only</button>` | |
| 151 | + | : ''; | |
| 152 | + | } | |
| 153 | + | ||
| 131 | 154 | if (projects.length === 0) { | |
| 132 | 155 | grid.innerHTML = GoingsOn.ui.renderEmptyState('No projects yet.', 'Create First Project', 'projects.openNew', 'projects'); | |
| 133 | 156 | return; | |
| 134 | 157 | } | |
| 135 | 158 | ||
| 136 | - | const live = projects.filter(p => !isRetired(p)); | |
| 137 | - | const retired = projects.filter(isRetired); | |
| 159 | + | const scoped = showSharedOnly ? projects.filter(p => p.groupId) : projects; | |
| 160 | + | if (scoped.length === 0) { | |
| 161 | + | grid.innerHTML = '<p class="cards-grid-note">No shared projects yet. Share a project from its menu to see it here.</p>'; | |
| 162 | + | return; | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | const live = scoped.filter(p => !isRetired(p)); | |
| 166 | + | const retired = scoped.filter(isRetired); | |
| 138 | 167 | ||
| 139 | 168 | let html = live.map(renderProjectCard).join(''); | |
| 140 | 169 | ||
| @@ -165,6 +194,8 @@ | |||
| 165 | 194 | const projects = await GoingsOn.api.projects.list(); | |
| 166 | 195 | GoingsOn.state.set('projects', projects); | |
| 167 | 196 | ||
| 197 | + | // Resolve group names for the "Shared" badges before rendering cards. | |
| 198 | + | await GoingsOn.groups.ensureLoaded(); | |
| 168 | 199 | render(projects); | |
| 169 | 200 | GoingsOn.cache.markLoaded('projects'); | |
| 170 | 201 | } catch (err) { | |
| @@ -402,6 +433,93 @@ | |||
| 402 | 433 | }); | |
| 403 | 434 | } | |
| 404 | 435 | ||
| 436 | + | // ============ Sharing (Groups) ============ | |
| 437 | + | ||
| 438 | + | /** | |
| 439 | + | * Open the share dialog for a project. Reflects current scope: a shared | |
| 440 | + | * project offers "Stop Sharing"; a personal one offers a group picker (or a | |
| 441 | + | * pointer to create a group first). Sharing is whole-project — the entire | |
| 442 | + | * subtree moves scope. Group id reaches the frontend as `project.groupId`. | |
| 443 | + | * @param {string} id - Project ID | |
| 444 | + | */ | |
| 445 | + | async function openShareDialog(id) { | |
| 446 | + | const project = GoingsOn.state.projects.find(p => p.id === id); | |
| 447 | + | if (!project) return; | |
| 448 | + | ||
| 449 | + | let groups; | |
| 450 | + | try { | |
| 451 | + | groups = await GoingsOn.api.groups.list(); | |
| 452 | + | } catch (err) { | |
| 453 | + | GoingsOn.ui.showToast('Connect cloud sync to share projects (Settings › Sharing)', 'error'); | |
| 454 | + | return; | |
| 455 | + | } | |
| 456 | + | ||
| 457 | + | let content; | |
| 458 | + | if (project.groupId) { | |
| 459 | + | const current = groups.find(g => g.id === project.groupId); | |
| 460 | + | const name = current ? current.name : 'a group'; | |
| 461 | + | content = ` | |
| 462 | + | <p class="settings-desc">Shared with <strong>${esc(name)}</strong>. The whole project syncs to that group's members.</p> | |
| 463 | + | <div class="form-actions"> | |
| 464 | + | <button type="button" class="btn btn-secondary" data-act="ui.closeModal">Close</button> | |
| 465 | + | <button type="button" class="btn btn-danger" data-act="projects.unshareCurrent" data-a1="${escAttr(id)}">Stop Sharing</button> | |
| 466 | + | </div> | |
| 467 | + | `; | |
| 468 | + | } else if (groups.length === 0) { | |
| 469 | + | content = ` | |
| 470 | + | <p class="settings-desc">You are not in any groups yet. Create one in Settings › Sharing, then share this project into it.</p> | |
| 471 | + | <div class="form-actions"> | |
| 472 | + | <button type="button" class="btn btn-secondary" data-act="ui.closeModal">Close</button> | |
| 473 | + | </div> | |
| 474 | + | `; | |
| 475 | + | } else { | |
| 476 | + | const options = groups.map(g => `<option value="${escAttr(g.id)}">${esc(g.name)}</option>`).join(''); | |
| 477 | + | content = ` | |
| 478 | + | <form id="share-project-form" data-submit="projects.submitShare" data-a1="@event" data-a2="${escAttr(id)}"> | |
| 479 | + | <div class="form-group"> | |
| 480 | + | <label class="form-label">Share with group</label> | |
| 481 | + | <select class="form-select" name="group_id" required>${options}</select> | |
| 482 | + | <p class="form-hint">The whole project — tasks, events, milestones, and attachments — becomes visible to that group's members.</p> | |
| 483 | + | </div> | |
| 484 | + | <div class="form-actions"> | |
| 485 | + | <button type="button" class="btn btn-secondary" data-act="ui.closeModal">Cancel</button> | |
| 486 | + | <button type="submit" class="btn btn-primary">Share Project</button> | |
| 487 | + | </div> | |
| 488 | + | </form> | |
| 489 | + | `; | |
| 490 | + | } | |
| 491 | + | GoingsOn.ui.openModal('Share Project', content); | |
| 492 | + | } | |
| 493 | + | ||
| 494 | + | async function submitShare(e, projectId) { | |
| 495 | + | e.preventDefault(); | |
| 496 | + | const groupId = e.target.group_id.value; | |
| 497 | + | if (!groupId) return; | |
| 498 | + | GoingsOn.ui.closeModal(); | |
| 499 | + | GoingsOn.cache.invalidate('projects'); | |
| 500 | + | await GoingsOn.ui.apiCall(GoingsOn.api.projects.share(projectId, groupId), { | |
| 501 | + | successMessage: 'Project shared', | |
| 502 | + | errorMessage: 'Failed to share project', | |
| 503 | + | reload: load, | |
| 504 | + | }); | |
| 505 | + | } | |
| 506 | + | ||
| 507 | + | async function unshareCurrent(projectId) { | |
| 508 | + | const confirmed = await GoingsOn.ui.showConfirmDialog( | |
| 509 | + | 'Stop sharing', | |
| 510 | + | 'Move this project back to personal? Group members will no longer receive its updates.', | |
| 511 | + | { confirmText: 'Stop Sharing', danger: true } | |
| 512 | + | ); | |
| 513 | + | if (!confirmed) return; | |
| 514 | + | GoingsOn.ui.closeModal(); | |
| 515 | + | GoingsOn.cache.invalidate('projects'); | |
| 516 | + | await GoingsOn.ui.apiCall(GoingsOn.api.projects.unshare(projectId), { | |
| 517 | + | successMessage: 'Project moved to personal', | |
| 518 | + | errorMessage: 'Failed to stop sharing', | |
| 519 | + | reload: load, | |
| 520 | + | }); | |
| 521 | + | } | |
| 522 | + | ||
| 405 | 523 | // ============ Cache Accessors (for backward compatibility) ============ | |
| 406 | 524 | ||
| 407 | 525 | /** | |
| @@ -577,6 +695,9 @@ | |||
| 577 | 695 | addEvent, | |
| 578 | 696 | linkEmail, | |
| 579 | 697 | submitLinkEmail, | |
| 698 | + | openShareDialog, | |
| 699 | + | submitShare, | |
| 700 | + | unshareCurrent, | |
| 580 | 701 | getCache, | |
| 581 | 702 | setCache, | |
| 582 | 703 | getCurrentId, | |
| @@ -587,6 +708,7 @@ | |||
| 587 | 708 | moveMilestone, | |
| 588 | 709 | toggleCompletedMilestones, | |
| 589 | 710 | toggleRetired, | |
| 711 | + | toggleSharedOnly, | |
| 590 | 712 | // Expose form fields for potential reuse | |
| 591 | 713 | getFormFields: getProjectFormFields, | |
| 592 | 714 | }; |
| @@ -0,0 +1,244 @@ | |||
| 1 | + | /** | |
| 2 | + | * GoingsOn - Sharing (Groups) Module | |
| 3 | + | * Shared-workspace admin: your identity public key, the groups you belong to, | |
| 4 | + | * and (for groups you administer) member management. Sharing a project into a | |
| 5 | + | * group lives on the project itself; this section is the group side of it. | |
| 6 | + | * | |
| 7 | + | * Backend: SyncKit group commands (Groups p4 / GO -> SyncStore M4). Admin | |
| 8 | + | * commands round-trip to the server via the SyncKit client; listing members is | |
| 9 | + | * admin-only (the server gates it), so member panels render only for groups you | |
| 10 | + | * administer — a listMembers failure is treated as "not the admin". | |
| 11 | + | */ | |
| 12 | + | ||
| 13 | + | (function() { | |
| 14 | + | 'use strict'; | |
| 15 | + | const esc = GoingsOn.utils.escapeHtml; | |
| 16 | + | const escAttr = GoingsOn.utils.escapeAttrValue; | |
| 17 | + | ||
| 18 | + | /** | |
| 19 | + | * Render the sharing section into a settings container. Loads the groups you | |
| 20 | + | * belong to, your public key, and each group's members (admin-only). | |
| 21 | + | * @param {HTMLElement} container | |
| 22 | + | */ | |
| 23 | + | async function renderSharingSection(container) { | |
| 24 | + | let groups; | |
| 25 | + | try { | |
| 26 | + | groups = await GoingsOn.api.groups.list(); | |
| 27 | + | } catch (err) { | |
| 28 | + | // The commands require a configured sync client; guide the user there. | |
| 29 | + | container.innerHTML = ` | |
| 30 | + | <div class="settings-section"> | |
| 31 | + | <h3 class="settings-heading">Sharing</h3> | |
| 32 | + | <p class="text-secondary">Connect cloud sync to share projects with a group.</p> | |
| 33 | + | <div class="sync-section-actions"> | |
| 34 | + | <button class="btn btn-secondary" data-act="settings.showSection" data-a1="sync">Go to Cloud Sync</button> | |
| 35 | + | </div> | |
| 36 | + | </div> | |
| 37 | + | `; | |
| 38 | + | return; | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | // Your public key is what an admin needs to add you. Non-fatal if | |
| 42 | + | // encryption is not set up yet. | |
| 43 | + | let pubkey = null; | |
| 44 | + | try { | |
| 45 | + | pubkey = await GoingsOn.api.groups.myPubkey(); | |
| 46 | + | } catch (_) {} | |
| 47 | + | ||
| 48 | + | // Members are admin-only; fetch per group and swallow the not-admin error. | |
| 49 | + | const cards = await Promise.all(groups.map(async (g) => { | |
| 50 | + | let members = null; | |
| 51 | + | try { | |
| 52 | + | members = await GoingsOn.api.groups.listMembers(g.id); | |
| 53 | + | } catch (_) {} | |
| 54 | + | return renderGroupCard(g, members); | |
| 55 | + | })); | |
| 56 | + | ||
| 57 | + | const pubkeyBlock = pubkey | |
| 58 | + | ? ` | |
| 59 | + | <p class="settings-desc">Share this public key with a group admin so they can add you. It is not a secret.</p> | |
| 60 | + | <div class="form-group"> | |
| 61 | + | <input type="text" class="form-input" id="my-group-pubkey" value="${escAttr(pubkey)}" readonly> | |
| 62 | + | </div> | |
| 63 | + | <div class="sync-section-actions"> | |
| 64 | + | <button class="btn btn-secondary" data-act="settings.copyMyGroupPubkey">Copy public key</button> | |
| 65 | + | </div> | |
| 66 | + | ` | |
| 67 | + | : `<p class="text-secondary">Set up sync encryption first — your sharing key is created with it.</p>`; | |
| 68 | + | ||
| 69 | + | const groupsBlock = groups.length | |
| 70 | + | ? cards.join('') | |
| 71 | + | : `<p class="text-secondary">You are not in any groups yet. Create one to share a project.</p>`; | |
| 72 | + | ||
| 73 | + | container.innerHTML = ` | |
| 74 | + | <div class="settings-section"> | |
| 75 | + | <h3 class="settings-heading">Sharing</h3> | |
| 76 | + | ||
| 77 | + | <div class="settings-section-block"> | |
| 78 | + | <h4 class="settings-subheading">Your public key</h4> | |
| 79 | + | ${pubkeyBlock} | |
| 80 | + | </div> | |
| 81 | + | ||
| 82 | + | <div class="settings-section-block"> | |
| 83 | + | <h4 class="settings-subheading">Create a group</h4> | |
| 84 | + | <p class="settings-desc">You become the group admin and can add members and share projects into it.</p> | |
| 85 | + | <div class="form-group"> | |
| 86 | + | <input type="text" class="form-input" id="new-group-name" placeholder="Group name" maxlength="100"> | |
| 87 | + | </div> | |
| 88 | + | <div class="sync-section-actions"> | |
| 89 | + | <button class="btn btn-primary" data-act="settings.createGroup">Create Group</button> | |
| 90 | + | </div> | |
| 91 | + | </div> | |
| 92 | + | ||
| 93 | + | <div class="settings-section-block"> | |
| 94 | + | <h4 class="settings-subheading">Your groups</h4> | |
| 95 | + | ${groupsBlock} | |
| 96 | + | </div> | |
| 97 | + | </div> | |
| 98 | + | `; | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | /** | |
| 102 | + | * One group card. When `members` is null the caller is not the group admin, | |
| 103 | + | * so only the name and a member badge render; otherwise the admin member | |
| 104 | + | * panel (list + add + remove) renders. | |
| 105 | + | */ | |
| 106 | + | function renderGroupCard(group, members) { | |
| 107 | + | const gid = escAttr(group.id); | |
| 108 | + | ||
| 109 | + | if (members === null) { | |
| 110 | + | return ` | |
| 111 | + | <div class="group-card"> | |
| 112 | + | <div class="group-card-header"> | |
| 113 | + | <span class="group-card-name">${esc(group.name)}</span> | |
| 114 | + | <span class="group-card-role text-secondary">Member</span> | |
| 115 | + | </div> | |
| 116 | + | </div> | |
| 117 | + | `; | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | const memberRows = members.map((m) => { | |
| 121 | + | const isAdmin = m.role === 'admin'; | |
| 122 | + | const removeBtn = isAdmin | |
| 123 | + | ? '' | |
| 124 | + | : `<button class="btn btn-small btn-danger" data-act="settings.removeGroupMember" data-a1="${gid}" data-a2="${escAttr(m.userId)}" data-a3="${escAttr(m.email)}">Remove</button>`; | |
| 125 | + | return ` | |
| 126 | + | <div class="group-member-row"> | |
| 127 | + | <span class="group-member-email">${esc(m.email)}</span> | |
| 128 | + | <span class="group-member-role text-secondary">${esc(m.role)}</span> | |
| 129 | + | ${removeBtn} | |
| 130 | + | </div> | |
| 131 | + | `; | |
| 132 | + | }).join(''); | |
| 133 | + | ||
| 134 | + | return ` | |
| 135 | + | <div class="group-card"> | |
| 136 | + | <div class="group-card-header"> | |
| 137 | + | <span class="group-card-name">${esc(group.name)}</span> | |
| 138 | + | <span class="group-card-role text-secondary">Admin</span> | |
| 139 | + | </div> | |
| 140 | + | <div class="group-member-list"> | |
| 141 | + | ${memberRows || '<p class="text-secondary">No members yet.</p>'} | |
| 142 | + | </div> | |
| 143 | + | <div class="group-add-member"> | |
| 144 | + | <div class="form-group"> | |
| 145 | + | <input type="text" class="form-input" id="add-member-email-${gid}" placeholder="Member email"> | |
| 146 | + | </div> | |
| 147 | + | <div class="form-group"> | |
| 148 | + | <textarea class="form-textarea" id="add-member-pubkey-${gid}" placeholder="Paste the member's public key"></textarea> | |
| 149 | + | </div> | |
| 150 | + | <div class="sync-section-actions"> | |
| 151 | + | <button class="btn btn-secondary" data-act="settings.addGroupMember" data-a1="${gid}">Add Member</button> | |
| 152 | + | </div> | |
| 153 | + | </div> | |
| 154 | + | </div> | |
| 155 | + | `; | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | /** | |
| 159 | + | * Re-render the sharing section if the settings overlay is open. Called after | |
| 160 | + | * every mutation so the panel reflects server state. | |
| 161 | + | */ | |
| 162 | + | function refreshSharingSection() { | |
| 163 | + | const overlay = document.getElementById('settings-overlay'); | |
| 164 | + | const container = document.getElementById('settings-content'); | |
| 165 | + | if (overlay && !overlay.classList.contains('hidden') && container) { | |
| 166 | + | return renderSharingSection(container); | |
| 167 | + | } | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | async function createGroup() { | |
| 171 | + | const input = document.getElementById('new-group-name'); | |
| 172 | + | const name = input?.value.trim(); | |
| 173 | + | if (!name) { | |
| 174 | + | GoingsOn.ui.showToast('Enter a group name', 'error'); | |
| 175 | + | return; | |
| 176 | + | } | |
| 177 | + | try { | |
| 178 | + | await GoingsOn.api.groups.create(name); | |
| 179 | + | GoingsOn.groups.invalidate(); // New group name must resolve in badges. | |
| 180 | + | GoingsOn.ui.showToast(`Group "${name}" created`); | |
| 181 | + | refreshSharingSection(); | |
| 182 | + | } catch (err) { | |
| 183 | + | GoingsOn.ui.showToast('Failed to create group: ' + GoingsOn.utils.getErrorMessage(err), 'error'); | |
| 184 | + | } | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | async function copyMyGroupPubkey() { | |
| 188 | + | const input = document.getElementById('my-group-pubkey'); | |
| 189 | + | if (!input) return; | |
| 190 | + | const value = input.value; | |
| 191 | + | try { | |
| 192 | + | await navigator.clipboard.writeText(value); | |
| 193 | + | GoingsOn.ui.showToast('Public key copied'); | |
| 194 | + | } catch (_) { | |
| 195 | + | // Clipboard API unavailable — select the field so the user can copy it. | |
| 196 | + | input.focus(); | |
| 197 | + | input.select(); | |
| 198 | + | GoingsOn.ui.showToast('Press Ctrl/Cmd+C to copy', 'info'); | |
| 199 | + | } | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | async function addGroupMember(groupId) { | |
| 203 | + | const email = document.getElementById(`add-member-email-${groupId}`)?.value.trim(); | |
| 204 | + | const pubkey = document.getElementById(`add-member-pubkey-${groupId}`)?.value.trim(); | |
| 205 | + | if (!email || !pubkey) { | |
| 206 | + | GoingsOn.ui.showToast('Enter the member email and their public key', 'error'); | |
| 207 | + | return; | |
| 208 | + | } | |
| 209 | + | try { | |
| 210 | + | await GoingsOn.api.groups.addMember(groupId, email, pubkey); | |
| 211 | + | GoingsOn.ui.showToast(`Added ${email}`); | |
| 212 | + | refreshSharingSection(); | |
| 213 | + | } catch (err) { | |
| 214 | + | GoingsOn.ui.showToast('Failed to add member: ' + GoingsOn.utils.getErrorMessage(err), 'error'); | |
| 215 | + | } | |
| 216 | + | } | |
| 217 | + | ||
| 218 | + | async function removeGroupMember(groupId, userId, email) { | |
| 219 | + | const confirmed = await GoingsOn.ui.showConfirmDialog( | |
| 220 | + | 'Remove member', | |
| 221 | + | `Remove ${email} from this group? They keep whatever they already synced but see nothing new.`, | |
| 222 | + | { confirmText: 'Remove', danger: true } | |
| 223 | + | ); | |
| 224 | + | if (!confirmed) return; | |
| 225 | + | try { | |
| 226 | + | await GoingsOn.api.groups.removeMember(groupId, userId); | |
| 227 | + | GoingsOn.ui.showToast(`Removed ${email}`); | |
| 228 | + | refreshSharingSection(); | |
| 229 | + | } catch (err) { | |
| 230 | + | GoingsOn.ui.showToast('Failed to remove member: ' + GoingsOn.utils.getErrorMessage(err), 'error'); | |
| 231 | + | } | |
| 232 | + | } | |
| 233 | + | ||
| 234 | + | // ============ Populate GoingsOn Namespace ============ | |
| 235 | + | ||
| 236 | + | Object.assign(GoingsOn.settings, { | |
| 237 | + | renderSharingSection, | |
| 238 | + | createGroup, | |
| 239 | + | copyMyGroupPubkey, | |
| 240 | + | addGroupMember, | |
| 241 | + | removeGroupMember, | |
| 242 | + | }); | |
| 243 | + | ||
| 244 | + | })(); |
| @@ -77,6 +77,7 @@ | |||
| 77 | 77 | case 'email': await GoingsOn.emails.renderAccountsSection(container); break; | |
| 78 | 78 | case 'planning': renderPlanning(container); break; | |
| 79 | 79 | case 'sync': await renderSync(container); break; | |
| 80 | + | case 'sharing': await renderSharing(container); break; | |
| 80 | 81 | case 'data': await renderData(container); break; | |
| 81 | 82 | case 'about': await renderAbout(container); break; | |
| 82 | 83 | } | |
| @@ -185,6 +186,15 @@ | |||
| 185 | 186 | } | |
| 186 | 187 | } | |
| 187 | 188 | ||
| 189 | + | async function renderSharing(container) { | |
| 190 | + | // Delegate to settings-sharing.js which extends GoingsOn.settings. | |
| 191 | + | if (GoingsOn.settings.renderSharingSection) { | |
| 192 | + | await GoingsOn.settings.renderSharingSection(container); | |
| 193 | + | } else { | |
| 194 | + | container.innerHTML = '<p class="text-secondary">Sharing module not loaded.</p>'; | |
| 195 | + | } | |
| 196 | + | } | |
| 197 | + | ||
| 188 | 198 | async function renderData(container) { | |
| 189 | 199 | // Build backup settings inline | |
| 190 | 200 | let backupHtml = ''; |
| @@ -124,7 +124,7 @@ | |||
| 124 | 124 | ${t.contactName ? `<span class="contact-badge" title="${escAttrVal(t.contactName)}">${esc(t.contactName)}</span>` : ''} | |
| 125 | 125 | ${t.isSnoozed ? `<span class="snooze-badge" title="Snoozed until ${escAttr(t.snoozedUntilFormatted || '')}" aria-label="Snoozed until ${escAttr(t.snoozedUntilFormatted || '')}">Snoozed</span>` : ''} | |
| 126 | 126 | </div> | |
| 127 | - | <div class="task-cell task-project">${esc(t.projectName) || '-'}</div> | |
| 127 | + | <div class="task-cell task-project">${esc(t.projectName) || '-'}${GoingsOn.groups.taskSharedBadge(t.projectId)}</div> | |
| 128 | 128 | <div class="task-cell priority-${t.priority.toLowerCase()}" aria-label="Priority ${t.priority}">${t.priority.charAt(0)}</div> | |
| 129 | 129 | <div class="task-cell task-due">${t.dueFormatted || '-'}</div> | |
| 130 | 130 | <div class="task-cell task-recurrence">${formatRecurrence(t)}</div> |
| @@ -85,6 +85,7 @@ pub async fn group_list(state: State<'_, Arc<AppState>>) -> Result<Vec<GroupDto> | |||
| 85 | 85 | #[serde(rename_all = "camelCase")] | |
| 86 | 86 | pub struct GroupMemberDto { | |
| 87 | 87 | pub user_id: String, | |
| 88 | + | pub email: String, | |
| 88 | 89 | pub role: String, | |
| 89 | 90 | pub added_at: String, | |
| 90 | 91 | } | |
| @@ -107,6 +108,7 @@ pub async fn group_list_members( | |||
| 107 | 108 | .into_iter() | |
| 108 | 109 | .map(|m| GroupMemberDto { | |
| 109 | 110 | user_id: m.user_id.to_string(), | |
| 111 | + | email: m.email, | |
| 110 | 112 | role: m.role, | |
| 111 | 113 | added_at: m.added_at.to_rfc3339(), | |
| 112 | 114 | }) |