github-issue-tower-defence-management 1.89.0 → 1.90.0

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 (26) hide show
  1. package/.github/CODEOWNERS +1 -2
  2. package/CHANGELOG.md +7 -0
  3. package/README.md +2 -2
  4. package/bin/adapter/entry-points/console/consoleServer.js +40 -13
  5. package/bin/adapter/entry-points/console/consoleServer.js.map +1 -1
  6. package/bin/adapter/entry-points/console/ui-dist/assets/{index-DcOZ02ON.js → index-DDjYPXRT.js} +10 -10
  7. package/bin/adapter/entry-points/console/ui-dist/assets/index-DHlBLm7d.css +1 -0
  8. package/bin/adapter/entry-points/console/ui-dist/index.html +2 -2
  9. package/package.json +1 -1
  10. package/src/adapter/entry-points/console/consoleServer.test.ts +81 -0
  11. package/src/adapter/entry-points/console/consoleServer.ts +48 -16
  12. package/src/adapter/entry-points/console/ui/src/features/console/components/ConsoleProjectHeader.stories.tsx +29 -0
  13. package/src/adapter/entry-points/console/ui/src/features/console/components/ConsoleProjectHeader.tsx +14 -0
  14. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleList.ts +17 -4
  15. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsolePjcode.test.ts +24 -0
  16. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsolePjcode.ts +17 -0
  17. package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsolePage.tsx +5 -1
  18. package/src/adapter/entry-points/console/ui/tsconfig.json +2 -1
  19. package/src/adapter/entry-points/console/ui/vite.config.ts +1 -1
  20. package/src/adapter/entry-points/console/ui-dist/assets/{index-DcOZ02ON.js → index-DDjYPXRT.js} +10 -10
  21. package/src/adapter/entry-points/console/ui-dist/assets/index-DHlBLm7d.css +1 -0
  22. package/src/adapter/entry-points/console/ui-dist/index.html +2 -2
  23. package/types/adapter/entry-points/console/consoleServer.d.ts +1 -0
  24. package/types/adapter/entry-points/console/consoleServer.d.ts.map +1 -1
  25. package/bin/adapter/entry-points/console/ui-dist/assets/index-DFxrSRH4.css +0 -1
  26. package/src/adapter/entry-points/console/ui-dist/assets/index-DFxrSRH4.css +0 -1
