Skip to main content

max / goingson

Add Groups sharing UI (Groups p4 M4 frontend) Front-end for shared workspaces: - a Sharing settings section (your public key, create group, per-group member admin using the member email from group_list_members) - share/unshare a project from its context menu, via a scope-aware dialog - a Shared source badge on project cards and task rows, backed by a client-side group-name cache (groups.js) - a Shared-only filter in the Projects view api.js gains the group namespace plus projects.share/unshare, and group_list_members now carries member email through GroupMemberDto. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-23 23:52 UTC
Commit: e0bb57f780003e3f6c54de30b9b6f361b9de888a
Parent: 808fde6
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 &amp; 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 &amp; 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 })