├── .github └── workflows │ └── webpack.yml ├── .gitignore ├── LICENSE.md ├── README.md ├── docker-compose.yml ├── docker ├── .dockerignore ├── client.Dockerfile └── server.Dockerfile ├── docs ├── .babelrc ├── dashboard.png ├── grid.svg ├── help.png ├── package-lock.json ├── package.json ├── postcss.config.js ├── public │ ├── index.html │ └── logo.svg ├── src │ ├── App.js │ ├── components │ │ ├── Card.js │ │ └── Mochi.js │ ├── index.js │ └── styles.css ├── tailwind.config.js └── webpack.config.js ├── logo.svg ├── package.json ├── packages ├── client │ ├── .gitignore │ ├── index.html │ ├── package.json │ ├── src │ │ ├── App.module.css │ │ ├── App.tsx │ │ ├── Home.module.css │ │ ├── Home.tsx │ │ ├── assets │ │ │ ├── favicon.ico │ │ │ ├── logo.svg │ │ │ └── xmas.png │ │ ├── commandPipeline │ │ │ ├── commandProcessor.ts │ │ │ ├── commandRegistry.ts │ │ │ ├── commands │ │ │ │ ├── assignTask.command.ts │ │ │ │ ├── createProject.command.ts │ │ │ │ ├── createRule.command.ts │ │ │ │ ├── createTask.command.ts │ │ │ │ ├── listProjects.command.ts │ │ │ │ ├── listRules.command.ts │ │ │ │ └── toggleDraft.command.ts │ │ │ ├── index.ts │ │ │ └── types.ts │ │ ├── components │ │ │ ├── Kanban │ │ │ │ ├── TaskCard │ │ │ │ │ ├── TaskCard.module.css │ │ │ │ │ └── TaskCard.tsx │ │ │ │ └── TaskColumn │ │ │ │ │ ├── TaskColumn.module.css │ │ │ │ │ └── TaskColumn.tsx │ │ │ ├── SetupWizard │ │ │ │ ├── setupWizard.module.css │ │ │ │ └── setupWizard.tsx │ │ │ ├── TimeTrack │ │ │ │ ├── Appointment │ │ │ │ │ ├── Appointment.module.css │ │ │ │ │ └── Appointment.tsx │ │ │ │ ├── TimeMarker │ │ │ │ │ ├── TimeMarker.module.css │ │ │ │ │ └── TimeMarker.tsx │ │ │ │ ├── WeekCalendar │ │ │ │ │ ├── WeekCalendar.module.css │ │ │ │ │ └── WeekCalendar.tsx │ │ │ │ ├── WeekCalendarBody │ │ │ │ │ ├── WeekCalendarBody.module.css │ │ │ │ │ └── WeekCalendarBody.tsx │ │ │ │ ├── WeekCalendarHeader │ │ │ │ │ ├── WeekCalendarHeader.module.css │ │ │ │ │ └── WeekCalendarHeader.tsx │ │ │ │ └── WeekCalendarHourRow │ │ │ │ │ ├── WeekCalendarHourRow.module.css │ │ │ │ │ └── WeekCalendarHourRow.tsx │ │ │ ├── modals │ │ │ │ ├── BaseModal │ │ │ │ │ ├── BaseModal.module.css │ │ │ │ │ └── BaseModal.tsx │ │ │ │ ├── BranchNameModal │ │ │ │ │ ├── BranchNameModal.module.css │ │ │ │ │ └── BranchNameModal.tsx │ │ │ │ ├── DeleteModal │ │ │ │ │ └── DeleteModal.tsx │ │ │ │ ├── EditAppointmentModal │ │ │ │ │ ├── EditAppointmentModal.module.css │ │ │ │ │ └── EditAppointmentModal.tsx │ │ │ │ ├── EditOrCreateTaskModal │ │ │ │ │ ├── EditOrCreateTaskModal.module.css │ │ │ │ │ └── EditOrCreateTaskModal.tsx │ │ │ │ ├── HelpModal │ │ │ │ │ ├── HelpModal.module.css │ │ │ │ │ └── HelpModal.tsx │ │ │ │ ├── PipelineModal │ │ │ │ │ ├── PipelineModal.module.css │ │ │ │ │ └── PipelineModal.tsx │ │ │ │ ├── ReplyModal │ │ │ │ │ ├── ReplyModal.module.css │ │ │ │ │ └── ReplyModal.tsx │ │ │ │ ├── TaskDetailsModal │ │ │ │ │ ├── TaskDetailsModal.module.css │ │ │ │ │ ├── TaskDetailsModal.tsx │ │ │ │ │ └── task-details-modal.store.ts │ │ │ │ └── index.ts │ │ │ └── shared │ │ │ │ ├── Badge │ │ │ │ ├── Badge.module.css │ │ │ │ └── Badge.tsx │ │ │ │ ├── Button │ │ │ │ ├── Button.module.css │ │ │ │ └── Button.tsx │ │ │ │ ├── Card │ │ │ │ ├── Card.module.css │ │ │ │ └── Card.tsx │ │ │ │ ├── CommandLine │ │ │ │ ├── CommandLine.module.css │ │ │ │ └── CommandLine.tsx │ │ │ │ ├── Comment │ │ │ │ ├── Comment.module.css │ │ │ │ └── Comment.tsx │ │ │ │ ├── DiscussionCard │ │ │ │ ├── DiscussionCard.module.css │ │ │ │ └── DiscussionCard.tsx │ │ │ │ ├── Header │ │ │ │ ├── Header.module.css │ │ │ │ └── Header.tsx │ │ │ │ ├── Loading │ │ │ │ ├── Loading.module.css │ │ │ │ └── Loading.tsx │ │ │ │ ├── Notification │ │ │ │ ├── Notification.module.css │ │ │ │ └── Notification.tsx │ │ │ │ ├── NotificationManager.tsx │ │ │ │ ├── Pagination │ │ │ │ ├── Pagination.module.css │ │ │ │ └── Pagination.tsx │ │ │ │ ├── StatusBar │ │ │ │ ├── StatusBar.module.css │ │ │ │ └── StatusBar.tsx │ │ │ │ ├── Tooltip │ │ │ │ ├── Tooltip.module.css │ │ │ │ └── Tooltip.tsx │ │ │ │ └── WaveText │ │ │ │ ├── WaveText.module.css │ │ │ │ └── WaveText.tsx │ │ ├── constants.ts │ │ ├── index.tsx │ │ ├── logo.svg │ │ ├── modules │ │ │ ├── KanbanBoard │ │ │ │ ├── KanbanBoard.module.css │ │ │ │ └── KanbanBoard.tsx │ │ │ ├── TimeTrack │ │ │ │ ├── TimeTrack.module.css │ │ │ │ └── TimeTrack.tsx │ │ │ └── Todos │ │ │ │ ├── Todos.module.css │ │ │ │ └── Todos.tsx │ │ ├── routes.ts │ │ ├── services │ │ │ ├── customProjectService.ts │ │ │ ├── gitlabService.ts │ │ │ ├── keyboardShortcutHandler.ts │ │ │ ├── modalService.ts │ │ │ ├── notificationService.ts │ │ │ ├── ruleService.ts │ │ │ ├── settingsService.ts │ │ │ ├── taskNavigationService.ts │ │ │ ├── taskService.ts │ │ │ ├── timeTrackService.ts │ │ │ ├── timetrackNavigationService.ts │ │ │ ├── uiService.ts │ │ │ └── userService.ts │ │ ├── shortcutMaps │ │ │ ├── baseShortcutMap.ts │ │ │ ├── kanbanShortcutMap.ts │ │ │ ├── shortcutRegistry.ts │ │ │ ├── taskDetails.shortcut-map.ts │ │ │ ├── timetrackShortcutMap.ts │ │ │ └── types.ts │ │ ├── sockets │ │ │ ├── WebSocketHandler.ts │ │ │ └── taskSockets.ts │ │ ├── stores │ │ │ ├── commandStore.ts │ │ │ ├── discussion.store.ts │ │ │ ├── keyboardNavigationStore.ts │ │ │ ├── modalStore.ts │ │ │ ├── ruleStore.ts │ │ │ ├── settings.store.ts │ │ │ ├── taskStore.ts │ │ │ ├── timeTrackStore.ts │ │ │ ├── todoStore.ts │ │ │ └── uiStore.ts │ │ ├── styles │ │ │ ├── base.css │ │ │ ├── notification.css │ │ │ ├── scrollbar.css │ │ │ └── variables.css │ │ └── utils │ │ │ ├── orderLabels.ts │ │ │ ├── parseMarkdown.ts │ │ │ └── scrollIntoView.ts │ ├── tsconfig.json │ └── vite.config.ts ├── server │ ├── background-jobs │ │ └── closeMergedMergeRequests.ts │ ├── bunfig.toml │ ├── clients │ │ └── gitlab.client.ts │ ├── controllers │ │ ├── gitlab.controller.ts │ │ ├── project.controller.ts │ │ ├── rule.controller.ts │ │ ├── settings.controller.ts │ │ ├── task.controller.ts │ │ └── timeTrack.controller.ts │ ├── decorators │ │ ├── ruleAction.decorator.ts │ │ ├── ruleEvent.decorator.ts │ │ └── transactional.decorator.ts │ ├── dtos │ │ └── task.dto.ts │ ├── errors │ │ ├── gitlab.error.ts │ │ └── mochi.error.ts │ ├── events │ │ ├── eventEmitterHandler.ts │ │ ├── eventRegistry.ts │ │ └── eventTypes.ts │ ├── middlewares │ │ ├── context.middleware.ts │ │ └── globalErrorHandler.middleware.ts │ ├── models │ │ ├── appState.model.ts │ │ ├── discussion.model.ts │ │ ├── event.model.ts │ │ ├── project.model.ts │ │ ├── rule.model.ts │ │ ├── setting.model.ts │ │ ├── task.model.ts │ │ ├── timeTrack.model.ts │ │ └── user.model.ts │ ├── package.json │ ├── repositories │ │ ├── appState.repo.ts │ │ ├── base.repo.ts │ │ ├── discussion.repo.ts │ │ ├── project.repo.ts │ │ ├── rule.repo.ts │ │ ├── setting.repo.ts │ │ ├── task.repo.ts │ │ ├── timeTrack.repo.ts │ │ └── user.repo.ts │ ├── routes │ │ ├── gitlab.routes.ts │ │ ├── project.router.ts │ │ ├── rule.router.ts │ │ ├── setting.router.ts │ │ ├── task.router.ts │ │ └── timeTrack.router.ts │ ├── server.ts │ ├── services │ │ ├── actions │ │ │ ├── gitlabActionHandler.ts │ │ │ ├── index.ts │ │ │ └── taskActionHandler.ts │ │ ├── appState.service.ts │ │ ├── base.service.ts │ │ ├── discussion.service.ts │ │ ├── emitters │ │ │ ├── index.ts │ │ │ └── taskEventEmitter.ts │ │ ├── gitlab.service.ts │ │ ├── project.service.ts │ │ ├── rule.service.ts │ │ ├── settings.service.ts │ │ ├── task.service.ts │ │ ├── timeTrack.service.ts │ │ └── user.service.ts │ ├── sockets │ │ └── index.ts │ ├── syncs │ │ ├── gitlab.sync.ts │ │ └── gitlab │ │ │ ├── noteCount.processor.ts │ │ │ └── pipeline.processor.ts │ ├── tsconfig.json │ └── utils │ │ ├── asyncContext.ts │ │ ├── chunkArray.ts │ │ ├── fetchAllFromPaginatedApi.ts │ │ ├── findFile.ts │ │ ├── logger.ts │ │ ├── mochiResult.ts │ │ ├── taskUtils.ts │ │ └── transformHelpers.ts └── shared │ ├── enums │ ├── index.ts │ └── settings.ts │ ├── index.ts │ ├── package.json │ ├── types │ ├── fieldProcessor.ts │ ├── index.ts │ ├── pagination.ts │ ├── request.ts │ ├── syncer.ts │ └── task.ts │ └── utils │ ├── isEqual.ts │ └── random.ts ├── scripts └── setup.sh └── tsconfig.json /.github/workflows/webpack.yml: -------------------------------------------------------------------------------- 1 | name: NodeJS with Webpack 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | pull_request: 7 | branches: [ "main" ] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | 13 | strategy: 14 | matrix: 15 | node-version: [18.x, 20.x, 22.x] 16 | 17 | steps: 18 | - uses: actions/checkout@v4 19 | 20 | - name: Use Node.js ${{ matrix.node-version }} 21 | uses: actions/setup-node@v4 22 | with: 23 | node-version: ${{ matrix.node-version }} 24 | 25 | - name: Build 26 | run: | 27 | npm install 28 | npm start 29 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Maximilian Kriegl 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Mochi - GitLab-Integrated Kanban Board 2 | 3 | Mochi Logo 4 | 5 | Mochi is a **keyboard-friendly, GitLab-integrated Kanban board** that makes task management efficient and intuitive. Organize your GitLab issues, handle tasks via keyboard shortcuts, and keep everything in sync with GitLab—all in one place. 6 | 7 | Mochi Dashboard 8 | 9 | ## Features 10 | 11 | - **Kanban**: Sort tasks by state and track progress visually. 12 | - **GitLab Integration**: Sync tasks, issues, merge requests, comments and more. 13 | - **Keyboard-Driven**: Navigate, move tasks, and open details without a mouse. 14 | - **Pipeline Status at a Glance**: Keep up with CI/CD progress easily. 15 | 16 | ## Prerequisites 17 | 18 | - **Node.js** 19 | - **docker** 20 | 21 | ## Installation 22 | 23 | **Clone the repository**: 24 | 25 | ```bash 26 | git clone https://github.com/Coding0tter/GIT-Mochi.git 27 | cd mochi 28 | ``` 29 | 30 | 2. **Start Mochi**: 31 | 32 | ```bash 33 | docker-compose up -d 34 | ``` 35 | 36 | - Mochi will start at http://localhost:3005 37 | 38 | 3. **Complete the Wizard and you're good to go** 39 | 40 | ## Keyboard Shortcuts 41 | 42 | Mochi Help 43 | 44 | Open the help modal with `?` 45 | 46 | ## GitLab Syncing 47 | 48 | Make sure your **[GitLab Personal Access Token](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens)** is set in the `.env` file before syncing. Use `Shift + S` for syncing manually. Git-Mochi syncs data every 5 minutes automatically 49 | 50 | ## Support the Project 51 | 52 | If you find Mochi helpful, please consider [buying me a coffee](https://www.buymeacoffee.com/maxikriegl)! 53 | 54 | [Buy Me A Coffee](https://www.buymeacoffee.com/maxikriegl) 55 | 56 | ## License 57 | 58 | Mochi is released under the [MIT License](https://github.com/Coding0tter/GIT-Mochi/blob/main/LICENSE.md). 59 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | client: 3 | build: 4 | context: . 5 | dockerfile: ./docker/client.Dockerfile 6 | ports: 7 | - "3005:3005" 8 | depends_on: 9 | - server 10 | volumes: 11 | - ./packages/client:/app/packages/client 12 | - ./packages/shared:/app/packages/shared 13 | 14 | server: 15 | build: 16 | context: . 17 | dockerfile: ./docker/server.Dockerfile 18 | volumes: 19 | - ./packages/server:/app/packages/server 20 | - ./packages/shared:/app/packages/shared 21 | ports: 22 | - "5000:5000" 23 | - "6499:6499" 24 | depends_on: 25 | - mongo 26 | 27 | mongo: 28 | image: mongo:latest 29 | restart: always 30 | ports: 31 | - "27018:27017" 32 | volumes: 33 | - mongo-data:/data/db 34 | 35 | volumes: 36 | mongo-data: 37 | -------------------------------------------------------------------------------- /docker/.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules -------------------------------------------------------------------------------- /docker/client.Dockerfile: -------------------------------------------------------------------------------- 1 | FROM oven/bun:latest 2 | 3 | WORKDIR /app 4 | COPY . . 5 | RUN bun install 6 | WORKDIR /app/packages/client 7 | 8 | CMD ["bunx", "vite"] 9 | EXPOSE 3005 -------------------------------------------------------------------------------- /docker/server.Dockerfile: -------------------------------------------------------------------------------- 1 | FROM oven/bun:latest 2 | 3 | WORKDIR /app 4 | COPY . . 5 | RUN bun install 6 | WORKDIR /app/packages/server 7 | 8 | CMD ["bun", "dev"] 9 | 10 | EXPOSE 6499 11 | EXPOSE 5000 -------------------------------------------------------------------------------- /docs/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/preset-env", "@babel/preset-react"] 3 | } 4 | -------------------------------------------------------------------------------- /docs/dashboard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Coding0tter/GIT-Mochi/4ad5e729df950c7000eb5fbaab49eac92a47c017/docs/dashboard.png -------------------------------------------------------------------------------- /docs/grid.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /docs/help.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Coding0tter/GIT-Mochi/4ad5e729df950c7000eb5fbaab49eac92a47c017/docs/help.png -------------------------------------------------------------------------------- /docs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "docs", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "scripts": { 6 | "start": "webpack serve --mode development --open", 7 | "build": "webpack --mode production", 8 | "deploy": "gh-pages -d dist" 9 | }, 10 | "keywords": [], 11 | "author": "", 12 | "license": "ISC", 13 | "description": "", 14 | "dependencies": { 15 | "lucide-react": "^0.468.0", 16 | "react": "^19.0.0", 17 | "react-dom": "^19.0.0" 18 | }, 19 | "devDependencies": { 20 | "@babel/core": "^7.26.0", 21 | "@babel/preset-env": "^7.26.0", 22 | "@babel/preset-react": "^7.26.3", 23 | "autoprefixer": "^10.4.20", 24 | "babel-loader": "^9.2.1", 25 | "css-loader": "^7.1.2", 26 | "gh-pages": "^6.2.0", 27 | "html-webpack-plugin": "^5.6.3", 28 | "postcss": "^8.4.49", 29 | "postcss-loader": "^8.1.1", 30 | "style-loader": "^4.0.0", 31 | "tailwindcss": "^3.4.16", 32 | "webpack": "^5.97.1", 33 | "webpack-cli": "^5.1.4", 34 | "webpack-dev-server": "^5.2.0" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /docs/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /docs/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | React with Webpack 7 | 8 | 9 |
10 | 11 | 12 | -------------------------------------------------------------------------------- /docs/src/components/Card.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { 3 | Navigation, 4 | ListTodo, 5 | Settings, 6 | Zap, 7 | Filter, 8 | RefreshCw, 9 | } from "lucide-react"; 10 | import "../styles.css"; 11 | 12 | const iconMap = { 13 | navigation: Navigation, 14 | tasks: ListTodo, 15 | customize: Settings, 16 | zen: Zap, 17 | filter: Filter, 18 | sync: RefreshCw, 19 | }; 20 | 21 | const Card = ({ title, description, icon }) => { 22 | const Icon = iconMap[icon] || Navigation; 23 | 24 | return ( 25 |
26 |
27 | 28 |
29 |

{title}

30 |

{description}

31 |
32 | ); 33 | }; 34 | 35 | export default Card; 36 | -------------------------------------------------------------------------------- /docs/src/components/Mochi.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef } from "react"; 2 | 3 | export function MochiBlob() { 4 | const canvasRef = useRef(null); 5 | 6 | useEffect(() => { 7 | const canvas = canvasRef.current; 8 | if (!canvas) return; 9 | 10 | const ctx = canvas.getContext("2d"); 11 | if (!ctx) return; 12 | 13 | canvas.width = window.innerWidth; 14 | canvas.height = window.innerHeight; 15 | 16 | let time = 0; 17 | const color = "#FFC0CB"; // Light pink color 18 | 19 | function animate() { 20 | ctx.clearRect(0, 0, canvas.width, canvas.height); 21 | ctx.fillStyle = color; 22 | ctx.beginPath(); 23 | 24 | const w = canvas.width; 25 | const h = canvas.height; 26 | const scale = Math.min(w, h) * 0.3; 27 | 28 | for (let i = 0; i < 360; i++) { 29 | const angle = (i * Math.PI) / 180; 30 | const x = 31 | w / 2 + 32 | Math.cos(angle) * scale * (1 + Math.sin(time + i * 0.05) * 0.1); 33 | const y = 34 | h / 2 + 35 | Math.sin(angle) * scale * (1 + Math.sin(time + i * 0.05) * 0.1); 36 | 37 | if (i === 0) { 38 | ctx.moveTo(x, y); 39 | } else { 40 | ctx.lineTo(x, y); 41 | } 42 | } 43 | 44 | ctx.closePath(); 45 | ctx.fill(); 46 | 47 | time += 0.01; 48 | requestAnimationFrame(animate); 49 | } 50 | 51 | animate(); 52 | 53 | const handleResize = () => { 54 | canvas.width = window.innerWidth; 55 | canvas.height = window.innerHeight; 56 | }; 57 | 58 | window.addEventListener("resize", handleResize); 59 | 60 | return () => { 61 | window.removeEventListener("resize", handleResize); 62 | }; 63 | }, []); 64 | 65 | return ( 66 | 70 | ); 71 | } 72 | -------------------------------------------------------------------------------- /docs/src/index.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { createRoot } from "react-dom/client"; 3 | import App from "./App"; 4 | 5 | const domNode = document.getElementById("root"); 6 | const root = createRoot(domNode); 7 | 8 | root.render(); 9 | -------------------------------------------------------------------------------- /docs/src/styles.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | -------------------------------------------------------------------------------- /docs/tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: ["./src/**/*.{js,jsx,ts,tsx}", "./public/index.html"], // Update paths as needed 4 | theme: { 5 | extend: {}, 6 | }, 7 | plugins: [], 8 | }; 9 | -------------------------------------------------------------------------------- /docs/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const HtmlWebpackPlugin = require("html-webpack-plugin"); 3 | 4 | module.exports = { 5 | entry: path.join(__dirname, "src", "index.js"), 6 | output: { 7 | path: path.resolve(__dirname, "dist"), 8 | }, 9 | plugins: [ 10 | new HtmlWebpackPlugin({ 11 | template: path.join(__dirname, "public", "index.html"), 12 | }), 13 | ], 14 | module: { 15 | rules: [ 16 | { 17 | test: /\.js$/, 18 | exclude: /node_modules/, 19 | use: { 20 | loader: "babel-loader", 21 | options: { 22 | presets: ["@babel/preset-env", "@babel/preset-react"], 23 | }, 24 | }, 25 | }, 26 | { 27 | test: /\.css$/i, 28 | use: [ 29 | "style-loader", 30 | "css-loader", 31 | { 32 | loader: "postcss-loader", 33 | options: { 34 | postcssOptions: { 35 | plugins: [require("tailwindcss"), require("autoprefixer")], 36 | }, 37 | }, 38 | }, 39 | ], 40 | }, 41 | ], 42 | }, 43 | devServer: { 44 | port: 3000, 45 | }, 46 | }; 47 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "git-mochi", 3 | "private": true, 4 | "workspaces": [ 5 | "packages/*" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /packages/client/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist -------------------------------------------------------------------------------- /packages/client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | Mochi 13 | 14 | 24 | 25 | 26 |
27 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /packages/client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": { 3 | "name": "Coding0tter", 4 | "url": "https://github.com/Coding0tter" 5 | }, 6 | "name": "mochi-client", 7 | "version": "0.0.0", 8 | "type": "module", 9 | "scripts": { 10 | "start": "vite", 11 | "dev": "vite", 12 | "build": "vite build", 13 | "serve": "vite preview" 14 | }, 15 | "license": "MIT", 16 | "devDependencies": { 17 | "bun-types": "^1.1.38", 18 | "solid-devtools": "^0.29.3", 19 | "typescript": "^5.7.2", 20 | "vite": "^5.4.11", 21 | "vite-plugin-solid": "^2.11.0" 22 | }, 23 | "dependencies": { 24 | "shared": "file:../shared", 25 | "@solidjs/router": "^0.15.1", 26 | "@types/dompurify": "^3.2.0", 27 | "@types/lodash": "^4.17.13", 28 | "axios": "^1.7.9", 29 | "dayjs": "^1.11.13", 30 | "dompurify": "^3.2.3", 31 | "fuse.js": "^7.0.0", 32 | "lodash": "^4.17.21", 33 | "marked": "^15.0.3", 34 | "socket.io-client": "^4.8.1", 35 | "solid-js": "^1.9.3", 36 | "solid-motionone": "^1.0.2" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /packages/client/src/App.module.css: -------------------------------------------------------------------------------- 1 | .app { 2 | display: flex; 3 | flex-direction: column; 4 | height: 100vh; 5 | } 6 | 7 | .container { 8 | flex-shrink: 0; 9 | } 10 | 11 | .content { 12 | flex-grow: 1; 13 | height: auto; 14 | overflow-y: scroll; 15 | } 16 | -------------------------------------------------------------------------------- /packages/client/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { HelpModal } from "@client/components/modals"; 2 | import Header from "@client/components/shared/Header/Header"; 3 | import NotificationManager from "@client/components/shared/NotificationManager"; 4 | import StatusBar from "@client/components/shared/StatusBar/StatusBar"; 5 | import { handleKeyDown } from "@client/services/keyboardShortcutHandler"; 6 | import { getUserAsync } from "@client/services/userService"; 7 | import { 8 | setSelectedAppointmentForModal, 9 | modalStore, 10 | ModalType, 11 | handleCloseModal, 12 | } from "@client/stores/modalStore"; 13 | import { 14 | fetchRecordingStateAsync, 15 | fetchTimeTrackEntries, 16 | timeTrackStore, 17 | } from "@client/stores/timeTrackStore"; 18 | import { useNavigate, useLocation } from "@solidjs/router"; 19 | import dayjs from "dayjs"; 20 | import { onMount, onCleanup, Show } from "solid-js"; 21 | import styles from "./App.module.css"; 22 | 23 | import weekday from "dayjs/plugin/weekday"; 24 | import { getGitlabUrl } from "./stores/settings.store"; 25 | 26 | dayjs.extend(weekday); 27 | 28 | const App = (props: any) => { 29 | const navigator = useNavigate(); 30 | const location = useLocation(); 31 | const keydownHandler = (event: KeyboardEvent) => 32 | handleKeyDown(event, navigator, location); 33 | 34 | onMount(async () => { 35 | if (location.pathname === "/setup") { 36 | return; 37 | } 38 | 39 | await getGitlabUrl(); 40 | await fetchRecordingStateAsync(); 41 | await fetchTimeTrackEntries(); 42 | await getUserAsync(); 43 | setSelectedAppointmentForModal(timeTrackStore.entries?.at(0) || null); 44 | window.addEventListener("keydown", keydownHandler); 45 | }); 46 | 47 | onCleanup(() => { 48 | window.removeEventListener("keydown", keydownHandler); 49 | }); 50 | 51 | return ( 52 |
53 | 54 |
55 |
56 | 57 |
58 |
59 |
{props.children}
60 | 61 |
62 | 63 |
64 |
65 | 66 | {modalStore.activeModals.includes(ModalType.Help) && ( 67 | 68 | )} 69 |
70 | ); 71 | }; 72 | 73 | export default App; 74 | -------------------------------------------------------------------------------- /packages/client/src/Home.module.css: -------------------------------------------------------------------------------- 1 | .homeWrapper { 2 | padding-inline: var(--spacing-sm); 3 | height: calc(100% - 30px - var(--spacing-sm)); 4 | } 5 | 6 | .home { 7 | color: var(--color-text); 8 | background: var(--color-base); 9 | height: 100%; 10 | border-radius: var(--spacing-md); 11 | display: flex; 12 | flex-direction: column; 13 | padding-top: var(--spacing-xl); 14 | align-items: center; 15 | text-align: center; 16 | } 17 | 18 | .logo { 19 | width: 150px; 20 | height: 150px; 21 | margin-bottom: var(--spacing-lg); 22 | } 23 | 24 | .homeHeader h1 { 25 | font-size: var(--font-size-xxl); 26 | font-weight: var(--font-weight-bold); 27 | margin-bottom: var(--spacing-md); 28 | } 29 | 30 | .homeHeader p { 31 | font-size: var(--font-size-md); 32 | color: var(--color-subtext1); 33 | margin-bottom: var(--spacing-xl); 34 | } 35 | 36 | .features { 37 | display: flex; 38 | gap: var(--spacing-lg); 39 | justify-content: center; 40 | } 41 | 42 | .featureCard { 43 | cursor: pointer; 44 | background: var(--color-surface0); 45 | border-radius: var(--spacing-md); 46 | padding: var(--spacing-lg); 47 | min-width: 300px; 48 | box-shadow: 0px 4px 12px rgba(0, 0, 0, 0.2); 49 | transition: transform 0.3s ease; 50 | } 51 | 52 | .featureCard:hover { 53 | transform: translateY(-5px); 54 | box-shadow: 0px 6px 16px rgba(0, 0, 0, 0.25); 55 | } 56 | 57 | .featureCard h2 { 58 | font-size: var(--font-size-lg); 59 | color: var(--color-lavender); 60 | margin-bottom: var(--spacing-sm); 61 | } 62 | 63 | .featureCard p { 64 | font-size: var(--font-size-sm); 65 | color: var(--color-subtext0); 66 | margin-bottom: var(--spacing-md); 67 | } 68 | 69 | .featureCard ul { 70 | list-style: none; 71 | padding: 0; 72 | margin: 0; 73 | font-size: var(--font-size-xs); 74 | } 75 | 76 | .featureCard ul li { 77 | padding: var(--spacing-xxs) 0; 78 | color: var(--color-text); 79 | } 80 | 81 | .kanban h2 { 82 | color: var(--color-blue); 83 | } 84 | 85 | .time-tracking h2 { 86 | color: var(--color-peach); 87 | } 88 | 89 | .todo h2 { 90 | color: var(--color-green); 91 | } 92 | -------------------------------------------------------------------------------- /packages/client/src/Home.tsx: -------------------------------------------------------------------------------- 1 | import { useNavigate } from "@solidjs/router"; 2 | import styles from "./Home.module.css"; 3 | import logo from "./assets/logo.svg"; 4 | 5 | const Home = () => { 6 | const navigator = useNavigate(); 7 | 8 | const handleClick = (key: string) => { 9 | navigator(key); 10 | }; 11 | 12 | return ( 13 |
14 |
15 |
16 | Git Mochi Logo 17 |
18 | 19 |
20 |

Welcome to Git Mochi

21 |

Effortlessly manage tasks and track time, all in one place.

22 |
23 | 24 |
25 |
handleClick("/kanban")} 27 | class={`${styles.featureCard} ${styles.kanban}`} 28 | > 29 |

Kanban Board

30 |

Organize and manage tasks visually.

31 |

32 | Press Shift+1 to open the Kanban board. 33 |

34 |
35 | 36 |
handleClick("/timetrack")} 38 | class={`${styles.featureCard} ${styles.timeTracking}`} 39 | > 40 |

Time-Tracking Tool

41 |

Track hours with ease.

42 |

43 | Press Shift+2 to open the Time-Tracking tool. 44 |

45 |
46 | 47 |
handleClick("/todo")} 49 | class={`${styles.featureCard} ${styles.todo}`} 50 | > 51 |