@@ -0,0 +1 @@
1
+ /*! tailwindcss v4.3.0 | MIT License | https://tailwindcss.com */@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-font-weight:initial;--tw-outline-style:solid;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000}}}@layer theme{:root,:host{--font-sans:ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--spacing:.25rem;--container-3xl:48rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height: 1.5 ;--font-weight-medium:500;--font-weight-semibold:600;--radius-md:.375rem;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--color-border:#e5e5e5;--color-input:#e5e5e5;--color-ring:#0a0a0a;--color-background:#fff;--color-foreground:#0a0a0a;--color-primary:#171717;--color-primary-foreground:#fafafa;--color-secondary:#f5f5f5;--color-secondary-foreground:#171717;--color-muted-foreground:#737373;--color-accent:#f5f5f5;--color-accent-foreground:#171717;--color-destructive:#ef4444}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;-moz-tab-size:4;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab,red,red)){::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){-webkit-appearance:button;-moz-appearance:button;appearance:button}::file-selector-button{-webkit-appearance:button;-moz-appearance:button;appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.container{width:100%}@media(min-width:40rem){.container{max-width:40rem}}@media(min-width:48rem){.container{max-width:48rem}}@media(min-width:64rem){.container{max-width:64rem}}@media(min-width:80rem){.container{max-width:80rem}}@media(min-width:96rem){.container{max-width:96rem}}.mx-auto{margin-inline:auto}.flex{display:flex}.inline-flex{display:inline-flex}.h-8{height:calc(var(--spacing) * 8)}.h-9{height:calc(var(--spacing) * 9)}.h-10{height:calc(var(--spacing) * 10)}.max-w-3xl{max-width:var(--container-3xl)}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-baseline{align-items:baseline}.items-center{align-items:center}.justify-center{justify-content:center}.gap-1{gap:calc(var(--spacing) * 1)}.gap-2{gap:calc(var(--spacing) * 2)}:where(.divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)))}:where(.divide-border>:not(:last-child)){border-color:var(--color-border)}.rounded-md{border-radius:var(--radius-md)}.border{border-style:var(--tw-border-style);border-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-border{border-color:var(--color-border)}.border-input{border-color:var(--color-input)}.border-transparent{border-color:#0000}.bg-background{background-color:var(--color-background)}.bg-primary{background-color:var(--color-primary)}.bg-secondary{background-color:var(--color-secondary)}.p-2{padding:calc(var(--spacing) * 2)}.p-3{padding:calc(var(--spacing) * 3)}.p-4{padding:calc(var(--spacing) * 4)}.px-2\.5{padding-inline:calc(var(--spacing) * 2.5)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-8{padding-inline:calc(var(--spacing) * 8)}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-2{padding-block:calc(var(--spacing) * 2)}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.whitespace-nowrap{white-space:nowrap}.text-destructive{color:var(--color-destructive)}.text-foreground{color:var(--color-foreground)}.text-muted-foreground{color:var(--color-muted-foreground)}.text-primary-foreground{color:var(--color-primary-foreground)}.text-secondary-foreground{color:var(--color-secondary-foreground)}.underline-offset-2{text-underline-offset:2px}.outline{outline-style:var(--tw-outline-style);outline-width:1px}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}@media(hover:hover){.hover\:bg-accent:hover{background-color:var(--color-accent)}.hover\:bg-primary\/90:hover{background-color:#171717e6}@supports (color:color-mix(in lab,red,red)){.hover\:bg-primary\/90:hover{background-color:color-mix(in oklab,var(--color-primary) 90%,transparent)}}.hover\:bg-secondary\/80:hover{background-color:#f5f5f5cc}@supports (color:color-mix(in lab,red,red)){.hover\:bg-secondary\/80:hover{background-color:color-mix(in oklab,var(--color-secondary) 80%,transparent)}}.hover\:text-accent-foreground:hover{color:var(--color-accent-foreground)}.hover\:underline:hover{text-decoration-line:underline}}.focus-visible\:ring-2:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus-visible\:ring-ring:focus-visible{--tw-ring-color:var(--color-ring)}.focus-visible\:outline-none:focus-visible{--tw-outline-style:none;outline-style:none}.disabled\:pointer-events-none:disabled{pointer-events:none}.disabled\:opacity-50:disabled{opacity:.5}}body{background-color:var(--color-background);color:var(--color-foreground);margin:0;font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica,Arial,sans-serif}@property --tw-divide-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-outline-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"<length>";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}
@@ -4,8 +4,8 @@
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <title>TDPM Console</title>
7
- <script type="module" crossorigin src="./assets/index-DcOZ02ON.js"></script>
8
- <link rel="stylesheet" crossorigin href="./assets/index-DFxrSRH4.css">
7
+ <script type="module" crossorigin src="/assets/index-DDjYPXRT.js"></script>
8
+ <link rel="stylesheet" crossorigin href="/assets/index-DHlBLm7d.css">
9
9
  </head>
10
10
  <body>
11
11
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "github-issue-tower-defence-management",
3
- "version": "1.89.0",
3
+ "version": "1.90.0",
4
4
  "description": "",
5
5
  "main": "bin/index.js",
6
6
  "scripts": {
@@ -9,6 +9,7 @@ import {
9
9
  hasDotSegment,
10
10
  requiresToken,
11
11
  isTokenValid,
12
+ isConsoleAppRoute,
12
13
  extractProvidedToken,
13
14
  startConsoleServer,
14
15
  } from './consoleServer';
@@ -61,6 +62,35 @@ describe('consoleServer pure helpers', () => {
61
62
  });
62
63
  });
63
64
 
65
+ describe('isConsoleAppRoute', () => {
66
+ it('matches a per-project root route', () => {
67
+ expect(isConsoleAppRoute('/projects/umino')).toBe(true);
68
+ expect(isConsoleAppRoute('/projects/umino/')).toBe(true);
69
+ });
70
+
71
+ it('matches a per-project tab route for every list tab', () => {
72
+ expect(isConsoleAppRoute('/projects/umino/prs')).toBe(true);
73
+ expect(isConsoleAppRoute('/projects/xmile/triage')).toBe(true);
74
+ expect(isConsoleAppRoute('/projects/xcare/unread')).toBe(true);
75
+ expect(isConsoleAppRoute('/projects/utage3/failed-preparation')).toBe(
76
+ true,
77
+ );
78
+ expect(isConsoleAppRoute('/projects/utage3/todo-by-human')).toBe(true);
79
+ });
80
+
81
+ it('does not match data, api, or unknown tab routes', () => {
82
+ expect(isConsoleAppRoute('/projects/umino/prs/list.json')).toBe(false);
83
+ expect(isConsoleAppRoute('/projects/umino/unknown')).toBe(false);
84
+ expect(isConsoleAppRoute('/projects')).toBe(false);
85
+ expect(isConsoleAppRoute('/api/review')).toBe(false);
86
+ expect(isConsoleAppRoute('/')).toBe(false);
87
+ });
88
+
89
+ it('does not match a dot-prefixed pjcode', () => {
90
+ expect(isConsoleAppRoute('/projects/.git')).toBe(false);
91
+ });
92
+ });
93
+
64
94
  describe('isTokenValid', () => {
65
95
  it('accepts a matching token', () => {
66
96
  expect(isTokenValid('expected', 'expected')).toBe(true);
@@ -230,6 +260,57 @@ describe('consoleServer integration', () => {
230
260
  }
231
261
  });
232
262
 
263
+ it('serves the SPA index for per-project app routes without a token', async () => {
264
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'console-server-'));
265
+ const uiDistDir = path.join(tmpDir, 'ui-dist');
266
+ fs.mkdirSync(uiDistDir, { recursive: true });
267
+ fs.writeFileSync(
268
+ path.join(uiDistDir, 'index.html'),
269
+ '<!DOCTYPE html><title>spa</title><div id="root"></div>',
270
+ );
271
+ const server = await startConsoleServer({
272
+ accessToken: testToken,
273
+ uiDistDir,
274
+ consoleDataOutputDir: null,
275
+ port: 0,
276
+ });
277
+ try {
278
+ const projectRoot = await requestServer(server, '/projects/umino');
279
+ expect(projectRoot.statusCode).toBe(200);
280
+ expect(projectRoot.body).toContain('spa');
281
+ expect(projectRoot.contentType).toContain('text/html');
282
+ expect(projectRoot.cacheControl).toBe('no-store');
283
+
284
+ const projectTab = await requestServer(server, '/projects/xmile/prs');
285
+ expect(projectTab.statusCode).toBe(200);
286
+ expect(projectTab.body).toContain('spa');
287
+
288
+ const unknownTab = await requestServer(server, '/projects/xmile/unknown');
289
+ expect(unknownTab.statusCode).toBe(404);
290
+ } finally {
291
+ await closeServer(server);
292
+ fs.rmSync(tmpDir, { recursive: true, force: true });
293
+ }
294
+ });
295
+
296
+ it('serves the placeholder index for per-project routes when ui-dist is absent', async () => {
297
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'console-server-'));
298
+ const server = await startConsoleServer({
299
+ accessToken: testToken,
300
+ uiDistDir: path.join(tmpDir, 'missing-ui-dist'),
301
+ consoleDataOutputDir: null,
302
+ port: 0,
303
+ });
304
+ try {
305
+ const projectRoot = await requestServer(server, '/projects/umino/triage');
306
+ expect(projectRoot.statusCode).toBe(200);
307
+ expect(projectRoot.body).toContain('TDPM Console');
308
+ } finally {
309
+ await closeServer(server);
310
+ fs.rmSync(tmpDir, { recursive: true, force: true });
311
+ }
312
+ });
313
+
233
314
  it('rejects dot-prefixed paths with 404', async () => {
234
315
  const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'console-server-'));
