@unbrained/pm-web 2026.6.2 → 2026.6.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/README.md +92 -1
  3. package/dist/auth.js +6 -0
  4. package/dist/auth.js.map +1 -1
  5. package/dist/board.js +27 -7
  6. package/dist/board.js.map +1 -1
  7. package/dist/ical.js +141 -0
  8. package/dist/ical.js.map +1 -0
  9. package/dist/index.js +351 -6
  10. package/dist/index.js.map +1 -1
  11. package/dist/routes/admin.js +11 -10
  12. package/dist/routes/admin.js.map +1 -1
  13. package/dist/routes/github.js +19 -18
  14. package/dist/routes/github.js.map +1 -1
  15. package/dist/routes/groups.js +11 -10
  16. package/dist/routes/groups.js.map +1 -1
  17. package/dist/routes/pm.js +241 -197
  18. package/dist/routes/pm.js.map +1 -1
  19. package/dist/routes/projects.js +4 -3
  20. package/dist/routes/projects.js.map +1 -1
  21. package/dist/routes/route-params.js +7 -0
  22. package/dist/routes/route-params.js.map +1 -0
  23. package/dist/routes/sharing.js +5 -4
  24. package/dist/routes/sharing.js.map +1 -1
  25. package/dist/server.js +13 -2
  26. package/dist/server.js.map +1 -1
  27. package/manifest.json +1 -1
  28. package/package.json +13 -13
  29. package/public/index.html +2 -1
  30. package/public/src/app.js +12 -1
  31. package/public/src/app.js.map +1 -1
  32. package/public/src/app.ts +12 -1
  33. package/public/src/filters.js +40 -0
  34. package/public/src/filters.js.map +1 -0
  35. package/public/src/filters.ts +52 -0
  36. package/public/src/state.js +1 -1
  37. package/public/src/state.js.map +1 -1
  38. package/public/src/state.ts +1 -1
  39. package/public/src/theme.js +74 -0
  40. package/public/src/theme.js.map +1 -0
  41. package/public/src/theme.ts +75 -0
  42. package/public/src/types.ts +1 -0
  43. package/public/src/utils.js +3 -1
  44. package/public/src/utils.js.map +1 -1
  45. package/public/src/utils.ts +3 -1
  46. package/public/src/views/calendar.js +1 -0
  47. package/public/src/views/calendar.js.map +1 -1
  48. package/public/src/views/calendar.ts +1 -0
  49. package/public/src/views/items.js +56 -3
  50. package/public/src/views/items.js.map +1 -1
  51. package/public/src/views/items.ts +55 -3
  52. package/public/styles.css +85 -0
@@ -10,8 +10,31 @@ import { getTypes, getStatuses, TYPE_ICONS, PRIORITY_LABELS } from '../constants
10
10
  import { showView } from './router.js';
11
11
  import { loadItemsBadge } from './projects.js';
12
12
  import { renderLocalGraph, destroyLocalGraph } from './graph.js';
13
+ import { EMPTY_FILTERS, filtersToQueryString, filtersFromSearchParams, hasActiveFilters } from '../filters.js';
13
14
  import type { Item } from '../types.js';
14
15
 
