@x4lt7ab/tab-for-projects 0.1.0 → 0.1.1
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/package.json +6 -5
- package/src/domain/args.ts +11 -1
- package/src/domain/bootstrap.ts +12 -4
- package/src/domain/db/schema.ts +28 -7
- package/src/domain/entities.ts +8 -0
- package/src/domain/events.ts +26 -0
- package/src/domain/index.ts +3 -0
- package/src/domain/inputs.ts +2 -2
- package/src/domain/repositories/projects.ts +16 -3
- package/src/domain/repositories/tags.ts +77 -0
- package/src/domain/repositories/tasks.ts +69 -27
- package/src/domain/services/projects.ts +22 -7
- package/src/domain/services/tags.ts +85 -0
- package/src/domain/services/tasks.ts +50 -9
- package/src/domain/services.ts +31 -4
- package/src/mcp/index.ts +1 -1
- package/src/mcp/server.ts +175 -31
- package/src/mcp/standalone.ts +10 -7
- package/src/server/index.ts +44 -10
- package/src/server/routes/projects.ts +16 -5
- package/src/server/routes/tags.ts +61 -0
- package/src/server/routes/tasks.ts +86 -9
- package/src/web/dist/assets/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa0ZL7SUc-DqGufNeO.woff2 +0 -0
- package/src/web/dist/assets/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa1ZL7-Dx4kXJAl.woff2 +0 -0
- package/src/web/dist/assets/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa1pL7SUc-CkhJZR-_.woff2 +0 -0
- package/src/web/dist/assets/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa25L7SUc-DO1Apj_S.woff2 +0 -0
- package/src/web/dist/assets/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2JL7SUc-BOeWTOD4.woff2 +0 -0
- package/src/web/dist/assets/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2ZL7SUc-DlzME5K_.woff2 +0 -0
- package/src/web/dist/assets/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2pL7SUc-CBcvBZtf.woff2 +0 -0
- package/src/web/dist/assets/index-aUW2zejq.js +49 -0
- package/src/web/dist/assets/kJEPBvYX7BgnkSrUwT8OhrdQw4oELdPIeeII9v6oDMzBwG-RpA6RzaxHMPdY40KH8nGzv3fzfVJO1Q-CpotRDAj.woff2 +0 -0
- package/src/web/dist/assets/xn7gYHE41ni1AdIRggOxSuXd-Dvxsihut.woff2 +0 -0
- package/src/web/dist/assets/xn7gYHE41ni1AdIRggSxSuXd-DL7QRZyv.woff2 +0 -0
- package/src/web/dist/assets/xn7gYHE41ni1AdIRggexSg-DHIcAJRg.woff2 +0 -0
- package/src/web/dist/assets/xn7gYHE41ni1AdIRggixSuXd-usUDDRr7.woff2 +0 -0
- package/src/web/dist/assets/xn7gYHE41ni1AdIRggmxSuXd-Ch3YOpNY.woff2 +0 -0
- package/src/web/dist/assets/xn7gYHE41ni1AdIRggqxSuXd-C8S-KRRz.woff2 +0 -0
- package/src/web/dist/index.html +2 -11
- package/src/web/src/App.tsx +222 -22
- package/src/web/src/components/TopBar.tsx +4 -2
- package/src/web/src/useRealtimeEvents.ts +62 -0
- package/src/web/vite.config.ts +6 -1
- package/src/web/dist/assets/index-Bonqd4_2.js +0 -49
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/src/web/dist/index.html
CHANGED
|
@@ -4,23 +4,14 @@
|
|
|
4
4
|
<meta charset="UTF-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
6
|
<title>Project Management</title>
|
|
7
|
-
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
8
|
-
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
9
|
-
<link
|
|
10
|
-
href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=Inter:wght@400;500;600&display=swap"
|
|
11
|
-
rel="stylesheet"
|
|
12
|
-
/>
|
|
13
|
-
<link
|
|
14
|
-
href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap"
|
|
15
|
-
rel="stylesheet"
|
|
16
|
-
/>
|
|
17
7
|
<style>
|
|
18
8
|
.material-symbols-outlined {
|
|
19
9
|
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
|
|
20
10
|
vertical-align: middle;
|
|
21
11
|
}
|
|
22
12
|
</style>
|
|
23
|
-
<script type="module" crossorigin src="/assets/index-
|
|
13
|
+
<script type="module" crossorigin src="/assets/index-aUW2zejq.js"></script>
|
|
14
|
+
<style>@font-face{font-family:Inter;font-style:normal;font-weight:400;font-display:swap;src:url(/assets/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2JL7SUc-BOeWTOD4.woff2) format('woff2');unicode-range:U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F}@font-face{font-family:Inter;font-style:normal;font-weight:400;font-display:swap;src:url(/assets/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa0ZL7SUc-DqGufNeO.woff2) format('woff2');unicode-range:U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116}@font-face{font-family:Inter;font-style:normal;font-weight:400;font-display:swap;src:url(/assets/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2ZL7SUc-DlzME5K_.woff2) format('woff2');unicode-range:U+1F00-1FFF}@font-face{font-family:Inter;font-style:normal;font-weight:400;font-display:swap;src:url(/assets/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa1pL7SUc-CkhJZR-_.woff2) format('woff2');unicode-range:U+0370-0377,U+037A-037F,U+0384-038A,U+038C,U+038E-03A1,U+03A3-03FF}@font-face{font-family:Inter;font-style:normal;font-weight:400;font-display:swap;src:url(/assets/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2pL7SUc-CBcvBZtf.woff2) format('woff2');unicode-range:U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB}@font-face{font-family:Inter;font-style:normal;font-weight:400;font-display:swap;src:url(/assets/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa25L7SUc-DO1Apj_S.woff2) format('woff2');unicode-range:U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:Inter;font-style:normal;font-weight:400;font-display:swap;src:url(/assets/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa1ZL7-Dx4kXJAl.woff2) format('woff2');unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@font-face{font-family:Inter;font-style:normal;font-weight:500;font-display:swap;src:url(/assets/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2JL7SUc-BOeWTOD4.woff2) format('woff2');unicode-range:U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F}@font-face{font-family:Inter;font-style:normal;font-weight:500;font-display:swap;src:url(/assets/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa0ZL7SUc-DqGufNeO.woff2) format('woff2');unicode-range:U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116}@font-face{font-family:Inter;font-style:normal;font-weight:500;font-display:swap;src:url(/assets/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2ZL7SUc-DlzME5K_.woff2) format('woff2');unicode-range:U+1F00-1FFF}@font-face{font-family:Inter;font-style:normal;font-weight:500;font-display:swap;src:url(/assets/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa1pL7SUc-CkhJZR-_.woff2) format('woff2');unicode-range:U+0370-0377,U+037A-037F,U+0384-038A,U+038C,U+038E-03A1,U+03A3-03FF}@font-face{font-family:Inter;font-style:normal;font-weight:500;font-display:swap;src:url(/assets/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2pL7SUc-CBcvBZtf.woff2) format('woff2');unicode-range:U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB}@font-face{font-family:Inter;font-style:normal;font-weight:500;font-display:swap;src:url(/assets/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa25L7SUc-DO1Apj_S.woff2) format('woff2');unicode-range:U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:Inter;font-style:normal;font-weight:500;font-display:swap;src:url(/assets/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa1ZL7-Dx4kXJAl.woff2) format('woff2');unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@font-face{font-family:Inter;font-style:normal;font-weight:600;font-display:swap;src:url(/assets/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2JL7SUc-BOeWTOD4.woff2) format('woff2');unicode-range:U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F}@font-face{font-family:Inter;font-style:normal;font-weight:600;font-display:swap;src:url(/assets/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa0ZL7SUc-DqGufNeO.woff2) format('woff2');unicode-range:U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116}@font-face{font-family:Inter;font-style:normal;font-weight:600;font-display:swap;src:url(/assets/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2ZL7SUc-DlzME5K_.woff2) format('woff2');unicode-range:U+1F00-1FFF}@font-face{font-family:Inter;font-style:normal;font-weight:600;font-display:swap;src:url(/assets/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa1pL7SUc-CkhJZR-_.woff2) format('woff2');unicode-range:U+0370-0377,U+037A-037F,U+0384-038A,U+038C,U+038E-03A1,U+03A3-03FF}@font-face{font-family:Inter;font-style:normal;font-weight:600;font-display:swap;src:url(/assets/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2pL7SUc-CBcvBZtf.woff2) format('woff2');unicode-range:U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB}@font-face{font-family:Inter;font-style:normal;font-weight:600;font-display:swap;src:url(/assets/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa25L7SUc-DO1Apj_S.woff2) format('woff2');unicode-range:U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:Inter;font-style:normal;font-weight:600;font-display:swap;src:url(/assets/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa1ZL7-Dx4kXJAl.woff2) format('woff2');unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@font-face{font-family:Manrope;font-style:normal;font-weight:400;font-display:swap;src:url(/assets/xn7gYHE41ni1AdIRggqxSuXd-C8S-KRRz.woff2) format('woff2');unicode-range:U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F}@font-face{font-family:Manrope;font-style:normal;font-weight:400;font-display:swap;src:url(/assets/xn7gYHE41ni1AdIRggOxSuXd-Dvxsihut.woff2) format('woff2');unicode-range:U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116}@font-face{font-family:Manrope;font-style:normal;font-weight:400;font-display:swap;src:url(/assets/xn7gYHE41ni1AdIRggSxSuXd-DL7QRZyv.woff2) format('woff2');unicode-range:U+0370-0377,U+037A-037F,U+0384-038A,U+038C,U+038E-03A1,U+03A3-03FF}@font-face{font-family:Manrope;font-style:normal;font-weight:400;font-display:swap;src:url(/assets/xn7gYHE41ni1AdIRggixSuXd-usUDDRr7.woff2) format('woff2');unicode-range:U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB}@font-face{font-family:Manrope;font-style:normal;font-weight:400;font-display:swap;src:url(/assets/xn7gYHE41ni1AdIRggmxSuXd-Ch3YOpNY.woff2) format('woff2');unicode-range:U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:Manrope;font-style:normal;font-weight:400;font-display:swap;src:url(/assets/xn7gYHE41ni1AdIRggexSg-DHIcAJRg.woff2) format('woff2');unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@font-face{font-family:Manrope;font-style:normal;font-weight:500;font-display:swap;src:url(/assets/xn7gYHE41ni1AdIRggqxSuXd-C8S-KRRz.woff2) format('woff2');unicode-range:U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F}@font-face{font-family:Manrope;font-style:normal;font-weight:500;font-display:swap;src:url(/assets/xn7gYHE41ni1AdIRggOxSuXd-Dvxsihut.woff2) format('woff2');unicode-range:U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116}@font-face{font-family:Manrope;font-style:normal;font-weight:500;font-display:swap;src:url(/assets/xn7gYHE41ni1AdIRggSxSuXd-DL7QRZyv.woff2) format('woff2');unicode-range:U+0370-0377,U+037A-037F,U+0384-038A,U+038C,U+038E-03A1,U+03A3-03FF}@font-face{font-family:Manrope;font-style:normal;font-weight:500;font-display:swap;src:url(/assets/xn7gYHE41ni1AdIRggixSuXd-usUDDRr7.woff2) format('woff2');unicode-range:U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB}@font-face{font-family:Manrope;font-style:normal;font-weight:500;font-display:swap;src:url(/assets/xn7gYHE41ni1AdIRggmxSuXd-Ch3YOpNY.woff2) format('woff2');unicode-range:U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:Manrope;font-style:normal;font-weight:500;font-display:swap;src:url(/assets/xn7gYHE41ni1AdIRggexSg-DHIcAJRg.woff2) format('woff2');unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@font-face{font-family:Manrope;font-style:normal;font-weight:600;font-display:swap;src:url(/assets/xn7gYHE41ni1AdIRggqxSuXd-C8S-KRRz.woff2) format('woff2');unicode-range:U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F}@font-face{font-family:Manrope;font-style:normal;font-weight:600;font-display:swap;src:url(/assets/xn7gYHE41ni1AdIRggOxSuXd-Dvxsihut.woff2) format('woff2');unicode-range:U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116}@font-face{font-family:Manrope;font-style:normal;font-weight:600;font-display:swap;src:url(/assets/xn7gYHE41ni1AdIRggSxSuXd-DL7QRZyv.woff2) format('woff2');unicode-range:U+0370-0377,U+037A-037F,U+0384-038A,U+038C,U+038E-03A1,U+03A3-03FF}@font-face{font-family:Manrope;font-style:normal;font-weight:600;font-display:swap;src:url(/assets/xn7gYHE41ni1AdIRggixSuXd-usUDDRr7.woff2) format('woff2');unicode-range:U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB}@font-face{font-family:Manrope;font-style:normal;font-weight:600;font-display:swap;src:url(/assets/xn7gYHE41ni1AdIRggmxSuXd-Ch3YOpNY.woff2) format('woff2');unicode-range:U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:Manrope;font-style:normal;font-weight:600;font-display:swap;src:url(/assets/xn7gYHE41ni1AdIRggexSg-DHIcAJRg.woff2) format('woff2');unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@font-face{font-family:Manrope;font-style:normal;font-weight:700;font-display:swap;src:url(/assets/xn7gYHE41ni1AdIRggqxSuXd-C8S-KRRz.woff2) format('woff2');unicode-range:U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F}@font-face{font-family:Manrope;font-style:normal;font-weight:700;font-display:swap;src:url(/assets/xn7gYHE41ni1AdIRggOxSuXd-Dvxsihut.woff2) format('woff2');unicode-range:U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116}@font-face{font-family:Manrope;font-style:normal;font-weight:700;font-display:swap;src:url(/assets/xn7gYHE41ni1AdIRggSxSuXd-DL7QRZyv.woff2) format('woff2');unicode-range:U+0370-0377,U+037A-037F,U+0384-038A,U+038C,U+038E-03A1,U+03A3-03FF}@font-face{font-family:Manrope;font-style:normal;font-weight:700;font-display:swap;src:url(/assets/xn7gYHE41ni1AdIRggixSuXd-usUDDRr7.woff2) format('woff2');unicode-range:U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB}@font-face{font-family:Manrope;font-style:normal;font-weight:700;font-display:swap;src:url(/assets/xn7gYHE41ni1AdIRggmxSuXd-Ch3YOpNY.woff2) format('woff2');unicode-range:U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:Manrope;font-style:normal;font-weight:700;font-display:swap;src:url(/assets/xn7gYHE41ni1AdIRggexSg-DHIcAJRg.woff2) format('woff2');unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@font-face{font-family:Manrope;font-style:normal;font-weight:800;font-display:swap;src:url(/assets/xn7gYHE41ni1AdIRggqxSuXd-C8S-KRRz.woff2) format('woff2');unicode-range:U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F}@font-face{font-family:Manrope;font-style:normal;font-weight:800;font-display:swap;src:url(/assets/xn7gYHE41ni1AdIRggOxSuXd-Dvxsihut.woff2) format('woff2');unicode-range:U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116}@font-face{font-family:Manrope;font-style:normal;font-weight:800;font-display:swap;src:url(/assets/xn7gYHE41ni1AdIRggSxSuXd-DL7QRZyv.woff2) format('woff2');unicode-range:U+0370-0377,U+037A-037F,U+0384-038A,U+038C,U+038E-03A1,U+03A3-03FF}@font-face{font-family:Manrope;font-style:normal;font-weight:800;font-display:swap;src:url(/assets/xn7gYHE41ni1AdIRggixSuXd-usUDDRr7.woff2) format('woff2');unicode-range:U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB}@font-face{font-family:Manrope;font-style:normal;font-weight:800;font-display:swap;src:url(/assets/xn7gYHE41ni1AdIRggmxSuXd-Ch3YOpNY.woff2) format('woff2');unicode-range:U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:Manrope;font-style:normal;font-weight:800;font-display:swap;src:url(/assets/xn7gYHE41ni1AdIRggexSg-DHIcAJRg.woff2) format('woff2');unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@font-face{font-family:'Material Symbols Outlined';font-style:normal;font-weight:100 700;font-display:swap;src:url(/assets/kJEPBvYX7BgnkSrUwT8OhrdQw4oELdPIeeII9v6oDMzBwG-RpA6RzaxHMPdY40KH8nGzv3fzfVJO1Q-CpotRDAj.woff2) format('woff2')}.material-symbols-outlined{font-family:'Material Symbols Outlined';font-weight:400;font-style:normal;font-size:24px;line-height:1;letter-spacing:normal;text-transform:none;display:inline-block;white-space:nowrap;word-wrap:normal;direction:ltr;-webkit-font-feature-settings:'liga';-webkit-font-smoothing:antialiased}</style>
|
|
24
15
|
</head>
|
|
25
16
|
<body>
|
|
26
17
|
<div id="root"></div>
|
package/src/web/src/App.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useEffect, useState } from "react";
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
2
2
|
import {
|
|
3
3
|
Badge,
|
|
4
4
|
Button,
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
useTheme,
|
|
13
13
|
} from "./components";
|
|
14
14
|
import { API_BASE } from "./api";
|
|
15
|
+
import { useRealtimeEvents, type DomainEvent } from "./useRealtimeEvents";
|
|
15
16
|
|
|
16
17
|
// ---------------------------------------------------------------------------
|
|
17
18
|
// Types
|
|
@@ -30,13 +31,21 @@ interface Project {
|
|
|
30
31
|
interface Task {
|
|
31
32
|
id: string;
|
|
32
33
|
project_id: string;
|
|
34
|
+
number: number;
|
|
33
35
|
title: string;
|
|
34
36
|
description: string;
|
|
35
37
|
status: "todo" | "in_progress" | "done";
|
|
38
|
+
priority: number | null;
|
|
36
39
|
created_at: string;
|
|
37
40
|
updated_at: string;
|
|
38
41
|
}
|
|
39
42
|
|
|
43
|
+
interface Tag {
|
|
44
|
+
id: string;
|
|
45
|
+
name: string;
|
|
46
|
+
created_at: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
40
49
|
const statusOptions = [
|
|
41
50
|
{ value: "active", label: "Active" },
|
|
42
51
|
{ value: "paused", label: "Paused" },
|
|
@@ -78,26 +87,61 @@ export function App() {
|
|
|
78
87
|
const projectSlugMatch = path.match(/^\/projects\/([^/]+)$/);
|
|
79
88
|
const projectSlug = projectSlugMatch?.[1] ?? null;
|
|
80
89
|
|
|
90
|
+
// Real-time event listeners from child views
|
|
91
|
+
const eventListenersRef = useRef(new Set<(e: DomainEvent) => void>());
|
|
92
|
+
|
|
93
|
+
const onEvent = useCallback((event: DomainEvent) => {
|
|
94
|
+
for (const fn of eventListenersRef.current) fn(event);
|
|
95
|
+
}, []);
|
|
96
|
+
|
|
97
|
+
const { connected } = useRealtimeEvents(onEvent);
|
|
98
|
+
|
|
99
|
+
const subscribeEvents = useCallback((fn: (e: DomainEvent) => void) => {
|
|
100
|
+
eventListenersRef.current.add(fn);
|
|
101
|
+
return () => { eventListenersRef.current.delete(fn); };
|
|
102
|
+
}, []);
|
|
103
|
+
|
|
81
104
|
return (
|
|
82
105
|
<div style={{ display: "flex", flexDirection: "column", minHeight: "100vh", fontFamily: theme.font.body }}>
|
|
83
|
-
<TopBar />
|
|
106
|
+
<TopBar trailing={<ConnectionIndicator connected={connected} />} />
|
|
84
107
|
|
|
85
108
|
<main style={{ flex: 1, display: "flex", flexDirection: "column", alignItems: "center", minWidth: 0 }}>
|
|
86
109
|
{projectSlug ? (
|
|
87
|
-
<ProjectView slug={projectSlug} onBack={() => navigate("/")} />
|
|
110
|
+
<ProjectView slug={projectSlug} onBack={() => navigate("/")} subscribeEvents={subscribeEvents} />
|
|
88
111
|
) : (
|
|
89
|
-
<DashboardView onOpenProject={(slug) => navigate(`/projects/${slug}`)} />
|
|
112
|
+
<DashboardView onOpenProject={(slug) => navigate(`/projects/${slug}`)} subscribeEvents={subscribeEvents} />
|
|
90
113
|
)}
|
|
91
114
|
</main>
|
|
92
115
|
</div>
|
|
93
116
|
);
|
|
94
117
|
}
|
|
95
118
|
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
// ConnectionIndicator
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
function ConnectionIndicator({ connected }: { connected: boolean }) {
|
|
124
|
+
const { theme } = useTheme();
|
|
125
|
+
return (
|
|
126
|
+
<span
|
|
127
|
+
title={connected ? "Live updates active" : "Reconnecting..."}
|
|
128
|
+
style={{
|
|
129
|
+
display: "inline-block",
|
|
130
|
+
width: 8,
|
|
131
|
+
height: 8,
|
|
132
|
+
borderRadius: theme.radius.full,
|
|
133
|
+
background: connected ? theme.color.success : theme.color.textFaint,
|
|
134
|
+
transition: "background 0.3s",
|
|
135
|
+
}}
|
|
136
|
+
/>
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
96
140
|
// ---------------------------------------------------------------------------
|
|
97
141
|
// DashboardView
|
|
98
142
|
// ---------------------------------------------------------------------------
|
|
99
143
|
|
|
100
|
-
function DashboardView({ onOpenProject }: { onOpenProject: (slug: string) => void }) {
|
|
144
|
+
function DashboardView({ onOpenProject, subscribeEvents }: { onOpenProject: (slug: string) => void; subscribeEvents: (fn: (e: DomainEvent) => void) => () => void }) {
|
|
101
145
|
const { theme } = useTheme();
|
|
102
146
|
const [projects, setProjects] = useState<Project[]>([]);
|
|
103
147
|
const [name, setName] = useState("");
|
|
@@ -105,28 +149,37 @@ function DashboardView({ onOpenProject }: { onOpenProject: (slug: string) => voi
|
|
|
105
149
|
const [description, setDescription] = useState("");
|
|
106
150
|
const [showCreateForm, setShowCreateForm] = useState(false);
|
|
107
151
|
|
|
152
|
+
const fetchProjectsRef = useRef<() => void>();
|
|
153
|
+
|
|
108
154
|
async function fetchProjects() {
|
|
109
155
|
const res = await fetch(`${API_BASE}/api/projects`);
|
|
110
|
-
|
|
156
|
+
if (!res.ok) return;
|
|
157
|
+
const body = await res.json();
|
|
158
|
+
setProjects(body.data);
|
|
111
159
|
}
|
|
112
160
|
|
|
161
|
+
fetchProjectsRef.current = fetchProjects;
|
|
162
|
+
|
|
113
163
|
useEffect(() => {
|
|
114
164
|
fetchProjects();
|
|
115
|
-
|
|
165
|
+
return subscribeEvents((event) => {
|
|
166
|
+
if (event.entity === "project") fetchProjectsRef.current?.();
|
|
167
|
+
});
|
|
168
|
+
}, [subscribeEvents]);
|
|
116
169
|
|
|
117
170
|
async function handleCreate(e: React.FormEvent) {
|
|
118
171
|
e.preventDefault();
|
|
119
172
|
if (!name.trim()) return;
|
|
120
|
-
await fetch(`${API_BASE}/api/projects`, {
|
|
173
|
+
const res = await fetch(`${API_BASE}/api/projects`, {
|
|
121
174
|
method: "POST",
|
|
122
175
|
headers: { "Content-Type": "application/json" },
|
|
123
176
|
body: JSON.stringify({ name, slug, description }),
|
|
124
177
|
});
|
|
178
|
+
if (!res.ok) return;
|
|
125
179
|
setName("");
|
|
126
180
|
setSlug("");
|
|
127
181
|
setDescription("");
|
|
128
182
|
setShowCreateForm(false);
|
|
129
|
-
fetchProjects();
|
|
130
183
|
}
|
|
131
184
|
|
|
132
185
|
return (
|
|
@@ -349,7 +402,7 @@ const taskStatusOptions = [
|
|
|
349
402
|
{ value: "done", label: "Done" },
|
|
350
403
|
];
|
|
351
404
|
|
|
352
|
-
function ProjectView({ slug, onBack }: { slug: string; onBack: () => void }) {
|
|
405
|
+
function ProjectView({ slug, onBack, subscribeEvents }: { slug: string; onBack: () => void; subscribeEvents: (fn: (e: DomainEvent) => void) => () => void }) {
|
|
353
406
|
const { theme } = useTheme();
|
|
354
407
|
const [project, setProject] = useState<Project | null>(null);
|
|
355
408
|
const [tasks, setTasks] = useState<Task[]>([]);
|
|
@@ -359,6 +412,9 @@ function ProjectView({ slug, onBack }: { slug: string; onBack: () => void }) {
|
|
|
359
412
|
|
|
360
413
|
const selectedTask = selectedTaskId ? tasks.find((t) => t.id === selectedTaskId) ?? null : null;
|
|
361
414
|
|
|
415
|
+
const slugRef = useRef(slug);
|
|
416
|
+
slugRef.current = slug;
|
|
417
|
+
|
|
362
418
|
async function fetchProject() {
|
|
363
419
|
const res = await fetch(`${API_BASE}/api/projects/${encodeURIComponent(slug)}`);
|
|
364
420
|
if (!res.ok) { setNotFound(true); return; }
|
|
@@ -368,54 +424,65 @@ function ProjectView({ slug, onBack }: { slug: string; onBack: () => void }) {
|
|
|
368
424
|
}
|
|
369
425
|
|
|
370
426
|
async function fetchTasks(projectSlug: string) {
|
|
371
|
-
const res = await fetch(`${API_BASE}/api/projects/${projectSlug}/tasks`);
|
|
372
|
-
|
|
427
|
+
const res = await fetch(`${API_BASE}/api/projects/${encodeURIComponent(projectSlug)}/tasks`);
|
|
428
|
+
if (!res.ok) return;
|
|
429
|
+
const body = await res.json();
|
|
430
|
+
setTasks(body.data);
|
|
373
431
|
}
|
|
374
432
|
|
|
433
|
+
const fetchProjectRef = useRef(fetchProject);
|
|
434
|
+
fetchProjectRef.current = fetchProject;
|
|
435
|
+
|
|
375
436
|
useEffect(() => {
|
|
376
437
|
setNotFound(false);
|
|
377
438
|
setProject(null);
|
|
378
439
|
setTasks([]);
|
|
379
440
|
setSelectedTaskId(null);
|
|
380
441
|
fetchProject();
|
|
381
|
-
|
|
442
|
+
|
|
443
|
+
return subscribeEvents((event) => {
|
|
444
|
+
if (event.entity === "project" || event.entity === "task") {
|
|
445
|
+
fetchProjectRef.current();
|
|
446
|
+
}
|
|
447
|
+
});
|
|
448
|
+
}, [slug, subscribeEvents]);
|
|
382
449
|
|
|
383
450
|
async function handleStatusChange(status: Project["status"]) {
|
|
384
451
|
if (!project) return;
|
|
385
|
-
await fetch(`${API_BASE}/api/projects/${project.slug}`, {
|
|
452
|
+
const res = await fetch(`${API_BASE}/api/projects/${encodeURIComponent(project.slug)}`, {
|
|
386
453
|
method: "PATCH",
|
|
387
454
|
headers: { "Content-Type": "application/json" },
|
|
388
455
|
body: JSON.stringify({ status }),
|
|
389
456
|
});
|
|
390
|
-
|
|
457
|
+
if (!res.ok) return;
|
|
391
458
|
}
|
|
392
459
|
|
|
393
460
|
async function handleAddTask(e: React.FormEvent) {
|
|
394
461
|
e.preventDefault();
|
|
395
462
|
if (!newTaskTitle.trim() || !project) return;
|
|
396
|
-
await fetch(`${API_BASE}/api/projects/${project.slug}/tasks`, {
|
|
463
|
+
const res = await fetch(`${API_BASE}/api/projects/${encodeURIComponent(project.slug)}/tasks`, {
|
|
397
464
|
method: "POST",
|
|
398
465
|
headers: { "Content-Type": "application/json" },
|
|
399
466
|
body: JSON.stringify({ title: newTaskTitle }),
|
|
400
467
|
});
|
|
468
|
+
if (!res.ok) return;
|
|
401
469
|
setNewTaskTitle("");
|
|
402
|
-
fetchTasks(project.slug);
|
|
403
470
|
}
|
|
404
471
|
|
|
405
472
|
async function handleTaskStatusChange(taskId: string, status: Task["status"]) {
|
|
406
473
|
if (!project) return;
|
|
407
|
-
await fetch(`${API_BASE}/api/projects/${project.slug}/tasks/${taskId}`, {
|
|
474
|
+
const res = await fetch(`${API_BASE}/api/projects/${encodeURIComponent(project.slug)}/tasks/${taskId}`, {
|
|
408
475
|
method: "PATCH",
|
|
409
476
|
headers: { "Content-Type": "application/json" },
|
|
410
477
|
body: JSON.stringify({ status }),
|
|
411
478
|
});
|
|
412
|
-
|
|
479
|
+
if (!res.ok) return;
|
|
413
480
|
}
|
|
414
481
|
|
|
415
482
|
async function handleDeleteTask(taskId: string) {
|
|
416
483
|
if (!project) return;
|
|
417
|
-
await fetch(`${API_BASE}/api/projects/${project.slug}/tasks/${taskId}`, { method: "DELETE" });
|
|
418
|
-
|
|
484
|
+
const res = await fetch(`${API_BASE}/api/projects/${encodeURIComponent(project.slug)}/tasks/${taskId}`, { method: "DELETE" });
|
|
485
|
+
if (!res.ok) return;
|
|
419
486
|
}
|
|
420
487
|
|
|
421
488
|
if (notFound) {
|
|
@@ -585,9 +652,30 @@ function ProjectView({ slug, onBack }: { slug: string; onBack: () => void }) {
|
|
|
585
652
|
overflow: "hidden",
|
|
586
653
|
textOverflow: "ellipsis",
|
|
587
654
|
whiteSpace: "nowrap",
|
|
655
|
+
display: "flex",
|
|
656
|
+
alignItems: "center",
|
|
657
|
+
gap: theme.spacing.xs,
|
|
588
658
|
}}
|
|
589
659
|
>
|
|
660
|
+
<span style={{ color: theme.color.textFaint, fontFamily: "monospace", fontSize: theme.font.size.xs, flexShrink: 0 }}>
|
|
661
|
+
#{task.number}
|
|
662
|
+
</span>
|
|
590
663
|
{task.title}
|
|
664
|
+
{task.priority != null && (
|
|
665
|
+
<span
|
|
666
|
+
style={{
|
|
667
|
+
flexShrink: 0,
|
|
668
|
+
fontSize: "0.6rem",
|
|
669
|
+
fontWeight: 600,
|
|
670
|
+
color: task.priority <= 3 ? theme.color.error : task.priority <= 6 ? theme.color.tertiary : theme.color.textFaint,
|
|
671
|
+
background: theme.color.surfaceContainerHigh,
|
|
672
|
+
borderRadius: theme.radius.sm,
|
|
673
|
+
padding: "1px 4px",
|
|
674
|
+
}}
|
|
675
|
+
>
|
|
676
|
+
P{task.priority}
|
|
677
|
+
</span>
|
|
678
|
+
)}
|
|
591
679
|
</span>
|
|
592
680
|
<Stack direction="row" gap="xs" style={{ flexShrink: 0 }} onClick={(e) => e.stopPropagation()}>
|
|
593
681
|
<Select
|
|
@@ -622,6 +710,7 @@ function ProjectView({ slug, onBack }: { slug: string; onBack: () => void }) {
|
|
|
622
710
|
{selectedTask && (
|
|
623
711
|
<TaskDetailPanel
|
|
624
712
|
task={selectedTask}
|
|
713
|
+
projectSlug={slug}
|
|
625
714
|
onClose={() => setSelectedTaskId(null)}
|
|
626
715
|
/>
|
|
627
716
|
)}
|
|
@@ -652,10 +741,42 @@ function useWindowWidth() {
|
|
|
652
741
|
|
|
653
742
|
const SMALL_BREAKPOINT = 768;
|
|
654
743
|
|
|
655
|
-
function TaskDetailPanel({ task, onClose }: { task: Task; onClose: () => void }) {
|
|
744
|
+
function TaskDetailPanel({ task, projectSlug, onClose }: { task: Task; projectSlug: string; onClose: () => void }) {
|
|
656
745
|
const { theme } = useTheme();
|
|
657
746
|
const windowWidth = useWindowWidth();
|
|
658
747
|
const isSmall = windowWidth < SMALL_BREAKPOINT;
|
|
748
|
+
const [tags, setTags] = useState<Tag[]>([]);
|
|
749
|
+
const [newTagName, setNewTagName] = useState("");
|
|
750
|
+
|
|
751
|
+
useEffect(() => {
|
|
752
|
+
fetchTags();
|
|
753
|
+
}, [task.id]);
|
|
754
|
+
|
|
755
|
+
async function fetchTags() {
|
|
756
|
+
const res = await fetch(`${API_BASE}/api/projects/${encodeURIComponent(projectSlug)}/tasks/${task.id}/tags`);
|
|
757
|
+
if (!res.ok) return;
|
|
758
|
+
const body = await res.json();
|
|
759
|
+
setTags(body.data);
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
async function handleAddTag(e: React.FormEvent) {
|
|
763
|
+
e.preventDefault();
|
|
764
|
+
if (!newTagName.trim()) return;
|
|
765
|
+
const res = await fetch(`${API_BASE}/api/projects/${encodeURIComponent(projectSlug)}/tasks/${task.id}/tags`, {
|
|
766
|
+
method: "POST",
|
|
767
|
+
headers: { "Content-Type": "application/json" },
|
|
768
|
+
body: JSON.stringify({ name: newTagName.trim().toLowerCase() }),
|
|
769
|
+
});
|
|
770
|
+
if (!res.ok) return;
|
|
771
|
+
setNewTagName("");
|
|
772
|
+
fetchTags();
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
async function handleRemoveTag(tagId: string) {
|
|
776
|
+
const res = await fetch(`${API_BASE}/api/projects/${encodeURIComponent(projectSlug)}/tasks/${task.id}/tags/${tagId}`, { method: "DELETE" });
|
|
777
|
+
if (!res.ok) return;
|
|
778
|
+
fetchTags();
|
|
779
|
+
}
|
|
659
780
|
|
|
660
781
|
const formatDate = (iso: string) =>
|
|
661
782
|
new Date(iso).toLocaleDateString("en-US", {
|
|
@@ -722,8 +843,27 @@ function TaskDetailPanel({ task, onClose }: { task: Task; onClose: () => void })
|
|
|
722
843
|
lineHeight: 1.3,
|
|
723
844
|
}}
|
|
724
845
|
>
|
|
846
|
+
<span style={{ color: theme.color.textFaint, fontFamily: "monospace", fontWeight: 500, fontSize: theme.font.size.sm }}>
|
|
847
|
+
#{task.number}
|
|
848
|
+
</span>{" "}
|
|
725
849
|
{task.title}
|
|
726
850
|
</h2>
|
|
851
|
+
{task.priority != null && (
|
|
852
|
+
<span
|
|
853
|
+
style={{
|
|
854
|
+
display: "inline-block",
|
|
855
|
+
marginTop: theme.spacing.xs,
|
|
856
|
+
fontSize: theme.font.size.xs,
|
|
857
|
+
fontWeight: 600,
|
|
858
|
+
color: task.priority <= 3 ? theme.color.error : task.priority <= 6 ? theme.color.tertiary : theme.color.textFaint,
|
|
859
|
+
background: theme.color.surfaceContainerHigh,
|
|
860
|
+
borderRadius: theme.radius.sm,
|
|
861
|
+
padding: "2px 6px",
|
|
862
|
+
}}
|
|
863
|
+
>
|
|
864
|
+
Priority {task.priority}
|
|
865
|
+
</span>
|
|
866
|
+
)}
|
|
727
867
|
</div>
|
|
728
868
|
<IconButton icon="close" size={18} onClick={onClose} />
|
|
729
869
|
</Stack>
|
|
@@ -766,6 +906,64 @@ function TaskDetailPanel({ task, onClose }: { task: Task; onClose: () => void })
|
|
|
766
906
|
</p>
|
|
767
907
|
)}
|
|
768
908
|
|
|
909
|
+
{/* Tags */}
|
|
910
|
+
<div style={{ marginTop: theme.spacing.xl }}>
|
|
911
|
+
<span
|
|
912
|
+
style={{
|
|
913
|
+
display: "block",
|
|
914
|
+
fontSize: "0.625rem",
|
|
915
|
+
fontWeight: 700,
|
|
916
|
+
letterSpacing: "0.08em",
|
|
917
|
+
textTransform: "uppercase",
|
|
918
|
+
color: theme.color.textFaint,
|
|
919
|
+
marginBottom: theme.spacing.sm,
|
|
920
|
+
}}
|
|
921
|
+
>
|
|
922
|
+
Tags
|
|
923
|
+
</span>
|
|
924
|
+
<div style={{ display: "flex", flexWrap: "wrap", gap: theme.spacing.xs, marginBottom: theme.spacing.sm }}>
|
|
925
|
+
{tags.map((tag) => (
|
|
926
|
+
<span
|
|
927
|
+
key={tag.id}
|
|
928
|
+
style={{
|
|
929
|
+
display: "inline-flex",
|
|
930
|
+
alignItems: "center",
|
|
931
|
+
gap: 4,
|
|
932
|
+
fontSize: theme.font.size.xs,
|
|
933
|
+
color: theme.color.primary,
|
|
934
|
+
background: theme.color.surfaceContainerHigh,
|
|
935
|
+
borderRadius: theme.radius.full,
|
|
936
|
+
padding: "2px 8px",
|
|
937
|
+
}}
|
|
938
|
+
>
|
|
939
|
+
{tag.name}
|
|
940
|
+
<span
|
|
941
|
+
onClick={() => handleRemoveTag(tag.id)}
|
|
942
|
+
style={{ cursor: "pointer", color: theme.color.textFaint, fontSize: "0.6rem", lineHeight: 1 }}
|
|
943
|
+
>
|
|
944
|
+
x
|
|
945
|
+
</span>
|
|
946
|
+
</span>
|
|
947
|
+
))}
|
|
948
|
+
{tags.length === 0 && (
|
|
949
|
+
<span style={{ fontSize: theme.font.size.xs, color: theme.color.textFaint, fontStyle: "italic" }}>
|
|
950
|
+
No tags
|
|
951
|
+
</span>
|
|
952
|
+
)}
|
|
953
|
+
</div>
|
|
954
|
+
<form onSubmit={handleAddTag} style={{ display: "flex", gap: theme.spacing.xs }}>
|
|
955
|
+
<Input
|
|
956
|
+
placeholder="Add tag..."
|
|
957
|
+
value={newTagName}
|
|
958
|
+
onChange={(e) => setNewTagName(e.target.value)}
|
|
959
|
+
style={{ flex: 1, fontSize: theme.font.size.xs, padding: "2px 6px" }}
|
|
960
|
+
/>
|
|
961
|
+
<Button type="submit" size="sm" style={{ fontSize: theme.font.size.xs, padding: "2px 8px" }}>
|
|
962
|
+
Add
|
|
963
|
+
</Button>
|
|
964
|
+
</form>
|
|
965
|
+
</div>
|
|
966
|
+
|
|
769
967
|
{/* Metadata */}
|
|
770
968
|
<div
|
|
771
969
|
style={{
|
|
@@ -789,7 +987,9 @@ function TaskDetailPanel({ task, onClose }: { task: Task; onClose: () => void })
|
|
|
789
987
|
</span>
|
|
790
988
|
<Stack gap="xs">
|
|
791
989
|
{[
|
|
990
|
+
{ label: "Number", value: `#${task.number}` },
|
|
792
991
|
{ label: "ID", value: task.id },
|
|
992
|
+
{ label: "Priority", value: task.priority != null ? `${task.priority}` : "—" },
|
|
793
993
|
{ label: "Created", value: formatDate(task.created_at) },
|
|
794
994
|
{ label: "Updated", value: formatDate(task.updated_at) },
|
|
795
995
|
].map((row) => (
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useTheme } from "./ThemeContext";
|
|
2
2
|
import { ThemeSwitcher } from "./ThemeSwitcher";
|
|
3
3
|
|
|
4
|
-
export function TopBar() {
|
|
4
|
+
export function TopBar({ trailing }: { trailing?: React.ReactNode }) {
|
|
5
5
|
const { theme } = useTheme();
|
|
6
6
|
|
|
7
7
|
return (
|
|
@@ -27,9 +27,11 @@ export function TopBar() {
|
|
|
27
27
|
maxWidth: 1400,
|
|
28
28
|
padding: `${theme.spacing.md} ${theme.spacing["2xl"]}`,
|
|
29
29
|
boxSizing: "border-box",
|
|
30
|
+
gap: 8,
|
|
30
31
|
}}
|
|
31
32
|
>
|
|
32
|
-
<div style={{ display: "flex", alignItems: "center" }}>
|
|
33
|
+
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
|
34
|
+
{trailing}
|
|
33
35
|
<ThemeSwitcher />
|
|
34
36
|
</div>
|
|
35
37
|
</div>
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from "react";
|
|
2
|
+
|
|
3
|
+
export interface DomainEvent {
|
|
4
|
+
entity: "project" | "task";
|
|
5
|
+
action: "created" | "updated" | "deleted";
|
|
6
|
+
payload: Record<string, unknown>;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function useRealtimeEvents(onEvent: (event: DomainEvent) => void): { connected: boolean } {
|
|
10
|
+
const [connected, setConnected] = useState(false);
|
|
11
|
+
const onEventRef = useRef(onEvent);
|
|
12
|
+
onEventRef.current = onEvent;
|
|
13
|
+
|
|
14
|
+
useEffect(() => {
|
|
15
|
+
let ws: WebSocket | null = null;
|
|
16
|
+
let reconnectTimer: ReturnType<typeof setTimeout>;
|
|
17
|
+
let attempt = 0;
|
|
18
|
+
let unmounted = false;
|
|
19
|
+
|
|
20
|
+
function connect() {
|
|
21
|
+
if (unmounted) return;
|
|
22
|
+
const proto = location.protocol === "https:" ? "wss:" : "ws:";
|
|
23
|
+
ws = new WebSocket(`${proto}//${location.host}/ws`);
|
|
24
|
+
|
|
25
|
+
ws.onopen = () => {
|
|
26
|
+
attempt = 0;
|
|
27
|
+
setConnected(true);
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
ws.onmessage = (e) => {
|
|
31
|
+
try {
|
|
32
|
+
const event: DomainEvent = JSON.parse(e.data);
|
|
33
|
+
onEventRef.current(event);
|
|
34
|
+
} catch {
|
|
35
|
+
// ignore malformed messages
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
ws.onclose = () => {
|
|
40
|
+
setConnected(false);
|
|
41
|
+
if (unmounted) return;
|
|
42
|
+
const delay = Math.min(1000 * 2 ** attempt, 30000);
|
|
43
|
+
attempt++;
|
|
44
|
+
reconnectTimer = setTimeout(connect, delay);
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
ws.onerror = () => {
|
|
48
|
+
ws?.close();
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
connect();
|
|
53
|
+
|
|
54
|
+
return () => {
|
|
55
|
+
unmounted = true;
|
|
56
|
+
clearTimeout(reconnectTimer);
|
|
57
|
+
ws?.close();
|
|
58
|
+
};
|
|
59
|
+
}, []);
|
|
60
|
+
|
|
61
|
+
return { connected };
|
|
62
|
+
}
|
package/src/web/vite.config.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { defineConfig } from "vite";
|
|
2
2
|
import react from "@vitejs/plugin-react";
|
|
3
|
+
import webfontDownload from "vite-plugin-webfont-dl";
|
|
3
4
|
|
|
4
5
|
export default defineConfig({
|
|
5
|
-
plugins: [react()],
|
|
6
|
+
plugins: [react(), webfontDownload()],
|
|
6
7
|
build: {
|
|
7
8
|
outDir: "dist",
|
|
8
9
|
},
|
|
@@ -11,6 +12,10 @@ export default defineConfig({
|
|
|
11
12
|
proxy: {
|
|
12
13
|
"/api": "http://localhost:3000",
|
|
13
14
|
"/mcp": "http://localhost:3000",
|
|
15
|
+
"/ws": {
|
|
16
|
+
target: "ws://localhost:3000",
|
|
17
|
+
ws: true,
|
|
18
|
+
},
|
|
14
19
|
},
|
|
15
20
|
},
|
|
16
21
|
});
|