@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.
- package/CHANGELOG.md +36 -0
- package/README.md +92 -1
- package/dist/auth.js +6 -0
- package/dist/auth.js.map +1 -1
- package/dist/board.js +27 -7
- package/dist/board.js.map +1 -1
- package/dist/ical.js +141 -0
- package/dist/ical.js.map +1 -0
- package/dist/index.js +351 -6
- package/dist/index.js.map +1 -1
- package/dist/routes/admin.js +11 -10
- package/dist/routes/admin.js.map +1 -1
- package/dist/routes/github.js +19 -18
- package/dist/routes/github.js.map +1 -1
- package/dist/routes/groups.js +11 -10
- package/dist/routes/groups.js.map +1 -1
- package/dist/routes/pm.js +241 -197
- package/dist/routes/pm.js.map +1 -1
- package/dist/routes/projects.js +4 -3
- package/dist/routes/projects.js.map +1 -1
- package/dist/routes/route-params.js +7 -0
- package/dist/routes/route-params.js.map +1 -0
- package/dist/routes/sharing.js +5 -4
- package/dist/routes/sharing.js.map +1 -1
- package/dist/server.js +13 -2
- package/dist/server.js.map +1 -1
- package/manifest.json +1 -1
- package/package.json +13 -13
- package/public/index.html +2 -1
- package/public/src/app.js +12 -1
- package/public/src/app.js.map +1 -1
- package/public/src/app.ts +12 -1
- package/public/src/filters.js +40 -0
- package/public/src/filters.js.map +1 -0
- package/public/src/filters.ts +52 -0
- package/public/src/state.js +1 -1
- package/public/src/state.js.map +1 -1
- package/public/src/state.ts +1 -1
- package/public/src/theme.js +74 -0
- package/public/src/theme.js.map +1 -0
- package/public/src/theme.ts +75 -0
- package/public/src/types.ts +1 -0
- package/public/src/utils.js +3 -1
- package/public/src/utils.js.map +1 -1
- package/public/src/utils.ts +3 -1
- package/public/src/views/calendar.js +1 -0
- package/public/src/views/calendar.js.map +1 -1
- package/public/src/views/calendar.ts +1 -0
- package/public/src/views/items.js +56 -3
- package/public/src/views/items.js.map +1 -1
- package/public/src/views/items.ts +55 -3
- 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
|
-
|
|
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 = {
|
|
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
|
|