16
+ // Mirror the active item filters into the URL query string (on the /items
17
+ // path) so a filtered view is shareable and bookmarkable. Replaces the current
18
+ // history entry to avoid polluting back/forward with every keystroke.
19
+ function syncFiltersToUrl(): void {
20
+ if (state.currentView !== 'items') return;
21
+ const qs = filtersToQueryString(state.itemFilters);
22
+ const url = '/items' + (qs ? `?${qs}` : '');
23
+ try { history.replaceState(history.state, '', url); } catch { /* ignore */ }
24
+ }
25
+
26
+ // Read filters from the current URL's query string into state. Called when the
27
+ // Items view is opened so a shared link applies its filters.
28
+ export function loadFiltersFromUrl(): void {
29
+ const params = new URLSearchParams(window.location.search);
30
+ // Only override when the URL actually carries filter params, so navigating
31
+ // to /items without a query string preserves the in-memory filters.
32
+ const parsed = filtersFromSearchParams(params);
33
+ if (hasActiveFilters(parsed)) {
34
+ state.itemFilters = parsed;
35
+ }
36
+ }
37
+
15
38
  type RawDependency = {
16
39
  targetId?: string;
17
40
  id?: string;
@@ -415,6 +438,9 @@ export async function renderItemsView(): Promise<void> {
415
438
  return;
416
439
  }
417
440
 
441
+ // Apply any filters carried in the URL query string (shared/bookmarked link).
442
+ loadFiltersFromUrl();
443
+
418
444
  el.innerHTML = `
419
445
  <div class="page-header">
420
446
  <div>
@@ -447,7 +473,9 @@ export async function renderItemsView(): Promise<void> {
447
473
  <input class="filter-select" id="filter-sprint" type="text" placeholder="Sprint…" value="${escHtml(state.itemFilters.sprint)}" oninput="window.__app.applyItemFilters()" style="width:100px">
448
474
  <input class="filter-select" id="filter-release" type="text" placeholder="Release…" value="${escHtml(state.itemFilters.release)}" oninput="window.__app.applyItemFilters()" style="width:100px">
449
475
  <input class="filter-select" id="filter-assignee" type="text" placeholder="Assignee…" value="${escHtml(state.itemFilters.assignee)}" oninput="window.__app.applyItemFilters()" style="width:110px">
476
+ <input class="filter-select" id="filter-tag" type="text" placeholder="Tag…" value="${escHtml(state.itemFilters.tag)}" oninput="window.__app.applyItemFilters()" style="width:100px">
450
477
  <button class="btn btn-ghost btn-sm" onclick="window.__app.clearFilters()">Clear</button>
478
+ <button class="btn btn-ghost btn-sm" onclick="window.__app.copyFilterLink()" title="Copy a shareable link to this filtered view">🔗 Copy link</button>
451
479
  </div>
452
480
  <div id="items-list"><div class="loading-state"><div class="loading-spinner"></div></div></div>`;
453
481
 
@@ -470,7 +498,14 @@ export async function fetchAndRenderItems(): Promise<void> {
470
498
 
471
499
  try {
472
500
  const data = await api('GET',`/projects/${pid}/pm/${endpoint}`);
473
- state.items = data.items || [];
501
+ let items: Item[] = data.items || [];
502
+ // Tag is filtered client-side (pm list has no --tag flag): match items
503
+ // whose tag list contains the requested tag (case-insensitive).
504
+ const tag = (f.tag || '').trim().toLowerCase();
505
+ if (tag) {
506
+ items = items.filter((i) => (i.tags || []).some((t) => t.toLowerCase() === tag));
507
+ }
508
+ state.items = items;
474
509
  const sub = document.getElementById('items-subtitle');
475
510
  if (sub) sub.textContent = `${state.items.length} item${state.items.length!==1?'s':''}`;
476
511
  renderItemsList();
@@ -515,27 +550,44 @@ export function applyItemFilters(): void {
515
550
  const fsp = document.getElementById('filter-sprint') as HTMLInputElement | null;
516
551
  const frl = document.getElementById('filter-release') as HTMLInputElement | null;
517
552
  const fas = document.getElementById('filter-assignee') as HTMLInputElement | null;
553
+ const ftg = document.getElementById('filter-tag') as HTMLInputElement | null;
518
554
  state.itemFilters.status = fs?.value || '';
519
555
  state.itemFilters.type = ft?.value || '';
520
556
  state.itemFilters.priority = fp?.value || '';
521
557
  state.itemFilters.sprint = fsp?.value || '';
522
558
  state.itemFilters.release = frl?.value || '';
523
559
  state.itemFilters.assignee = fas?.value || '';
560
+ state.itemFilters.tag = ftg?.value || '';
561
+ syncFiltersToUrl();
524
562
  fetchAndRenderItems();
525
563
  }
526
564
 
527
565
  export function clearFilters(): void {
528
- state.itemFilters = { status:'', type:'', priority:'', sprint:'', release:'', assignee:'' };
529
- const ids = ['filter-status','filter-type','filter-priority','filter-sprint','filter-release','filter-assignee'];
566
+ state.itemFilters = { ...EMPTY_FILTERS };
567
+ const ids = ['filter-status','filter-type','filter-priority','filter-sprint','filter-release','filter-assignee','filter-tag'];
530
568
  ids.forEach(id => {
531
569
  const el = document.getElementById(id) as (HTMLInputElement | HTMLSelectElement) | null;
532
570
  if (el) el.value = '';
533
571
  });
572
+ syncFiltersToUrl();
534
573
  fetchAndRenderItems();
535
574
  }
536
575
 
576
+ // Copy a shareable URL for the current filtered Items view to the clipboard.
577
+ export async function copyFilterLink(): Promise<void> {
578
+ const qs = filtersToQueryString(state.itemFilters);
579
+ const url = `${window.location.origin}/items${qs ? `?${qs}` : ''}`;
580
+ try {
581
+ await navigator.clipboard.writeText(url);
582
+ toast('Filtered view link copied', 'success');
583
+ } catch {
584
+ toast(url, 'info');
585
+ }
586
+ }
587
+
537
588
  export function setStatusFilter(status: string): void {
538
589
  state.itemFilters.status = status;
590
+ syncFiltersToUrl();
539
591
  renderItemsView();
540
592
  }
541
593
 
package/public/styles.css CHANGED
@@ -81,7 +81,92 @@
81
81
  --radius-lg: 12px;
82
82
  --shadow: 0 4px 24px rgba(0,0,0,0.4);
83
83
  --transition: 0.15s ease;
84
+ color-scheme: dark;
85
+ }
86
+
87
+ /* ── Light theme ──────────────────────────────────────────────
88
+ Applied when the user explicitly selects "light" via the theme
89
+ toggle (html[data-theme="light"]) and, for "auto", when the OS
90
+ prefers a light scheme and no explicit theme is set. Only the
91
+ palette variables are overridden; component rules reference the
92
+ variables so they recolor automatically. */
93
+ :root[data-theme="light"] {
94
+ --bg-base: #f6f8fc;
95
+ --bg-sidebar: #ffffff;
96
+ --bg-sidebar-hover: #eef2f9;
97
+ --bg-card: #ffffff;
98
+ --bg-card2: #f1f5fb;
99
+ --bg-input: #ffffff;
100
+ --bg-modal: #ffffff;
101
+ --border: #e2e8f0;
102
+ --border-light: #cbd5e1;
103
+ --accent: #0d9488;
104
+ --accent-dim: #99f6e4;
105
+ --accent-glow: rgba(13,148,136,0.12);
106
+ --text-primary: #0f172a;
107
+ --text-secondary: #475569;
108
+ --text-muted: #94a3b8;
109
+ --text-accent: #0d9488;
110
+ --status-draft: #64748b;
111
+ --status-open: #2563eb;
112
+ --status-in_progress: #0d9488;
113
+ --status-blocked: #dc2626;
114
+ --status-closed: #16a34a;
115
+ --status-canceled: #94a3b8;
116
+ --priority-1: #dc2626;
117
+ --priority-2: #ea580c;
118
+ --priority-3: #ca8a04;
119
+ --priority-4: #2563eb;
120
+ --priority-5: #64748b;
121
+ --shadow: 0 4px 24px rgba(15,23,42,0.12);
122
+ color-scheme: light;
123
+ }
124
+
125
+ @media (prefers-color-scheme: light) {
126
+ /* Auto theme: honor the OS preference only when the user has not pinned a
127
+ theme. data-theme="dark"/"light" both opt out of this block. */
128
+ :root[data-theme="auto"] {
129
+ --bg-base: #f6f8fc;
130
+ --bg-sidebar: #ffffff;
131
+ --bg-sidebar-hover: #eef2f9;
132
+ --bg-card: #ffffff;
133
+ --bg-card2: #f1f5fb;
134
+ --bg-input: #ffffff;
135
+ --bg-modal: #ffffff;
136
+ --border: #e2e8f0;
137
+ --border-light: #cbd5e1;
138
+ --accent: #0d9488;
139
+ --accent-dim: #99f6e4;
140
+ --accent-glow: rgba(13,148,136,0.12);
141
+ --text-primary: #0f172a;
142
+ --text-secondary: #475569;
143
+ --text-muted: #94a3b8;
144
+ --text-accent: #0d9488;
145
+ --status-draft: #64748b;
146
+ --status-open: #2563eb;
147
+ --status-in_progress: #0d9488;
148
+ --status-blocked: #dc2626;
149
+ --status-closed: #16a34a;
150
+ --status-canceled: #94a3b8;
151
+ --priority-1: #dc2626;
152
+ --priority-2: #ea580c;
153
+ --priority-3: #ca8a04;
154
+ --priority-4: #2563eb;
155
+ --priority-5: #64748b;
156
+ --shadow: 0 4px 24px rgba(15,23,42,0.12);
157
+ color-scheme: light;
158
+ }
159
+ }
160
+
161
+ /* Theme toggle button in the top nav */
162
+ .theme-toggle {
163
+ display: inline-flex; align-items: center; justify-content: center;
164
+ width: 32px; height: 32px; border-radius: 50%;
165
+ background: var(--bg-input); border: 1px solid var(--border-light);
166
+ color: var(--text-secondary); cursor: pointer; flex-shrink: 0;
167
+ font-size: 15px; line-height: 1; transition: var(--transition);
84
168
  }
169
+ .theme-toggle:hover { border-color: var(--accent); color: var(--accent); }
85
170
 
86
171
  html, body { height: 100%; font-family: 'Inter', sans-serif; background: var(--bg-base); color: var(--text-primary); font-size: 14px; line-height: 1.5; }
87
172