Todo Tool

52 |

Manage your todos.

53 |

54 | Press Shift+3 to open the Todo tool. 55 |

56 |
57 |
58 |
59 |
60 | ); 61 | }; 62 | 63 | export default Home; 64 | -------------------------------------------------------------------------------- /packages/client/src/assets/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Coding0tter/GIT-Mochi/4ad5e729df950c7000eb5fbaab49eac92a47c017/packages/client/src/assets/favicon.ico -------------------------------------------------------------------------------- /packages/client/src/assets/xmas.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Coding0tter/GIT-Mochi/4ad5e729df950c7000eb5fbaab49eac92a47c017/packages/client/src/assets/xmas.png -------------------------------------------------------------------------------- /packages/client/src/commandPipeline/commandProcessor.ts: -------------------------------------------------------------------------------- 1 | import type { CommandPipeline } from "@client/commandPipeline/types"; 2 | import { unfocusInputs } from "@client/services/uiService"; 3 | import { 4 | getActiveDropdownValue, 5 | setActiveDropdownIndex, 6 | resetCommandline, 7 | setDropdownValues, 8 | } from "@client/stores/commandStore"; 9 | import { 10 | setCommandInputValue, 11 | setCommandPlaceholder, 12 | } from "@client/stores/uiStore"; 13 | 14 | export class CommandProcessor { 15 | private currentStepIndex: number = 0; 16 | private pipeline: CommandPipeline; 17 | private resolveInput?: (input: any) => void; // Stores the resolve function to continue execution 18 | 19 | constructor() { 20 | this.pipeline = { 21 | ...getActiveDropdownValue().value, 22 | }; 23 | } 24 | 25 | async start() { 26 | await this.executeCurrentStep(); 27 | } 28 | 29 | next = () => { 30 | this.currentStepIndex++; 31 | this.executeCurrentStep(); 32 | }; 33 | 34 | repeat = () => { 35 | this.executeCurrentStep(); 36 | }; 37 | 38 | goto = (key: string) => { 39 | this.currentStepIndex = this.pipeline.steps.findIndex( 40 | (step) => step.key === key, 41 | ); 42 | this.executeCurrentStep(); 43 | }; 44 | 45 | private async executeCurrentStep() { 46 | setCommandInputValue(""); 47 | setActiveDropdownIndex(0); 48 | 49 | if (this.currentStepIndex >= this.pipeline.steps.length) { 50 | resetCommandline(); 51 | unfocusInputs(); 52 | 53 | return; 54 | } 55 | 56 | const step = this.pipeline.steps[this.currentStepIndex]; 57 | 58 | setCommandPlaceholder(step.prompt); 59 | if (step.dropdownValues) 60 | setDropdownValues( 61 | typeof step.dropdownValues === "function" 62 | ? step.dropdownValues() 63 | : step.dropdownValues, 64 | ); 65 | else if (step.cleanDropdown) setDropdownValues([]); 66 | 67 | try { 68 | if (step.awaitInput) { 69 | await step.executeAsync({ 70 | input: await this.waitForUserInput(), 71 | next: this.next, 72 | repeat: this.repeat, 73 | goto: this.goto, 74 | }); 75 | } else { 76 | await step.executeAsync({ 77 | next: this.next, 78 | repeat: this.repeat, 79 | goto: this.goto, 80 | }); 81 | } 82 | } catch (error: any) { 83 | if (step.onError) { 84 | step.onError(error, this.repeat); 85 | } else { 86 | console.error("Error:", error.message); 87 | } 88 | } 89 | } 90 | 91 | private waitForUserInput(): Promise { 92 | return new Promise((resolve) => { 93 | this.resolveInput = resolve; 94 | }); 95 | } 96 | 97 | receiveInput(input: any) { 98 | if (this.resolveInput) { 99 | this.resolveInput(input); 100 | this.resolveInput = undefined; 101 | } 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /packages/client/src/commandPipeline/commandRegistry.ts: -------------------------------------------------------------------------------- 1 | import { cloneDeep } from "lodash"; 2 | import type { CommandPipeline } from "./types"; 3 | 4 | const commandRegistry: CommandPipeline[] = []; 5 | 6 | export const registerCommand = (command: CommandPipeline) => { 7 | if (!commandRegistry.some((cmd) => cmd.name === command.name)) 8 | commandRegistry.push(cloneDeep(command)); 9 | }; 10 | 11 | export const getRegisteredCommands = () => { 12 | return [...commandRegistry]; 13 | }; 14 | 15 | export const getCommandByName = (name: string) => { 16 | return [...commandRegistry].find((cmd) => cmd.name === name); 17 | }; 18 | 19 | export const resetCommandRegistry = () => { 20 | commandRegistry.length = 0; 21 | }; 22 | -------------------------------------------------------------------------------- /packages/client/src/commandPipeline/commands/assignTask.command.ts: -------------------------------------------------------------------------------- 1 | import { 2 | assignTaskAsync, 3 | getGitLabUsersAsync, 4 | } from "../../services/gitlabService"; 5 | import { addNotification } from "../../services/notificationService"; 6 | import { 7 | getActiveDropdownValue, 8 | setDropdownValues, 9 | } from "../../stores/commandStore"; 10 | import { keyboardNavigationStore } from "../../stores/keyboardNavigationStore"; 11 | import { getColumnTasks } from "../../stores/taskStore"; 12 | import { registerCommand } from "../commandRegistry"; 13 | import type { CommandPipeline } from "../types"; 14 | 15 | const assignTaskComand: CommandPipeline = { 16 | name: "assignTask", 17 | description: "Assign a task to a user", 18 | steps: [ 19 | { 20 | prompt: "Loading users...", 21 | executeAsync: async ({ next }) => { 22 | const users = await getGitLabUsersAsync(); 23 | 24 | setDropdownValues( 25 | users.map((user: any) => ({ 26 | text: user.name, 27 | value: user, 28 | })) 29 | ); 30 | 31 | next(); 32 | }, 33 | }, 34 | { 35 | prompt: "Choose a user to assign the task to", 36 | awaitInput: true, 37 | executeAsync: async ({ next }) => { 38 | const user = getActiveDropdownValue().value; 39 | const selectedTask = 40 | getColumnTasks()[keyboardNavigationStore.selectedTaskIndex]; 41 | 42 | await assignTaskAsync(selectedTask._id!, user.id); 43 | 44 | addNotification({ 45 | title: "Task assigned", 46 | description: "Task has been assigned", 47 | type: "success", 48 | }); 49 | 50 | next(); 51 | }, 52 | }, 53 | ], 54 | }; 55 | 56 | registerCommand(assignTaskComand); 57 | -------------------------------------------------------------------------------- /packages/client/src/commandPipeline/commands/createProject.command.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createProjectAsync, 3 | getProjectAsync, 4 | setProjectAsync, 5 | } from "../../services/customProjectService"; 6 | import { addNotification } from "../../services/notificationService"; 7 | import { 8 | commandStore, 9 | getActiveDropdownValue, 10 | setBuffer, 11 | } from "../../stores/commandStore"; 12 | import { fetchTasksAsync } from "../../stores/taskStore"; 13 | import { setCurrentProject } from "../../stores/uiStore"; 14 | import { registerCommand } from "../commandRegistry"; 15 | import { type CommandPipeline } from "../types"; 16 | 17 | const createProjectCommand: CommandPipeline = { 18 | name: "createProject", 19 | description: "Create a new project and set it as active", 20 | steps: [ 21 | { 22 | awaitInput: true, 23 | cleanDropdown: true, 24 | executeAsync: async ({ input, next, repeat }) => { 25 | if (input === "") { 26 | repeat(); 27 | return; 28 | } 29 | 30 | const project = await createProjectAsync(input); 31 | if (project === null) { 32 | addNotification({ 33 | title: "Error", 34 | description: "Failed to create project", 35 | type: "error", 36 | }); 37 | 38 | repeat(); 39 | return; 40 | } 41 | setBuffer(project._id); 42 | next(); 43 | }, 44 | prompt: "Enter a name for the new project", 45 | }, 46 | { 47 | awaitInput: true, 48 | executeAsync: async ({ next }) => { 49 | const choice = getActiveDropdownValue().value; 50 | if (choice === "yes") { 51 | await setProjectAsync("custom_project/" + commandStore.buffer); 52 | setCurrentProject(await getProjectAsync()); 53 | 54 | await fetchTasksAsync(); 55 | 56 | addNotification({ 57 | title: "Success", 58 | description: "Project set successfully", 59 | type: "success", 60 | }); 61 | 62 | next(); 63 | } 64 | }, 65 | prompt: "Do you want to set the new project as active?", 66 | dropdownValues: [ 67 | { 68 | text: "Yes", 69 | value: "yes", 70 | }, 71 | { 72 | text: "No", 73 | value: "no", 74 | }, 75 | ], 76 | }, 77 | ], 78 | }; 79 | 80 | registerCommand(createProjectCommand); 81 | -------------------------------------------------------------------------------- /packages/client/src/commandPipeline/commands/createTask.command.ts: -------------------------------------------------------------------------------- 1 | import { openCreateModal } from "../../services/modalService"; 2 | import { registerCommand } from "../commandRegistry"; 3 | import { type CommandPipeline } from "../types"; 4 | 5 | const createTaskCommand: CommandPipeline = { 6 | name: "createTask", 7 | description: "Create a new task", 8 | steps: [ 9 | { 10 | cleanDropdown: true, 11 | executeAsync: async () => { 12 | openCreateModal(); 13 | }, 14 | prompt: "Opening create task modal...", 15 | }, 16 | ], 17 | }; 18 | 19 | registerCommand(createTaskCommand); 20 | -------------------------------------------------------------------------------- /packages/client/src/commandPipeline/commands/toggleDraft.command.ts: -------------------------------------------------------------------------------- 1 | import { toggleDraft } from "../../services/gitlabService"; 2 | import { keyboardNavigationStore } from "../../stores/keyboardNavigationStore"; 3 | import { getColumnTasks } from "../../stores/taskStore"; 4 | import { registerCommand } from "../commandRegistry"; 5 | import type { CommandPipeline } from "../types"; 6 | 7 | const toggleDraftCommand: CommandPipeline = { 8 | name: "toggleDraft", 9 | description: "Toggle the draft flag", 10 | steps: [ 11 | { 12 | prompt: "Updating draft flag...", 13 | executeAsync: async ({ next }) => { 14 | const selectedTask = 15 | getColumnTasks()[keyboardNavigationStore.selectedTaskIndex]; 16 | 17 | await toggleDraft(selectedTask._id!); 18 | 19 | next(); 20 | }, 21 | }, 22 | ], 23 | }; 24 | 25 | registerCommand(toggleDraftCommand); 26 | -------------------------------------------------------------------------------- /packages/client/src/commandPipeline/index.ts: -------------------------------------------------------------------------------- 1 | const commandModules = import.meta.glob("./commands/*.command.ts"); 2 | 3 | for (const path in commandModules) { 4 | await commandModules[path](); // This will load each command file 5 | } 6 | 7 | import { getRegisteredCommands } from "./commandRegistry"; 8 | export const COMMANDS = [...getRegisteredCommands()]; 9 | -------------------------------------------------------------------------------- /packages/client/src/commandPipeline/types.ts: -------------------------------------------------------------------------------- 1 | import type { DropdownValue } from "../stores/commandStore"; 2 | 3 | export interface CommandStep { 4 | prompt: string; 5 | key?: string; 6 | dropdownValues?: DropdownValue[] | (() => DropdownValue[]); 7 | awaitInput?: boolean; 8 | cleanDropdown?: boolean; 9 | executeAsync: (props: CommandProps) => Promise; 10 | onError?: (error: Error, repeat: () => void) => void; 11 | } 12 | 13 | export interface CommandProps { 14 | input?: any; 15 | next: () => void; 16 | repeat: () => void; 17 | goto: (key: string) => void; 18 | } 19 | 20 | export interface CommandPipeline { 21 | name: string; 22 | description: string; 23 | steps: CommandStep[]; 24 | } 25 | -------------------------------------------------------------------------------- /packages/client/src/components/Kanban/TaskColumn/TaskColumn.module.css: -------------------------------------------------------------------------------- 1 | .column { 2 | flex: 1; 3 | background: var(--color-base); 4 | border-radius: var(--spacing-md); 5 | padding: var(--spacing-sm); 6 | border: 1px solid rgba(255, 255, 255, 0.1); 7 | box-shadow: 0 8px 30px rgba(0, 0, 0, 0.1); 8 | max-width: 100%; 9 | overflow: hidden; 10 | } 11 | -------------------------------------------------------------------------------- /packages/client/src/components/TimeTrack/Appointment/Appointment.module.css: -------------------------------------------------------------------------------- 1 | .appointment { 2 | background-color: var(--color-teal); 3 | filter: brightness(80%); 4 | color: var(--color-crust); 5 | border-radius: var(--spacing-xs); 6 | text-align: center; 7 | box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1); 8 | z-index: 1; 9 | width: 100%; 10 | 11 | &.selected { 12 | border: 2px solid var(--color-red); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /packages/client/src/components/TimeTrack/Appointment/Appointment.tsx: -------------------------------------------------------------------------------- 1 | import { Dayjs } from "dayjs"; 2 | import { createSignal, onMount, onCleanup, createEffect } from "solid-js"; 3 | import styles from "./Appointment.module.css"; 4 | import { CalendarMode, uiStore } from "../../../stores/uiStore"; 5 | import { timeTrackStore } from "../../../stores/timeTrackStore"; 6 | import { keyboardNavigationStore } from "../../../stores/keyboardNavigationStore"; 7 | 8 | const Appointment = ({ 9 | length, 10 | title, 11 | start, 12 | index, 13 | }: { 14 | length: number; 15 | title: string; 16 | start: Dayjs; 17 | index: number; 18 | }) => { 19 | const [minuteHeight, setMinuteHeight] = createSignal(0); 20 | 21 | const calculateMinuteHeight = () => { 22 | const quarterElement = document.getElementById("day-0-hour-8-quarter-0"); 23 | 24 | if (quarterElement) { 25 | const quarterHeight = quarterElement.getBoundingClientRect().height; 26 | setMinuteHeight(quarterHeight / 15); // Each quarter has 15 minutes 27 | } 28 | }; 29 | 30 | onMount(() => { 31 | calculateMinuteHeight(); 32 | 33 | // Recalculate minute height on resize 34 | window.addEventListener("resize", calculateMinuteHeight); 35 | 36 | return () => { 37 | window.removeEventListener("resize", calculateMinuteHeight); 38 | }; 39 | }); 40 | 41 | const getMarginTop = () => { 42 | const calendarStartHour = 8; // The start hour of your calendar 43 | const startHour = start.hour(); 44 | const startMinute = start.minute(); 45 | 46 | // Calculate total minutes since the calendar's start 47 | const totalMinutes = (startHour - calendarStartHour) * 60 + startMinute; 48 | 49 | // Return the total pixel margin from the top 50 | return totalMinutes * minuteHeight(); 51 | }; 52 | 53 | return ( 54 |
66 |
{title}
67 |
68 | ); 69 | }; 70 | 71 | export default Appointment; 72 | -------------------------------------------------------------------------------- /packages/client/src/components/TimeTrack/TimeMarker/TimeMarker.module.css: -------------------------------------------------------------------------------- 1 | .currentTimeMarker { 2 | position: absolute; 3 | left: 0; 4 | right: 0; 5 | height: 2px; 6 | background-color: var(--color-peach); 7 | z-index: 20; 8 | pointer-events: none; 9 | } 10 | 11 | .time { 12 | position: absolute; 13 | top: -25px; 14 | right: 0; 15 | display: inline-block; 16 | padding: var(--spacing-xxs); 17 | background-color: var(--color-sapphire); 18 | border-radius: var(--spacing-xs); 19 | color: var(--color-base); 20 | border-bottom-right-radius: 0; 21 | } 22 | -------------------------------------------------------------------------------- /packages/client/src/components/TimeTrack/TimeMarker/TimeMarker.tsx: -------------------------------------------------------------------------------- 1 | import { createSignal, onMount, onCleanup, type Component } from "solid-js"; 2 | import styles from "./TimeMarker.module.css"; 3 | 4 | interface TimeMarkerProps { 5 | startHour: number; 6 | endHour: number; 7 | } 8 | 9 | const TimeMarker: Component = (props) => { 10 | const [currentTimePosition, setCurrentTimePosition] = createSignal(0); 11 | const [currentTime, setCurrentTime] = createSignal(new Date()); 12 | 13 | const updateCurrentTimePosition = () => { 14 | const now = new Date(); 15 | setCurrentTime(now); 16 | const totalMinutes = (props.endHour - props.startHour + 1) * 60; 17 | const currentMinutes = 18 | (now.getHours() - props.startHour) * 60 + now.getMinutes(); 19 | 20 | const position = Math.max( 21 | Math.min((currentMinutes / totalMinutes) * 100, 99), 22 | 0 23 | ); 24 | 25 | setCurrentTimePosition(position); 26 | }; 27 | 28 | onMount(async () => { 29 | updateCurrentTimePosition(); 30 | const interval = setInterval(updateCurrentTimePosition, 60000); 31 | onCleanup(() => clearInterval(interval)); 32 | }); 33 | 34 | return ( 35 |
41 |
42 | {" "} 43 | {currentTime().toLocaleTimeString([], { 44 | hour: "2-digit", 45 | minute: "2-digit", 46 | hour12: false, 47 | })} 48 |
49 |
50 | ); 51 | }; 52 | 53 | export default TimeMarker; 54 | -------------------------------------------------------------------------------- /packages/client/src/components/TimeTrack/WeekCalendar/WeekCalendar.module.css: -------------------------------------------------------------------------------- 1 | .weekCalendar { 2 | width: 100%; 3 | height: 100%; 4 | background: var(--color-mantle); 5 | border: 1px solid rgba(255, 255, 255, 0.1); 6 | border-radius: var(--spacing-md); 7 | position: relative; 8 | display: flex; 9 | flex-direction: column; 10 | overflow-y: auto; 11 | box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); 12 | flex-grow: 1; /* Ensure it fills available height */ 13 | } 14 | -------------------------------------------------------------------------------- /packages/client/src/components/TimeTrack/WeekCalendar/WeekCalendar.tsx: -------------------------------------------------------------------------------- 1 | import { createSignal, onMount } from "solid-js"; 2 | import WeekCalendarBody from "../WeekCalendarBody/WeekCalendarBody"; 3 | import WeekCalendarHeader from "../WeekCalendarHeader/WeekCalendarHeader"; 4 | import styles from "./WeekCalendar.module.css"; 5 | import { 6 | setSelectedDayIndex, 7 | setSelectedHourIndex, 8 | setSelectedQuarterHourIndex, 9 | setSelectedQuarterHourIndexes, 10 | } from "../../../stores/keyboardNavigationStore"; 11 | import { setCalendarHeight } from "../../../stores/uiStore"; 12 | 13 | interface WeekCalendarProps { 14 | startHour?: number; 15 | endHour?: number; 16 | } 17 | 18 | function WeekCalendar(props: WeekCalendarProps) { 19 | const startHour = props.startHour ?? 0; 20 | const endHour = props.endHour ?? 23; 21 | 22 | // Signal for the current date 23 | const [currentDay, setCurrentDay] = createSignal(new Date()); 24 | 25 | // Calculate the dates for the current week starting from Monday 26 | const [weekDates, setWeekDates] = createSignal([]); 27 | 28 | onMount(() => { 29 | const today = new Date(); 30 | const dayOfWeek = (today.getDay() + 6) % 7; 31 | const monday = new Date(today); 32 | monday.setDate(today.getDate() - dayOfWeek); 33 | 34 | const dates = Array.from({ length: 7 }, (_, i) => { 35 | const date = new Date(monday); 36 | date.setDate(monday.getDate() + i); 37 | return date; 38 | }); 39 | 40 | const calendarHeight = document 41 | .getElementById("calendar-body") 42 | ?.getBoundingClientRect() 43 | .height.toFixed(4) as unknown as number; 44 | 45 | setCalendarHeight(calendarHeight); 46 | 47 | setSelectedDayIndex(dayOfWeek); 48 | setSelectedHourIndex(today.getHours()); 49 | setSelectedQuarterHourIndex(Math.floor(today.getMinutes() / 15)); 50 | setSelectedQuarterHourIndexes([ 51 | today.getHours() * 4 + Math.floor(today.getMinutes() / 15), 52 | ]); 53 | setWeekDates(dates); 54 | }); 55 | 56 | return ( 57 |
58 | 59 | 65 |
66 | ); 67 | } 68 | 69 | export default WeekCalendar; 70 | -------------------------------------------------------------------------------- /packages/client/src/components/TimeTrack/WeekCalendarBody/WeekCalendarBody.module.css: -------------------------------------------------------------------------------- 1 | .weekCalendarBody { 2 | flex: 1; /* Allow it to grow within the .weekCalendar */ 3 | display: flex; 4 | flex-direction: column; 5 | height: 100%; /* Ensure it takes the full height of .weekCalendar */ 6 | position: relative; 7 | } 8 | -------------------------------------------------------------------------------- /packages/client/src/components/TimeTrack/WeekCalendarBody/WeekCalendarBody.tsx: -------------------------------------------------------------------------------- 1 | import { type Component } from "solid-js"; 2 | import styles from "./WeekCalendarBody.module.css"; 3 | import TimeMarker from "../TimeMarker/TimeMarker"; 4 | import WeekCalendarHourRow from "../WeekCalendarHourRow/WeekCalendarHourRow"; 5 | 6 | interface WeekCalendarBodyProps { 7 | weekDates: Date[]; 8 | startHour: number; 9 | endHour: number; 10 | currentDay: Date; 11 | } 12 | 13 | const WeekCalendarBody: Component = (props) => { 14 | return ( 15 |
16 | {Array.from( 17 | { length: props.endHour - props.startHour + 1 }, 18 | (_, idx) => props.startHour + idx 19 | ).map((hour) => ( 20 | <> 21 | 26 | 27 | ))} 28 | 29 |
30 | ); 31 | }; 32 | 33 | export default WeekCalendarBody; 34 | -------------------------------------------------------------------------------- /packages/client/src/components/TimeTrack/WeekCalendarHeader/WeekCalendarHeader.module.css: -------------------------------------------------------------------------------- 1 | .weekCalendarHeader { 2 | flex-shrink: 0; /* Prevents it from shrinking in flex layout */ 3 | display: flex; 4 | background-color: var(--color-surface1); 5 | border-bottom: 1px solid var(--color-overlay1); 6 | border-top-left-radius: var(--spacing-md); 7 | border-top-right-radius: var(--spacing-md); 8 | box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); 9 | } 10 | 11 | .weekCalendarHeaderTimeLabel { 12 | width: 80px; 13 | min-width: 80px; 14 | background-color: var(--color-surface1); 15 | border-right: 1px solid var(--color-overlay1); 16 | box-sizing: border-box; 17 | color: var(--color-subtext1); 18 | display: flex; 19 | align-items: center; 20 | justify-content: center; 21 | font-weight: var(--font-weight-medium); 22 | } 23 | 24 | .weekCalendarHeaderDay { 25 | flex: 1; 26 | padding: var(--spacing-md); 27 | text-align: center; 28 | border-right: 1px solid var(--color-overlay1); 29 | color: var(--color-text); 30 | font-weight: var(--font-weight-semibold); 31 | box-sizing: border-box; 32 | } 33 | 34 | .weekCalendarHeaderDay:last-child { 35 | border-right: none; 36 | } 37 | 38 | .currentDay { 39 | background-color: var(--color-mantle) !important; 40 | } 41 | 42 | .weekCalendarDayName { 43 | font-size: var(--font-size-lg); 44 | font-weight: var(--font-weight-bold); 45 | color: var(--color-peach); 46 | } 47 | 48 | .weekCalendarDate { 49 | font-size: var(--font-size-sm); 50 | color: var(--color-flamingo); 51 | } 52 | -------------------------------------------------------------------------------- /packages/client/src/components/TimeTrack/WeekCalendarHeader/WeekCalendarHeader.tsx: -------------------------------------------------------------------------------- 1 | import { type Component } from "solid-js"; 2 | import styles from "./WeekCalendarHeader.module.css"; 3 | 4 | interface WeekCalendarHeaderProps { 5 | weekDates: Date[]; 6 | currentDay: Date; 7 | } 8 | 9 | const WeekCalendarHeader: Component = (props) => { 10 | const daysOfWeek = [ 11 | "Monday", 12 | "Tuesday", 13 | "Wednesday", 14 | "Thursday", 15 | "Friday", 16 | "Saturday", 17 | "Sunday", 18 | ]; 19 | 20 | return ( 21 |
22 |
23 | {props.weekDates.map((date, index) => ( 24 |
31 |
{daysOfWeek[index]}
32 |
33 | {date.toLocaleDateString("de-DE")} 34 |
35 |
36 | ))} 37 |
38 | ); 39 | }; 40 | 41 | export default WeekCalendarHeader; 42 | -------------------------------------------------------------------------------- /packages/client/src/components/TimeTrack/WeekCalendarHourRow/WeekCalendarHourRow.module.css: -------------------------------------------------------------------------------- 1 | .weekCalendarHourRow { 2 | display: flex; 3 | border-bottom: 1px solid var(--color-overlay0); 4 | flex-grow: 1; /* Allows rows to expand and fill remaining space */ 5 | } 6 | 7 | .weekCalendarHourLabel { 8 | width: 80px; 9 | min-width: 80px; 10 | padding: var(--spacing-xxs) var(--spacing-xs); 11 | background-color: var(--color-surface2); 12 | border-right: 1px solid var(--color-overlay0); 13 | text-align: right; 14 | font-size: var(--font-size-sm); 15 | color: var(--color-crust); 16 | font-weight: var(--font-weight-bold); 17 | font-size: var(--font-size-sm); 18 | box-sizing: border-box; 19 | display: flex; 20 | justify-content: flex-end; 21 | } 22 | 23 | .weekCalendarDayColumn { 24 | flex: 1; 25 | display: flex; 26 | position: relative; 27 | flex-direction: column; 28 | border-right: 1px solid var(--color-overlay0); 29 | } 30 | 31 | .weekCalendarQuarterHourCell { 32 | flex: 1; 33 | border-bottom: 1px solid rgba(255, 255, 255, 0.05); 34 | box-sizing: border-box; 35 | transition: background-color 0.2s; 36 | } 37 | 38 | .weekCalendarQuarterHourCell:last-child { 39 | border-bottom: none; 40 | } 41 | 42 | .weekCalendarQuarterHourCell:hover { 43 | background-color: var(--color-surface1) !important; 44 | } 45 | 46 | .weekCalendarHourCell.currentHour { 47 | background-color: var(--color-surface1); 48 | } 49 | 50 | .weekCalendarHourCell:hover { 51 | background-color: var(--color-surface1) !important; 52 | } 53 | 54 | .weekCalendarHourCell:last-child { 55 | border-right: none; 56 | } 57 | 58 | .weekCalendarHourRow:last-child { 59 | border-bottom: none; 60 | } 61 | 62 | .selectedCell { 63 | background-color: var(--color-blue) !important; 64 | opacity: 0.5; 65 | z-index: 19; 66 | } 67 | 68 | .activeCell { 69 | border: 2px solid var(--color-red) !important; 70 | backdrop-filter: brightness(80%) !important; 71 | z-index: 20; 72 | margin: -1px; 73 | } 74 | 75 | .currentDay { 76 | background-color: var(--color-crust); 77 | background-clip: content-box; 78 | } 79 | 80 | .appointmentWrapper { 81 | width: 100%; 82 | position: absolute; 83 | top: 0; 84 | display: flex; 85 | flex-direction: row; 86 | flex: 1; 87 | z-index: 21; 88 | gap: var(--spacing-xxs); 89 | } 90 | -------------------------------------------------------------------------------- /packages/client/src/components/modals/BaseModal/BaseModal.module.css: -------------------------------------------------------------------------------- 1 | .modalWrapper { 2 | background-color: var(--color-base); 3 | border-radius: var(--spacing-sm); 4 | padding: var(--spacing-sm); 5 | box-shadow: 0 4px 12px rgba(0, 0, 0, 1); 6 | display: flex; 7 | flex-direction: column; 8 | gap: var(--spacing-sm); 9 | } 10 | 11 | .modalWrapper.hidden { 12 | animation: modalFadeOut 0.3s forwards; 13 | } 14 | 15 | .modalContent { 16 | color: var(--color-text); 17 | border-radius: var(--spacing-sm); 18 | opacity: 0; 19 | animation: modalFadeIn 0.3s forwards; 20 | } 21 | 22 | .modal.hidden { 23 | animation: modalFadeOut 0.3s forwards; 24 | } 25 | 26 | .modalButtons { 27 | display: flex; 28 | justify-content: flex-end; 29 | gap: var(--spacing-sm); 30 | } 31 | 32 | .modal { 33 | position: fixed; 34 | z-index: 1000; 35 | top: 0; 36 | left: 0; 37 | width: 100%; 38 | height: 100%; 39 | box-sizing: border-box; 40 | padding: 200px; 41 | background-color: rgba(0, 0, 0, 0.5); 42 | display: flex; 43 | align-items: center; 44 | justify-content: center; 45 | opacity: 0; 46 | animation: modalFadeIn 0.3s ease forwards; 47 | } 48 | 49 | .modal header { 50 | font-size: var(--font-size-xl); 51 | font-weight: var(--font-weight-semibold); 52 | color: var(--color-text); 53 | margin-bottom: var(--spacing-md); 54 | } 55 | 56 | .modal p { 57 | font-size: var(--font-size-md); 58 | font-weight: var(--font-weight-regular); 59 | color: var(--color-subtext1); 60 | } 61 | 62 | /* Keyframe Animations */ 63 | @keyframes modalFadeIn { 64 | from { 65 | opacity: 0; 66 | } 67 | to { 68 | opacity: 1; 69 | } 70 | } 71 | 72 | @keyframes modalFadeOut { 73 | from { 74 | opacity: 1; 75 | } 76 | to { 77 | opacity: 0; 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /packages/client/src/components/modals/BaseModal/BaseModal.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | createEffect, 3 | createSignal, 4 | onMount, 5 | type JSXElement, 6 | type ParentComponent, 7 | } from "solid-js"; 8 | import { modalStore } from "../../../stores/modalStore"; 9 | import styles from "./BaseModal.module.css"; 10 | import Button from "../../shared/Button/Button"; 11 | 12 | export interface BaseModalProps { 13 | onClose?: () => void; 14 | closeText?: string; 15 | onSubmit?: () => void; 16 | submitText?: string | (() => string); 17 | } 18 | 19 | const BaseModal: ParentComponent = (props): JSXElement => { 20 | const [index, setIndex] = createSignal(0); 21 | const [fadeOut, setFadeOut] = createSignal(false); 22 | 23 | onMount(() => { 24 | setIndex(modalStore.activeModals.length - 1); 25 | }); 26 | 27 | createEffect(() => { 28 | if (modalStore.closing && index() === modalStore.activeModals.length - 1) { 29 | setFadeOut(true); 30 | } 31 | }, [modalStore.closing]); 32 | 33 | return ( 34 |
35 |
36 |
{props.children}
37 |
38 | {props.submitText !== undefined && ( 39 | 44 | )} 45 | {props.closeText !== undefined && ( 46 | 49 | )} 50 |
51 |
52 |
53 | ); 54 | }; 55 | 56 | export default BaseModal; 57 | -------------------------------------------------------------------------------- /packages/client/src/components/modals/BranchNameModal/BranchNameModal.module.css: -------------------------------------------------------------------------------- 1 | .form { 2 | display: flex; 3 | flex-direction: column; 4 | gap: var(--spacing-sm); 5 | width: 400px; 6 | } 7 | -------------------------------------------------------------------------------- /packages/client/src/components/modals/DeleteModal/DeleteModal.tsx: -------------------------------------------------------------------------------- 1 | import { type JSXElement, onCleanup, onMount } from "solid-js"; 2 | import BaseModal, { type BaseModalProps } from "../BaseModal/BaseModal"; 3 | import { modalStore } from "../../../stores/modalStore"; 4 | 5 | interface DeleteModalProps extends BaseModalProps {} 6 | 7 | const DeleteModal = (props: DeleteModalProps): JSXElement => { 8 | const handleKeyDown = (event: KeyboardEvent) => { 9 | if (event.key === "Y" || event.key === "y") { 10 | props.onSubmit!(); 11 | } else if (event.key === "N" || event.key === "n") { 12 | props.onClose!(); 13 | } 14 | }; 15 | 16 | onMount(() => { 17 | window.addEventListener("keydown", handleKeyDown); 18 | 19 | onCleanup(() => { 20 | window.removeEventListener("keydown", handleKeyDown); 21 | }); 22 | }); 23 | 24 | return ( 25 | 26 |

Delete Task

27 |

Are you sure you want to delete selected tasks?

28 |

{modalStore.selectedTask?.title}

29 |
30 | ); 31 | }; 32 | 33 | export default DeleteModal; 34 | -------------------------------------------------------------------------------- /packages/client/src/components/modals/EditAppointmentModal/EditAppointmentModal.module.css: -------------------------------------------------------------------------------- 1 | .form { 2 | display: flex; 3 | flex-direction: column; 4 | gap: 1rem; 5 | 6 | label { 7 | font-weight: 500; 8 | line-height: 1; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /packages/client/src/components/modals/EditOrCreateTaskModal/EditOrCreateTaskModal.module.css: -------------------------------------------------------------------------------- 1 | .form { 2 | display: flex; 3 | flex-direction: column; 4 | gap: var(--spacing-sm); 5 | 6 | width: 400px; 7 | 8 | select { 9 | width: unset; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /packages/client/src/components/modals/EditOrCreateTaskModal/EditOrCreateTaskModal.tsx: -------------------------------------------------------------------------------- 1 | import { type JSXElement, onMount } from "solid-js"; 2 | import { STATES } from "../../../constants"; 3 | import { modalStore, setSelectedTaskValue } from "../../../stores/modalStore"; 4 | import BaseModal, { type BaseModalProps } from "../BaseModal/BaseModal"; 5 | import styles from "./EditOrCreateTaskModal.module.css"; 6 | 7 | interface EditTaskModalProps extends BaseModalProps {} 8 | 9 | const EditOrCreateTaskModal = (props: EditTaskModalProps): JSXElement => { 10 | let inputRef: HTMLInputElement | null = null; 11 | 12 | onMount(() => { 13 | if (inputRef) { 14 | setTimeout(() => inputRef?.focus(), 0); 15 | } 16 | }); 17 | 18 | return ( 19 | 20 |

21 | {modalStore.selectedTask?._id !== undefined ? "Edit" : "Create"} Custom 22 | Task 23 |

24 |
25 | { 29 | if (e.key === "Enter") { 30 | props.onSubmit!(); 31 | } else if (e.key === "Escape") { 32 | props.onClose!(); 33 | } 34 | }} 35 | ref={(el) => (inputRef = el)} 36 | onInput={(e) => setSelectedTaskValue("title", e.currentTarget.value)} 37 | placeholder="Task Title" 38 | /> 39 | 49 | 66 |
67 | 68 |
69 | ); 70 | }; 71 | 72 | export default ReplyModal; 73 | -------------------------------------------------------------------------------- /packages/client/src/components/modals/TaskDetailsModal/TaskDetailsModal.module.css: -------------------------------------------------------------------------------- 1 | .dialogContent { 2 | max-width: 70vw; 3 | display: flex; 4 | flex-direction: column; 5 | gap: var(--spacing-md); 6 | } 7 | 8 | .dialogTitle { 9 | font-size: var(--font-size-xl); 10 | font-weight: var(--font-weight-bold); 11 | } 12 | 13 | .contentContainer { 14 | display: flex; 15 | flex-direction: column; 16 | gap: var(--spacing-md); 17 | } 18 | 19 | .badgeContainer { 20 | display: flex; 21 | align-items: center; 22 | gap: var(--spacing-xs); 23 | } 24 | 25 | .badgeMarginLeft { 26 | margin-left: var(--spacing-xs); 27 | } 28 | 29 | .descriptionText { 30 | max-height: 400px; 31 | overflow: scroll; 32 | 33 | font-size: var(--font-size-sm); 34 | color: var(--color-subtext1); 35 | 36 | a { 37 | color: var(--color-crust); 38 | } 39 | 40 | img { 41 | max-height: 200px; 42 | } 43 | } 44 | 45 | .labelsContainer { 46 | display: flex; 47 | flex-wrap: wrap; 48 | gap: var(--spacing-xs); 49 | } 50 | 51 | .branchInfo, 52 | .externalLink { 53 | display: flex; 54 | align-items: center; 55 | font-size: var(--font-size-sm); 56 | color: var(--color-subtext1); 57 | text-decoration: none; 58 | gap: var(--spacing-xs); 59 | } 60 | 61 | .branchInfo .icon, 62 | .externalLink .icon { 63 | width: var(--spacing-md); 64 | height: var(--spacing-md); 65 | margin-right: var(--spacing-xs); 66 | } 67 | 68 | .externalLink { 69 | color: var(--color-crust); 70 | cursor: pointer; 71 | } 72 | 73 | .externalLink:hover { 74 | text-decoration: underline; 75 | } 76 | 77 | .divider { 78 | border-top: 2px solid var(--color-overlay2); 79 | border-radius: var(--spacing-xs); 80 | } 81 | 82 | .discussionsHeader { 83 | font-size: var(--font-size-lg); 84 | font-weight: var(--font-weight-semibold); 85 | } 86 | 87 | .discussionsContainer { 88 | display: flex; 89 | flex-direction: column; 90 | gap: var(--spacing-sm); 91 | max-height: 50vh; 92 | overflow-y: auto; 93 | } 94 | 95 | .mainSection { 96 | display: flex; 97 | gap: var(--spacing-md); 98 | flex-wrap: wrap; 99 | align-items: flex-start; 100 | } 101 | 102 | .card { 103 | background-color: var(--color-surface1); 104 | box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.1); 105 | padding: var(--spacing-xs); 106 | border-radius: var(--spacing-xs); 107 | 108 | &.selected { 109 | border: 2px solid var(--color-blue); 110 | } 111 | } 112 | 113 | .card.system { 114 | background-color: var(--color-surface0) !important; 115 | min-width: 300px; 116 | } 117 | 118 | .card.user { 119 | background-color: var(--color-surface1) !important; 120 | min-width: 300px; 121 | } 122 | 123 | .card.resolved { 124 | border-left: 4px solid var(--color-green); 125 | min-width: 300px; 126 | } 127 | -------------------------------------------------------------------------------- /packages/client/src/components/modals/index.ts: -------------------------------------------------------------------------------- 1 | import BaseModal from "./BaseModal/BaseModal"; 2 | import HelpModal from "./HelpModal/HelpModal"; 3 | import DeleteModal from "./DeleteModal/DeleteModal"; 4 | import EditOrCreateTaskModal from "./EditOrCreateTaskModal/EditOrCreateTaskModal"; 5 | import BranchNameModal from "./BranchNameModal/BranchNameModal"; 6 | 7 | export { 8 | BaseModal, 9 | HelpModal, 10 | DeleteModal, 11 | EditOrCreateTaskModal, 12 | BranchNameModal, 13 | }; 14 | -------------------------------------------------------------------------------- /packages/client/src/components/shared/Badge/Badge.module.css: -------------------------------------------------------------------------------- 1 | .badge { 2 | white-space: nowrap; 3 | overflow: hidden; 4 | text-overflow: ellipsis; 5 | color: var(--color-crust); 6 | padding: var(--spacing-xxs) var(--spacing-xs); 7 | border-radius: var(--spacing-xxs); 8 | font-size: var(--font-size-xs); 9 | display: inline-block; 10 | margin-top: var(--spacing-xxs); 11 | margin-right: var(--spacing-xxs); 12 | 13 | &.maxLength { 14 | max-width: 100px; 15 | } 16 | } 17 | 18 | .badge.default { 19 | background-color: var(--color-lavender); 20 | color: var(--color-crust); 21 | } 22 | 23 | .badge.secondary { 24 | background-color: var(--color-surface); 25 | color: var(--color-text); 26 | } 27 | 28 | .badge.outline { 29 | background-color: var(--color-surface); 30 | color: var(--color-text); 31 | border: 1px solid var(--color-crust); 32 | } 33 | 34 | .badge.none { 35 | background-color: var(--color-crust); 36 | color: var(--color-text); 37 | } 38 | 39 | .badge.medium { 40 | background-color: var(--color-yellow); 41 | } 42 | 43 | .badge.low { 44 | background-color: var(--color-green); 45 | } 46 | 47 | .badge.high, 48 | .badge.staging, 49 | .badge.intermediate { 50 | background-color: var(--color-red); 51 | color: var(--color-crust); 52 | animation: 53 | pulsate 1.5s infinite, 54 | borderGlow 1.5s linear infinite; 55 | padding: calc(var(--spacing-xxs) - 2px) calc(var(--spacing-xs) - 2px); 56 | border: 2px solid transparent; 57 | position: relative; 58 | text-transform: uppercase; 59 | font-weight: var(--font-weight-bold); 60 | } 61 | 62 | /* Pulsating effect */ 63 | @keyframes pulsate { 64 | 0%, 65 | 100% { 66 | transform: scale(1); 67 | } 68 | 50% { 69 | transform: scale(1); 70 | } 71 | } 72 | 73 | /* Border animation */ 74 | @keyframes borderGlow { 75 | 0% { 76 | border-color: var(--color-red); 77 | } 78 | 25% { 79 | border-color: var(--color-green); 80 | } 81 | 50% { 82 | border-color: var(--color-yellow); 83 | } 84 | 75% { 85 | border-color: var(--color-green); 86 | } 87 | 100% { 88 | border-color: var(--color-red); 89 | } 90 | } 91 | 92 | .badge.deleted { 93 | background-color: var(--color-red); 94 | color: var(--color-base); 95 | } 96 | 97 | .badge.issue { 98 | background-color: var(--color-green); 99 | color: var(--color-base); 100 | } 101 | 102 | .badge.custom { 103 | background-color: var(--color-peach); 104 | color: var(--color-crust); 105 | } 106 | 107 | .badge.mergeRequest { 108 | background-color: var(--color-blue); 109 | color: var(--color-crust); 110 | } 111 | -------------------------------------------------------------------------------- /packages/client/src/components/shared/Badge/Badge.tsx: -------------------------------------------------------------------------------- 1 | import type { JSXElement } from "solid-js"; 2 | import styles from "./Badge.module.css"; 3 | import { addNotification } from "@client/services/notificationService"; 4 | import Tooltip from "@client/components/shared/Tooltip/Tooltip"; 5 | 6 | type BadgeProps = { 7 | type?: 8 | | "issue" 9 | | "merge_request" 10 | | "custom" 11 | | "intermediate" 12 | | "staging" 13 | | "high" 14 | | "medium" 15 | | "low" 16 | | "outline" 17 | | string; 18 | cutOffText?: boolean; 19 | clipBoardText?: string; 20 | hasTooltip?: boolean; 21 | children: JSXElement; 22 | onClick?: (event: MouseEvent) => void; 23 | }; 24 | 25 | const Badge = ({ 26 | type = "custom", 27 | children, 28 | cutOffText, 29 | clipBoardText, 30 | onClick, 31 | hasTooltip, 32 | }: BadgeProps) => { 33 | const copyToClipboard = (event: MouseEvent) => { 34 | event.stopPropagation(); 35 | event.preventDefault(); 36 | 37 | if (!clipBoardText) return; 38 | navigator.clipboard.writeText(clipBoardText); 39 | 40 | addNotification({ 41 | type: "success", 42 | title: "Copied to clipboard", 43 | duration: 1000, 44 | }); 45 | }; 46 | 47 | return ( 48 | 54 | {hasTooltip ? ( 55 | 56 | 61 | {children} 62 | 63 | 64 | ) : ( 65 | 70 | {children} 71 | 72 | )} 73 | 74 | ); 75 | }; 76 | 77 | export default Badge; 78 | -------------------------------------------------------------------------------- /packages/client/src/components/shared/Button/Button.module.css: -------------------------------------------------------------------------------- 1 | .mochiButton { 2 | background-color: var(--color-blue); 3 | color: var(--color-crust); 4 | border: none; 5 | padding: var(--spacing-xs) var(--spacing-sm); 6 | border-radius: var(--spacing-xs); 7 | cursor: pointer; 8 | font-size: var(--font-size-sm); 9 | transition: background-color 0.3s ease, transform 0.2s; 10 | 11 | &.primary { 12 | background-color: var(--color-blue); 13 | } 14 | 15 | &.secondary { 16 | background-color: var(--color-surface1); 17 | 18 | &:hover { 19 | background-color: var(--color-surface0); 20 | } 21 | } 22 | } 23 | 24 | .mochiButton:hover { 25 | background-color: var(--color-sapphire); 26 | } 27 | 28 | .mochiButton:active { 29 | transform: translateY(0); 30 | } 31 | -------------------------------------------------------------------------------- /packages/client/src/components/shared/Button/Button.tsx: -------------------------------------------------------------------------------- 1 | import type { JSXElement } from "solid-js"; 2 | import styles from "./Button.module.css"; 3 | 4 | type ButtonProps = { 5 | children: JSXElement | string; 6 | disabled?: boolean; 7 | type?: "primary" | "secondary" | "default"; 8 | onClick: (e: MouseEvent) => void; 9 | style?: any; 10 | }; 11 | 12 | const Button = ({ 13 | children, 14 | onClick, 15 | disabled, 16 | type = "default", 17 | style, 18 | }: ButtonProps) => { 19 | return ( 20 | 28 | ); 29 | }; 30 | 31 | export default Button; 32 | -------------------------------------------------------------------------------- /packages/client/src/components/shared/Card/Card.module.css: -------------------------------------------------------------------------------- 1 | .card { 2 | background-color: var(--color-surface1); 3 | box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.1); 4 | padding: var(--spacing-xs); 5 | border-radius: var(--spacing-xs); 6 | position: relative; 7 | 8 | &.blue { 9 | border: 2px solid var(--color-blue); 10 | } 11 | 12 | &.green { 13 | border: 2px solid var(--color-green); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /packages/client/src/components/shared/Card/Card.tsx: -------------------------------------------------------------------------------- 1 | import styles from "./Card.module.css"; 2 | 3 | interface CardProps { 4 | children: any; 5 | class?: string; 6 | selectedBorderColor?: string; 7 | selected: () => boolean; 8 | } 9 | 10 | const Card = (props: CardProps) => { 11 | return ( 12 |
17 | {props.children} 18 |
19 | ); 20 | }; 21 | 22 | export default Card; 23 | -------------------------------------------------------------------------------- /packages/client/src/components/shared/CommandLine/CommandLine.module.css: -------------------------------------------------------------------------------- 1 | .commandline { 2 | flex-grow: 1; 3 | margin: 0 var(--spacing-sm); 4 | 5 | .commandlineInput { 6 | display: flex; 7 | align-items: center; 8 | position: relative; 9 | } 10 | 11 | i { 12 | color: var(--color-text); 13 | font-size: var(--font-size-md); 14 | border: 1px solid var(--color-blue); 15 | border-radius: var(--spacing-xs); 16 | padding: var(--spacing-xs); 17 | padding-right: 0; 18 | border-right: none; 19 | border-top-right-radius: 0; 20 | border-bottom-right-radius: 0; 21 | background-color: var(--color-mantle); 22 | } 23 | 24 | input { 25 | width: 100%; 26 | padding: var(--spacing-xs); 27 | border-radius: var(--spacing-xs); 28 | border: 1px solid var(--color-blue); 29 | background-color: var(--color-mantle); 30 | color: var(--color-text); 31 | font-size: var(--font-size-sm); 32 | } 33 | 34 | .placeholder { 35 | position: absolute; 36 | top: 50%; 37 | left: var(--spacing-sm); 38 | transform: translateY(-50%); 39 | color: var(--color-text); 40 | } 41 | 42 | input:focus { 43 | outline: none; 44 | border-color: var(--color-blue) !important; 45 | } 46 | 47 | &.active { 48 | input { 49 | border-left: none; 50 | border-top-left-radius: 0; 51 | border-bottom-left-radius: 0; 52 | border-color: var(--color-peach) !important; 53 | } 54 | 55 | i { 56 | border-color: var(--color-peach) !important; 57 | } 58 | 59 | .placeholder { 60 | /* add cursor width */ 61 | left: calc(var(--spacing-xl) + 5px); 62 | } 63 | } 64 | 65 | .dropdown { 66 | position: absolute; 67 | background-color: var(--color-mantle); 68 | color: var(--color-text); 69 | border: 1px solid var(--color-peach); 70 | border-radius: var(--spacing-xs); 71 | padding: var(--spacing-xxs); 72 | max-height: 500px; 73 | min-width: 200px; 74 | overflow-y: auto; 75 | margin-top: var(--spacing-xxs); 76 | z-index: 999; 77 | } 78 | 79 | .dropdownItem { 80 | padding: var(--spacing-xs); 81 | font-size: var(--font-size-sm); 82 | cursor: pointer; 83 | } 84 | 85 | .dropdownItem:hover { 86 | background-color: var(--color-sapphire); 87 | color: var(--color-text); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /packages/client/src/components/shared/Comment/Comment.module.css: -------------------------------------------------------------------------------- 1 | .card { 2 | background-color: var(--color-surface1); 3 | box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.1); 4 | padding: var(--spacing-xs); 5 | border-radius: var(--spacing-xs); 6 | 7 | &.selected { 8 | border: 2px solid var(--color-blue); 9 | } 10 | } 11 | 12 | .card.system { 13 | background-color: var(--color-surface0) !important; 14 | min-width: 300px; 15 | } 16 | 17 | .card.user { 18 | background-color: var(--color-surface1) !important; 19 | min-width: 300px; 20 | } 21 | 22 | .card.resolved { 23 | border-left: 4px solid var(--color-green); 24 | min-width: 300px; 25 | } 26 | 27 | .commentHeader { 28 | display: flex; 29 | align-items: center; 30 | gap: var(--spacing-xs); 31 | } 32 | 33 | .commentAvatar img { 34 | width: var(--spacing-xxl); 35 | height: var(--spacing-xxl); 36 | border-radius: 50%; 37 | image-rendering: optimizeQuality; 38 | background-color: var(--color-overlay2); 39 | } 40 | 41 | .commentAuthor { 42 | font-weight: var(--font-weight-medium); 43 | } 44 | 45 | .commentText { 46 | font-size: var(--font-size-sm); 47 | 48 | a { 49 | color: var(--color-crust); 50 | } 51 | 52 | img { 53 | max-height: 200px; 54 | } 55 | } 56 | 57 | .commentText.clamped { 58 | display: -webkit-box; 59 | -webkit-line-clamp: 3; 60 | -webkit-box-orient: vertical; 61 | overflow: hidden; 62 | } 63 | 64 | .expandButton { 65 | font-size: var(--font-size-sm); 66 | color: var(--color-blue); 67 | margin-top: var(--spacing-xxs); 68 | background: none; 69 | border: none; 70 | padding: 0; 71 | cursor: pointer; 72 | } 73 | 74 | .expandButton:hover { 75 | text-decoration: underline; 76 | } 77 | 78 | .commentImages { 79 | margin-top: var(--spacing-xs); 80 | display: flex; 81 | flex-wrap: wrap; 82 | gap: var(--spacing-xs); 83 | } 84 | 85 | .commentImage { 86 | border-radius: var(--spacing-xxs); 87 | object-fit: cover; 88 | max-width: 100%; 89 | } 90 | -------------------------------------------------------------------------------- /packages/client/src/components/shared/Comment/Comment.tsx: -------------------------------------------------------------------------------- 1 | import { createSignal } from "solid-js"; 2 | import { parseMarkdown } from "../../../utils/parseMarkdown"; 3 | import Badge from "../Badge/Badge"; 4 | import styles from "./Comment.module.css"; 5 | import type { IComment } from "shared/types/task"; 6 | import { settingsStore } from "@client/stores/settings.store"; 7 | 8 | interface CommentProps { 9 | comment: IComment; 10 | selected: () => boolean; 11 | id: string; 12 | } 13 | 14 | const CommentCard = ({ comment, selected, id }: CommentProps) => { 15 | const [expand, setExpand] = createSignal(false); 16 | 17 | return ( 18 |
24 |
25 | {comment.system ? ( 26 | <> 27 |
28 | 31 |
32 | System 33 | 34 | ) : ( 35 | <> 36 |
37 | 43 |
44 | {comment.author.name} 45 | 46 | )} 47 | 48 | {comment.resolved && Resolved} 49 |
50 |
51 |

55 | {comment.body.length > 150 && ( 56 | 62 | )} 63 |

64 |
65 | ); 66 | }; 67 | 68 | export default CommentCard; 69 | -------------------------------------------------------------------------------- /packages/client/src/components/shared/DiscussionCard/DiscussionCard.module.css: -------------------------------------------------------------------------------- 1 | .discussionHeader { 2 | display: flex; 3 | align-items: center; 4 | gap: var(--spacing-xs); 5 | } 6 | 7 | .discussionAvatar i, 8 | .discussionAvatar img { 9 | display: flex; 10 | justify-content: center; 11 | align-items: center; 12 | color: var(--color-crust); 13 | width: var(--spacing-xxl); 14 | height: var(--spacing-xxl); 15 | border-radius: 50%; 16 | image-rendering: optimizeQuality; 17 | background-color: var(--color-overlay2); 18 | } 19 | 20 | .discussionAuthor { 21 | font-weight: var(--font-weight-medium); 22 | } 23 | 24 | .discussionText { 25 | font-size: var(--font-size-sm); 26 | 27 | a { 28 | color: var(--color-crust); 29 | } 30 | 31 | img { 32 | max-height: 200px; 33 | } 34 | } 35 | 36 | .discussionText.clamped { 37 | display: -webkit-box; 38 | -webkit-line-clamp: 3; 39 | line-clamp: 3; 40 | -webkit-box-orient: vertical; 41 | overflow: hidden; 42 | } 43 | 44 | .expandButton { 45 | font-size: var(--font-size-sm); 46 | color: var(--color-blue); 47 | margin-top: var(--spacing-xxs); 48 | background: none; 49 | border: none; 50 | padding: 0; 51 | cursor: pointer; 52 | } 53 | 54 | .expandButton:hover { 55 | text-decoration: underline; 56 | } 57 | 58 | .discussionImages { 59 | margin-top: var(--spacing-xs); 60 | display: flex; 61 | flex-wrap: wrap; 62 | gap: var(--spacing-xs); 63 | } 64 | 65 | .discussionImage { 66 | border-radius: var(--spacing-xxs); 67 | object-fit: cover; 68 | max-width: 100%; 69 | } 70 | 71 | .discussionThread { 72 | display: flex; 73 | flex-direction: column; 74 | gap: var(--spacing-xs); 75 | padding-left: var(--spacing-md); 76 | border-top: 1px solid var(--color-overlay1); 77 | padding-top: var(--spacing-xs); 78 | } 79 | 80 | .discussionThreadNote { 81 | position: relative; 82 | padding: var(--spacing-xxs) var(--spacing-xs); 83 | border-radius: var(--spacing-xs); 84 | background-color: var(--color-surface0); 85 | box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.1); 86 | 87 | &.selected { 88 | border: 2px solid var(--color-blue); 89 | } 90 | } 91 | 92 | .discussionThreadNoteHeader { 93 | display: flex; 94 | align-items: center; 95 | gap: var(--spacing-xs); 96 | 97 | img { 98 | width: var(--spacing-xl); 99 | height: var(--spacing-xl); 100 | } 101 | } 102 | 103 | .resolvedBadge { 104 | position: absolute; 105 | top: 0; 106 | right: 0; 107 | } 108 | 109 | .resolvedCard { 110 | border-left: 4px solid var(--color-green); 111 | } 112 | -------------------------------------------------------------------------------- /packages/client/src/components/shared/Header/Header.tsx: -------------------------------------------------------------------------------- 1 | import { createSignal, type JSXElement, onMount } from "solid-js"; 2 | import logo from "../../../assets/logo.svg"; 3 | import { getProjectAsync } from "../../../services/customProjectService"; 4 | import { setCurrentProject, uiStore } from "../../../stores/uiStore"; 5 | import CommandLine from "../CommandLine/CommandLine"; 6 | import styles from "./Header.module.css"; 7 | import { random } from "lodash"; 8 | import { WaveText } from "../WaveText/WaveText"; 9 | import dayjs from "dayjs"; 10 | 11 | const slogans = ["vim-like", "keyboard-first", "mice-are-for-cats", "h-j-k-l"]; 12 | 13 | const Header = (): JSXElement => { 14 | const [slogan, setSlogan] = createSignal(""); 15 | const isJune = dayjs().month() === 5; 16 | 17 | onMount(async () => { 18 | const randomIndex = random(slogans.length); 19 | setSlogan(slogans.at(randomIndex) || "happy-little-accidents"); 20 | 21 | setCurrentProject(await getProjectAsync()); 22 | }); 23 | 24 | return ( 25 |
29 |
30 |
31 | GitLab-Mochi Logo 32 |

Mochi

33 | 34 |
35 | 36 |
37 | {uiStore.user && ( 38 |
39 | {uiStore.user.name} 40 |
41 | )} 42 |
43 |
44 |
45 | ); 46 | }; 47 | 48 | export default Header; 49 | -------------------------------------------------------------------------------- /packages/client/src/components/shared/Loading/Loading.module.css: -------------------------------------------------------------------------------- 1 | .loading { 2 | display: flex; 3 | position: absolute; 4 | top: 0; 5 | left: 0; 6 | width: 100vw; 7 | height: 100vh; 8 | overflow: hidden; 9 | justify-content: center; 10 | align-items: center; 11 | font-size: var(--font-size-lg); 12 | } 13 | 14 | .dots { 15 | display: flex; 16 | gap: var(--spacing-xs); 17 | } 18 | 19 | .dot { 20 | width: var(--spacing-md); 21 | height: var(--spacing-md); 22 | background-color: var(--color-blue); 23 | border-radius: 50%; 24 | animation: bounce 1s infinite ease-in-out; 25 | } 26 | 27 | .dot:nth-child(2) { 28 | animation-delay: 0.2s; 29 | } 30 | 31 | .dot:nth-child(3) { 32 | animation-delay: 0.4s; 33 | } 34 | 35 | @keyframes bounce { 36 | 0%, 37 | 100% { 38 | transform: translateY(0); 39 | } 40 | 50% { 41 | transform: translateY(-20px); /* Adjust height for bounce */ 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /packages/client/src/components/shared/Loading/Loading.tsx: -------------------------------------------------------------------------------- 1 | import styles from "./Loading.module.css"; 2 | 3 | const Loading = () => { 4 | return ( 5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 | ); 13 | }; 14 | 15 | export default Loading; 16 | -------------------------------------------------------------------------------- /packages/client/src/components/shared/Notification/Notification.module.css: -------------------------------------------------------------------------------- 1 | .notification { 2 | min-width: 300px; 3 | color: var(--color-text); 4 | padding: var(--spacing-md) var(--spacing-lg); 5 | border-radius: var(--spacing-xs); 6 | box-shadow: 0 8px 15px rgba(0, 0, 0, 0.2); 7 | transform: translateY(20px); 8 | opacity: 0; 9 | transition: opacity 0.3s ease-in-out, transform 0.3s ease-in-out; 10 | display: flex; 11 | flex-direction: column; 12 | gap: var(--spacing-xxs); 13 | background-color: var(--color-surface1); /* Solid background color */ 14 | } 15 | 16 | .notification.success { 17 | border-left: 10px solid var(--color-green); 18 | } 19 | 20 | .notification.error { 21 | border-left: 10px solid var(--color-red); 22 | } 23 | 24 | .notification.warning { 25 | border-left: 10px solid var(--color-yellow); 26 | } 27 | 28 | .notification strong { 29 | font-size: var(--font-size-md); 30 | font-weight: var(--font-weight-semibold); 31 | } 32 | 33 | .notification p { 34 | margin: 0; 35 | font-size: var(--font-size-sm); 36 | line-height: 1.4; 37 | } 38 | -------------------------------------------------------------------------------- /packages/client/src/components/shared/Notification/Notification.tsx: -------------------------------------------------------------------------------- 1 | import { createEffect, onCleanup } from "solid-js"; 2 | import type { Notification } from "@client/services/notificationService"; 3 | import styles from "./Notification.module.css"; 4 | 5 | const NotificationComponent = (props: { notification: Notification }) => { 6 | let notificationRef: HTMLElement | null = null; 7 | 8 | createEffect(() => { 9 | setTimeout(() => { 10 | if (notificationRef) { 11 | notificationRef.style.opacity = "1"; 12 | notificationRef.style.transform = "translateY(0)"; 13 | } 14 | }, 0); 15 | 16 | const timeoutId = setTimeout(() => { 17 | if (notificationRef) { 18 | notificationRef.style.opacity = "0"; 19 | notificationRef.style.transform = "translateX(20px)"; 20 | } 21 | }, props.notification.duration - 300); 22 | 23 | onCleanup(() => clearTimeout(timeoutId)); 24 | }); 25 | 26 | return ( 27 |
(notificationRef = el)} 29 | class={`${styles.notification} ${styles[props.notification.type]}`} 30 | style={{ 31 | opacity: 0, 32 | transform: "translateY(20px)", 33 | transition: "opacity 0.3s ease-in-out, transform 0.3s ease-in-out", 34 | }} 35 | > 36 | {props.notification.title} 37 |

{props.notification.description}

38 |
39 | ); 40 | }; 41 | 42 | export default NotificationComponent; 43 | -------------------------------------------------------------------------------- /packages/client/src/components/shared/NotificationManager.tsx: -------------------------------------------------------------------------------- 1 | import { For } from "solid-js"; 2 | import { useNotifications } from "../../services/notificationService"; 3 | import NotificationComponent from "./Notification/Notification"; 4 | 5 | const NotificationManager = () => { 6 | const notifications = useNotifications(); 7 | 8 | return ( 9 |
10 | 11 | {(notification) => ( 12 | 13 | )} 14 | 15 |
16 | ); 17 | }; 18 | 19 | export default NotificationManager; 20 | -------------------------------------------------------------------------------- /packages/client/src/components/shared/Pagination/Pagination.module.css: -------------------------------------------------------------------------------- 1 | .pagination { 2 | display: flex; 3 | justify-content: center; 4 | margin-top: 20px; 5 | button { 6 | padding: 5px 10px; 7 | margin: 0 5px; 8 | border: 1px solid #ccc; 9 | border-radius: 5px; 10 | cursor: pointer; 11 | &:hover { 12 | background-color: #f0f0f0; 13 | } 14 | &.active { 15 | background-color: #007bff; 16 | color: #fff; 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /packages/client/src/components/shared/Pagination/Pagination.tsx: -------------------------------------------------------------------------------- 1 | import type { IPagination } from "@mochi-shared/types/pagination"; 2 | import styles from "./Pagination.module.css"; 3 | import { createEffect, createSignal } from "solid-js"; 4 | 5 | interface PaginationProps { 6 | pagination: () => IPagination; 7 | } 8 | 9 | const Pagination = (props: PaginationProps) => { 10 | const [pages, setPages] = createSignal([]); 11 | 12 | createEffect(() => { 13 | const pages = Array.from( 14 | { length: props.pagination().totalPages }, 15 | (_, i) => i + 1 16 | ); 17 | 18 | setPages(pages); 19 | }, [props.pagination()]); 20 | 21 | return ( 22 |
23 | {pages().map((page) => ( 24 | 29 | ))} 30 |
31 | ); 32 | }; 33 | 34 | export default Pagination; 35 | -------------------------------------------------------------------------------- /packages/client/src/components/shared/StatusBar/StatusBar.module.css: -------------------------------------------------------------------------------- 1 | .statusBar { 2 | position: fixed; 3 | bottom: 0; 4 | left: 0; 5 | width: calc(100%); 6 | background-color: var(--color-mantle); 7 | color: var(--color-text); 8 | display: flex; 9 | align-items: center; 10 | justify-content: space-between; 11 | font-size: var(--font-size-sm); 12 | z-index: 1000; 13 | box-shadow: 0 -2px 4px rgba(0, 0, 0, 0.1); 14 | } 15 | .calendarMode { 16 | white-space: nowrap; 17 | overflow: hidden; 18 | text-overflow: ellipsis; 19 | color: var(--color-crust); 20 | padding: var(--spacing-xxs) var(--spacing-xs); 21 | border-radius: var(--spacing-xxs); 22 | font-size: var(--font-size-xs); 23 | display: inline-block; 24 | margin-top: var(--spacing-xxs); 25 | margin-right: var(--spacing-xxs); 26 | margin-left: var(--spacing-xxs); 27 | 28 | &.time { 29 | background-color: var(--color-green); 30 | } 31 | 32 | &.appointment { 33 | background-color: var(--color-blue); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /packages/client/src/components/shared/Tooltip/Tooltip.module.css: -------------------------------------------------------------------------------- 1 | .tooltip { 2 | position: relative; 3 | display: inline-block; 4 | cursor: pointer; 5 | } 6 | 7 | .tooltipText { 8 | visibility: hidden; 9 | opacity: 0; 10 | width: max-content; 11 | max-width: 200px; 12 | background-color: var(--color-lavender); /* Brighter background */ 13 | color: var(--color-crust); /* Bright text color */ 14 | text-align: center; 15 | padding: var(--spacing-xs); 16 | border-radius: var(--spacing-md); /* Increased border radius */ 17 | box-shadow: 0 4px 10px rgba(0, 0, 0, 0.2); 18 | font-size: var(--font-size-sm); 19 | position: absolute; 20 | bottom: 125%; /* Position tooltip above the element */ 21 | left: 50%; 22 | transform: translateX(-50%); 23 | transition: opacity 0.3s ease, visibility 0.3s ease; 24 | z-index: 10; 25 | } 26 | 27 | .tooltipText::after { 28 | content: ""; 29 | position: absolute; 30 | top: 100%; /* Arrow at the bottom of the tooltip */ 31 | left: 50%; 32 | margin-left: -5px; 33 | border-width: 5px; 34 | border-style: solid; 35 | border-color: var(--color-lavender) transparent transparent transparent; /* Match background color */ 36 | } 37 | 38 | .tooltip:hover .tooltipText { 39 | visibility: visible; 40 | opacity: 1; 41 | } 42 | -------------------------------------------------------------------------------- /packages/client/src/components/shared/Tooltip/Tooltip.tsx: -------------------------------------------------------------------------------- 1 | import type { JSXElement } from "solid-js"; 2 | import styles from "./Tooltip.module.css"; 3 | 4 | type TooltipProps = { 5 | children: JSXElement; 6 | text: string | JSXElement; 7 | }; 8 | 9 | const Tooltip = ({ children, text }: TooltipProps) => { 10 | return ( 11 | 12 | {children} 13 | {text} 14 | 15 | ); 16 | }; 17 | 18 | export default Tooltip; 19 | -------------------------------------------------------------------------------- /packages/client/src/components/shared/WaveText/WaveText.module.css: -------------------------------------------------------------------------------- 1 | .waveText { 2 | display: flex; 3 | overflow: hidden; 4 | 5 | --color-start: var(--color-blue); 6 | --color-end: var(--color-sapphire); 7 | 8 | &.pride { 9 | --color-start: var(--color-crust); 10 | --color-end: var(--color-yellow); 11 | } 12 | } 13 | 14 | .waveText span { 15 | display: inline-block; 16 | animation: 17 | wave 2s ease-in-out infinite, 18 | waveColor 2s ease-in-out infinite; 19 | } 20 | 21 | @keyframes wave { 22 | 0%, 23 | 100% { 24 | transform: translateY(0); 25 | } 26 | 50% { 27 | transform: translateY(-0.2em); 28 | } 29 | } 30 | 31 | @keyframes waveColor { 32 | 0%, 33 | 100% { 34 | color: var(--color-start); 35 | } 36 | 50% { 37 | color: var(--color-end); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /packages/client/src/components/shared/WaveText/WaveText.tsx: -------------------------------------------------------------------------------- 1 | import { For } from "solid-js"; 2 | import styles from "./WaveText.module.css"; 3 | import dayjs from "dayjs"; 4 | 5 | interface WaveTextProps { 6 | text: () => string; 7 | class?: string; 8 | } 9 | 10 | export function WaveText(props: WaveTextProps) { 11 | const isJune = dayjs().month() === 5; 12 | 13 | return ( 14 |
17 | 18 | {(char, index) => ( 19 | 24 | {char === " " ? "\u00A0" : char} 25 | 26 | )} 27 | 28 |
29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /packages/client/src/constants.ts: -------------------------------------------------------------------------------- 1 | export const STATES = [ 2 | { display_name: "Backlog", id: "opened" }, 3 | { display_name: "In Progress", id: "inprogress" }, 4 | { display_name: "Done", id: "done" }, 5 | { display_name: "Review", id: "review" }, 6 | { display_name: "Closed", id: "closed" }, 7 | ]; 8 | 9 | export const BACKEND_URL = "http://localhost:5000"; 10 | export const SOCKET_URL = "ws://localhost:5000"; 11 | -------------------------------------------------------------------------------- /packages/client/src/index.tsx: -------------------------------------------------------------------------------- 1 | import { render } from "solid-js/web"; 2 | 3 | import "./styles/variables.css"; 4 | import "./styles/base.css"; 5 | import "./styles/scrollbar.css"; 6 | import "./styles/notification.css"; 7 | 8 | import App from "./App"; 9 | import axios from "axios"; 10 | import WebSocketHandler from "./sockets/WebSocketHandler"; 11 | import { Route, Router } from "@solidjs/router"; 12 | import KanbanBoard from "./modules/KanbanBoard/KanbanBoard"; 13 | import TimeTrack from "./modules/TimeTrack/TimeTrack"; 14 | import Home from "./Home"; 15 | import ShortcutRegistry from "./shortcutMaps/shortcutRegistry"; 16 | import { BACKEND_URL, SOCKET_URL } from "./constants"; 17 | import Todos from "@client/modules/Todos/Todos"; 18 | import { settingsService } from "./services/settingsService"; 19 | import SetupWizard from "./components/SetupWizard/setupWizard"; 20 | 21 | const root = document.getElementById("root"); 22 | 23 | axios.defaults.baseURL = `${BACKEND_URL}/api`; 24 | WebSocketHandler.getInstance().init(SOCKET_URL); 25 | 26 | if (import.meta.env.DEV && !(root instanceof HTMLElement)) { 27 | throw new Error( 28 | "Root element not found. Did you forget to add it to your index.html? Or maybe the id attribute got misspelled?", 29 | ); 30 | } 31 | 32 | await ShortcutRegistry.getInstance().initializeAsync(); 33 | 34 | const setupStatus = await settingsService.getSetupStatus(); 35 | if (!setupStatus.isComplete && window.location.pathname !== "/setup") { 36 | window.location.href = "/setup"; 37 | } 38 | 39 | render( 40 | () => ( 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | ), 49 | root!, 50 | ); 51 | -------------------------------------------------------------------------------- /packages/client/src/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/client/src/modules/KanbanBoard/KanbanBoard.module.css: -------------------------------------------------------------------------------- 1 | .kanban { 2 | padding-inline: var(--spacing-sm); 3 | display: flex; 4 | gap: var(--spacing-md); 5 | justify-content: space-between; 6 | align-items: flex-start; 7 | padding-bottom: 30px; 8 | padding-top: var(--spacing-sm); 9 | } 10 | 11 | .noProject { 12 | display: flex; 13 | flex-direction: column; 14 | align-items: center; 15 | gap: var(--spacing-md); 16 | width: 100%; 17 | padding: var(--spacing-md); 18 | 19 | font-size: var(--font-size-lg); 20 | } 21 | -------------------------------------------------------------------------------- /packages/client/src/modules/TimeTrack/TimeTrack.module.css: -------------------------------------------------------------------------------- 1 | .timetrack { 2 | padding-inline: var(--spacing-sm); 3 | display: flex; 4 | gap: var(--spacing-md); 5 | justify-content: space-between; 6 | align-items: flex-start; 7 | height: calc(100% - 30px - var(--spacing-sm)); 8 | } 9 | -------------------------------------------------------------------------------- /packages/client/src/modules/TimeTrack/TimeTrack.tsx: -------------------------------------------------------------------------------- 1 | import EditAppointmentModal from "../../components/modals/EditAppointmentModal/EditAppointmentModal"; 2 | import WeekCalendar from "../../components/TimeTrack/WeekCalendar/WeekCalendar"; 3 | import { updateTimeTrackEntryAsync } from "../../services/timeTrackService"; 4 | import { unfocusInputs } from "../../services/uiService"; 5 | import { 6 | handleCloseModal, 7 | modalStore, 8 | ModalType, 9 | } from "../../stores/modalStore"; 10 | import styles from "./TimeTrack.module.css"; 11 | 12 | const TimeTrack = () => { 13 | const handleEditAppointment = async () => { 14 | const timeTrackEntry = modalStore.selectedAppointment; 15 | if (!timeTrackEntry) return; 16 | 17 | await updateTimeTrackEntryAsync(timeTrackEntry); 18 | unfocusInputs(); 19 | }; 20 | 21 | return ( 22 | <> 23 |
24 | 25 |
26 | 27 | {modalStore.activeModals.includes(ModalType.EditAppointment) && ( 28 | 32 | )} 33 | 34 | ); 35 | }; 36 | 37 | export default TimeTrack; 38 | -------------------------------------------------------------------------------- /packages/client/src/routes.ts: -------------------------------------------------------------------------------- 1 | import { lazy } from "solid-js"; 2 | 3 | export const routes = [ 4 | { 5 | path: "/", 6 | component: lazy(() => import("./modules/KanbanBoard/KanbanBoard")), 7 | }, 8 | { 9 | path: "/kanban", 10 | component: lazy(() => import("./modules/KanbanBoard/KanbanBoard")), 11 | }, 12 | { 13 | path: "/timetrack", 14 | component: lazy(() => import("./modules/TimeTrack/TimeTrack")), 15 | }, 16 | ]; 17 | -------------------------------------------------------------------------------- /packages/client/src/services/customProjectService.ts: -------------------------------------------------------------------------------- 1 | import { addNotification } from "@client/services/notificationService"; 2 | import type { Project } from "@client/stores/uiStore"; 3 | import axios, { AxiosError } from "axios"; 4 | 5 | export const createProjectAsync = async (name: string) => { 6 | try { 7 | const response = await axios.post("/projects", { name }); 8 | if (response.status === 200) { 9 | addNotification({ 10 | title: "Project created", 11 | description: `Project ${name} has been created`, 12 | type: "success", 13 | }); 14 | } 15 | 16 | return response.data; 17 | } catch (error) { 18 | if (error instanceof AxiosError) 19 | addNotification({ 20 | title: "Error", 21 | description: error.message, 22 | type: "error", 23 | }); 24 | } 25 | }; 26 | 27 | export const deleteProjectAsync = async (projectId: string) => { 28 | try { 29 | const response = await axios.delete(`/projects/${projectId}`); 30 | if (response.status === 200) { 31 | addNotification({ 32 | title: "Project deleted", 33 | description: `Project has been deleted`, 34 | type: "success", 35 | }); 36 | } 37 | } catch (error) { 38 | if (error instanceof AxiosError) 39 | addNotification({ 40 | title: "Error", 41 | description: error.message, 42 | type: "error", 43 | }); 44 | } 45 | }; 46 | 47 | export const loadCustomProjectsAsync = async () => { 48 | try { 49 | const response = await axios.get("/projects"); 50 | return response.data.map((project: Project) => ({ 51 | ...project, 52 | custom: true, 53 | id: project._id, 54 | })); 55 | } catch (error) { 56 | if (error instanceof AxiosError) 57 | addNotification({ 58 | title: "Error", 59 | description: error.message, 60 | type: "error", 61 | }); 62 | return []; 63 | } 64 | }; 65 | 66 | export const setProjectAsync = async (projectId: string) => { 67 | try { 68 | const response = await axios.patch("/projects", { projectId }); 69 | return response; 70 | } catch (error) { 71 | return; 72 | } 73 | }; 74 | 75 | export const getProjectAsync = async () => { 76 | try { 77 | const response = await axios.get("/projects/current"); 78 | return { 79 | ...response.data, 80 | name: response.data?.name_with_namespace || response.data?.name, 81 | }; 82 | } catch (error) { 83 | return null; 84 | } 85 | }; 86 | -------------------------------------------------------------------------------- /packages/client/src/services/keyboardShortcutHandler.ts: -------------------------------------------------------------------------------- 1 | import type { Location, Navigator } from "@solidjs/router"; 2 | import ShortcutRegistry from "../shortcutMaps/shortcutRegistry"; 3 | import { resetCommandline } from "../stores/commandStore"; 4 | import { modalStore } from "../stores/modalStore"; 5 | import { 6 | CalendarMode, 7 | InputMode, 8 | setCalendarMode, 9 | uiStore, 10 | } from "../stores/uiStore"; 11 | import { closeTopModal, getTopModal, openHelpModal } from "./modalService"; 12 | import { unfocusInputs } from "./uiService"; 13 | 14 | export const handleKeyDown = async ( 15 | event: KeyboardEvent, 16 | navigator: Navigator, 17 | location: Location, 18 | ) => { 19 | const key = location.pathname.split("/")[1]; 20 | 21 | if ( 22 | (event.key === "Escape" || event.key === "q") && 23 | !modalStore.activeModals.length 24 | ) { 25 | unfocusInputs(); 26 | setCalendarMode(CalendarMode.Time); 27 | 28 | if ( 29 | uiStore.inputMode === InputMode.Commandline || 30 | (uiStore.inputMode === InputMode.Search && 31 | uiStore.commandInputValue === "") 32 | ) { 33 | resetCommandline(); 34 | } 35 | return; 36 | } 37 | 38 | if ( 39 | document.activeElement?.tagName === "INPUT" || 40 | document.activeElement?.tagName === "TEXTAREA" || 41 | document.activeElement?.tagName === "SELECT" 42 | ) { 43 | return; 44 | } 45 | 46 | if (event.shiftKey) { 47 | switch (event.code) { 48 | case "Digit1": 49 | navigator("/kanban"); 50 | return; 51 | case "Digit2": 52 | navigator("/timetrack"); 53 | return; 54 | case "Digit3": 55 | navigator("/todo"); 56 | return; 57 | case "Digit0": 58 | navigator("/"); 59 | return; 60 | } 61 | } 62 | 63 | if (event.shiftKey && event.key === "?") { 64 | openHelpModal(); 65 | return; 66 | } 67 | 68 | if (!modalStore.activeModals.length) { 69 | ShortcutRegistry.getInstance().executeShortcut(key, event); 70 | } else { 71 | const topModal = getTopModal(); 72 | ShortcutRegistry.getInstance().executeShortcut(topModal, event); 73 | } 74 | }; 75 | -------------------------------------------------------------------------------- /packages/client/src/services/modalService.ts: -------------------------------------------------------------------------------- 1 | import { STATES } from "../constants"; 2 | import { keyboardNavigationStore } from "../stores/keyboardNavigationStore"; 3 | import { 4 | closeModal, 5 | handleCloseModal, 6 | modalStore, 7 | ModalType, 8 | openModal, 9 | setSelectedAppointmentForModal, 10 | setSelectedTaskForModal, 11 | } from "../stores/modalStore"; 12 | import { getColumnTasks } from "../stores/taskStore"; 13 | import { timeTrackStore } from "../stores/timeTrackStore"; 14 | import type { ITask } from "shared/types/task"; 15 | 16 | export const openHelpModal = () => { 17 | openModal(ModalType.Help); 18 | }; 19 | 20 | export const openDeleteModal = () => { 21 | openModal(ModalType.DeleteTask); 22 | setSelectedTaskForModal( 23 | getColumnTasks()[keyboardNavigationStore.selectedTaskIndex], 24 | ); 25 | }; 26 | 27 | export const openCreateModal = () => { 28 | openModal(ModalType.CreateTask); 29 | setSelectedTaskForModal({ 30 | title: "", 31 | description: "", 32 | status: STATES.at(0)!.id, 33 | }); 34 | }; 35 | 36 | export const openEditTaskModal = () => { 37 | openModal(ModalType.CreateTask); 38 | setSelectedTaskForModal( 39 | getColumnTasks()[keyboardNavigationStore.selectedTaskIndex], 40 | ); 41 | }; 42 | 43 | export const openEditAppointmentModal = () => { 44 | openModal(ModalType.EditAppointment); 45 | setSelectedAppointmentForModal( 46 | timeTrackStore.entries[keyboardNavigationStore.selectedAppointmentIndex], 47 | ); 48 | }; 49 | 50 | export const openDetailsModal = () => { 51 | setSelectedTaskForModal( 52 | getColumnTasks()[keyboardNavigationStore.selectedTaskIndex], 53 | ); 54 | 55 | openModal(ModalType.TaskDetails); 56 | }; 57 | 58 | export const openPipelineModal = () => { 59 | setSelectedTaskForModal( 60 | getColumnTasks()[keyboardNavigationStore.selectedTaskIndex], 61 | ); 62 | 63 | openModal(ModalType.Pipeline); 64 | }; 65 | 66 | export const openBranchNameModal = (task: ITask) => { 67 | setSelectedTaskForModal(task); 68 | openModal(ModalType.BranchName); 69 | }; 70 | 71 | export const getTopModal = () => { 72 | return modalStore.activeModals?.at(-1) ?? ModalType.None; 73 | }; 74 | 75 | export const closeTopModal = () => { 76 | handleCloseModal(); 77 | }; 78 | -------------------------------------------------------------------------------- /packages/client/src/services/notificationService.ts: -------------------------------------------------------------------------------- 1 | import { createSignal } from "solid-js"; 2 | 3 | export interface Notification { 4 | id: number; 5 | title: string; 6 | description: string; 7 | type: "success" | "error" | "warning"; 8 | duration: number; 9 | } 10 | 11 | const [notifications, setNotifications] = createSignal([]); 12 | 13 | export const addNotification = ( 14 | notification: Omit, "id"> 15 | ) => { 16 | const id = new Date().getTime(); 17 | const newNotification = { 18 | ...notification, 19 | id, 20 | duration: notification.duration || 3000, 21 | }; 22 | setNotifications([...notifications(), newNotification as Notification]); 23 | 24 | setTimeout(() => { 25 | setNotifications((prev) => prev.filter((n) => n.id !== id)); 26 | }, notification.duration || 3000); 27 | }; 28 | 29 | export const clearNotifications = () => setNotifications([]); 30 | 31 | export const useNotifications = () => notifications; 32 | -------------------------------------------------------------------------------- /packages/client/src/services/settingsService.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import { addNotification } from "./notificationService"; 3 | 4 | export interface SetupStatus { 5 | isComplete: boolean; 6 | currentStep?: string; 7 | } 8 | 9 | export interface GitlabValidationResult { 10 | isValid: boolean; 11 | user?: any; 12 | error?: string; 13 | } 14 | 15 | class SettingsService { 16 | async getSetupStatus(): Promise { 17 | try { 18 | const response = await axios.get("/settings/setup-status"); 19 | return response.data; 20 | } catch (error) { 21 | return { isComplete: false }; 22 | } 23 | } 24 | 25 | async validateGitLabConnection( 26 | url: string, 27 | token: string, 28 | ): Promise { 29 | try { 30 | const response = await axios.post("/settings/validate-gitlab", { 31 | url, 32 | token, 33 | }); 34 | return response.data; 35 | } catch (error) { 36 | return { 37 | isValid: false, 38 | error: "Failed to validate connection", 39 | }; 40 | } 41 | } 42 | 43 | async saveGitLabConfig(url: string, token: string): Promise { 44 | try { 45 | await axios.post("/settings/gitlab-config", { url, token }); 46 | } catch (error) { 47 | throw new Error("Failed to save GitLab configuration"); 48 | } 49 | } 50 | 51 | async completeSetup(): Promise { 52 | try { 53 | await axios.post("/settings/complete-setup"); 54 | } catch (error) { 55 | throw new Error("Failed to complete setup"); 56 | } 57 | } 58 | 59 | async updateSetting(key: string, value: any): Promise { 60 | try { 61 | await axios.put(`/settings/${key}`, { value }); 62 | addNotification({ 63 | title: "Success", 64 | description: "Setting updated successfully", 65 | type: "success", 66 | }); 67 | } catch (error) { 68 | addNotification({ 69 | title: "Error", 70 | description: "Failed to update setting", 71 | type: "error", 72 | }); 73 | throw error; 74 | } 75 | } 76 | } 77 | 78 | export const settingsService = new SettingsService(); 79 | -------------------------------------------------------------------------------- /packages/client/src/services/taskNavigationService.ts: -------------------------------------------------------------------------------- 1 | import { STATES } from "../constants"; 2 | import { 3 | keyboardNavigationStore, 4 | setSelectedColumnIndex, 5 | setSelectedTaskIndex, 6 | setSelectedTaskIndexes, 7 | } from "../stores/keyboardNavigationStore"; 8 | import { filteredTasks, getColumnTasks } from "../stores/taskStore"; 9 | 10 | export enum Direction { 11 | Up, 12 | Down, 13 | Left, 14 | Right, 15 | } 16 | 17 | export const moveSelection = (direction: Direction) => { 18 | const columnTasks = getColumnTasks(); 19 | switch (direction) { 20 | case Direction.Up: 21 | setSelectedTaskIndex( 22 | (prev) => (prev - 1 + columnTasks.length) % columnTasks.length, 23 | ); 24 | setSelectedTaskIndexes([keyboardNavigationStore.selectedTaskIndex]); 25 | break; 26 | case Direction.Down: 27 | setSelectedTaskIndex((prev) => (prev + 1) % columnTasks.length); 28 | setSelectedTaskIndexes([keyboardNavigationStore.selectedTaskIndex]); 29 | 30 | break; 31 | case Direction.Left: 32 | do { 33 | setSelectedColumnIndex( 34 | (prev) => (prev - 1 + STATES.length) % STATES.length, 35 | ); 36 | } while (filteredTasks().length > 0 && getColumnTasks().length === 0); 37 | setSelectedTaskIndexes([0]); 38 | setSelectedTaskIndex(0); 39 | break; 40 | case Direction.Right: 41 | do { 42 | setSelectedColumnIndex((prev) => (prev + 1) % STATES.length); 43 | } while (filteredTasks().length > 0 && getColumnTasks().length === 0); 44 | setSelectedTaskIndex(0); 45 | setSelectedTaskIndexes([0]); 46 | break; 47 | } 48 | }; 49 | 50 | export const moveSelectionToTop = () => { 51 | setSelectedTaskIndex(0); 52 | setSelectedTaskIndexes([0]); 53 | }; 54 | 55 | export const moveSelectionToBottom = () => { 56 | setSelectedTaskIndex(getColumnTasks().length - 1); 57 | setSelectedTaskIndexes([getColumnTasks().length - 1]); 58 | }; 59 | 60 | export const addToSelection = (direction: Direction) => { 61 | const columnTasks = getColumnTasks(); 62 | switch (direction) { 63 | case Direction.Up: 64 | setSelectedTaskIndex((prev) => Math.max(prev - 1, 0)); 65 | 66 | setSelectedTaskIndexes((prev) => [ 67 | ...prev.filter( 68 | (index) => 69 | index <= prev.at(0)! || 70 | index < keyboardNavigationStore.selectedTaskIndex, 71 | ), 72 | keyboardNavigationStore.selectedTaskIndex, 73 | ]); 74 | break; 75 | case Direction.Down: 76 | setSelectedTaskIndex((prev) => 77 | Math.min(prev + 1, columnTasks.length - 1), 78 | ); 79 | 80 | setSelectedTaskIndexes((prev) => [ 81 | ...prev.filter( 82 | (index) => 83 | index >= prev.at(0)! || 84 | index > keyboardNavigationStore.selectedTaskIndex, 85 | ), 86 | keyboardNavigationStore.selectedTaskIndex, 87 | ]); 88 | break; 89 | } 90 | }; 91 | -------------------------------------------------------------------------------- /packages/client/src/services/timeTrackService.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import { type TimeTrackEntry } from "../stores/timeTrackStore"; 3 | import { addNotification } from "./notificationService"; 4 | 5 | export const updateTimeTrackEntryAsync = async ( 6 | timeTrackEntry: TimeTrackEntry 7 | ) => { 8 | try { 9 | const timeTrackEntryId = timeTrackEntry._id; 10 | const res = await axios.put( 11 | `/timetrack/${timeTrackEntryId}`, 12 | timeTrackEntry 13 | ); 14 | 15 | addNotification({ 16 | title: "Success", 17 | description: "Appointment updated successfully", 18 | type: "success", 19 | }); 20 | 21 | return res; 22 | } catch (error) { 23 | addNotification({ 24 | title: "Error", 25 | description: "Failed to update appointment", 26 | type: "error", 27 | }); 28 | } 29 | }; 30 | -------------------------------------------------------------------------------- /packages/client/src/services/uiService.ts: -------------------------------------------------------------------------------- 1 | import { handleCloseModal } from "../stores/modalStore"; 2 | import { InputMode, setInputMode, uiStore } from "../stores/uiStore"; 3 | 4 | export const focusInput = (inputMode: InputMode) => { 5 | if (uiStore.commandInputRef === null) { 6 | return; 7 | } 8 | setInputMode(inputMode); 9 | 10 | setTimeout(() => { 11 | uiStore.commandInputRef!.select(); 12 | uiStore.commandInputRef!.focus(); 13 | }, 0); 14 | }; 15 | 16 | export const unfocusInputs = () => { 17 | setTimeout(() => { 18 | const activeElement = document.activeElement as HTMLElement; 19 | if (activeElement) { 20 | activeElement.blur(); 21 | } 22 | }, 0); 23 | }; 24 | -------------------------------------------------------------------------------- /packages/client/src/services/userService.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import { setUser } from "../stores/uiStore"; 3 | 4 | export const getUserAsync = async () => { 5 | try { 6 | const userResponse = await axios.get(`/git/user`); 7 | setUser(userResponse.data); 8 | } catch (error) { 9 | return null; 10 | } 11 | }; 12 | -------------------------------------------------------------------------------- /packages/client/src/shortcutMaps/baseShortcutMap.ts: -------------------------------------------------------------------------------- 1 | import { closeTopModal, openHelpModal } from "../services/modalService"; 2 | import { focusInput } from "../services/uiService"; 3 | import { InputMode } from "../stores/uiStore"; 4 | import ShortcutRegistry from "./shortcutRegistry"; 5 | import { type KeyboardShortcutMap, KeyboardShortcutCategory } from "./types"; 6 | 7 | const shortcuts: KeyboardShortcutMap = { 8 | key: "base", 9 | shortcuts: [ 10 | { 11 | key: ["q", "Escape"], 12 | action: () => closeTopModal(), 13 | category: KeyboardShortcutCategory.Commands, 14 | description: "Close modal", 15 | }, 16 | { 17 | key: ":", 18 | action: () => focusInput(InputMode.Commandline), 19 | category: KeyboardShortcutCategory.Commands, 20 | description: "Open commandline", 21 | }, 22 | { 23 | key: "/", 24 | action: () => focusInput(InputMode.Search), 25 | category: KeyboardShortcutCategory.Commands, 26 | description: "Open search", 27 | }, 28 | { 29 | shiftKey: true, 30 | key: ":", 31 | action: () => focusInput(InputMode.Commandline), 32 | category: KeyboardShortcutCategory.Commands, 33 | description: "Open commandline", 34 | }, 35 | { 36 | shiftKey: true, 37 | key: "?", 38 | action: () => openHelpModal(), 39 | category: KeyboardShortcutCategory.Commands, 40 | description: "Open help", 41 | }, 42 | 43 | { 44 | ctrlKey: true, 45 | key: "p", 46 | action: () => focusInput(InputMode.Commandline), 47 | category: KeyboardShortcutCategory.Commands, 48 | description: "Focus commandline", 49 | }, 50 | { 51 | ctrlKey: true, 52 | key: "f", 53 | action: () => focusInput(InputMode.Search), 54 | category: KeyboardShortcutCategory.Commands, 55 | description: "Focus search", 56 | }, 57 | ], 58 | }; 59 | 60 | ShortcutRegistry.getInstance().registerShortcut(shortcuts); 61 | -------------------------------------------------------------------------------- /packages/client/src/shortcutMaps/shortcutRegistry.ts: -------------------------------------------------------------------------------- 1 | import type { KeyboardShortcutMap, Shortcut } from "./types"; 2 | 3 | class ShortcutRegistry { 4 | private static instance: ShortcutRegistry; 5 | 6 | private static shortcutMaps: KeyboardShortcutMap[] = []; 7 | 8 | private constructor() {} 9 | 10 | public static getInstance(): ShortcutRegistry { 11 | if (!ShortcutRegistry.instance) { 12 | ShortcutRegistry.instance = new ShortcutRegistry(); 13 | } 14 | 15 | return ShortcutRegistry.instance; 16 | } 17 | 18 | public registerShortcut(shortcutMap: KeyboardShortcutMap) { 19 | if (!ShortcutRegistry.shortcutMaps.some((sc) => sc.key === shortcutMap.key)) 20 | ShortcutRegistry.shortcutMaps.push(shortcutMap); 21 | } 22 | 23 | public async initializeAsync() { 24 | const shortcutModules = import.meta.glob([ 25 | "./*ShortcutMap.ts", 26 | "./*shortcut-map.ts", 27 | ]); 28 | 29 | for (const path in shortcutModules) { 30 | await shortcutModules[path](); // This will load each command file 31 | } 32 | } 33 | 34 | public getShortcutsByKey(key: string) { 35 | return ShortcutRegistry.shortcutMaps.find((sc) => sc.key === key); 36 | } 37 | 38 | public resetShortcutRegistry() { 39 | ShortcutRegistry.shortcutMaps.length = 0; 40 | } 41 | 42 | public executeShortcut(mapKey: string, event: KeyboardEvent) { 43 | const { key, shiftKey, ctrlKey, altKey } = event; 44 | 45 | const baseMap = this.getShortcutsByKey("base"); 46 | const map = this.getShortcutsByKey(mapKey); 47 | 48 | const shortCuts = (map?.shortcuts || []).concat(baseMap?.shortcuts || []); 49 | 50 | if (shortCuts === undefined || shortCuts?.length === 0) return; 51 | 52 | const shortcut = shortCuts.find((sc: Shortcut) => { 53 | if (Array.isArray(sc.key)) { 54 | return ( 55 | sc.key.includes(key) && 56 | ((sc.shiftKey === undefined && !shiftKey) || 57 | sc.shiftKey === shiftKey) && 58 | ((sc.ctrlKey === undefined && !ctrlKey) || sc.ctrlKey === ctrlKey) && 59 | ((sc.altKey === undefined && !altKey) || sc.altKey === altKey) 60 | ); 61 | } else { 62 | return ( 63 | sc.key === key && 64 | ((sc.shiftKey === undefined && !shiftKey) || 65 | sc.shiftKey === shiftKey) && 66 | ((sc.ctrlKey === undefined && !ctrlKey) || sc.ctrlKey === ctrlKey) && 67 | ((sc.altKey === undefined && !altKey) || sc.altKey === altKey) 68 | ); 69 | } 70 | }); 71 | 72 | if (shortcut) { 73 | event.preventDefault(); 74 | event.stopPropagation(); 75 | shortcut.action(); 76 | } 77 | } 78 | } 79 | 80 | export default ShortcutRegistry; 81 | -------------------------------------------------------------------------------- /packages/client/src/shortcutMaps/taskDetails.shortcut-map.ts: -------------------------------------------------------------------------------- 1 | import { modalStore, ModalType } from "@client/stores/modalStore"; 2 | import ShortcutRegistry from "./shortcutRegistry"; 3 | import { type KeyboardShortcutMap, KeyboardShortcutCategory } from "./types"; 4 | import { TaskDetailsModalService } from "@client/components/modals/TaskDetailsModal/task-details-modal.store"; 5 | import { Direction } from "@client/services/taskNavigationService"; 6 | import { closeTopModal } from "@client/services/modalService"; 7 | 8 | const shortcuts: KeyboardShortcutMap = { 9 | key: ModalType.TaskDetails, 10 | shortcuts: [ 11 | { 12 | key: ["q", "Escape"], 13 | action: () => closeTopModal(), 14 | category: KeyboardShortcutCategory.TaskDetails, 15 | description: "Close modal", 16 | }, 17 | { 18 | key: ["j", "ArrowDown"], 19 | action: () => TaskDetailsModalService.moveSelection(Direction.Down), 20 | category: KeyboardShortcutCategory.TaskDetails, 21 | description: "Move selection down", 22 | }, 23 | { 24 | key: ["k", "ArrowUp"], 25 | action: () => TaskDetailsModalService.moveSelection(Direction.Up), 26 | category: KeyboardShortcutCategory.TaskDetails, 27 | description: "Move selection up", 28 | }, 29 | { 30 | key: "r", 31 | action: () => TaskDetailsModalService.reply(), 32 | category: KeyboardShortcutCategory.TaskDetails, 33 | description: "Reply to discussion", 34 | }, 35 | { 36 | key: "R", 37 | shiftKey: true, 38 | action: () => TaskDetailsModalService.resolveThread(), 39 | category: KeyboardShortcutCategory.TaskDetails, 40 | description: "Mark discussion as resolved", 41 | }, 42 | { 43 | key: "s", 44 | action: () => TaskDetailsModalService.toggleSystemDiscussions(), 45 | category: KeyboardShortcutCategory.TaskDetails, 46 | description: "Toggles system notes", 47 | }, 48 | { 49 | key: "t", 50 | action: () => TaskDetailsModalService.toggleResolvedDiscussions(), 51 | category: KeyboardShortcutCategory.TaskDetails, 52 | description: "Toggles resolved discussions", 53 | }, 54 | { 55 | key: "O", 56 | shiftKey: true, 57 | action: () => window.open(modalStore.selectedTask!.web_url, "_blank"), 58 | category: KeyboardShortcutCategory.TaskDetails, 59 | description: "Open task in new tab", 60 | }, 61 | ], 62 | }; 63 | 64 | ShortcutRegistry.getInstance().registerShortcut(shortcuts); 65 | -------------------------------------------------------------------------------- /packages/client/src/shortcutMaps/types.ts: -------------------------------------------------------------------------------- 1 | export enum KeyboardShortcutCategory { 2 | Navigation = "Navigation", 3 | TaskManagement = "Task Management", 4 | GitlabAction = "Gitlab Action", 5 | CalendarManagement = "Calendar Management", 6 | Commands = "Commands", 7 | 8 | TaskDetails = "Task Details", 9 | } 10 | 11 | export type KeyboardShortcutMap = { 12 | key: string; 13 | shortcuts: Shortcut[]; 14 | }; 15 | 16 | export type Shortcut = { 17 | action: () => any; 18 | key: string[] | string; 19 | ctrlKey?: boolean; 20 | shiftKey?: boolean; 21 | altKey?: boolean; 22 | category: KeyboardShortcutCategory; 23 | description: string; 24 | }; 25 | -------------------------------------------------------------------------------- /packages/client/src/sockets/taskSockets.ts: -------------------------------------------------------------------------------- 1 | import { 2 | updateComments, 3 | updateTasks, 4 | updateDiscussions, 5 | } from "@client/stores/taskStore"; 6 | import { debounce } from "lodash"; 7 | import type { ITask } from "shared/types/task"; 8 | import { Socket } from "socket.io-client"; 9 | 10 | export class TaskSockets { 11 | private activeCalls: { [key: string]: any[] } = { 12 | comments: [], 13 | tasks: [], 14 | discussions: [], 15 | }; 16 | 17 | public addListeners(io: Socket) { 18 | io.on("updateComments", (data: { taskId: string; comments: Comment[] }) => { 19 | this.handleUpdateComments(data); 20 | }); 21 | 22 | io.on( 23 | "updateDiscussions", 24 | (data: { taskId: string; discussions: any[] }) => { 25 | this.handleUpdateDiscussions(data); 26 | } 27 | ); 28 | 29 | io.on("updateTasks", (data: ITask[]) => { 30 | this.handleUpdateTasks(data); 31 | }); 32 | } 33 | 34 | private handleUpdateComments = (data: { 35 | taskId: string; 36 | comments: Comment[]; 37 | }) => { 38 | this.activeCalls["comments"].push(data); 39 | 40 | this.debouncedUpdate(); 41 | }; 42 | 43 | private handleUpdateDiscussions = (data: { 44 | taskId: string; 45 | discussions: any[]; 46 | }) => { 47 | this.activeCalls["discussions"].push(data); 48 | 49 | this.debouncedUpdate(); 50 | }; 51 | 52 | private handleUpdateTasks = (data: ITask[]) => { 53 | this.activeCalls["tasks"].push(...data); 54 | 55 | this.debouncedUpdate(); 56 | }; 57 | 58 | private debouncedUpdate = debounce(() => { 59 | if (this.activeCalls["comments"].length > 0) { 60 | updateComments(this.activeCalls["comments"]); 61 | } 62 | 63 | if (this.activeCalls["tasks"].length > 0) { 64 | updateTasks(this.activeCalls["tasks"]); 65 | } 66 | 67 | if (this.activeCalls["discussions"].length > 0) { 68 | updateDiscussions(this.activeCalls["discussions"]); 69 | } 70 | 71 | this.activeCalls["comments"] = []; 72 | this.activeCalls["tasks"] = []; 73 | this.activeCalls["discussions"] = []; 74 | }, 1000); 75 | } 76 | -------------------------------------------------------------------------------- /packages/client/src/stores/discussion.store.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import type { IDiscussion } from "shared/types/task"; 3 | import { createStore } from "solid-js/store"; 4 | import { LoadingTarget, setLoading, uiStore } from "./uiStore"; 5 | 6 | interface DiscussionStore { 7 | discussions: IDiscussion[]; 8 | } 9 | 10 | export const [discussionStore, setDiscussionStore] = 11 | createStore({ 12 | discussions: [], 13 | }); 14 | 15 | export const fetchDiscussions = async (id: number, type: string) => { 16 | setLoading(LoadingTarget.LoadDiscussions); 17 | const res = await axios.get(`/tasks/discussions?id=${id}&type=${type}`); 18 | 19 | setDiscussionStore("discussions", res.data); 20 | setLoading(LoadingTarget.None); 21 | }; 22 | -------------------------------------------------------------------------------- /packages/client/src/stores/keyboardNavigationStore.ts: -------------------------------------------------------------------------------- 1 | import { createStore } from "solid-js/store"; 2 | 3 | export enum NavigationKeys { 4 | Discussion, 5 | } 6 | 7 | export const [keyboardNavigationStore, setKeyboardNavigationStore] = 8 | createStore({ 9 | selectedColumnIndex: 0, 10 | selectedTaskIndex: 0, 11 | selectedTaskIndexes: [0] as number[], 12 | selectedDayIndex: 0, 13 | selectedHourIndex: 0, 14 | selectedQuarterHourIndex: 0, 15 | selectedQuarterHourIndexes: [0] as number[], 16 | selectedAppointmentIndex: 0, 17 | selectedIndices: [] as { key: number; value: number }[], 18 | }); 19 | 20 | const updateStoreIndex = ( 21 | key: keyof typeof keyboardNavigationStore, 22 | updater: T | ((prev: T) => T), 23 | ) => { 24 | const prevValue = keyboardNavigationStore[key]; 25 | const newValue = 26 | typeof updater === "function" ? (updater as Function)(prevValue) : updater; 27 | 28 | // Validate only if the value is a single number and check for NaN 29 | if (typeof newValue === "number" && isNaN(newValue)) return; 30 | 31 | setKeyboardNavigationStore(key, newValue); 32 | }; 33 | 34 | export const setNavIndex = (key: number, index: number) => { 35 | setKeyboardNavigationStore("selectedIndices", (prev) => { 36 | if (prev.some((item) => item.key === key)) { 37 | return prev.map((item) => 38 | item.key === key ? { key, value: index } : item, 39 | ); 40 | } 41 | 42 | return [...prev, { key, value: index }]; 43 | }); 44 | }; 45 | 46 | export const getNavIndex = (key: number): number => { 47 | return ( 48 | keyboardNavigationStore.selectedIndices.find((item) => item.key === key) 49 | ?.value ?? 0 50 | ); 51 | }; 52 | 53 | // Individual setter functions using the generic update function 54 | export const setSelectedColumnIndex = ( 55 | updater: number | ((prev: number) => number), 56 | ) => updateStoreIndex("selectedColumnIndex", updater); 57 | 58 | export const setSelectedTaskIndex = ( 59 | updater: number | ((prev: number) => number), 60 | ) => updateStoreIndex("selectedTaskIndex", updater); 61 | 62 | export const setSelectedTaskIndexes = ( 63 | updater: number[] | ((prev: number[]) => number[]), 64 | ) => updateStoreIndex("selectedTaskIndexes", updater); 65 | 66 | export const setSelectedDayIndex = ( 67 | updater: number | ((prev: number) => number), 68 | ) => updateStoreIndex("selectedDayIndex", updater); 69 | 70 | export const setSelectedHourIndex = ( 71 | updater: number | ((prev: number) => number), 72 | ) => updateStoreIndex("selectedHourIndex", updater); 73 | 74 | export const setSelectedQuarterHourIndex = ( 75 | updater: number | ((prev: number) => number), 76 | ) => updateStoreIndex("selectedQuarterHourIndex", updater); 77 | 78 | export const setSelectedQuarterHourIndexes = ( 79 | updater: number[] | ((prev: number[]) => number[]), 80 | ) => updateStoreIndex("selectedQuarterHourIndexes", updater); 81 | 82 | export const setSelectedAppointmentIndex = ( 83 | updater: number | ((prev: number) => number), 84 | ) => updateStoreIndex("selectedAppointmentIndex", updater); 85 | -------------------------------------------------------------------------------- /packages/client/src/stores/ruleStore.ts: -------------------------------------------------------------------------------- 1 | import { createStore } from "solid-js/store"; 2 | 3 | export interface Emitter { 4 | className: string; 5 | eventNamespace: string; 6 | eventType: string; 7 | methodName: string; 8 | } 9 | 10 | export interface Listener { 11 | className: string; 12 | eventNamespace: string; 13 | eventType: string; 14 | methodName: string; 15 | hasParams: boolean; 16 | } 17 | 18 | export type Rule = { 19 | _id: string; 20 | name: string; 21 | eventType: string; 22 | conditions: Condition[]; 23 | actions: Action[]; 24 | enabled: boolean; 25 | }; 26 | 27 | export type Condition = { 28 | fieldPath: string; 29 | operator: "==" | "!=" | ">" | "<"; 30 | value: any; 31 | }; 32 | 33 | export type Action = { 34 | targetPath: string; 35 | value: any; 36 | }; 37 | 38 | export const [ruleStore, setRuleStore] = createStore({ 39 | emitters: [] as Emitter[], 40 | listeners: [] as Listener[], 41 | rules: [] as Rule[], 42 | }); 43 | -------------------------------------------------------------------------------- /packages/client/src/stores/settings.store.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import { createStore } from "solid-js/store"; 3 | 4 | export const [settingsStore, setSettingsStore] = createStore({ 5 | gitlab_url: "", 6 | }); 7 | 8 | export const getGitlabUrl = async () => { 9 | try { 10 | const res = await axios.get(`/settings/gitlab-url`); 11 | const response = res.data; 12 | 13 | setSettingsStore("gitlab_url", response); 14 | } catch (error) { 15 | console.error("Failed to fetch GitLab URL:", error); 16 | } 17 | }; 18 | -------------------------------------------------------------------------------- /packages/client/src/stores/timeTrackStore.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import { createStore } from "solid-js/store"; 3 | import { addNotification } from "../services/notificationService"; 4 | 5 | export type TimeTrackEntry = { 6 | _id: string; 7 | start: Date; 8 | end: Date; 9 | }; 10 | 11 | export const [timeTrackStore, setTimeTrackStore] = createStore({ 12 | entries: [] as TimeTrackEntry[], 13 | recording: false, 14 | }); 15 | 16 | export const fetchTimeTrackEntries = async () => { 17 | const res = await axios.get(`/timetrack`); 18 | const response = res.data; 19 | 20 | if (response.error) { 21 | addNotification({ 22 | title: "Error", 23 | description: response.error, 24 | type: "error", 25 | duration: 5000, 26 | }); 27 | } 28 | 29 | setTimeTrackStore("entries", response); 30 | 31 | return response; 32 | }; 33 | 34 | export const fetchRecordingStateAsync = async () => { 35 | const res = await axios.get(`/timetrack/recording`); 36 | const response = res.data; 37 | if (response.error) { 38 | addNotification({ 39 | title: "Error", 40 | description: response.error, 41 | type: "error", 42 | duration: 5000, 43 | }); 44 | } 45 | setTimeTrackStore("recording", response); 46 | return response; 47 | }; 48 | 49 | export const toggleTimetrackAsync = async () => { 50 | const res = await axios.put(`/timetrack/recording`); 51 | const response = res.data; 52 | 53 | if (response.error) { 54 | addNotification({ 55 | title: "Error", 56 | description: response.error, 57 | type: "error", 58 | duration: 5000, 59 | }); 60 | } 61 | 62 | setTimeTrackStore("recording", response); 63 | return response; 64 | }; 65 | -------------------------------------------------------------------------------- /packages/client/src/styles/base.css: -------------------------------------------------------------------------------- 1 | html, 2 | body, 3 | #root { 4 | height: 100%; 5 | } 6 | 7 | body { 8 | margin: 0; 9 | font-family: "sans-serif"; 10 | background-color: var(--color-mantle); 11 | color: var(--color-text); 12 | -webkit-font-smoothing: antialiased; 13 | -moz-osx-font-smoothing: grayscale; 14 | } 15 | 16 | h1, 17 | h2, 18 | h3 { 19 | color: var(--color-rosewater); 20 | margin: 0; 21 | } 22 | 23 | h1 { 24 | font-size: var(--font-size-xxl); 25 | } 26 | 27 | h2 { 28 | font-size: var(--font-size-xl); 29 | margin-bottom: var(--spacing-sm); 30 | border-bottom: 1px solid rgba(255, 255, 255, 0.1); 31 | padding-bottom: var(--spacing-sm); 32 | } 33 | 34 | p { 35 | margin: 0; 36 | line-height: 1.5; 37 | } 38 | 39 | .divider { 40 | border-bottom: 1px solid rgba(255, 255, 255, 0.1); 41 | margin: var(--spacing-sm) 0; 42 | } 43 | 44 | input, 45 | textarea, 46 | select { 47 | background-color: var(--color-surface1); 48 | color: var(--color-text); 49 | border: 1px solid var(--color-sky); 50 | border-radius: var(--spacing-xs); 51 | padding: var(--spacing-sm); 52 | font-size: var(--font-size-sm); 53 | } 54 | 55 | input::placeholder { 56 | color: var(--color-text); 57 | } 58 | 59 | select { 60 | padding: var(--spacing-sm); 61 | } 62 | 63 | input:focus, 64 | select:focus, 65 | textarea:focus { 66 | outline: none; 67 | border-color: var(--color-sky) !important; 68 | } 69 | 70 | kbd { 71 | background: var(--color-surface1); 72 | border: 1px solid var(--color-sky); 73 | border-radius: var(--spacing-xxs); 74 | color: var(--color-text); 75 | padding: var(--spacing-xxs) var(--spacing-xs); 76 | font-size: var(--font-size-sm); 77 | font-family: "Courier New", Courier, monospace !important; 78 | } 79 | 80 | section { 81 | min-height: 200px; 82 | padding: var(--spacing-sm); 83 | padding-bottom: 0px; 84 | border-radius: var(--spacing-md); 85 | border: 2px dashed rgba(200, 200, 255, 0.3); 86 | } 87 | 88 | /* Wave Text Animation */ 89 | .wave-text { 90 | display: inline-flex; 91 | gap: 2px; 92 | } 93 | 94 | .wave-char { 95 | display: inline-block; 96 | position: relative; 97 | animation: wave 3s cubic-bezier(0.39, 0, 0.565, 1) infinite; 98 | animation-delay: calc(var(--index) * 0.05s); 99 | } 100 | 101 | @keyframes wave { 102 | 0% { 103 | transform: translateY(4px); 104 | color: var(--color-text); 105 | } 106 | 50% { 107 | transform: translateY(-4px); 108 | color: var(--color-teal); 109 | } 110 | 100% { 111 | transform: translateY(4px); 112 | color: var(--color-sky); 113 | } 114 | } 115 | 116 | .mention { 117 | display: inline-block; 118 | color: var(--color-teal); 119 | font-weight: bold; 120 | } 121 | -------------------------------------------------------------------------------- /packages/client/src/styles/notification.css: -------------------------------------------------------------------------------- 1 | .notification-manager { 2 | position: fixed; 3 | top: 20px; 4 | right: 0; 5 | display: flex; 6 | flex-direction: column; 7 | gap: 15px; 8 | z-index: 1000; 9 | padding-right: 20px; 10 | } 11 | -------------------------------------------------------------------------------- /packages/client/src/styles/scrollbar.css: -------------------------------------------------------------------------------- 1 | *::-webkit-scrollbar { 2 | width: 5px; 3 | } 4 | 5 | *::-webkit-scrollbar-thumb { 6 | background-color: var(--color-sky); 7 | border-radius: 10px; 8 | } 9 | 10 | *::-webkit-scrollbar-track { 11 | background-color: var(--color-mantle); 12 | } 13 | 14 | *::-webkit-scrollbar-thumb:hover { 15 | background-color: var(--color-teal); 16 | } 17 | 18 | *::-webkit-scrollbar-thumb:active { 19 | background-color: var(--color-sky); 20 | } 21 | 22 | *::-webkit-scrollbar-corner { 23 | background-color: var(--color-mantle); 24 | } 25 | 26 | *::-webkit-scrollbar-button { 27 | display: none; 28 | } 29 | 30 | *::-webkit-scrollbar-track-piece { 31 | background-color: var(--color-mantle); 32 | } 33 | -------------------------------------------------------------------------------- /packages/client/src/styles/variables.css: -------------------------------------------------------------------------------- 1 | :root { 2 | /* Color Variables */ 3 | --color-rosewater: #f4dbd6; 4 | --color-flamingo: #f0c6c6; 5 | --color-pink: #f5bde6; 6 | --color-mauve: #c6a0f6; 7 | --color-red: #ed8796; 8 | --color-maroon: #ee99a0; 9 | --color-peach: #f5a97f; 10 | --color-yellow: #eed49f; 11 | --color-green: #a6da95; 12 | --color-teal: #8bd5ca; 13 | --color-sky: #91d7e3; 14 | --color-sapphire: #7dc4e4; 15 | --color-blue: #8aadf4; 16 | --color-lavender: #b7bdf8; 17 | --color-text: #cad3f5; 18 | --color-subtext1: #b8c0e0; 19 | --color-subtext0: #a5adcb; 20 | --color-overlay2: #939ab7; 21 | --color-overlay1: #8087a2; 22 | --color-overlay0: #6e738d; 23 | --color-surface2: #5b6078; 24 | --color-surface1: #494d64; 25 | --color-surface0: #363a4f; 26 | --color-base: #24273a; 27 | --color-mantle: #1e2030; 28 | --color-crust: #181926; 29 | 30 | /* Spacing Variables */ 31 | --spacing-xxs: 4px; 32 | --spacing-xs: 8px; 33 | --spacing-sm: 12px; 34 | --spacing-md: 16px; 35 | --spacing-lg: 24px; 36 | --spacing-xl: 32px; 37 | --spacing-xxl: 48px; 38 | 39 | /* Font Size Variables */ 40 | --font-size-xs: 12px; 41 | --font-size-sm: 14px; 42 | --font-size-md: 16px; 43 | --font-size-lg: 20px; 44 | --font-size-xl: 24px; 45 | --font-size-xxl: 32px; 46 | --font-size-xxxl: 40px; 47 | 48 | /* Font Weight Variables */ 49 | --font-weight-regular: 400; 50 | --font-weight-medium: 500; 51 | --font-weight-semibold: 600; 52 | --font-weight-bold: 700; 53 | } 54 | -------------------------------------------------------------------------------- /packages/client/src/utils/orderLabels.ts: -------------------------------------------------------------------------------- 1 | import { sortBy } from "lodash"; 2 | 3 | const priorityOrder = [ 4 | "priority/INTERMEDIATE", 5 | "priority/STAGING", 6 | "priority/HIGH", 7 | "priority/MEDIUM", 8 | "priority/LOW", 9 | ]; 10 | 11 | export const orderPriorityLabels = (labels: string[]) => 12 | sortBy(labels ?? [], (label) => { 13 | const idx = priorityOrder.indexOf(label); 14 | return idx === -1 ? priorityOrder.length : idx; 15 | }).filter((item: string) => item.toLowerCase().includes("priority")); 16 | -------------------------------------------------------------------------------- /packages/client/src/utils/parseMarkdown.ts: -------------------------------------------------------------------------------- 1 | import { marked } from "marked"; 2 | import { uiStore } from "../stores/uiStore"; 3 | import DOMPurify from "dompurify"; 4 | import { settingsStore } from "@client/stores/settings.store"; 5 | 6 | export const parseMarkdown = (input: string) => { 7 | const sanitized = DOMPurify.sanitize(input); 8 | const markdown = marked(sanitized, { async: false }); 9 | const user = uiStore.user?.name; 10 | 11 | return markdown 12 | .replaceAll( 13 | 'src="/', 14 | `src="${settingsStore.gitlab_url}/-/project/${uiStore.currentProject?.id}/`, 15 | ) 16 | .replaceAll( 17 | //g, 18 | '', 19 | ) 20 | .replaceAll( 21 | //g, 22 | '', 23 | ) 24 | .replaceAll(/@(\w+)/g, (match, mentionedUser) => 25 | mentionedUser === user ? `
${match}
` : match, 26 | ); 27 | }; 28 | -------------------------------------------------------------------------------- /packages/client/src/utils/scrollIntoView.ts: -------------------------------------------------------------------------------- 1 | import { debounce } from "lodash"; 2 | 3 | export const scrollIntoView = debounce((element: HTMLElement | null) => { 4 | if (element) { 5 | element.scrollIntoView({ 6 | behavior: "smooth", 7 | block: "center", 8 | }); 9 | } 10 | }, 100); 11 | -------------------------------------------------------------------------------- /packages/client/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "allowSyntheticDefaultImports": true, 5 | "esModuleInterop": true, 6 | "jsx": "preserve", 7 | "jsxImportSource": "solid-js", 8 | "types": ["vite/client", "bun-types"], 9 | "noEmit": true, 10 | "isolatedModules": true, 11 | 12 | "baseUrl": "./src", 13 | "paths": { 14 | "@client/*": ["*"] 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /packages/client/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import solidPlugin from "vite-plugin-solid"; 3 | import path from "path"; 4 | 5 | // import devtools from 'solid-devtools/vite'; 6 | 7 | export default defineConfig({ 8 | plugins: [ 9 | /* 10 | Uncomment the following line to enable solid-devtools. 11 | For more info see https://github.com/thetarnav/solid-devtools/tree/main/packages/extension#readme 12 | */ 13 | // devtools(), 14 | solidPlugin(), 15 | ], 16 | server: { 17 | watch: { 18 | usePolling: true, 19 | }, 20 | host: true, 21 | port: 3005, 22 | }, 23 | build: { 24 | target: "esnext", 25 | }, 26 | resolve: { 27 | alias: { 28 | "@client": path.resolve(__dirname, "./src"), 29 | }, 30 | }, 31 | }); 32 | -------------------------------------------------------------------------------- /packages/server/background-jobs/closeMergedMergeRequests.ts: -------------------------------------------------------------------------------- 1 | import { GitlabService } from "../services/gitlab.service"; 2 | import { SocketHandler } from "../sockets"; 3 | import { MochiError } from "../errors/mochi.error"; 4 | import { logError, logInfo } from "../utils/logger"; 5 | 6 | const closeMergedMRJob = async () => { 7 | try { 8 | logInfo("Close merged MergeRequests..."); 9 | 10 | const gitlabService = new GitlabService(); 11 | 12 | const changes = await gitlabService.closeMergedMergeRequestsAsync(); 13 | 14 | SocketHandler.getInstance().getIO().emit("updateTasks", changes); 15 | 16 | logInfo("Closed merged MergeRequests synced!"); 17 | } catch (error) { 18 | logError( 19 | new MochiError("Failed to close merge requests", 500, error as Error), 20 | ); 21 | } 22 | }; 23 | 24 | export { closeMergedMRJob }; 25 | -------------------------------------------------------------------------------- /packages/server/bunfig.toml: -------------------------------------------------------------------------------- 1 | [test] 2 | coverageSkipTestFiles = true -------------------------------------------------------------------------------- /packages/server/controllers/rule.controller.ts: -------------------------------------------------------------------------------- 1 | import type { Request, Response, NextFunction } from "express"; 2 | import { EventRegistry } from "../events/eventRegistry"; 3 | import { RuleService } from "../services/rule.service"; 4 | import { handleControllerError } from "../errors/mochi.error"; 5 | 6 | export class RuleController { 7 | private ruleService: RuleService; 8 | 9 | constructor() { 10 | this.ruleService = new RuleService(); 11 | } 12 | 13 | getEmittersAsync = async ( 14 | req: Request, 15 | res: Response, 16 | next: NextFunction, 17 | ) => { 18 | try { 19 | res.json(EventRegistry.getInstance().getEmitters()); 20 | } catch (error) { 21 | next(error); 22 | } 23 | }; 24 | 25 | getListenersAsync = async ( 26 | req: Request, 27 | res: Response, 28 | next: NextFunction, 29 | ) => { 30 | try { 31 | res.json(EventRegistry.getInstance().getListeners()); 32 | } catch (error) { 33 | next(error); 34 | } 35 | }; 36 | 37 | createRuleAsync = async (req: Request, res: Response, next: NextFunction) => { 38 | try { 39 | const newRule = await this.ruleService.createRuleAsync(req.body); 40 | res.status(201).send(newRule); 41 | } catch (error) { 42 | handleControllerError(error, next); 43 | } 44 | }; 45 | 46 | getAllRulesAsync = async ( 47 | req: Request, 48 | res: Response, 49 | next: NextFunction, 50 | ) => { 51 | try { 52 | const rules = await this.ruleService.getAllAsync(); 53 | res.json(rules); 54 | } catch (error) { 55 | handleControllerError(error, next); 56 | } 57 | }; 58 | 59 | deleteRuleAsync = async (req: Request, res: Response, next: NextFunction) => { 60 | try { 61 | await this.ruleService.deleteRuleAsync(req.params.id); 62 | res.sendStatus(204); 63 | } catch (error) { 64 | handleControllerError(error, next); 65 | } 66 | }; 67 | 68 | toggleRuleAsync = async (req: Request, res: Response, next: NextFunction) => { 69 | try { 70 | const rule = await this.ruleService.toggleRuleAsync(req.params.id); 71 | res.json(rule); 72 | } catch (error) { 73 | handleControllerError(error, next); 74 | } 75 | }; 76 | } 77 | -------------------------------------------------------------------------------- /packages/server/controllers/settings.controller.ts: -------------------------------------------------------------------------------- 1 | import { handleControllerError } from "@server/errors/mochi.error"; 2 | import { SettingsService } from "@server/services/settings.service"; 3 | import type { NextFunction, Request, Response } from "express"; 4 | 5 | export class SettingsController { 6 | private service = new SettingsService(); 7 | 8 | getSetupStatus = async (req: Request, res: Response, next: NextFunction) => { 9 | try { 10 | const status = await this.service.getSetupStatus(); 11 | res.status(200).json(status); 12 | } catch (error) { 13 | handleControllerError(error, next); 14 | } 15 | }; 16 | 17 | getGitlabUrl = async (req: Request, res: Response, next: NextFunction) => { 18 | try { 19 | const config = await this.service.getGitlabConfig(); 20 | 21 | if (!config) { 22 | res.status(404).json({ 23 | error: "GitLab configuration not found", 24 | }); 25 | return; 26 | } 27 | 28 | res.status(200).json(config.url); 29 | } catch (error) { 30 | handleControllerError(error, next); 31 | } 32 | }; 33 | 34 | validateGitlabConnection = async ( 35 | req: Request, 36 | res: Response, 37 | next: NextFunction, 38 | ) => { 39 | try { 40 | const { url, token } = req.body; 41 | 42 | if (!url || !token) { 43 | res.status(400).json({ 44 | error: "URL and token are required for validation", 45 | }); 46 | return; 47 | } 48 | 49 | const result = await this.service.validateGitlabConnection({ 50 | url, 51 | token, 52 | }); 53 | 54 | res.status(200).json(result); 55 | } catch (error) { 56 | handleControllerError(error, next); 57 | } 58 | }; 59 | 60 | saveGitlabConfig = async ( 61 | req: Request, 62 | res: Response, 63 | next: NextFunction, 64 | ) => { 65 | try { 66 | const { url, token } = req.body; 67 | 68 | if (!url || !token) { 69 | res.status(400).json({ 70 | error: "URL and token are required to save GitLab configuration", 71 | }); 72 | return; 73 | } 74 | 75 | await this.service.saveGitlabConfig({ url, token }); 76 | res 77 | .status(200) 78 | .json({ message: "GitLab configuration saved successfully" }); 79 | } catch (error) { 80 | handleControllerError(error, next); 81 | } 82 | }; 83 | 84 | completeSetup = async (req: Request, res: Response, next: NextFunction) => { 85 | try { 86 | await this.service.completeSetup(); 87 | res.status(200).json({ message: "Setup completed successfully" }); 88 | } catch (error) { 89 | handleControllerError(error, next); 90 | } 91 | }; 92 | } 93 | -------------------------------------------------------------------------------- /packages/server/controllers/timeTrack.controller.ts: -------------------------------------------------------------------------------- 1 | import { handleControllerError } from "../errors/mochi.error"; 2 | import TimeTrackService from "../services/timeTrack.service"; 3 | import type { Request, Response, NextFunction } from "express"; 4 | 5 | export class TimeTrackController { 6 | private timeTrackService: TimeTrackService; 7 | 8 | constructor() { 9 | this.timeTrackService = new TimeTrackService(); 10 | } 11 | 12 | getTimeTrackEntriesAsync = async ( 13 | req: Request, 14 | res: Response, 15 | next: NextFunction, 16 | ) => { 17 | try { 18 | const entries = await this.timeTrackService.getTimetrackEntriesAsync(); 19 | res.status(200).json(entries); 20 | } catch (error) { 21 | handleControllerError(error, next); 22 | } 23 | }; 24 | 25 | updateTimeTrackEntryAsync = async ( 26 | req: Request, 27 | res: Response, 28 | next: NextFunction, 29 | ) => { 30 | try { 31 | const { id, entry } = req.body; 32 | const result = await this.timeTrackService.updateTimeTrackEntryAsync( 33 | id, 34 | entry, 35 | ); 36 | res.status(200).json(result); 37 | } catch (error) { 38 | handleControllerError(error, next); 39 | } 40 | }; 41 | 42 | getRecordingStateAsync = async ( 43 | req: Request, 44 | res: Response, 45 | next: NextFunction, 46 | ) => { 47 | try { 48 | const recording = await this.timeTrackService.getRecordingStateAsync(); 49 | res.status(200).json(recording); 50 | } catch (error) { 51 | handleControllerError(error, next); 52 | } 53 | }; 54 | 55 | toggleRecordingAsync = async ( 56 | req: Request, 57 | res: Response, 58 | next: NextFunction, 59 | ) => { 60 | try { 61 | const state = await this.timeTrackService.toggleRecordingAsync(); 62 | res.status(200).send(state); 63 | } catch (error) { 64 | handleControllerError(error, next); 65 | } 66 | }; 67 | } 68 | -------------------------------------------------------------------------------- /packages/server/decorators/ruleAction.decorator.ts: -------------------------------------------------------------------------------- 1 | import { EventRegistry } from "../events/eventRegistry"; 2 | 3 | type RuleActionDecoratorProps = { 4 | eventNamespace: string; 5 | eventName: string; 6 | hasParams?: boolean; 7 | }; 8 | 9 | export function ruleAction(props: RuleActionDecoratorProps) { 10 | return function ( 11 | target: any, 12 | propertyKey: string, 13 | descriptor: PropertyDescriptor 14 | ) { 15 | const { eventNamespace, eventName, hasParams } = props; 16 | const className = target.constructor.name; 17 | 18 | // Register the decorated method in the EventRegistry immediately 19 | EventRegistry.getInstance().registerListener( 20 | eventName, 21 | eventNamespace, 22 | className, 23 | propertyKey, 24 | hasParams ?? false 25 | ); 26 | 27 | // No need to wrap descriptor.value; just return it as is 28 | return descriptor; 29 | }; 30 | } 31 | -------------------------------------------------------------------------------- /packages/server/decorators/ruleEvent.decorator.ts: -------------------------------------------------------------------------------- 1 | import { MochiResult } from "../utils/mochiResult"; 2 | import { MochiError } from "../errors/mochi.error"; 3 | import { EventEmitterHandler } from "../events/eventEmitterHandler"; 4 | import { logError } from "../utils/logger"; 5 | import { EventRegistry } from "../events/eventRegistry"; 6 | import "reflect-metadata"; 7 | 8 | export function ruleEvent(eventNamespace: string, eventName: string) { 9 | return function ( 10 | target: any, 11 | propertyKey: string, 12 | descriptor: PropertyDescriptor, 13 | ) { 14 | const originalMethod = descriptor.value; 15 | const className = target.constructor.name; 16 | 17 | EventRegistry.getInstance().registerEmitter( 18 | eventName, 19 | eventNamespace, 20 | className, 21 | propertyKey, 22 | ); 23 | 24 | descriptor.value = async function (...args: any[]) { 25 | try { 26 | const result = await originalMethod.apply(this, args); 27 | 28 | const eventType = `${eventNamespace}.${eventName}`; 29 | 30 | const resultData = 31 | result instanceof MochiResult 32 | ? result 33 | : new MochiResult(eventType, result); 34 | 35 | EventEmitterHandler.getEmitter().emit(eventType, resultData); 36 | 37 | return result; 38 | } catch (error: any) { 39 | logError(new MochiError(`Error in ${propertyKey}`, 500, error)); 40 | throw error; 41 | } 42 | }; 43 | }; 44 | } 45 | -------------------------------------------------------------------------------- /packages/server/decorators/transactional.decorator.ts: -------------------------------------------------------------------------------- 1 | import mongoose from "mongoose"; 2 | import { logError } from "../utils/logger"; 3 | import { MochiError } from "../errors/mochi.error"; 4 | 5 | export function transactional( 6 | target: any, 7 | propertyKey: string, 8 | descriptor: PropertyDescriptor, 9 | ) { 10 | const originalMethod = descriptor.value; 11 | 12 | descriptor.value = async function (...args: any[]) { 13 | const session = await mongoose.startSession(); 14 | 15 | session.startTransaction(); 16 | 17 | try { 18 | const result = await originalMethod.apply(this, [...args, session]); 19 | 20 | await session.commitTransaction(); 21 | session.endSession(); 22 | 23 | return result; 24 | } catch (error: any) { 25 | await session.abortTransaction(); 26 | session.endSession(); 27 | 28 | logError(new MochiError(`Error in ${propertyKey}`, 500, error)); 29 | throw error; 30 | } 31 | }; 32 | } 33 | -------------------------------------------------------------------------------- /packages/server/dtos/task.dto.ts: -------------------------------------------------------------------------------- 1 | export interface TaskDto { 2 | title: string; 3 | status: string; 4 | description: string; 5 | custom: boolean; 6 | } 7 | -------------------------------------------------------------------------------- /packages/server/errors/gitlab.error.ts: -------------------------------------------------------------------------------- 1 | export class GitlabError extends Error { 2 | public statusCode: number; 3 | public originalError?: Error; 4 | 5 | constructor( 6 | message: string, 7 | statusCode: number = 500, 8 | originalError?: Error 9 | ) { 10 | super(message); 11 | this.name = "GitlabError"; 12 | this.statusCode = statusCode; 13 | this.originalError = originalError; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /packages/server/errors/mochi.error.ts: -------------------------------------------------------------------------------- 1 | import type { NextFunction } from "express"; 2 | 3 | export class MochiError extends Error { 4 | public statusCode: number; 5 | public data: any; 6 | 7 | constructor( 8 | message: string, 9 | statusCode: number = 500, 10 | error?: Error, 11 | data?: any, 12 | ) { 13 | super(message + (error ? `: ${error.message}` : "")); 14 | this.statusCode = statusCode; 15 | this.data = data; 16 | Error.captureStackTrace(this, this.constructor); 17 | } 18 | } 19 | 20 | export const handleControllerError = (error: unknown, next: NextFunction) => { 21 | if (error instanceof MochiError) { 22 | next(error); 23 | } else { 24 | next(new MochiError("Unexpected error occurred", 500, error as Error)); 25 | } 26 | }; 27 | -------------------------------------------------------------------------------- /packages/server/events/eventRegistry.ts: -------------------------------------------------------------------------------- 1 | import { logInfo } from "../utils/logger"; 2 | 3 | type EventMetadata = { 4 | eventType: string; 5 | eventNamespace: string; 6 | className: string; 7 | methodName: string; 8 | hasParams?: boolean; 9 | }; 10 | 11 | export class EventRegistry { 12 | private static instance: EventRegistry; 13 | private emitters: EventMetadata[] = []; 14 | private listeners: EventMetadata[] = []; 15 | 16 | private constructor() {} 17 | 18 | static getInstance() { 19 | if (!EventRegistry.instance) { 20 | EventRegistry.instance = new EventRegistry(); 21 | } 22 | 23 | return EventRegistry.instance; 24 | } 25 | 26 | registerEmitter( 27 | eventType: string, 28 | eventNamespace: string, 29 | className: string, 30 | methodName: string 31 | ) { 32 | this.emitters.push({ eventType, className, methodName, eventNamespace }); 33 | } 34 | 35 | registerListener( 36 | eventType: string, 37 | eventNamespace: string, 38 | className: string, 39 | methodName: string, 40 | hasParams: boolean 41 | ) { 42 | this.listeners.push({ 43 | eventType, 44 | className, 45 | methodName, 46 | eventNamespace, 47 | hasParams, 48 | }); 49 | } 50 | 51 | getEmitters() { 52 | return this.emitters; 53 | } 54 | 55 | getListeners() { 56 | return this.listeners; 57 | } 58 | 59 | getListenerByEvent(event: string) { 60 | const [eventNamespace, eventType] = event.split("."); 61 | return this.listeners.find( 62 | (listener) => 63 | listener.eventNamespace == eventNamespace && 64 | listener.eventType === eventType 65 | ); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /packages/server/events/eventTypes.ts: -------------------------------------------------------------------------------- 1 | export enum EventTypes { 2 | Created = "created", 3 | Updated = "updated", 4 | Deleted = "deleted", 5 | Moved = "moved", 6 | CreateBranch = "createBranch", 7 | } 8 | 9 | export enum ActionTypes { 10 | // Task actions 11 | Move = "move", 12 | Restore = "restore", 13 | Delete = "delete", 14 | 15 | // GitLab actions 16 | UpdateAssignee = "updateAssignee", 17 | } 18 | 19 | export enum EventNamespaces { 20 | GitLab = "gitlab", 21 | Task = "task", 22 | } 23 | -------------------------------------------------------------------------------- /packages/server/middlewares/context.middleware.ts: -------------------------------------------------------------------------------- 1 | import type { NextFunction, Request, Response } from "express"; 2 | import { ProjectService } from "../services/project.service"; 3 | import { 4 | asyncLocalStorage, 5 | ContextKeys, 6 | setContext, 7 | } from "../utils/asyncContext"; 8 | 9 | export const contextMiddleware = ( 10 | req: Request, 11 | res: Response, 12 | next: NextFunction, 13 | ) => { 14 | asyncLocalStorage.run(new Map(), async () => { 15 | try { 16 | const projectService = new ProjectService(); 17 | const currentProject = await projectService.getCurrentProjectAsync(); 18 | 19 | setContext(ContextKeys.Project, currentProject); 20 | } finally { 21 | next(); 22 | } 23 | }); 24 | }; 25 | -------------------------------------------------------------------------------- /packages/server/middlewares/globalErrorHandler.middleware.ts: -------------------------------------------------------------------------------- 1 | import { MochiError } from "../errors/mochi.error.js"; 2 | import { logError } from "../utils/logger.js"; 3 | import type { Request, Response, NextFunction } from "express"; 4 | 5 | export const globalErrorHandler = ( 6 | err: MochiError | Error, 7 | req: Request, 8 | res: Response, 9 | next: NextFunction, 10 | ) => { 11 | if (err instanceof MochiError) { 12 | res.status(err.statusCode).json({ 13 | status: "error", 14 | message: err.message, 15 | }); 16 | logError(err); 17 | } else { 18 | res.status(500).json({ 19 | status: "error", 20 | message: "Unexpected error occurred", 21 | }); 22 | logError(new MochiError("unhandled error", 500)); 23 | } 24 | }; 25 | -------------------------------------------------------------------------------- /packages/server/models/appState.model.ts: -------------------------------------------------------------------------------- 1 | import { Schema, model, Document } from "mongoose"; 2 | 3 | export enum AppStateKey { 4 | Recording = "recording", 5 | } 6 | 7 | export interface IAppStateEntry extends Document { 8 | key: string; 9 | value: string; 10 | } 11 | 12 | const AppStateSchema = new Schema({ 13 | key: { type: String, unique: true }, 14 | value: String, 15 | }); 16 | 17 | export const AppState = model("AppState", AppStateSchema); 18 | -------------------------------------------------------------------------------- /packages/server/models/discussion.model.ts: -------------------------------------------------------------------------------- 1 | import { Schema, model } from "mongoose"; 2 | import type { IDiscussion } from "shared/types/task"; 3 | 4 | const DiscussionSchema = new Schema({ 5 | taskId: { type: String, required: true, ref: "Task" }, 6 | discussionId: { type: String, required: true }, 7 | individual_note: { type: Boolean, default: false }, 8 | notes: [ 9 | { 10 | author: { 11 | authorId: Number, 12 | name: String, 13 | username: String, 14 | avatar_url: String, 15 | }, 16 | body: { type: String, required: true }, 17 | created_at: { type: String, required: true }, 18 | noteId: { type: String, required: true }, 19 | resolvable: { type: Boolean, default: false }, 20 | resolved: { type: Boolean, default: false }, 21 | resolved_at: { type: String, default: null }, 22 | resolved_by: { 23 | authorId: Number, 24 | name: String, 25 | username: String, 26 | avatar_url: String, 27 | }, 28 | system: { type: Boolean, default: false }, 29 | }, 30 | ], 31 | }); 32 | 33 | export const Discussion = model("Discussion", DiscussionSchema); 34 | -------------------------------------------------------------------------------- /packages/server/models/event.model.ts: -------------------------------------------------------------------------------- 1 | import { model, Schema } from "mongoose"; 2 | 3 | export interface IEvent extends Document { 4 | eventType: string; 5 | timestamp: Date; 6 | data: Record; 7 | } 8 | 9 | const EventSchema = new Schema({ 10 | eventType: { type: String, required: true }, 11 | timestamp: { type: Date, default: Date.now }, 12 | data: Schema.Types.Mixed, 13 | }); 14 | 15 | export const Event = model("Event", EventSchema); 16 | -------------------------------------------------------------------------------- /packages/server/models/project.model.ts: -------------------------------------------------------------------------------- 1 | import { Schema, model, Document } from "mongoose"; 2 | 3 | export interface IProject extends Document { 4 | name: string; 5 | deleted: boolean; 6 | } 7 | 8 | const ProjectSchema = new Schema({ 9 | name: { type: String, unique: true }, 10 | deleted: { type: Boolean, default: false }, 11 | }); 12 | 13 | export const Project = model("Project", ProjectSchema); 14 | -------------------------------------------------------------------------------- /packages/server/models/rule.model.ts: -------------------------------------------------------------------------------- 1 | import { model, Schema, Document } from "mongoose"; 2 | 3 | // Define Condition interface and schema 4 | export interface ICondition { 5 | fieldPath: string; 6 | operator: "==" | "!=" | ">" | "<"; 7 | value: any; 8 | } 9 | 10 | const ConditionSchema = new Schema({ 11 | fieldPath: { type: String, required: true }, 12 | operator: { type: String, enum: ["==", "!=", ">", "<"], required: true }, 13 | value: { type: Schema.Types.Mixed, required: true }, 14 | }); 15 | 16 | // Define Action interface and schema 17 | export interface IAction { 18 | targetPath: string; 19 | value: any; 20 | } 21 | 22 | const ActionSchema = new Schema({ 23 | targetPath: { type: String, required: true }, 24 | value: { type: Schema.Types.Mixed, required: false }, 25 | }); 26 | 27 | // Define Rule interface and schema 28 | export interface IRule extends Document { 29 | name: string; 30 | eventType: string; 31 | conditions?: ICondition[]; 32 | actions: IAction[]; 33 | enabled: boolean; 34 | } 35 | 36 | const RuleSchema = new Schema({ 37 | name: { type: String, required: true }, 38 | eventType: { type: String, required: true }, 39 | conditions: [ConditionSchema], 40 | actions: [ActionSchema], 41 | enabled: { type: Boolean, default: true }, 42 | }); 43 | 44 | // Export Rule model 45 | export const Rule = model("Rule", RuleSchema); 46 | -------------------------------------------------------------------------------- /packages/server/models/setting.model.ts: -------------------------------------------------------------------------------- 1 | import { Schema, model, Document } from "mongoose"; 2 | 3 | export interface ISetting extends Document { 4 | key: string; 5 | value: string; 6 | } 7 | 8 | const SettingSchema = new Schema({ 9 | key: { type: String, unique: true }, 10 | value: String, 11 | }); 12 | 13 | export const Setting = model("Setting", SettingSchema); 14 | -------------------------------------------------------------------------------- /packages/server/models/task.model.ts: -------------------------------------------------------------------------------- 1 | import { Schema, model } from "mongoose"; 2 | import type { ITask } from "shared/types/task"; 3 | 4 | const TaskSchema = new Schema({ 5 | gitlabIid: { type: Number, unique: false }, 6 | gitlabId: { type: Number, unique: true, sparse: true }, 7 | web_url: String, 8 | title: { type: String, required: true }, 9 | description: String, 10 | draft: Boolean, 11 | milestoneId: Number, 12 | milestoneName: String, 13 | assignee: { 14 | authorId: Number, 15 | name: String, 16 | username: String, 17 | avatar_url: String, 18 | }, 19 | labels: [String], 20 | branch: String, 21 | status: String, 22 | type: String, 23 | relevantDiscussionCount: { type: Number, default: 0 }, 24 | projectId: String, 25 | custom: { type: Boolean, default: false }, 26 | deleted: { type: Boolean, default: false }, 27 | order: Number, 28 | latestPipelineId: Number, 29 | pipelineStatus: String, 30 | pipelineReports: [ 31 | { 32 | name: String, 33 | classname: String, 34 | attachment_url: String, 35 | }, 36 | ], 37 | }); 38 | 39 | export const Task = model("Task", TaskSchema); 40 | -------------------------------------------------------------------------------- /packages/server/models/timeTrack.model.ts: -------------------------------------------------------------------------------- 1 | import { model, Schema, type Document } from "mongoose"; 2 | 3 | export interface ITimeTrackEntry extends Document { 4 | start: Date; 5 | end: Date; 6 | break: number; 7 | } 8 | 9 | const TimeTrackSchema = new Schema({ 10 | start: { type: Date, required: true }, 11 | end: { type: Date, required: false }, 12 | break: { type: Number, required: false }, 13 | }) 14 | 15 | export const TimeTrack = model("TimeTrack", TimeTrackSchema); 16 | -------------------------------------------------------------------------------- /packages/server/models/user.model.ts: -------------------------------------------------------------------------------- 1 | import { Schema, model, Document } from "mongoose"; 2 | 3 | export interface IUser extends Document { 4 | gitlabId: number; 5 | username: string; 6 | email: string; 7 | name: string; 8 | avatar_url: string; 9 | } 10 | 11 | const UserSchema = new Schema({ 12 | gitlabId: { type: Number, required: true, unique: true }, 13 | username: { type: String, required: true }, 14 | email: { type: String, required: true }, 15 | name: { type: String }, 16 | avatar_url: { type: String }, 17 | }); 18 | 19 | export const User = model("User", UserSchema); 20 | -------------------------------------------------------------------------------- /packages/server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": { 3 | "name": "Coding0tter", 4 | "url": "https://github.com/Coding0tter" 5 | }, 6 | "scripts": { 7 | "dev": "bun run --watch server.ts" 8 | }, 9 | "name": "mochi-backend", 10 | "module": "index.ts", 11 | "type": "module", 12 | "devDependencies": { 13 | "@types/bun": "latest" 14 | }, 15 | "peerDependencies": { 16 | "typescript": "^5.0.0" 17 | }, 18 | "dependencies": { 19 | "shared": "file:../shared", 20 | "@types/cors": "^2.8.17", 21 | "@types/express": "^5.0.0", 22 | "@types/xmldom": "^0.1.34", 23 | "async_hooks": "^1.0.0", 24 | "axios": "^1.7.7", 25 | "cors": "^2.8.5", 26 | "eventemitter2": "^6.4.9", 27 | "express": "^4.21.0", 28 | "lodash": "^4.17.21", 29 | "mongoose": "^8.6.3", 30 | "reflect-metadata": "^0.2.2", 31 | "socket.io": "^4.8.0", 32 | "xmldom": "^0.6.0" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /packages/server/repositories/appState.repo.ts: -------------------------------------------------------------------------------- 1 | import { type IAppStateEntry, AppState } from "../models/appState.model"; 2 | import BaseRepo from "./base.repo"; 3 | 4 | export class AppStateRepo extends BaseRepo { 5 | constructor() { 6 | super(AppState); 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /packages/server/repositories/base.repo.ts: -------------------------------------------------------------------------------- 1 | class BaseRepo { 2 | constructor(public model: any) {} 3 | 4 | public async getAllAsync( 5 | filter?: Record | null, 6 | ): Promise { 7 | if (filter) { 8 | return await this.model.find(filter); 9 | } 10 | 11 | return await this.model.find(); 12 | } 13 | 14 | public async findOneAsync(filter: Record): Promise { 15 | return await this.model.findOne(filter); 16 | } 17 | 18 | public async getByIdAsync(id: string): Promise { 19 | return await this.model.findById(id); 20 | } 21 | 22 | public async createAsync(data: Partial): Promise { 23 | return await this.model.create(data); 24 | } 25 | 26 | public async updateAsync(id: string, data: Partial): Promise { 27 | return await this.model.findByIdAndUpdate(id, data, { new: true }); 28 | } 29 | 30 | public async deleteAsync(id: string): Promise { 31 | return await this.model.findByIdAndDelete(id, { new: true }); 32 | } 33 | } 34 | 35 | export default BaseRepo; 36 | -------------------------------------------------------------------------------- /packages/server/repositories/discussion.repo.ts: -------------------------------------------------------------------------------- 1 | import { Discussion } from "@server/models/discussion.model"; 2 | import type { IDiscussion } from "shared/types/task"; 3 | import BaseRepo from "./base.repo"; 4 | 5 | export class DiscussionRepo extends BaseRepo { 6 | constructor() { 7 | super(Discussion); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /packages/server/repositories/project.repo.ts: -------------------------------------------------------------------------------- 1 | import BaseRepo from "./base.repo.js"; 2 | import { Project, type IProject } from "../models/project.model.js"; 3 | 4 | export class ProjectRepo extends BaseRepo { 5 | constructor() { 6 | super(Project); 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /packages/server/repositories/rule.repo.ts: -------------------------------------------------------------------------------- 1 | import { Rule, type IRule } from "../models/rule.model"; 2 | import BaseRepo from "./base.repo"; 3 | 4 | export class RuleRepo extends BaseRepo { 5 | constructor() { 6 | super(Rule); 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /packages/server/repositories/setting.repo.ts: -------------------------------------------------------------------------------- 1 | import BaseRepo from "./base.repo.js"; 2 | import { Setting, type ISetting } from "../models/setting.model.js"; 3 | import { MochiError } from "../errors/mochi.error.js"; 4 | 5 | export class SettingRepo extends BaseRepo { 6 | constructor() { 7 | super(Setting); 8 | } 9 | 10 | async getByKeyAsync(key: string): Promise { 11 | try { 12 | return this.model.findOne({ key }); 13 | } catch (error) { 14 | throw new MochiError( 15 | `Failed to get setting by key ${key}`, 16 | 500, 17 | error as Error, 18 | ); 19 | } 20 | } 21 | 22 | async setByKeyAsync(key: string, value: string): Promise { 23 | try { 24 | const setting = await this.model.findOneAndUpdate( 25 | { key }, 26 | { value }, 27 | { new: true }, 28 | ); 29 | 30 | if (!setting) { 31 | return super.createAsync({ key, value }); 32 | } 33 | 34 | return setting; 35 | } catch (error) { 36 | throw new MochiError( 37 | `Failed to set setting by key ${key}`, 38 | 500, 39 | error as Error, 40 | ); 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /packages/server/repositories/task.repo.ts: -------------------------------------------------------------------------------- 1 | import { Task } from "@server/models/task.model"; 2 | import type { ITask } from "shared/types/task"; 3 | import BaseRepo from "./base.repo"; 4 | 5 | export class TaskRepo extends BaseRepo { 6 | constructor() { 7 | super(Task); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /packages/server/repositories/timeTrack.repo.ts: -------------------------------------------------------------------------------- 1 | import { type ITimeTrackEntry, TimeTrack } from "../models/timeTrack.model"; 2 | import BaseRepo from "./base.repo"; 3 | 4 | export class TimeTrackRepo extends BaseRepo { 5 | constructor() { 6 | super(TimeTrack); 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /packages/server/repositories/user.repo.ts: -------------------------------------------------------------------------------- 1 | import BaseRepo from "./base.repo.js"; 2 | import { User, type IUser } from "../models/user.model.js"; 3 | 4 | export class UserRepo extends BaseRepo { 5 | constructor() { 6 | super(User); 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /packages/server/routes/gitlab.routes.ts: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import { GitlabController } from "../controllers/gitlab.controller"; 3 | 4 | const router = express.Router(); 5 | const gitlabController = new GitlabController(); 6 | 7 | router.post("/sync", gitlabController.syncGitLabAsync); 8 | router.post("/create-merge-request", gitlabController.createMergeRequestAsync); 9 | router.post("/assign", gitlabController.assignToUserAsync); 10 | router.post("/comment", gitlabController.commentOnTaskAsync); 11 | router.post("/resolve", gitlabController.resolveThreadAsync); 12 | router.post("/toggle-draft", gitlabController.toggleDraft); 13 | router.post("/mark_as_done", gitlabController.markTodoAsDone); 14 | router.get("/user", gitlabController.getUserAsync); 15 | router.get("/users", gitlabController.getUsersAsync); 16 | router.get("/projects", gitlabController.getProjectsAsync); 17 | router.get("/todos", gitlabController.getTodosAsync); 18 | 19 | export default router; 20 | -------------------------------------------------------------------------------- /packages/server/routes/project.router.ts: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import { ProjectController } from "../controllers/project.controller"; 3 | 4 | const router = express.Router(); 5 | const projectController = new ProjectController(); 6 | 7 | router.get("/", projectController.getProjectsAsync); 8 | router.get("/current", projectController.getCurrentProjectAsync); 9 | router.get("/:id", projectController.getProjectByIdAsync); 10 | router.post("/", projectController.createProjectAsync); 11 | router.put("/:id", projectController.updateProjectAsync); 12 | router.delete("/:id", projectController.deleteProjectAsync); 13 | router.patch("/", projectController.setCurrentProjectAsync); 14 | 15 | export default router; 16 | -------------------------------------------------------------------------------- /packages/server/routes/rule.router.ts: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import { RuleController } from "../controllers/rule.controller"; 3 | 4 | const router = express.Router(); 5 | const ruleController = new RuleController(); 6 | 7 | router.get("/emitters", ruleController.getEmittersAsync); 8 | router.get("/listeners", ruleController.getListenersAsync); 9 | router.post("/", ruleController.createRuleAsync); 10 | router.get("/", ruleController.getAllRulesAsync); 11 | router.delete("/:id", ruleController.deleteRuleAsync); 12 | router.put("/toggle/:id", ruleController.toggleRuleAsync); 13 | 14 | export default router; 15 | -------------------------------------------------------------------------------- /packages/server/routes/setting.router.ts: -------------------------------------------------------------------------------- 1 | import { SettingsController } from "@server/controllers/settings.controller"; 2 | import { SettingRepo } from "@server/repositories/setting.repo"; 3 | import express from "express"; 4 | import { SettingKeys } from "shared"; 5 | 6 | const router = express.Router(); 7 | const settingsRepo = new SettingRepo(); 8 | const controller = new SettingsController(); 9 | 10 | router.get("/lastSync", async (req, res) => { 11 | res.send( 12 | (await settingsRepo.getByKeyAsync(SettingKeys.LAST_SYNC))?.value ?? null, 13 | ); 14 | }); 15 | 16 | router.get("/setup-status", controller.getSetupStatus); 17 | router.get("/gitlab-url", controller.getGitlabUrl); 18 | router.post("/validate-gitlab", controller.validateGitlabConnection); 19 | router.post("/gitlab-config", controller.saveGitlabConfig); 20 | router.post("/complete-setup", controller.completeSetup); 21 | 22 | export default router; 23 | -------------------------------------------------------------------------------- /packages/server/routes/task.router.ts: -------------------------------------------------------------------------------- 1 | import { TaskController } from "@server/controllers/task.controller"; 2 | import express from "express"; 3 | 4 | const router = express.Router(); 5 | const taskController = new TaskController(); 6 | 7 | router.post("/", taskController.createTaskAsync); 8 | router.get("/", taskController.getTasksAsync); 9 | router.put("/order", taskController.updateTaskOrderAsync); 10 | router.get("/discussions", taskController.getDiscussions); 11 | router.put("/:id", taskController.updateTaskAsync); 12 | router.patch("/:id", taskController.restoreTaskAsync); 13 | router.delete("/:ids", taskController.deleteTaskAsync); 14 | 15 | export default router; 16 | -------------------------------------------------------------------------------- /packages/server/routes/timeTrack.router.ts: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import { TimeTrackController } from "../controllers/timeTrack.controller"; 3 | 4 | const router = express.Router(); 5 | const timeTrackController = new TimeTrackController(); 6 | 7 | router.get("/", timeTrackController.getTimeTrackEntriesAsync); 8 | router.get("/recording", timeTrackController.getRecordingStateAsync); 9 | router.put("/recording", timeTrackController.toggleRecordingAsync); 10 | router.put("/:id", timeTrackController.updateTimeTrackEntryAsync); 11 | 12 | export default router; 13 | -------------------------------------------------------------------------------- /packages/server/services/actions/gitlabActionHandler.ts: -------------------------------------------------------------------------------- 1 | import { GitlabClient } from "@server/clients/gitlab.client"; 2 | import { ruleAction } from "../../decorators/ruleAction.decorator"; 3 | import { ActionTypes, EventNamespaces } from "../../events/eventTypes"; 4 | 5 | class GitlabActionHandler { 6 | private gitlabClient = new GitlabClient(); 7 | 8 | @ruleAction({ 9 | eventNamespace: EventNamespaces.GitLab, 10 | eventName: ActionTypes.UpdateAssignee, 11 | hasParams: true, 12 | }) 13 | async updateAssigneeAsync(data: any, eventData: any) { 14 | try { 15 | const { id } = eventData; 16 | const { projectId, gitlabIid } = data.data; 17 | 18 | await this.gitlabClient.updateAssignee(projectId, gitlabIid, id); 19 | } catch (error: any) { 20 | throw error; 21 | } 22 | } 23 | } 24 | 25 | export default GitlabActionHandler; 26 | -------------------------------------------------------------------------------- /packages/server/services/actions/index.ts: -------------------------------------------------------------------------------- 1 | import "./taskActionHandler"; 2 | import "./gitlabActionHandler"; 3 | -------------------------------------------------------------------------------- /packages/server/services/actions/taskActionHandler.ts: -------------------------------------------------------------------------------- 1 | import { ruleAction } from "@server/decorators/ruleAction.decorator"; 2 | import { MochiError } from "@server/errors/mochi.error"; 3 | import { EventNamespaces, ActionTypes } from "@server/events/eventTypes"; 4 | import TaskService from "@server/services/task.service"; 5 | import { logError } from "@server/utils/logger"; 6 | import { MochiResult } from "@server/utils/mochiResult"; 7 | import type { ITask } from "shared/types/task"; 8 | 9 | class TaskActionHandler { 10 | private _service: TaskService; 11 | 12 | constructor() { 13 | this._service = new TaskService(); 14 | } 15 | 16 | @ruleAction({ 17 | eventNamespace: EventNamespaces.Task, 18 | eventName: ActionTypes.Move, 19 | hasParams: true, 20 | }) 21 | async moveTaskAsync(data: MochiResult, eventData: any) { 22 | try { 23 | const { _id } = data.data as ITask; 24 | const status = eventData; 25 | 26 | await this._service.moveTaskAsync(_id as string, status); 27 | } catch (error: any) { 28 | logError(new MochiError("Error in moveTaskAsync", 500, error)); 29 | throw error; 30 | } 31 | } 32 | 33 | @ruleAction({ 34 | eventNamespace: EventNamespaces.Task, 35 | eventName: ActionTypes.Delete, 36 | }) 37 | async deleteTaskAsync(data: MochiResult) { 38 | try { 39 | await this._service.setDeletedAsync(data.data._id as string); 40 | } catch (error: any) { 41 | logError(new MochiError("Error in deleteTaskAsync", 500, error)); 42 | throw error; 43 | } 44 | } 45 | 46 | @ruleAction({ 47 | eventNamespace: EventNamespaces.Task, 48 | eventName: ActionTypes.Restore, 49 | }) 50 | async restoreTaskAsync(data: MochiResult) { 51 | try { 52 | await this._service.restoreTaskAsync(data.data._id as string); 53 | } catch (error: any) { 54 | logError(new MochiError("Error in restoreTaskAsync", 500, error)); 55 | throw error; 56 | } 57 | } 58 | } 59 | 60 | export default TaskActionHandler; 61 | -------------------------------------------------------------------------------- /packages/server/services/appState.service.ts: -------------------------------------------------------------------------------- 1 | import type { IAppStateEntry } from "../models/appState.model"; 2 | import { AppStateRepo } from "../repositories/appState.repo"; 3 | 4 | export class AppStateService { 5 | private appStateRepo: AppStateRepo; 6 | 7 | constructor() { 8 | this.appStateRepo = new AppStateRepo(); 9 | } 10 | 11 | async getAppState(key: string): Promise { 12 | return this.appStateRepo.findOneAsync({ key }); 13 | } 14 | 15 | async setAppState(key: string, value: string): Promise { 16 | const existing = await this.appStateRepo.findOneAsync({ key }); 17 | if (existing) { 18 | return this.appStateRepo.updateAsync(existing._id as string, { value }); 19 | } else { 20 | return this.appStateRepo.createAsync({ key, value }); 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /packages/server/services/base.service.ts: -------------------------------------------------------------------------------- 1 | import type BaseRepo from "../repositories/base.repo"; 2 | import type { Document } from "mongoose"; 3 | 4 | export abstract class BaseService { 5 | protected repository: BaseRepo; 6 | protected entityName: string; 7 | 8 | constructor(repository: BaseRepo, entityName: string) { 9 | this.repository = repository; 10 | this.entityName = entityName; 11 | } 12 | 13 | protected async createAsync(data: Partial): Promise { 14 | const entity = await this.repository.createAsync(data); 15 | 16 | return entity; 17 | } 18 | 19 | protected async updateAsync(id: string, data: Partial): Promise { 20 | const entity = await this.repository.updateAsync(id, data); 21 | 22 | return entity; 23 | } 24 | 25 | protected async deleteAsync(id: string): Promise { 26 | await this.repository.deleteAsync(id); 27 | } 28 | 29 | async getByIdAsync(id: string): Promise { 30 | return await this.repository.getByIdAsync(id); 31 | } 32 | 33 | async findOneAsync(filter: Record): Promise { 34 | return await this.repository.findOneAsync(filter); 35 | } 36 | 37 | async getAllAsync(filter?: Record | null): Promise { 38 | return await this.repository.getAllAsync(filter); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /packages/server/services/discussion.service.ts: -------------------------------------------------------------------------------- 1 | import { DiscussionRepo } from "@server/repositories/discussion.repo"; 2 | import type { IDiscussion } from "shared/types/task"; 3 | import { MochiError } from "../errors/mochi.error"; 4 | import { BaseService } from "./base.service"; 5 | 6 | export class DiscussionService extends BaseService { 7 | constructor() { 8 | super(new DiscussionRepo(), "Discussion"); 9 | } 10 | 11 | async getDiscussionsByTaskId(id: string) { 12 | try { 13 | return await super.getAllAsync({ taskId: id }); 14 | } catch (error: any) { 15 | throw new MochiError("Error fetching discussions", 500, error); 16 | } 17 | } 18 | 19 | async upsertDiscussion(discussion: IDiscussion, taskId: string) { 20 | if (!discussion || !taskId) { 21 | throw new MochiError("Invalid discussion or task ID", 400); 22 | } 23 | 24 | const existing = await this.repository.findOneAsync({ 25 | discussionId: discussion.discussionId, 26 | }); 27 | 28 | if (existing) { 29 | if (existing.notes?.length !== discussion.notes?.length) { 30 | existing.set({ 31 | notes: discussion.notes, 32 | }); 33 | } 34 | } else { 35 | super.createAsync({ 36 | ...discussion, 37 | taskId, 38 | }); 39 | } 40 | } 41 | 42 | async deleteDiscussionAsync(id: string) { 43 | try { 44 | return super.deleteAsync(id); 45 | } catch (error: any) { 46 | throw new MochiError("Error deleting rule", 500, error); 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /packages/server/services/emitters/index.ts: -------------------------------------------------------------------------------- 1 | import "./taskEventEmitter"; 2 | -------------------------------------------------------------------------------- /packages/server/services/emitters/taskEventEmitter.ts: -------------------------------------------------------------------------------- 1 | import { ruleEvent } from "@server/decorators/ruleEvent.decorator"; 2 | import { MochiError } from "@server/errors/mochi.error"; 3 | import { EventNamespaces, EventTypes } from "@server/events/eventTypes"; 4 | import TaskService from "@server/services/task.service"; 5 | import { MochiResult } from "@server/utils/mochiResult"; 6 | import type { ITask } from "shared/types/task"; 7 | 8 | class TaskEventEmitter extends TaskService { 9 | constructor() { 10 | super(); 11 | } 12 | 13 | @ruleEvent(EventNamespaces.Task, EventTypes.Created) 14 | async createTaskAsync(projectId: string, task: Partial) { 15 | try { 16 | if (!projectId) { 17 | throw new MochiError("No project selected", 404); 18 | } 19 | 20 | task.projectId = projectId; 21 | 22 | const createdTask = await super.createAsync(task as ITask); 23 | return new MochiResult(createdTask); 24 | } catch (error: any) { 25 | return new MochiResult(null, error); 26 | } 27 | } 28 | 29 | @ruleEvent(EventNamespaces.Task, EventTypes.Updated) 30 | async updateTaskAsync(id: string, task: Partial) { 31 | try { 32 | const updatedTask = await super.updateAsync(id, task); 33 | 34 | if (!updatedTask) { 35 | throw new MochiError("Task not found", 404); 36 | } 37 | 38 | return new MochiResult(updatedTask); 39 | } catch (error: any) { 40 | return new MochiResult(null, error); 41 | } 42 | } 43 | 44 | @ruleEvent(EventNamespaces.Task, EventTypes.Deleted) 45 | async deleteTaskAsync(id: string) { 46 | try { 47 | const deletedTask = await super.setDeletedAsync(id); 48 | if (!deletedTask) { 49 | throw new MochiError("Task not found", 404); 50 | } 51 | 52 | return new MochiResult(deletedTask); 53 | } catch (error: any) { 54 | return new MochiResult(null, error); 55 | } 56 | } 57 | } 58 | 59 | export default TaskEventEmitter; 60 | -------------------------------------------------------------------------------- /packages/server/services/rule.service.ts: -------------------------------------------------------------------------------- 1 | import { MochiError } from "../errors/mochi.error"; 2 | import type { IRule } from "../models/rule.model"; 3 | import { RuleRepo } from "../repositories/rule.repo"; 4 | import { BaseService } from "./base.service"; 5 | 6 | export class RuleService extends BaseService { 7 | constructor() { 8 | super(new RuleRepo(), "Rule"); 9 | } 10 | 11 | async createRuleAsync(rule: IRule) { 12 | try { 13 | return super.createAsync(rule); 14 | } catch (error: any) { 15 | throw new MochiError("Error creating rule", 500, error); 16 | } 17 | } 18 | 19 | async deleteRuleAsync(id: string) { 20 | try { 21 | return super.deleteAsync(id); 22 | } catch (error: any) { 23 | throw new MochiError("Error deleting rule", 500, error); 24 | } 25 | } 26 | 27 | async toggleRuleAsync(id: string) { 28 | try { 29 | const rule = await super.getByIdAsync(id); 30 | 31 | if (!rule) { 32 | throw new Error("Rule not found"); 33 | } 34 | 35 | rule.enabled = !rule.enabled; 36 | 37 | return super.updateAsync(id, rule); 38 | } catch (error: any) { 39 | throw new MochiError("Error toggling rule", 500, error); 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /packages/server/services/task.service.ts: -------------------------------------------------------------------------------- 1 | import { MochiError } from "@server/errors/mochi.error"; 2 | import { TaskRepo } from "@server/repositories/task.repo"; 3 | import { BaseService } from "@server/services/base.service"; 4 | import { MochiResult } from "@server/utils/mochiResult"; 5 | import type { ITask } from "shared/types/task"; 6 | 7 | class TaskService extends BaseService { 8 | constructor() { 9 | super(new TaskRepo(), "Task"); 10 | } 11 | 12 | async getTasksAsync(projectId: string, showDeleted: boolean) { 13 | if (!projectId) { 14 | throw new MochiError("No project selected", 404); 15 | } 16 | 17 | const tasks = await super.getAllAsync({ 18 | projectId, 19 | }); 20 | 21 | return showDeleted ? tasks : tasks.filter((task) => !task.deleted); 22 | } 23 | 24 | async moveTaskAsync(taskId: string, status: string) { 25 | const updatedTask = await super.updateAsync(taskId, { status }); 26 | if (!updatedTask) { 27 | throw new MochiError("Task not found", 404); 28 | } 29 | return updatedTask; 30 | } 31 | 32 | async updateTaskOrderAsync(tasks: string[]) { 33 | const updatePromises = tasks.map((taskId, index) => { 34 | return super.updateAsync(taskId, { order: index }); 35 | }); 36 | 37 | await Promise.all(updatePromises); 38 | } 39 | 40 | async updateTaskAsync(id: string, task: Partial) { 41 | const updatedTask = await super.updateAsync(id, task); 42 | 43 | if (!updatedTask) { 44 | throw new MochiError("Task not found", 404); 45 | } 46 | 47 | return new MochiResult(updatedTask); 48 | } 49 | 50 | async restoreTaskAsync(id: string) { 51 | const restoredTask = await super.updateAsync(id, { 52 | deleted: false, 53 | }); 54 | if (!restoredTask) { 55 | throw new MochiError("Task not found", 404); 56 | } 57 | return restoredTask; 58 | } 59 | 60 | async setDeletedAsync(id: string) { 61 | const deletedTask = await super.updateAsync(id, { 62 | deleted: true, 63 | }); 64 | if (!deletedTask) { 65 | throw new MochiError("Task not found", 404); 66 | } 67 | return deletedTask; 68 | } 69 | } 70 | 71 | export { TaskService }; 72 | export default TaskService; 73 | -------------------------------------------------------------------------------- /packages/server/services/timeTrack.service.ts: -------------------------------------------------------------------------------- 1 | import { AppStateKey } from "../models/appState.model"; 2 | import type { ITimeTrackEntry } from "../models/timeTrack.model"; 3 | import { TimeTrackRepo } from "../repositories/timeTrack.repo"; 4 | import { MochiResult } from "../utils/mochiResult"; 5 | import { AppStateService } from "./appState.service"; 6 | import { BaseService } from "./base.service"; 7 | 8 | class TimeTrackService extends BaseService { 9 | private appStateService: AppStateService; 10 | 11 | constructor() { 12 | super(new TimeTrackRepo(), "TimeTrack"); 13 | 14 | this.appStateService = new AppStateService(); 15 | } 16 | 17 | public async updateTimeTrackEntryAsync(id: string, entry: ITimeTrackEntry) { 18 | try { 19 | return await super.updateAsync(id, entry); 20 | } catch (error: any) { 21 | return new MochiResult(null, error); 22 | } 23 | } 24 | 25 | public async toggleRecordingAsync() { 26 | const isRecording = await this.appStateService.getAppState( 27 | AppStateKey.Recording, 28 | ); 29 | 30 | if (isRecording?.value === undefined || isRecording.value === "false") { 31 | await this.appStateService.setAppState(AppStateKey.Recording, "true"); 32 | 33 | await this.createAsync({ 34 | start: new Date(), 35 | }); 36 | 37 | return true; 38 | } else { 39 | await this.appStateService.setAppState(AppStateKey.Recording, "false"); 40 | 41 | const currentEntry = await this.repository.findOneAsync({ 42 | end: undefined, 43 | }); 44 | 45 | if (currentEntry) { 46 | await this.updateAsync(currentEntry._id as string, { 47 | end: new Date(), 48 | }); 49 | } 50 | 51 | return false; 52 | } 53 | } 54 | 55 | public async getRecordingStateAsync(): Promise { 56 | const isRecording = await this.appStateService.getAppState( 57 | AppStateKey.Recording, 58 | ); 59 | return isRecording?.value === "true"; 60 | } 61 | 62 | public async getTimetrackEntriesAsync(): Promise { 63 | const startOfWeek = new Date(); 64 | startOfWeek.setHours(0, 0, 0, 0); 65 | startOfWeek.setDate(startOfWeek.getDate() - startOfWeek.getDay()); 66 | 67 | const endOfWeek = new Date(startOfWeek); 68 | endOfWeek.setDate(endOfWeek.getDate() + 7); 69 | 70 | return this.repository.getAllAsync({ 71 | start: { $gte: startOfWeek, $lt: endOfWeek }, 72 | }); 73 | } 74 | } 75 | 76 | export default TimeTrackService; 77 | -------------------------------------------------------------------------------- /packages/server/services/user.service.ts: -------------------------------------------------------------------------------- 1 | // services/UserService.ts 2 | import { UserRepo } from "../repositories/user.repo"; 3 | import type { IUser } from "../models/user.model"; 4 | 5 | export class UserService { 6 | private userRepo: UserRepo; 7 | 8 | constructor() { 9 | this.userRepo = new UserRepo(); 10 | } 11 | 12 | async getUser(): Promise { 13 | return this.userRepo.findOneAsync({}); 14 | } 15 | 16 | async createUser(userData: Partial): Promise { 17 | return this.userRepo.createAsync(userData); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /packages/server/sockets/index.ts: -------------------------------------------------------------------------------- 1 | import { Server, Socket } from "socket.io"; 2 | import { logInfo } from "../utils/logger"; 3 | 4 | export class SocketHandler { 5 | private static instance: SocketHandler | null = null; 6 | private io: Server | null = null; 7 | 8 | private constructor() {} // Private constructor to prevent direct instantiation 9 | 10 | // Method to get the singleton instance 11 | public static getInstance(): SocketHandler { 12 | if (!SocketHandler.instance) { 13 | SocketHandler.instance = new SocketHandler(); 14 | } 15 | return SocketHandler.instance; 16 | } 17 | 18 | // Init function to start the WebSocket connection 19 | public init(server: any) { 20 | if (this.io) { 21 | console.log("Socket.IO is already initialized."); 22 | return; 23 | } 24 | 25 | this.io = new Server(server, { 26 | cors: { 27 | origin: "*", // You can specify allowed origins here 28 | }, 29 | }); 30 | 31 | this.io.on("connection", (socket: Socket) => { 32 | console.log("New connection: " + socket.id); 33 | logInfo("New connection: " + socket.id); 34 | 35 | // Listen for ping events and respond with pong 36 | socket.on("ping", () => { 37 | socket.emit("pong"); 38 | }); 39 | 40 | socket.on("disconnect", () => { 41 | logInfo("Disconnected: " + socket.id); 42 | }); 43 | }); 44 | } 45 | 46 | // Method to get the io instance 47 | public getIO(): Server { 48 | if (!this.io) { 49 | throw new Error("Socket.IO not initialized"); 50 | } 51 | return this.io; 52 | } 53 | } 54 | 55 | export default SocketHandler; 56 | -------------------------------------------------------------------------------- /packages/server/syncs/gitlab/noteCount.processor.ts: -------------------------------------------------------------------------------- 1 | import { GitlabClient } from "@server/clients/gitlab.client"; 2 | import type { FieldProcessor } from "shared/types"; 3 | import type { ITask } from "shared/types/task"; 4 | 5 | export default class NoteCountProcessor implements FieldProcessor { 6 | fieldName = "noteCount"; 7 | private client = new GitlabClient(); 8 | 9 | async process(entity: any, task: ITask, projectId: string): Promise { 10 | const user = await this.client.getUserByAccessToken(); 11 | const notes = await this.client.request({ 12 | endpoint: `/projects/${projectId}/${task.type}s/${task.gitlabIid}/notes`, 13 | method: "GET", 14 | }); 15 | 16 | task.relevantDiscussionCount = notes.filter( 17 | (item: any) => 18 | !item.resolved && !item.system && item.body.includes(user.username), 19 | ).length; 20 | } 21 | 22 | shouldProcess(entity: any): boolean { 23 | return entity.type === "merge_request"; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /packages/server/syncs/gitlab/pipeline.processor.ts: -------------------------------------------------------------------------------- 1 | import { GitlabClient } from "@server/clients/gitlab.client"; 2 | import type { FieldProcessor } from "shared/types"; 3 | import type { ITask } from "shared/types/task"; 4 | 5 | export default class PipelineProcessor implements FieldProcessor { 6 | fieldName = "pipeline"; 7 | private client = new GitlabClient(); 8 | 9 | async process( 10 | entity: any, 11 | task: Partial, 12 | projectId: string, 13 | ): Promise { 14 | const pipelineState = await this.client.getPipelineState( 15 | projectId, 16 | task.gitlabIid!, 17 | ); 18 | 19 | task.pipelineStatus = pipelineState.pipelineStatus; 20 | task.latestPipelineId = pipelineState.latestPipelineId; 21 | task.pipelineReports = pipelineState.pipelineReports; 22 | } 23 | 24 | shouldProcess(entity: any): boolean { 25 | return entity.type === "merge_request"; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /packages/server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "allowSyntheticDefaultImports": true, 5 | "esModuleInterop": true, 6 | "jsx": "preserve", 7 | "types": ["bun-types"], 8 | "noEmit": true, 9 | "isolatedModules": true, 10 | 11 | "baseUrl": "./", 12 | "paths": { 13 | "@server/*": ["*"] 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /packages/server/utils/asyncContext.ts: -------------------------------------------------------------------------------- 1 | import { AsyncLocalStorage } from "async_hooks"; 2 | 3 | export enum ContextKeys { 4 | Project = "currentProject", 5 | } 6 | 7 | export const asyncLocalStorage = new AsyncLocalStorage>(); 8 | 9 | export function setContext(key: string, value: any) { 10 | const store = asyncLocalStorage.getStore(); 11 | if (store) { 12 | store.set(key, value); 13 | } 14 | } 15 | 16 | export function getContext(key: string) { 17 | const store = asyncLocalStorage.getStore(); 18 | if (store) { 19 | return store.get(key); 20 | } 21 | } 22 | 23 | export function clearContext() { 24 | asyncLocalStorage.run(new Map(), () => {}); 25 | } 26 | -------------------------------------------------------------------------------- /packages/server/utils/chunkArray.ts: -------------------------------------------------------------------------------- 1 | export function chunkArray(arr: T[], size: number): T[][] { 2 | const chunks = []; 3 | for (let i = 0; i < arr.length; i += size) { 4 | chunks.push(arr.slice(i, i + size)); 5 | } 6 | return chunks; 7 | } 8 | -------------------------------------------------------------------------------- /packages/server/utils/fetchAllFromPaginatedApi.ts: -------------------------------------------------------------------------------- 1 | import type { IPagination } from "shared/types/pagination"; 2 | 3 | export const fetchAllFromPaginatedApiAsync = async ( 4 | requestFn: (pagination: Partial) => any, 5 | ) => { 6 | let nextPage: number | null = 1; 7 | const limit = 100; 8 | const allEntries: any[] = []; 9 | 10 | while (nextPage != null) { 11 | const { data, pagination } = await requestFn({ 12 | currentPage: nextPage, 13 | limit, 14 | }); 15 | 16 | allEntries.push(...data); 17 | nextPage = !isNaN(pagination.nextPage!) ? pagination.nextPage : null; 18 | } 19 | 20 | return allEntries; 21 | }; 22 | -------------------------------------------------------------------------------- /packages/server/utils/findFile.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import path from "path"; 3 | 4 | export const findClassFile = ( 5 | className: string, 6 | startPath: string 7 | ): string | null => { 8 | const files = fs.readdirSync(startPath); 9 | 10 | for (const file of files) { 11 | const filePath = path.join(startPath, file); 12 | const stat = fs.statSync(filePath); 13 | 14 | if (stat.isDirectory()) { 15 | const found = findClassFile(className, filePath); 16 | if (found) return found; 17 | } else if ( 18 | file.toLowerCase() === `${className.toLowerCase()}.js` || 19 | file.toLowerCase() === `${className.toLowerCase()}.ts` 20 | ) { 21 | return filePath; 22 | } else { 23 | } 24 | } 25 | 26 | return null; 27 | }; 28 | -------------------------------------------------------------------------------- /packages/server/utils/logger.ts: -------------------------------------------------------------------------------- 1 | import type { MochiError } from "../errors/mochi.error"; 2 | 3 | export enum MessageType { 4 | INFO = "INFO", 5 | ERROR = "ERROR", 6 | } 7 | 8 | export const addLog = (message: string, type: MessageType): void => { 9 | console.log( 10 | `[BACKEND] [${type}] [${new Date().toLocaleString("de-DE", { 11 | timeZone: "Europe/Berlin", 12 | })}] ${message}`, 13 | ); 14 | }; 15 | 16 | export const logInfo = (message: string): void => { 17 | addLog(message, MessageType.INFO); 18 | }; 19 | 20 | export const logError = (error: MochiError): void => { 21 | addLog(`${error.message} ${error?.stack ?? ""}`, MessageType.ERROR); 22 | }; 23 | -------------------------------------------------------------------------------- /packages/server/utils/mochiResult.ts: -------------------------------------------------------------------------------- 1 | export class MochiResult { 2 | constructor(public data: any, public error: Error | null = null) {} 3 | 4 | static empty() { 5 | return new MochiResult("", null); 6 | } 7 | 8 | } 9 | -------------------------------------------------------------------------------- /packages/server/utils/taskUtils.ts: -------------------------------------------------------------------------------- 1 | import type { ITask } from "shared/types/task"; 2 | 3 | export function createTaskData( 4 | entity: any, 5 | entityType: "merge_request" | "issue", 6 | ): Partial { 7 | return { 8 | labels: entity.labels, 9 | milestoneId: entity.milestone?.id, 10 | draft: entity.draft, 11 | branch: entity.source_branch, 12 | projectId: entity.project_id, 13 | gitlabId: entity.id, 14 | gitlabIid: entity.iid, 15 | web_url: entity.web_url, 16 | type: entityType, 17 | title: entity.title, 18 | description: entity.description, 19 | status: entity.state || "opened", 20 | custom: false, 21 | assignee: { 22 | authorId: entity.assignee.id, 23 | name: entity.assignee.name, 24 | username: entity.assignee.username, 25 | avatar_url: entity.assignee.avatar_url, 26 | }, 27 | }; 28 | } 29 | -------------------------------------------------------------------------------- /packages/server/utils/transformHelpers.ts: -------------------------------------------------------------------------------- 1 | export const transformNote = (note: any): any => { 2 | note.noteId = note.id.toString(); 3 | delete note.id; 4 | note.author.authorId = note.author.id.toString(); 5 | note.resolved = note.resolved || false; 6 | if (note.resolved && note.resolved_by) { 7 | note.resolved_by.authorId = note.resolved_by.id.toString(); 8 | } 9 | return note; 10 | }; 11 | 12 | export const transformDiscussion = (discussion: any): any => { 13 | discussion.discussionId = discussion.id.toString(); 14 | delete discussion.id; 15 | if (Array.isArray(discussion.notes)) { 16 | discussion.notes = discussion.notes.map((note: any) => transformNote(note)); 17 | } 18 | 19 | return discussion; 20 | }; 21 | -------------------------------------------------------------------------------- /packages/shared/enums/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./settings"; 2 | -------------------------------------------------------------------------------- /packages/shared/enums/settings.ts: -------------------------------------------------------------------------------- 1 | export enum SettingKeys { 2 | GITLAB_URL = "gitlab_url", 3 | PRIVATE_TOKEN = "private_token", 4 | SETUP_COMPLETE = "setup_complete", 5 | CURRENT_PROJECT = "current_project", 6 | LAST_SYNC = "last_sync", 7 | } 8 | -------------------------------------------------------------------------------- /packages/shared/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./utils/isEqual"; 2 | export * from "./enums/"; 3 | -------------------------------------------------------------------------------- /packages/shared/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "shared", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.ts", 6 | "type": "module", 7 | "keywords": [], 8 | "author": "C0dingOtter" 9 | } 10 | -------------------------------------------------------------------------------- /packages/shared/types/fieldProcessor.ts: -------------------------------------------------------------------------------- 1 | import type { ITask } from "./task"; 2 | 3 | export interface FieldProcessor { 4 | fieldName: string; 5 | process(entity: any, task: Partial, projectId: string): Promise; 6 | shouldProcess(entity: any): boolean; 7 | } 8 | -------------------------------------------------------------------------------- /packages/shared/types/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./syncer"; 2 | export * from "./request"; 3 | export * from "./fieldProcessor"; 4 | -------------------------------------------------------------------------------- /packages/shared/types/pagination.ts: -------------------------------------------------------------------------------- 1 | export interface IPagination { 2 | currentPage: number; 3 | prevPage: number | null; 4 | nextPage: number | null; 5 | totalPages: number; 6 | limit: number; 7 | } 8 | -------------------------------------------------------------------------------- /packages/shared/types/request.ts: -------------------------------------------------------------------------------- 1 | export type RequestParams = { 2 | endpoint: string; 3 | method: "GET" | "POST" | "PUT"; 4 | data?: any; 5 | }; 6 | -------------------------------------------------------------------------------- /packages/shared/types/syncer.ts: -------------------------------------------------------------------------------- 1 | import type { ITask } from "./task"; 2 | 3 | export interface Syncer { 4 | name: string; 5 | sync(fullSync?: boolean): Promise; 6 | } 7 | -------------------------------------------------------------------------------- /packages/shared/types/task.ts: -------------------------------------------------------------------------------- 1 | import { Document } from "mongoose"; 2 | 3 | export interface ITask extends Document { 4 | gitlabIid?: number; 5 | gitlabId?: number; 6 | web_url?: string; 7 | title: string; 8 | description?: string; 9 | milestoneId?: number; 10 | milestoneName?: string; 11 | labels?: string[]; 12 | branch?: string; 13 | status?: string; 14 | assignee?: IAuthor; 15 | type?: string; 16 | custom?: boolean; 17 | deleted?: boolean; 18 | order?: number; 19 | comments: IComment[]; 20 | draft: boolean; 21 | discussions?: IDiscussion[]; 22 | projectId?: string; 23 | latestPipelineId?: number; 24 | pipelineStatus?: string; 25 | pipelineReports?: IPipelineReport[]; 26 | relevantDiscussionCount?: number; 27 | } 28 | 29 | export interface IComment { 30 | originalId?: number; 31 | body: string; 32 | images?: string[]; 33 | resolved: boolean; 34 | author: { 35 | name: string; 36 | username: string; 37 | avatar_url: string; 38 | }; 39 | created_at?: string; 40 | system: boolean; 41 | } 42 | 43 | export interface IDiscussion extends Document { 44 | taskId: string; 45 | discussionId: string; 46 | individual_note: boolean; 47 | notes?: INote[]; 48 | } 49 | 50 | export interface IAuthor { 51 | authorId: number; 52 | name: string; 53 | username: string; 54 | avatar_url: string; 55 | } 56 | 57 | export interface INote { 58 | noteId: string; 59 | type: string; 60 | body: string; 61 | author: IAuthor; 62 | created_at: string; 63 | system: boolean; 64 | resolvable: boolean; 65 | resolved: boolean; 66 | resolved_by: IAuthor; 67 | resolved_at: string; 68 | } 69 | 70 | export interface IPipelineReport { 71 | name: string; 72 | classname: string; 73 | attachment_url: string; 74 | } 75 | -------------------------------------------------------------------------------- /packages/shared/utils/isEqual.ts: -------------------------------------------------------------------------------- 1 | import type { IDiscussion } from "../types/task"; 2 | 3 | export const discussionsEqual = ( 4 | oldDiscussions: IDiscussion[], 5 | newDiscussions: IDiscussion[] 6 | ): boolean => { 7 | if (oldDiscussions.length !== newDiscussions.length) { 8 | return false; 9 | } 10 | 11 | for (let i = 0; i < oldDiscussions.length; i++) { 12 | const oldNotes = oldDiscussions[i].notes; 13 | const newNotes = newDiscussions[i].notes; 14 | 15 | if (oldNotes === undefined || newNotes === undefined) { 16 | return false; 17 | } 18 | 19 | if (oldNotes.length !== newNotes.length) { 20 | return false; 21 | } 22 | 23 | for (let j = 0; j < (oldNotes.length || 0); j++) { 24 | if (oldNotes.at(j)?.body !== newNotes.at(j)?.body) { 25 | return false; 26 | } 27 | 28 | if (oldNotes.at(j)?.resolved !== newNotes.at(j)?.resolved) { 29 | return false; 30 | } 31 | } 32 | } 33 | 34 | return true; 35 | }; 36 | -------------------------------------------------------------------------------- /packages/shared/utils/random.ts: -------------------------------------------------------------------------------- 1 | function cyrb53(str: string, seed = 0) { 2 | let h1 = 0xdeadbeef ^ seed, 3 | h2 = 0x41c6ce57 ^ seed; 4 | for (let i = 0, ch; i < str.length; i++) { 5 | ch = str.charCodeAt(i); 6 | h1 = Math.imul(h1 ^ ch, 2654435789); 7 | h2 = Math.imul(h2 ^ ch, 1597334677); 8 | } 9 | h1 = 10 | Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ 11 | Math.imul(h2 ^ (h2 >>> 13), 3266489909); 12 | h2 = 13 | Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ 14 | Math.imul(h1 ^ (h1 >>> 13), 3266489909); 15 | return 4294967296 * (2097151 & h2) + (h1 >>> 0); 16 | } 17 | 18 | export const random = (seedStr: string) => { 19 | const seed = cyrb53(seedStr); 20 | const m = 2 ** 35 - 31; 21 | const a = 185852; 22 | let s = seed % m; 23 | 24 | return (s = (s * a) % m) / m; 25 | }; 26 | -------------------------------------------------------------------------------- /scripts/setup.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # This installer uses basic ANSI escape codes for colors and does not rely on external tools like dialog. 4 | # Make sure your terminal supports ANSI colors. 5 | 6 | # ANSI Color Codes 7 | RED='\033[0;31m' 8 | GREEN='\033[0;32m' 9 | CYAN='\033[0;36m' 10 | BOLD='\033[1m' 11 | RESET='\033[0m' 12 | 13 | LOGO=' ##---## ##---# 14 | #--------.# #.--------# 15 | .++++++---##-------###----+++++# 16 | #.++---#+#---------------#++--+-.# 17 | #.---#--...-----------------#---.# 18 | #.+#----+++-------------------#-.#.# 19 | #------------------------------#.#...# 20 | ----++++++++++----+++++++++----#.....# 21 | #--+#..........#++#.........-+---#..... 22 | #--+.....###.............#.....+--#-.#.-# 23 | #-+......###...........####.....+---.+--# 24 | #-....##........###........##....+-#----# 25 | #-.....+.......#.#.........--.....------# 26 | #-................................#----- 27 | #................................#----# 28 | ...............................#----# 29 | .............................#---# 30 | #..........................#--## 31 | #......................#-## 32 | ##................# 33 | 34 | ' 35 | 36 | clear 37 | echo -e "${CYAN}${LOGO}${RESET}" 38 | echo -e "${BOLD}${CYAN}Kanban Board Installer${RESET}" 39 | echo -e "This script will help you create a .env file with the required configuration." 40 | echo 41 | 42 | # Prompt for PRIVATE_TOKEN 43 | echo -en "${BOLD}Please enter your PRIVATE_TOKEN:${RESET} " 44 | read -r PRIVATE_TOKEN 45 | 46 | # Prompt for GIT_URL 47 | echo -en "${BOLD}Please enter your GIT_URL (e.g. https://www.gitlab.at):${RESET} " 48 | read -r GIT_URL 49 | 50 | # Write the .env file 51 | cat > ../.env <