@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.
Files changed (43) hide show
  1. package/package.json +6 -5
  2. package/src/domain/args.ts +11 -1
  3. package/src/domain/bootstrap.ts +12 -4
  4. package/src/domain/db/schema.ts +28 -7
  5. package/src/domain/entities.ts +8 -0
  6. package/src/domain/events.ts +26 -0
  7. package/src/domain/index.ts +3 -0
  8. package/src/domain/inputs.ts +2 -2
  9. package/src/domain/repositories/projects.ts +16 -3
  10. package/src/domain/repositories/tags.ts +77 -0
  11. package/src/domain/repositories/tasks.ts +69 -27
  12. package/src/domain/services/projects.ts +22 -7
  13. package/src/domain/services/tags.ts +85 -0
  14. package/src/domain/services/tasks.ts +50 -9
  15. package/src/domain/services.ts +31 -4
  16. package/src/mcp/index.ts +1 -1
  17. package/src/mcp/server.ts +175 -31
  18. package/src/mcp/standalone.ts +10 -7
  19. package/src/server/index.ts +44 -10
  20. package/src/server/routes/projects.ts +16 -5
  21. package/src/server/routes/tags.ts +61 -0
  22. package/src/server/routes/tasks.ts +86 -9
  23. package/src/web/dist/assets/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa0ZL7SUc-DqGufNeO.woff2 +0 -0
  24. package/src/web/dist/assets/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa1ZL7-Dx4kXJAl.woff2 +0 -0
  25. package/src/web/dist/assets/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa1pL7SUc-CkhJZR-_.woff2 +0 -0
  26. package/src/web/dist/assets/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa25L7SUc-DO1Apj_S.woff2 +0 -0
  27. package/src/web/dist/assets/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2JL7SUc-BOeWTOD4.woff2 +0 -0
  28. package/src/web/dist/assets/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2ZL7SUc-DlzME5K_.woff2 +0 -0
  29. package/src/web/dist/assets/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2pL7SUc-CBcvBZtf.woff2 +0 -0
  30. package/src/web/dist/assets/index-aUW2zejq.js +49 -0
  31. package/src/web/dist/assets/kJEPBvYX7BgnkSrUwT8OhrdQw4oELdPIeeII9v6oDMzBwG-RpA6RzaxHMPdY40KH8nGzv3fzfVJO1Q-CpotRDAj.woff2 +0 -0
  32. package/src/web/dist/assets/xn7gYHE41ni1AdIRggOxSuXd-Dvxsihut.woff2 +0 -0
  33. package/src/web/dist/assets/xn7gYHE41ni1AdIRggSxSuXd-DL7QRZyv.woff2 +0 -0
  34. package/src/web/dist/assets/xn7gYHE41ni1AdIRggexSg-DHIcAJRg.woff2 +0 -0
  35. package/src/web/dist/assets/xn7gYHE41ni1AdIRggixSuXd-usUDDRr7.woff2 +0 -0
  36. package/src/web/dist/assets/xn7gYHE41ni1AdIRggmxSuXd-Ch3YOpNY.woff2 +0 -0
  37. package/src/web/dist/assets/xn7gYHE41ni1AdIRggqxSuXd-C8S-KRRz.woff2 +0 -0
  38. package/src/web/dist/index.html +2 -11
  39. package/src/web/src/App.tsx +222 -22
  40. package/src/web/src/components/TopBar.tsx +4 -2
  41. package/src/web/src/useRealtimeEvents.ts +62 -0
  42. package/src/web/vite.config.ts +6 -1
  43. package/src/web/dist/assets/index-Bonqd4_2.js +0 -49
@@ -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-Bonqd4_2.js"></script>
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>
@@ -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
- setProjects(await res.json());
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
- setTasks(await res.json());
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
- }, [slug]);
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
- fetchProject();
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
- fetchTasks(project.slug);
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
- fetchTasks(project.slug);
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
+ }
@@ -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
  });