235
316
  const uiDistDir = path.join(tmpDir, 'ui-dist');
@@ -4,6 +4,7 @@ import * as path from 'path';
4
4
  import { IssueRepository } from '../../../domain/usecases/adapter-interfaces/IssueRepository';
5
5
  import { Project } from '../../../domain/entities/Project';
6
6
  import {
7
+ CONSOLE_LIST_TAB_NAMES,
7
8
  buildConsoleDataResponse,
8
9
  parseConsoleDataRoute,
9
10
  } from './consoleDataDelivery';
@@ -70,6 +71,29 @@ export const requiresToken = (requestPath: string): boolean =>
70
71
  requestPath === '/api' ||
71
72
  requestPath.endsWith('.json');
72
73
 
74
+ const SAFE_PJCODE = /^[A-Za-z0-9._-]+$/;
75
+
76
+ export const isConsoleAppRoute = (requestPath: string): boolean => {
77
+ const segments = requestPath
78
+ .split('/')
79
+ .filter((segment) => segment.length > 0);
80
+ if (segments.length < 2 || segments[0] !== 'projects') {
81
+ return false;
82
+ }
83
+ const pjcode = segments[1];
84
+ if (!SAFE_PJCODE.test(pjcode) || pjcode.startsWith('.')) {
85
+ return false;
86
+ }
87
+ if (segments.length === 2) {
88
+ return true;
89
+ }
90
+ if (segments.length !== 3) {
91
+ return false;
92
+ }
93
+ const tab = segments[2];
94
+ return CONSOLE_LIST_TAB_NAMES.includes(tab);
95
+ };
96
+
73
97
  export const isTokenValid = (
74
98
  expectedToken: string,
75
99
  providedToken: string | null,
@@ -159,6 +183,24 @@ const serveBootstrapIndex = (response: http.ServerResponse): void => {
159
183
  response.end(PLACEHOLDER_INDEX_HTML);
160
184
  };
161
185
 
186
+ const serveIndexHtml = (
187
+ options: ConsoleServerOptions,
188
+ response: http.ServerResponse,
189
+ ): void => {
190
+ const indexFilePath = resolveStaticFilePath(options.uiDistDir, '/index.html');
191
+ const indexContent =
192
+ indexFilePath === null ? null : readStaticFile(indexFilePath);
193
+ if (indexContent === null) {
194
+ serveBootstrapIndex(response);
195
+ return;
196
+ }
197
+ response.writeHead(200, {
198
+ 'Content-Type': 'text/html; charset=utf-8',
199
+ 'Cache-Control': 'no-store',
200
+ });
201
+ response.end(indexContent);
202
+ };
203
+
162
204
  const sendJson = (
163
205
  response: http.ServerResponse,
164
206
  statusCode: number,
@@ -369,22 +411,12 @@ export const handleConsoleRequest = async (
369
411
  return;
370
412
  }
371
413
 
372
- if (requestPath === '/' || requestPath === '/index.html') {
373
- const indexFilePath = resolveStaticFilePath(
374
- options.uiDistDir,
375
- '/index.html',
376
- );
377
- const indexContent =
378
- indexFilePath === null ? null : readStaticFile(indexFilePath);
379
- if (indexContent === null) {
380
- serveBootstrapIndex(response);
381
- return;
382
- }
383
- response.writeHead(200, {
384
- 'Content-Type': 'text/html; charset=utf-8',
385
- 'Cache-Control': 'no-store',
386
- });
387
- response.end(indexContent);
414
+ if (
415
+ requestPath === '/' ||
416
+ requestPath === '/index.html' ||
417
+ isConsoleAppRoute(requestPath)
418
+ ) {
419
+ serveIndexHtml(options, response);
388
420
  return;
389
421
  }
390
422
 
@@ -0,0 +1,29 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
+ import { ConsoleProjectHeader } from './ConsoleProjectHeader';
3
+
4
+ const meta: Meta<typeof ConsoleProjectHeader> = {
5
+ title: 'Console/ConsoleProjectHeader',
6
+ component: ConsoleProjectHeader,
7
+ };
8
+
9
+ export default meta;
10
+
11
+ type Story = StoryObj<typeof ConsoleProjectHeader>;
12
+
13
+ export const WithProject: Story = {
14
+ args: {
15
+ pjcode: 'umino',
16
+ },
17
+ };
18
+
19
+ export const AnotherProject: Story = {
20
+ args: {
21
+ pjcode: 'xmile',
22
+ },
23
+ };
24
+
25
+ export const NoProject: Story = {
26
+ args: {
27
+ pjcode: null,
28
+ },
29
+ };
@@ -0,0 +1,14 @@
1
+ export type ConsoleProjectHeaderProps = {
2
+ pjcode: string | null;
3
+ };
4
+
5
+ export const ConsoleProjectHeader = ({ pjcode }: ConsoleProjectHeaderProps) => (
6
+ <header className="flex items-baseline gap-2 border-b border-border p-3">
7
+ <h1 className="text-base font-semibold">TDPM Console</h1>
8
+ {pjcode === null ? (
9
+ <span className="text-sm text-muted-foreground">no project selected</span>
10
+ ) : (
11
+ <span className="text-sm text-muted-foreground">project: {pjcode}</span>
12
+ )}
13
+ </header>
14
+ );
@@ -2,7 +2,8 @@ import { useEffect, useState } from 'react';
2
2
  import type { ConsoleListItem, ConsoleTabName } from '../types';
3
3
  import { useConsoleToken } from './useConsoleToken';
4
4
 
5
- const buildListUrl = (tab: ConsoleTabName): string => `./${tab}/list.json`;
5
+ const buildListUrl = (pjcode: string, tab: ConsoleTabName): string =>
6
+ `/projects/${pjcode}/${tab}/list.json`;
6
7
 
7
8
  const extractItems = (payload: unknown): ConsoleListItem[] => {
8
9
  if (
@@ -22,7 +23,10 @@ export type ConsoleListState = {
22
23
  error: string | null;
23
24
  };
24
25
 
25
- export const useConsoleList = (tab: ConsoleTabName): ConsoleListState => {
26
+ export const useConsoleList = (
27
+ pjcode: string | null,
28
+ tab: ConsoleTabName,
29
+ ): ConsoleListState => {
26
30
  const { appendToken } = useConsoleToken();
27
31
  const [items, setItems] = useState<ConsoleListItem[]>([]);
28
32
  const [isLoading, setIsLoading] = useState<boolean>(true);
@@ -33,7 +37,16 @@ export const useConsoleList = (tab: ConsoleTabName): ConsoleListState => {
33
37
  setIsLoading(true);
34
38
  setError(null);
35
39
 
36
- const url = appendToken(buildListUrl(tab));
40
+ if (pjcode === null) {
41
+ setItems([]);
42
+ setIsLoading(false);
43
+ setError('No project specified in the URL path.');
44
+ return () => {
45
+ cancelled = true;
46
+ };
47
+ }
48
+
49
+ const url = appendToken(buildListUrl(pjcode, tab));
37
50
  fetch(url)
38
51
  .then(async (response) => {
39
52
  if (!response.ok) {
@@ -59,7 +72,7 @@ export const useConsoleList = (tab: ConsoleTabName): ConsoleListState => {
59
72
  return () => {
60
73
  cancelled = true;
61
74
  };
62
- }, [tab, appendToken]);
75
+ }, [pjcode, tab, appendToken]);
63
76
 
64
77
  return { items, isLoading, error };
65
78
  };
@@ -0,0 +1,24 @@
1
+ import { parsePjcodeFromPath } from './useConsolePjcode';
2
+
3
+ describe('parsePjcodeFromPath', () => {
4
+ it('extracts the pjcode from a projects path', () => {
5
+ expect(parsePjcodeFromPath('/projects/umino')).toBe('umino');
6
+ expect(parsePjcodeFromPath('/projects/umino/prs')).toBe('umino');
7
+ expect(parsePjcodeFromPath('/projects/xmile/triage')).toBe('xmile');
8
+ });
9
+
10
+ it('tolerates a trailing slash', () => {
11
+ expect(parsePjcodeFromPath('/projects/utage3/')).toBe('utage3');
12
+ });
13
+
14
+ it('returns null when the path is not under projects', () => {
15
+ expect(parsePjcodeFromPath('/')).toBeNull();
16
+ expect(parsePjcodeFromPath('/index.html')).toBeNull();
17
+ expect(parsePjcodeFromPath('/assets/app.js')).toBeNull();
18
+ });
19
+
20
+ it('returns null when no pjcode segment follows projects', () => {
21
+ expect(parsePjcodeFromPath('/projects')).toBeNull();
22
+ expect(parsePjcodeFromPath('/projects/')).toBeNull();
23
+ });
24
+ });
@@ -0,0 +1,17 @@
1
+ export const parsePjcodeFromPath = (pathname: string): string | null => {
2
+ const segments = pathname.split('/').filter((segment) => segment.length > 0);
3
+ if (segments.length < 2 || segments[0] !== 'projects') {
4
+ return null;
5
+ }
6
+ const pjcode = segments[1];
7
+ if (pjcode.length === 0) {
8
+ return null;
9
+ }
10
+ return pjcode;
11
+ };
12
+
13
+ export const useConsolePjcode = (): string | null => {
14
+ const pathname =
15
+ typeof window === 'undefined' ? '' : window.location.pathname;
16
+ return parsePjcodeFromPath(pathname);
17
+ };
@@ -1,17 +1,21 @@
1
1
  import { useState } from 'react';
2
2
  import { ConsoleListView } from '../components/ConsoleListView';
3
+ import { ConsoleProjectHeader } from '../components/ConsoleProjectHeader';
3
4
  import { ConsoleTabBar } from '../components/ConsoleTabBar';
4
5
  import { useConsoleList } from '../hooks/useConsoleList';
6
+ import { useConsolePjcode } from '../hooks/useConsolePjcode';
5
7
  import { CONSOLE_TABS, type ConsoleTabName } from '../types';
6
8
 
7
9
  export const ConsolePage = () => {
10
+ const pjcode = useConsolePjcode();
8
11
  const [activeTab, setActiveTab] = useState<ConsoleTabName>(
9
12
  CONSOLE_TABS[0].name,
10
13
  );
11
- const { items, isLoading, error } = useConsoleList(activeTab);
14
+ const { items, isLoading, error } = useConsoleList(pjcode, activeTab);
12
15
 
13
16
  return (
14
17
  <main className="mx-auto flex max-w-3xl flex-col">
18
+ <ConsoleProjectHeader pjcode={pjcode} />
15
19
  <ConsoleTabBar activeTab={activeTab} onSelectTab={setActiveTab} />
16
20
  <ConsoleListView items={items} isLoading={isLoading} error={error} />
17
21
  </main>
@@ -20,5 +20,6 @@
20
20
  "@/*": ["./src/*"]
21
21
  }
22
22
  },
23
- "include": ["src", ".storybook"]
23
+ "include": ["src", ".storybook"],
24
+ "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"]
24
25
  }
@@ -5,7 +5,7 @@ import { defineConfig } from 'vite';
5
5
 
6
6
  export default defineConfig({
7
7
  root: path.resolve(__dirname),
8
- base: './',
8
+ base: '/',
9
9
  plugins: [react(), tailwindcss()],
10
10
  resolve: {
11
11
  alias: {