├── packages ├── api │ ├── README.md │ ├── src │ │ ├── errors │ │ │ ├── index.ts │ │ │ ├── asyncCatch.ts │ │ │ ├── customErrors.ts │ │ │ └── gqlError.ts │ │ ├── types │ │ │ ├── context.ts │ │ │ ├── express.d.ts │ │ │ └── env.d.ts │ │ ├── constants │ │ │ ├── project.ts │ │ │ └── issue.ts │ │ ├── models │ │ │ ├── index.ts │ │ │ ├── Comment.ts │ │ │ ├── User.ts │ │ │ ├── Project.ts │ │ │ └── Issue.ts │ │ ├── utils │ │ │ ├── javascript.ts │ │ │ ├── authToken.ts │ │ │ ├── validations.ts │ │ │ └── typeorm.ts │ │ ├── middlewares │ │ │ ├── resolveTime.ts │ │ │ ├── isAuth.ts │ │ │ └── errorInterceptor.ts │ │ ├── gql │ │ │ ├── index.ts │ │ │ ├── auth.ts │ │ │ ├── user.ts │ │ │ ├── comments.ts │ │ │ ├── project.ts │ │ │ ├── plugins │ │ │ │ └── sentry.ts │ │ │ ├── types.ts │ │ │ └── issues.ts │ │ ├── database │ │ │ ├── resetDatabase.ts │ │ │ ├── createTestConnection.ts │ │ │ ├── createConnection.ts │ │ │ ├── createTestAccount.ts │ │ │ └── createGuestAccount.ts │ │ └── index.ts │ ├── .turbo │ │ └── turbo-build.log │ ├── .env.example │ ├── tslint.json │ ├── scripts │ │ └── pg.sh │ ├── docker-compose.yml │ ├── tsconfig.json │ └── package.json └── web │ ├── public │ ├── _redirects │ ├── robots.txt │ ├── favicon.png │ └── fonts │ │ ├── CircularStd-Bold.woff │ │ ├── CircularStd-Book.woff │ │ ├── CircularStd-Black.woff │ │ ├── CircularStd-Black.woff2 │ │ ├── CircularStd-Bold.woff2 │ │ ├── CircularStd-Book.woff2 │ │ ├── CircularStd-Medium.woff │ │ └── CircularStd-Medium.woff2 │ ├── babel.config.js │ ├── .prettierrc │ ├── jest.config.js │ ├── postcss.config.cjs │ ├── src │ ├── assets │ │ └── img │ │ │ └── mountains.jpg │ ├── utils │ │ ├── eventBus.ts │ │ ├── authToken.ts │ │ ├── date.ts │ │ ├── colors.ts │ │ └── dnd.ts │ ├── types │ │ ├── filters.ts │ │ ├── index.ts │ │ ├── comment.ts │ │ ├── user.ts │ │ ├── project.ts │ │ └── issue.ts │ ├── vite-env.d.ts │ ├── components │ │ ├── shared │ │ │ ├── TextEditor │ │ │ │ ├── editor.ts │ │ │ │ └── TextEditor.vue │ │ │ ├── Breadcrumbs │ │ │ │ └── Breadcrumbs.vue │ │ │ ├── Icon │ │ │ │ └── Icon.vue │ │ │ ├── Avatar │ │ │ │ └── Avatar.vue │ │ │ ├── Textarea │ │ │ │ └── Textarea.vue │ │ │ ├── Input │ │ │ │ └── Input.vue │ │ │ ├── Button │ │ │ │ └── Button.vue │ │ │ └── Select │ │ │ │ └── Dropdown.vue │ │ ├── Navigation │ │ │ ├── Sidebar.ts │ │ │ ├── Navigation.vue │ │ │ ├── Sidebar.vue │ │ │ ├── Resizer.vue │ │ │ └── NavbarLeft.vue │ │ ├── ErrorPage.vue │ │ ├── Project │ │ │ ├── Issue │ │ │ │ ├── IssueDetails │ │ │ │ │ ├── Title.vue │ │ │ │ │ ├── Type.vue │ │ │ │ │ ├── Status.vue │ │ │ │ │ ├── Description.vue │ │ │ │ │ ├── Priority.vue │ │ │ │ │ ├── AssigneesReporter.vue │ │ │ │ │ └── Comment.vue │ │ │ │ ├── IssueSearch │ │ │ │ │ ├── SearchResult.vue │ │ │ │ │ └── IssueSearch.vue │ │ │ │ └── Issue.vue │ │ │ ├── IssueLoader.vue │ │ │ ├── Lists │ │ │ │ ├── Lists.vue │ │ │ │ └── List.vue │ │ │ └── Filters.vue │ │ ├── Modals │ │ │ ├── Confirm.vue │ │ │ ├── Modal.vue │ │ │ └── Modals.vue │ │ └── Loader.vue │ ├── plugins │ │ ├── toast.ts │ │ ├── loadSvg.ts │ │ ├── register.ts │ │ └── tippy.ts │ ├── graphql │ │ ├── queries │ │ │ ├── comment.ts │ │ │ ├── auth.ts │ │ │ ├── project.ts │ │ │ └── issue.ts │ │ └── client.ts │ ├── main.ts │ ├── auth │ │ └── authenticate.ts │ ├── store.ts │ ├── views │ │ ├── FullIIssueDetails.vue │ │ ├── Board.vue │ │ ├── Project.vue │ │ └── Settings.vue │ ├── hooks │ │ ├── useOutsideClick.ts │ │ └── useClipboard.ts │ ├── fonts.scss │ ├── main.scss │ ├── router.ts │ └── App.vue │ ├── tsconfig.node.json │ ├── vite.config.ts │ ├── .eslintrc.json │ ├── tsconfig.json │ ├── index.html │ ├── package.json │ ├── .turbo │ └── turbo-build.log │ └── tailwind.config.cjs ├── pnpm-workspace.yaml ├── netlify.toml ├── .gitignore ├── turbo.json ├── package.json ├── LICENCE └── README.md /packages/api/README.md: -------------------------------------------------------------------------------- 1 | # Backend 2 | -------------------------------------------------------------------------------- /packages/web/public/_redirects: -------------------------------------------------------------------------------- 1 | /* /index.html 200 -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - "packages/*" 3 | -------------------------------------------------------------------------------- /packages/web/public/robots.txt: -------------------------------------------------------------------------------- 1 | 2 | User-agent: * 3 | Disallow: -------------------------------------------------------------------------------- /packages/web/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: ['@vue/cli-plugin-babel/preset'] 3 | } 4 | -------------------------------------------------------------------------------- /packages/api/src/errors/index.ts: -------------------------------------------------------------------------------- 1 | export * from './customErrors'; 2 | export { catchErrors } from './asyncCatch'; 3 | -------------------------------------------------------------------------------- /packages/web/public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Datlyfe/jira_clone/HEAD/packages/web/public/favicon.png -------------------------------------------------------------------------------- /packages/web/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 2, 3 | "printWidth": 80, 4 | "semi": false, 5 | "singleQuote": true 6 | } 7 | -------------------------------------------------------------------------------- /packages/web/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: '@vue/cli-plugin-unit-jest/presets/typescript-and-babel' 3 | } 4 | -------------------------------------------------------------------------------- /packages/api/.turbo/turbo-build.log: -------------------------------------------------------------------------------- 1 | 2 | > api@1.0.0 build /Users/moezbouaggad/personal/jira_clone/packages/api 3 | > tsc 4 | 5 | -------------------------------------------------------------------------------- /packages/web/postcss.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } -------------------------------------------------------------------------------- /packages/web/src/assets/img/mountains.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Datlyfe/jira_clone/HEAD/packages/web/src/assets/img/mountains.jpg -------------------------------------------------------------------------------- /packages/web/public/fonts/CircularStd-Bold.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Datlyfe/jira_clone/HEAD/packages/web/public/fonts/CircularStd-Bold.woff -------------------------------------------------------------------------------- /packages/web/public/fonts/CircularStd-Book.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Datlyfe/jira_clone/HEAD/packages/web/public/fonts/CircularStd-Book.woff -------------------------------------------------------------------------------- /packages/web/src/utils/eventBus.ts: -------------------------------------------------------------------------------- 1 | import Mitt from 'mitt' 2 | 3 | type Events = { [key: string]: any } 4 | 5 | export default Mitt() 6 | -------------------------------------------------------------------------------- /packages/web/public/fonts/CircularStd-Black.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Datlyfe/jira_clone/HEAD/packages/web/public/fonts/CircularStd-Black.woff -------------------------------------------------------------------------------- /packages/web/public/fonts/CircularStd-Black.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Datlyfe/jira_clone/HEAD/packages/web/public/fonts/CircularStd-Black.woff2 -------------------------------------------------------------------------------- /packages/web/public/fonts/CircularStd-Bold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Datlyfe/jira_clone/HEAD/packages/web/public/fonts/CircularStd-Bold.woff2 -------------------------------------------------------------------------------- /packages/web/public/fonts/CircularStd-Book.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Datlyfe/jira_clone/HEAD/packages/web/public/fonts/CircularStd-Book.woff2 -------------------------------------------------------------------------------- /packages/web/public/fonts/CircularStd-Medium.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Datlyfe/jira_clone/HEAD/packages/web/public/fonts/CircularStd-Medium.woff -------------------------------------------------------------------------------- /packages/web/public/fonts/CircularStd-Medium.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Datlyfe/jira_clone/HEAD/packages/web/public/fonts/CircularStd-Medium.woff2 -------------------------------------------------------------------------------- /packages/api/src/types/context.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express"; 2 | export interface GQLContext { 3 | req: Request; 4 | res: Response; 5 | } 6 | -------------------------------------------------------------------------------- /packages/api/src/types/express.d.ts: -------------------------------------------------------------------------------- 1 | declare namespace Express { 2 | export interface Request { 3 | currentUser: import("@/models").User; 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /packages/api/src/constants/project.ts: -------------------------------------------------------------------------------- 1 | export enum ProjectCategory { 2 | SOFTWARE = "software", 3 | MARKETING = "marketing", 4 | BUSINESS = "business" 5 | } 6 | -------------------------------------------------------------------------------- /packages/api/.env.example: -------------------------------------------------------------------------------- 1 | DATABASE_URL=postgres://user:password@localhost:5432/jira_clone_gql?sslmode=disable 2 | JWT_SECRET=secret123 3 | SENTRY_DSN=https://sentry.io/... 4 | -------------------------------------------------------------------------------- /netlify.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | base = "packages/web" 3 | publish = "dist/" 4 | command = "npm run build" 5 | 6 | [[redirects]] 7 | from = "/*" 8 | to = "/index.html" 9 | status = 200 10 | -------------------------------------------------------------------------------- /packages/web/src/types/filters.ts: -------------------------------------------------------------------------------- 1 | export interface Filters { 2 | searchTerm: string 3 | userIds: string[] 4 | myOnly: boolean 5 | recent: boolean 6 | } 7 | 8 | export default Filters 9 | -------------------------------------------------------------------------------- /packages/api/src/models/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Comment } from "./Comment"; 2 | export { default as Issue } from "./Issue"; 3 | export { default as Project } from "./Project"; 4 | export { default as User } from "./User"; 5 | -------------------------------------------------------------------------------- /packages/web/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | declare module 'vue3-smooth-dnd' 3 | declare module 'vue-content-loader' 4 | declare module 'toastify-js' 5 | declare module 'autosize' { 6 | export default function autosize(element: HTMLElement): void 7 | } 8 | -------------------------------------------------------------------------------- /packages/web/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true 8 | }, 9 | "include": ["vite.config.ts"] 10 | } -------------------------------------------------------------------------------- /packages/web/src/types/index.ts: -------------------------------------------------------------------------------- 1 | export type { default as User } from './user' 2 | export type { default as Comment } from './comment' 3 | export type { default as Filters } from './filters' 4 | export type { default as Issue } from './issue' 5 | export type { default as Project } from './project' 6 | -------------------------------------------------------------------------------- /packages/web/src/utils/authToken.ts: -------------------------------------------------------------------------------- 1 | export const getStoredAuthToken = () => localStorage.getItem('authToken') 2 | 3 | export const storeAuthToken = (token: string) => 4 | localStorage.setItem('authToken', token) 5 | 6 | export const removeStoredAuthToken = () => localStorage.removeItem('authToken') 7 | -------------------------------------------------------------------------------- /packages/web/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import vue from '@vitejs/plugin-vue' 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | resolve: { 7 | alias: { 8 | '@': `${__dirname}/src` 9 | } 10 | }, 11 | plugins: [vue()] 12 | }) 13 | -------------------------------------------------------------------------------- /packages/api/src/utils/javascript.ts: -------------------------------------------------------------------------------- 1 | export const pick = >( 2 | object: T, 3 | keys: string[] 4 | ): Partial => { 5 | return keys.reduce((obj: any, key) => { 6 | if (object && object.hasOwnProperty(key)) { 7 | obj[key] = object[key]; 8 | } 9 | return obj; 10 | }, {}); 11 | }; 12 | -------------------------------------------------------------------------------- /packages/web/src/types/comment.ts: -------------------------------------------------------------------------------- 1 | import { User } from './user' 2 | import { Issue } from './issue' 3 | 4 | export interface Comment { 5 | id: number 6 | body: string 7 | createdAt: Date 8 | updatedAt: Date 9 | userId: string 10 | issueId: number 11 | user: User 12 | issue: Issue 13 | } 14 | 15 | export default Comment 16 | -------------------------------------------------------------------------------- /packages/api/src/middlewares/resolveTime.ts: -------------------------------------------------------------------------------- 1 | import { MiddlewareFn } from "type-graphql"; 2 | 3 | export const ResolveTime: MiddlewareFn = async ({ info }, next) => { 4 | const start = Date.now(); 5 | await next(); 6 | const resolveTime = Date.now() - start; 7 | console.log(`${info.parentType.name}.${info.fieldName} [${resolveTime} ms]`); 8 | }; 9 | -------------------------------------------------------------------------------- /packages/api/src/types/env.d.ts: -------------------------------------------------------------------------------- 1 | declare namespace NodeJS { 2 | export interface ProcessEnv { 3 | DB_HOST: string; 4 | DB_PORT: string; 5 | DB_USERNAME: string; 6 | DB_PASSWORD: string; 7 | DB_DATABASE: string; 8 | DB_TEST_DATABASE: string; 9 | JWT_SECRET: string; 10 | NODE_ENV: string; 11 | SENTRY_DSN: string; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /packages/api/src/errors/asyncCatch.ts: -------------------------------------------------------------------------------- 1 | import { RequestHandler } from "express"; 2 | 3 | export const catchErrors = (requestHandler: RequestHandler): RequestHandler => { 4 | return async (req, res, next): Promise => { 5 | try { 6 | return await requestHandler(req, res, next); 7 | } catch (error) { 8 | next(error); 9 | } 10 | }; 11 | }; 12 | -------------------------------------------------------------------------------- /packages/api/tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaultSeverity": "error", 3 | "extends": ["tslint:latest", "tslint-config-prettier"], 4 | "jsRules": {}, 5 | "rules": { 6 | "no-console": false, 7 | "member-access": false, 8 | "object-literal-sort-keys": false, 9 | "ordered-imports": false, 10 | "interface-name": false 11 | }, 12 | "rulesDirectory": [] 13 | } 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # dependencies 2 | **/node_modules 3 | 4 | # misc 5 | **/.DS_Store 6 | 7 | # environment config 8 | **/.env 9 | 10 | # production 11 | **/dist 12 | 13 | # Log files 14 | **/npm-debug.log* 15 | **/yarn-debug.log* 16 | **/yarn-error.log* 17 | 18 | # Editor directories and files 19 | **/.idea 20 | **/.vscode 21 | **/*.suo 22 | **/*.ntvs* 23 | **/*.njsproj 24 | **/*.sln 25 | **/*.sw? 26 | -------------------------------------------------------------------------------- /packages/web/src/types/user.ts: -------------------------------------------------------------------------------- 1 | import { Issue } from './issue' 2 | import { Project } from './project' 3 | 4 | export interface User { 5 | id: string 6 | name: string 7 | email: string 8 | avatarUrl: string 9 | createdAt: Date 10 | updatedAt: Date 11 | comments: Comment[] 12 | issues: Issue[] 13 | project: Project 14 | projectId: number 15 | } 16 | 17 | export default User 18 | -------------------------------------------------------------------------------- /packages/api/src/constants/issue.ts: -------------------------------------------------------------------------------- 1 | export enum IssueType { 2 | TASK = "task", 3 | BUG = "bug", 4 | STORY = "story" 5 | } 6 | 7 | export enum IssueStatus { 8 | BACKLOG = "backlog", 9 | SELECTED = "selected", 10 | INPROGRESS = "inprogress", 11 | DONE = "done" 12 | } 13 | 14 | export enum IssuePriority { 15 | HIGHEST = "5", 16 | HIGH = "4", 17 | MEDIUM = "3", 18 | LOW = "2", 19 | LOWEST = "1" 20 | } 21 | -------------------------------------------------------------------------------- /turbo.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://turbo.build/schema.json", 3 | "pipeline": { 4 | "build": { 5 | "dependsOn": [ 6 | "^build" 7 | ], 8 | "outputs": [ 9 | "dist/**", 10 | "build/**" 11 | ] 12 | }, 13 | "dev": { 14 | "dependsOn": [ 15 | "^build" 16 | ], 17 | "cache": false 18 | }, 19 | "lint": { 20 | "outputs": [] 21 | } 22 | } 23 | } -------------------------------------------------------------------------------- /packages/api/src/gql/index.ts: -------------------------------------------------------------------------------- 1 | import { default as AuthResolver } from "./auth"; 2 | import { default as UserResolver } from "./user"; 3 | import { default as CommentResolver } from "./comments"; 4 | import { default as IssueResolver } from "./issues"; 5 | import { default as ProjectResolver } from "./project"; 6 | 7 | export const RESOLVERS = [ 8 | AuthResolver, 9 | UserResolver, 10 | CommentResolver, 11 | IssueResolver, 12 | ProjectResolver, 13 | ] as const; 14 | -------------------------------------------------------------------------------- /packages/api/src/database/resetDatabase.ts: -------------------------------------------------------------------------------- 1 | import { getConnection } from "typeorm"; 2 | import { schedule } from "node-cron"; 3 | 4 | const resetDatabase = async (): Promise => { 5 | const connection = getConnection(); 6 | await connection.dropDatabase(); 7 | await connection.synchronize(); 8 | }; 9 | 10 | export const resetDatabaseJob = () => { 11 | schedule("0 0 * * *", () => { 12 | resetDatabase(); 13 | }); 14 | }; 15 | 16 | export default resetDatabase; 17 | -------------------------------------------------------------------------------- /packages/web/src/components/shared/TextEditor/editor.ts: -------------------------------------------------------------------------------- 1 | import { QuillOptionsStatic } from 'quill' 2 | 3 | export const quillConfig: QuillOptionsStatic = { 4 | theme: 'snow', 5 | modules: { 6 | toolbar: [ 7 | ['bold', 'italic', 'underline', 'strike'], 8 | ['blockquote', 'code-block'], 9 | [{ list: 'ordered' }, { list: 'bullet' }], 10 | [{ header: [1, 2, 3, 4, 5, 6, false] }], 11 | [{ color: [] }, { background: [] }], 12 | ['clean'] 13 | ] 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "jira-clone", 3 | "version": "1.0.0", 4 | "private": true, 5 | "author": { 6 | "name": "Moez Bouaggad", 7 | "email": "mrbouaggadmoez@gmail.com", 8 | "url": "https://github.com/datlyfe" 9 | }, 10 | "license": "MIT", 11 | "scripts": { 12 | "build": "turbo run build --include-dependencies", 13 | "dev": "turbo run dev --include-dependencies", 14 | "preinstall": "only-allow pnpm" 15 | }, 16 | "devDependencies": { 17 | "only-allow": "^1.1.1", 18 | "turbo": "^1.9.3" 19 | } 20 | } -------------------------------------------------------------------------------- /packages/web/src/plugins/toast.ts: -------------------------------------------------------------------------------- 1 | import Toastify from 'toastify-js' 2 | 3 | const commonConfig = { 4 | newWindow: true, 5 | close: true, 6 | gravity: 'top', 7 | position: 'right', 8 | stopOnFocus: true, 9 | duration: 5000 10 | } 11 | 12 | export const successToast = (message: string) => 13 | Toastify({ 14 | text: message, 15 | backgroundColor: '#0B875B', 16 | ...commonConfig 17 | }) 18 | export const errorToast = (message: string) => 19 | Toastify({ 20 | text: message, 21 | backgroundColor: '#E13C3C', 22 | ...commonConfig 23 | }) 24 | -------------------------------------------------------------------------------- /packages/api/src/gql/auth.ts: -------------------------------------------------------------------------------- 1 | import { Resolver, Query, UseMiddleware } from "type-graphql"; 2 | import { ErrorInterceptor } from "@/middlewares/errorInterceptor"; 3 | import createGuestAccount from "@/database/createGuestAccount"; 4 | import { signToken } from "@/utils/authToken"; 5 | 6 | @Resolver() 7 | class AuthResolver { 8 | @UseMiddleware([ErrorInterceptor]) 9 | @Query(() => String) 10 | async createGuestAccount(): Promise { 11 | const user = await createGuestAccount(); 12 | return signToken({ sub: user.id }); 13 | } 14 | } 15 | 16 | export default AuthResolver; 17 | -------------------------------------------------------------------------------- /packages/web/src/components/shared/Breadcrumbs/Breadcrumbs.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 22 | -------------------------------------------------------------------------------- /packages/web/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "env": { 4 | "node": true 5 | }, 6 | "extends": [ 7 | "plugin:vue/essential", 8 | "eslint:recommended", 9 | "@vue/typescript/recommended", 10 | "@vue/prettier", 11 | "@vue/prettier/@typescript-eslint" 12 | ], 13 | "parserOptions": { 14 | "ecmaVersion": 2020 15 | }, 16 | "rules": {}, 17 | "overrides": [ 18 | { 19 | "files": [ 20 | "**/__tests__/*.{j,t}s?(x)", 21 | "**/tests/unit/**/*.spec.{j,t}s?(x)" 22 | ], 23 | "env": { 24 | "jest": true 25 | } 26 | } 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /packages/web/src/components/Navigation/Sidebar.ts: -------------------------------------------------------------------------------- 1 | export const navLinks = [ 2 | { 3 | name: 'Kanban Board', 4 | icon: 'board', 5 | to: { name: 'board' } 6 | }, 7 | { 8 | name: 'Project settings', 9 | icon: 'cog', 10 | to: { name: 'settings' } 11 | }, 12 | { 13 | name: 'Releases', 14 | icon: 'ship' 15 | }, 16 | { 17 | name: 'Issues and filters', 18 | icon: 'filters' 19 | }, 20 | { 21 | name: 'Pages', 22 | icon: 'page' 23 | }, 24 | { 25 | name: 'Reports', 26 | icon: 'report' 27 | }, 28 | { 29 | name: 'Components', 30 | icon: 'component' 31 | } 32 | ] 33 | -------------------------------------------------------------------------------- /packages/api/scripts/pg.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | set -u 5 | 6 | function create_user_and_database() { 7 | local database=$1 8 | echo " Creating user and database '$database'" 9 | psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" <<-EOSQL 10 | CREATE USER $database; 11 | CREATE DATABASE $database; 12 | GRANT ALL PRIVILEGES ON DATABASE $database TO $database; 13 | EOSQL 14 | } 15 | 16 | if [ -n "$POSTGRES_MULTIPLE_DATABASES" ]; then 17 | echo "Multiple database creation requested: $POSTGRES_MULTIPLE_DATABASES" 18 | for db in $(echo $POSTGRES_MULTIPLE_DATABASES | tr ',' ' '); do 19 | create_user_and_database $db 20 | done 21 | echo "Multiple databases created" 22 | fi 23 | -------------------------------------------------------------------------------- /packages/web/src/types/project.ts: -------------------------------------------------------------------------------- 1 | import { Issue } from './issue' 2 | import { User } from './user' 3 | export enum ProjectCategory { 4 | SOFTWARE = 'software', 5 | MARKETING = 'marketing', 6 | BUSINESS = 'business' 7 | } 8 | 9 | export interface Project { 10 | id: number 11 | name: string 12 | url: string | null 13 | description: string | null 14 | category: ProjectCategory 15 | createdAt: Date 16 | updatedAt: Date 17 | issues: Issue[] 18 | users: User[] 19 | } 20 | 21 | export const ProjectCategoryCopy = { 22 | [ProjectCategory.SOFTWARE]: 'Software', 23 | [ProjectCategory.MARKETING]: 'Marketing', 24 | [ProjectCategory.BUSINESS]: 'Business' 25 | } 26 | 27 | export default Project 28 | -------------------------------------------------------------------------------- /packages/api/src/database/createTestConnection.ts: -------------------------------------------------------------------------------- 1 | import { createConnection, Connection, ConnectionOptions } from "typeorm"; 2 | 3 | import * as models from "@/models"; 4 | 5 | const connectionOptions: ConnectionOptions = { 6 | type: "postgres", 7 | host: process.env.DB_HOST, 8 | port: Number(process.env.DB_PORT), 9 | username: process.env.DB_USERNAME, 10 | password: process.env.DB_PASSWORD, 11 | database: process.env.DB_TEST_DATABASE, 12 | entities: Object.values(models), 13 | synchronize: true, 14 | dropSchema: true 15 | }; 16 | 17 | const createTestDatabaseConnection = (): Promise => 18 | createConnection(connectionOptions); 19 | 20 | export default createTestDatabaseConnection; 21 | -------------------------------------------------------------------------------- /packages/web/src/plugins/loadSvg.ts: -------------------------------------------------------------------------------- 1 | export const loadSprites = () => { 2 | const xmlFile = 'sprite.xml' 3 | const loadXML = new XMLHttpRequest() 4 | loadXML.onload = () => { 5 | const xmlString = loadXML.responseText 6 | const parser = new DOMParser() 7 | const mySpritesDoc = parser.parseFromString(xmlString, 'text/xml') 8 | .documentElement 9 | const addSprites = mySpritesDoc.childNodes 10 | for (let k = 0; k < addSprites.length; k++) { 11 | const sprite = addSprites.item(k).cloneNode(true) 12 | document.getElementById('spriteDefs')?.appendChild(sprite) 13 | } 14 | } 15 | loadXML.open('GET', window.location.origin + '/' + xmlFile, true) 16 | loadXML.send() 17 | } 18 | -------------------------------------------------------------------------------- /packages/web/src/utils/date.ts: -------------------------------------------------------------------------------- 1 | import dayjs from 'dayjs' 2 | import relativeTime from 'dayjs/plugin/relativeTime' 3 | import UTC from 'dayjs/plugin/utc' 4 | dayjs.extend(relativeTime) 5 | dayjs.extend(UTC) 6 | 7 | export const formatDate = (date: Date, format = 'MMMM D, YYYY') => 8 | date ? dayjs(date).format(format) : date 9 | 10 | export const formatDateTime = (date: Date, format = 'MMMM D, YYYY, h:mm A') => 11 | date ? dayjs(date).format(format) : date 12 | 13 | export const formatDateTimeForAPI = (date: Date) => 14 | date 15 | ? dayjs(date) 16 | .utc() 17 | .format() 18 | : date 19 | 20 | export const formatDateTimeConversational = (date: Date) => 21 | date ? dayjs(date).fromNow() : date 22 | -------------------------------------------------------------------------------- /packages/web/src/graphql/queries/comment.ts: -------------------------------------------------------------------------------- 1 | import gql from 'graphql-tag' 2 | 3 | export const createComment = gql` 4 | mutation createComment($comment: CommentInput!) { 5 | createComment(comment: $comment) { 6 | id 7 | body 8 | issueId 9 | userId 10 | createdAt 11 | updatedAt 12 | } 13 | } 14 | ` 15 | 16 | export const deleteComment = gql` 17 | mutation deleteComment($commentId: String!) { 18 | deleteComment(id: $commentId) { 19 | body 20 | } 21 | } 22 | ` 23 | export const updateComment = gql` 24 | mutation updateComment($commentId: String!, $comment: CommentInput!) { 25 | updateComment(comment: $comment, id: $commentId) { 26 | body 27 | } 28 | } 29 | ` 30 | -------------------------------------------------------------------------------- /packages/web/src/main.ts: -------------------------------------------------------------------------------- 1 | import { createApp } from 'vue' 2 | import App from './App.vue' 3 | import router from './router' 4 | import { DefaultApolloClient } from '@vue/apollo-composable' 5 | import { apolloClient } from '@/graphql/client' 6 | import { registerSharedComponents } from '@/plugins/register' 7 | import { loadSprites } from '@/plugins/loadSvg' 8 | 9 | // import '@/plugins/toast' 10 | import { registerTippy } from '@/plugins/tippy' 11 | import 'quill/dist/quill.snow.css' 12 | import '@/main.scss' 13 | 14 | loadSprites() 15 | 16 | const app = createApp(App) 17 | 18 | registerSharedComponents(app) 19 | registerTippy(app) 20 | 21 | app.provide(DefaultApolloClient, apolloClient) 22 | app.use(router) 23 | 24 | app.mount('#app') 25 | -------------------------------------------------------------------------------- /packages/web/src/graphql/queries/auth.ts: -------------------------------------------------------------------------------- 1 | import gql from 'graphql-tag' 2 | import { apolloClient } from '../client' 3 | import { User } from '@/types/user' 4 | 5 | export const createGuestAccount = gql` 6 | query createGuestAccount { 7 | createGuestAccount 8 | } 9 | ` 10 | 11 | export const currentUser = gql` 12 | query currentUser { 13 | currentUser { 14 | id 15 | name 16 | avatarUrl 17 | } 18 | } 19 | ` 20 | 21 | export const fetchMe = async (): Promise => { 22 | try { 23 | const res = await apolloClient.query<{ currentUser: User }>({ 24 | query: currentUser 25 | }) 26 | return Promise.resolve(res.data.currentUser) 27 | } catch (error) { 28 | return Promise.reject(error) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /packages/web/src/auth/authenticate.ts: -------------------------------------------------------------------------------- 1 | import { createGuestAccount, fetchMe } from '../graphql/queries/auth' 2 | import { apolloClient } from '../graphql/client' 3 | import { storeAuthToken } from '../utils/authToken' 4 | import store from '../store' 5 | 6 | export const authenticate = async () => { 7 | try { 8 | const result = await apolloClient.query<{ createGuestAccount: string }>({ 9 | query: createGuestAccount 10 | }) 11 | const { createGuestAccount: authToken } = result.data 12 | storeAuthToken(authToken) 13 | store.mutations.setIsAuthenticated(true) 14 | const currentUser = await fetchMe() 15 | store.mutations.setCurrentUser(currentUser) 16 | } catch (error) { 17 | // toast.error(error); 18 | console.error(error) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /packages/web/src/graphql/client.ts: -------------------------------------------------------------------------------- 1 | import { ApolloClient, InMemoryCache, HttpLink } from '@apollo/client/core' 2 | import { setContext } from '@apollo/client/link/context' 3 | import { getStoredAuthToken } from '@/utils/authToken' 4 | 5 | const httpLink = new HttpLink({ 6 | uri: import.meta.env.DEV 7 | ? 'http://localhost:5001/graphql' 8 | : 'https://jira-clone-api.onrender.com/graphql', 9 | }) 10 | 11 | const authLink = setContext((_, { headers }) => { 12 | return { 13 | headers: { 14 | ...headers, 15 | Authorization: getStoredAuthToken() 16 | ? `Bearer ${getStoredAuthToken()}` 17 | : undefined, 18 | }, 19 | } 20 | }) 21 | 22 | const cache = new InMemoryCache() 23 | 24 | export const apolloClient = new ApolloClient({ 25 | link: authLink.concat(httpLink), 26 | cache, 27 | }) 28 | -------------------------------------------------------------------------------- /packages/api/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | services: 3 | pg_db: 4 | container_name: pg_db 5 | image: "postgres:9.6.17" 6 | restart: always 7 | environment: 8 | POSTGRES_USER: jira_user 9 | POSTGRES_PASSWORD: jira_password 10 | POSTGRES_MULTIPLE_DATABASES: jira_db,jira_test_db 11 | ports: 12 | - "5432:5432" 13 | volumes: 14 | - ./scripts:/docker-entrypoint-initdb.d 15 | - database-data:/var/lib/postgresql/data/ 16 | 17 | pgweb: 18 | container_name: pgweb 19 | restart: always 20 | image: sosedoff/pgweb 21 | ports: 22 | - "8081:8081" 23 | links: 24 | - pg_db:pg_db 25 | environment: 26 | - DATABASE_URL=postgres://jira_user:jira_password@pg_db:5432/jira_db?sslmode=disable 27 | depends_on: 28 | - pg_db 29 | 30 | volumes: 31 | database-data: 32 | -------------------------------------------------------------------------------- /packages/web/src/graphql/queries/project.ts: -------------------------------------------------------------------------------- 1 | import gql from 'graphql-tag' 2 | 3 | export const getProjectWithUsersAndIssues = gql` 4 | { 5 | getProjectWithUsersAndIssues { 6 | id 7 | name 8 | url 9 | description 10 | category 11 | createdAt 12 | updatedAt 13 | users { 14 | id 15 | name 16 | avatarUrl 17 | projectId 18 | } 19 | issues { 20 | id 21 | title 22 | description 23 | type 24 | status 25 | priority 26 | listPosition 27 | createdAt 28 | updatedAt 29 | userIds 30 | } 31 | } 32 | } 33 | ` 34 | 35 | export const updateProject = gql` 36 | mutation updateProject($project: ProjectInput!) { 37 | updateProject(project: $project) { 38 | id 39 | } 40 | } 41 | ` 42 | -------------------------------------------------------------------------------- /packages/api/src/database/createConnection.ts: -------------------------------------------------------------------------------- 1 | import { DataSource, DataSourceOptions } from "typeorm"; 2 | 3 | import * as entities from "@/models"; 4 | 5 | const commonOptions: DataSourceOptions = { 6 | type: "postgres", 7 | entities: Object.values(entities), 8 | synchronize: true, 9 | }; 10 | 11 | const AppDataSourceOptions: DataSourceOptions = 12 | process.env.NODE_ENV === "production" 13 | ? { 14 | url: process.env.DATABASE_URL, 15 | ...commonOptions, 16 | extra: { 17 | max: 5, 18 | }, 19 | } 20 | : { 21 | url: process.env.DATABASE_URL, 22 | ...commonOptions, 23 | }; 24 | 25 | const createDatabaseConnection = (): Promise => { 26 | const AppDataSource = new DataSource(AppDataSourceOptions); 27 | return AppDataSource.initialize(); 28 | }; 29 | 30 | export default createDatabaseConnection; 31 | -------------------------------------------------------------------------------- /packages/web/src/components/shared/Icon/Icon.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 41 | -------------------------------------------------------------------------------- /packages/web/src/plugins/register.ts: -------------------------------------------------------------------------------- 1 | import { App } from 'vue' 2 | import Avatar from '@/components/shared/Avatar/Avatar.vue' 3 | import Breadcrumbs from '@/components/shared/Breadcrumbs/Breadcrumbs.vue' 4 | import Button from '@/components/shared/Button/Button.vue' 5 | import Icon from '@/components/shared/Icon/Icon.vue' 6 | import Input from '@/components/shared/Input/Input.vue' 7 | import Select from '@/components/shared/Select/Select.vue' 8 | import TextEditor from '@/components/shared/TextEditor/TextEditor.vue' 9 | import Textarea from '@/components/shared/Textarea/Textarea.vue' 10 | 11 | const sharedComponents = [ 12 | Avatar, 13 | Breadcrumbs, 14 | Button, 15 | Icon, 16 | Input, 17 | Select, 18 | TextEditor, 19 | Textarea 20 | ] 21 | 22 | export const registerSharedComponents = (app: App) => { 23 | // eslint-disable-next-line 24 | sharedComponents.forEach((c: any) => app.component(c.name, c)) 25 | } 26 | -------------------------------------------------------------------------------- /packages/web/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "module": "ESNext", 6 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 7 | "skipLibCheck": true, 8 | 9 | "allowSyntheticDefaultImports": true, 10 | 11 | /* Bundler mode */ 12 | "moduleResolution": "Node", 13 | "allowImportingTsExtensions": true, 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "noEmit": true, 17 | "jsx": "preserve", 18 | 19 | /* Linting */ 20 | "strict": true, 21 | "noUnusedLocals": true, 22 | "noUnusedParameters": true, 23 | "noFallthroughCasesInSwitch": true, 24 | 25 | "baseUrl": ".", 26 | "paths": { 27 | "@/*": ["src/*"] 28 | } 29 | }, 30 | "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"], 31 | "references": [{ "path": "./tsconfig.node.json" }] 32 | } 33 | -------------------------------------------------------------------------------- /packages/web/src/plugins/tippy.ts: -------------------------------------------------------------------------------- 1 | import { App, Directive } from 'vue' 2 | import tippy, { Placement, ReferenceElement } from 'tippy.js' 3 | 4 | const tippyDirective: Directive = { 5 | mounted: function(el, bind) { 6 | const { value } = bind 7 | let offset = [0, 20], 8 | content = '', 9 | placement: Placement = 'right' 10 | if (typeof value === 'object') { 11 | content = value.content 12 | offset = value.offset || [0, 20] 13 | placement = value.placement || 'right' 14 | } 15 | if (typeof value === 'string') { 16 | content = value as string 17 | } 18 | tippy(el, { 19 | content, 20 | placement, 21 | offset: offset as [number, number] 22 | }) 23 | }, 24 | unmounted: function(el) { 25 | ;(el as ReferenceElement)._tippy?.destroy() 26 | } 27 | } 28 | 29 | export const registerTippy = (app: App) => 30 | app.directive('tippy', tippyDirective) 31 | -------------------------------------------------------------------------------- /packages/api/src/middlewares/isAuth.ts: -------------------------------------------------------------------------------- 1 | import { verifyToken, getAuthTokenFromRequest } from "@/utils/authToken"; 2 | import { InvalidTokenError } from "@/errors"; 3 | import { User } from "@/models"; 4 | import { MiddlewareFn } from "type-graphql"; 5 | import { GQLContext } from "types/context"; 6 | 7 | export const IsAuth: MiddlewareFn = async ({ context }, next) => { 8 | const token = getAuthTokenFromRequest(context.req); 9 | if (!token) { 10 | throw new InvalidTokenError("Authentication token not found."); 11 | } 12 | const userId = verifyToken(token).sub; 13 | if (!userId) { 14 | throw new InvalidTokenError("Authentication token is invalid."); 15 | } 16 | const user = await User.findOneBy({ id: userId }); 17 | if (!user) { 18 | throw new InvalidTokenError( 19 | "Authentication token is invalid: User not found." 20 | ); 21 | } 22 | context.req.currentUser = user; 23 | return next(); 24 | }; 25 | -------------------------------------------------------------------------------- /packages/api/src/middlewares/errorInterceptor.ts: -------------------------------------------------------------------------------- 1 | import { MiddlewareFn } from "type-graphql"; 2 | import { CustomError } from "@/errors"; 3 | import { GQLContext } from "@/types/context"; 4 | import { pick } from "@/utils/javascript"; 5 | 6 | export const ErrorInterceptor: MiddlewareFn = async (_, next) => { 7 | try { 8 | return await next(); 9 | } catch (error) { 10 | console.error(error); 11 | const isErrorSafeForClient = error instanceof CustomError; 12 | 13 | if (isErrorSafeForClient) { 14 | const { code, message, data, status } = pick( 15 | error as InstanceType, 16 | ["message", "code", "status", "data"] 17 | ); 18 | 19 | throw new CustomError(message, code, status, data); 20 | } 21 | 22 | throw new CustomError( 23 | "Something went wrong, please contact our support.", 24 | "INTERNAL_ERROR", 25 | 500, 26 | {} 27 | ); 28 | } 29 | }; 30 | -------------------------------------------------------------------------------- /packages/web/src/components/ErrorPage.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 27 | 28 | 41 | -------------------------------------------------------------------------------- /packages/api/src/gql/user.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Resolver, 3 | Query, 4 | UseMiddleware, 5 | Ctx, 6 | Mutation, 7 | Arg, 8 | } from "type-graphql"; 9 | import { IsAuth } from "@/middlewares/isAuth"; 10 | import { GQLContext } from "@/types/context"; 11 | import { User } from "@/models"; 12 | import { ErrorInterceptor } from "@/middlewares/errorInterceptor"; 13 | import { UserCreateInput } from "./types"; 14 | import { createEntity } from "../utils/typeorm"; 15 | 16 | @Resolver() 17 | class UserResolver { 18 | @Query(() => String) 19 | hello(): string { 20 | return "hello World"; 21 | } 22 | @UseMiddleware([IsAuth, ErrorInterceptor]) 23 | @Query(() => User) 24 | currentUser(@Ctx() ctx: GQLContext): User { 25 | return ctx.req.currentUser; 26 | } 27 | 28 | @UseMiddleware([ErrorInterceptor]) 29 | @Mutation(() => User) 30 | async createUser(@Arg("user") userInput: UserCreateInput): Promise { 31 | return await createEntity(User, userInput); 32 | } 33 | } 34 | 35 | export default UserResolver; 36 | -------------------------------------------------------------------------------- /packages/web/src/store.ts: -------------------------------------------------------------------------------- 1 | import { reactive } from 'vue' 2 | import { getStoredAuthToken } from '@/utils/authToken' 3 | import { Filters, Project, User } from '@/types' 4 | 5 | const store = reactive({ 6 | currentUser: {} as User, 7 | project: {} as Project, 8 | isAuthenticated: !!getStoredAuthToken(), 9 | filters: { 10 | searchTerm: '', 11 | userIds: [], 12 | myOnly: false, 13 | recent: false 14 | } as Filters 15 | }) 16 | 17 | export const getters = { 18 | project: () => store.project, 19 | filters: () => store.filters, 20 | currentUser: () => store.currentUser, 21 | isAuthenticated: () => store.isAuthenticated 22 | } 23 | 24 | export const mutations = { 25 | setFilters: (filters: Filters) => (store.filters = filters), 26 | setCurrentUser: (user: User) => (store.currentUser = user), 27 | setProject: (project: Project) => (store.project = project), 28 | setIsAuthenticated: (isAuth: boolean) => (store.isAuthenticated = isAuth) 29 | } 30 | 31 | export default { 32 | store, 33 | getters, 34 | mutations 35 | } 36 | -------------------------------------------------------------------------------- /packages/web/src/views/FullIIssueDetails.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 35 | 36 | 41 | -------------------------------------------------------------------------------- /packages/web/src/hooks/useOutsideClick.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | import { Ref, onMounted, onUnmounted } from 'vue' 3 | 4 | export const useOutsideClick = ( 5 | $root: Ref, 6 | $bound: Ref, 7 | onOutsideClick: Function 8 | ) => { 9 | const handleClickOutside = (e: MouseEvent) => { 10 | if ($bound.value && !$bound.value.contains(e.target as any)) { 11 | onOutsideClick() 12 | } 13 | } 14 | const handleKeydown = (e: KeyboardEvent) => { 15 | if (e.key === 'Escape') { 16 | onOutsideClick() 17 | } 18 | } 19 | onMounted(() => { 20 | ;($root.value as HTMLElement)?.addEventListener( 21 | 'mousedown', 22 | handleClickOutside 23 | ) 24 | ;($root.value as HTMLElement)?.addEventListener('keydown', handleKeydown) 25 | }) 26 | 27 | onUnmounted(() => { 28 | ;($root.value as HTMLElement)?.removeEventListener( 29 | 'mousedown', 30 | handleClickOutside 31 | ) 32 | ;($root.value as HTMLElement)?.removeEventListener('keydown', handleKeydown) 33 | }) 34 | } 35 | -------------------------------------------------------------------------------- /packages/api/src/utils/authToken.ts: -------------------------------------------------------------------------------- 1 | import { Request } from "express"; 2 | import jwt, { SignOptions } from "jsonwebtoken"; 3 | import { InvalidTokenError } from "@/errors"; 4 | 5 | var isPlainObject = (value: any) => { 6 | return Object.prototype.toString.call(value) === "[object Object]"; 7 | }; 8 | 9 | export const signToken = (payload: object, options?: SignOptions): string => 10 | jwt.sign(payload, process.env.JWT_SECRET, { 11 | expiresIn: "180 days", 12 | ...options 13 | }); 14 | 15 | export const verifyToken = (token: string): { [key: string]: any } => { 16 | try { 17 | const payload = jwt.verify(token, process.env.JWT_SECRET); 18 | 19 | if (isPlainObject(payload)) { 20 | return payload as { [key: string]: any }; 21 | } 22 | throw new Error(); 23 | } catch (error) { 24 | throw new InvalidTokenError(); 25 | } 26 | }; 27 | 28 | export const getAuthTokenFromRequest = (req: Request): string | null => { 29 | const header = req.get("Authorization") || ""; 30 | const [bearer, token] = header.split(" "); 31 | return bearer === "Bearer" && token ? token : null; 32 | }; 33 | -------------------------------------------------------------------------------- /packages/web/src/components/Navigation/Navigation.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 38 | 39 | 48 | -------------------------------------------------------------------------------- /packages/api/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2018", 4 | "module": "commonjs", 5 | "lib": ["dom", "es6", "es2017", "es2019", "esnext.asynciterable"], 6 | "sourceMap": true, 7 | "outDir": "./dist", 8 | "removeComments": true, 9 | "strict": true, 10 | "noImplicitAny": true, 11 | "strictNullChecks": true, 12 | "strictFunctionTypes": true, 13 | "strictBindCallApply": true, 14 | "strictPropertyInitialization": false, 15 | "noImplicitThis": true, 16 | "alwaysStrict": true, 17 | "noUnusedLocals": true, 18 | "noUnusedParameters": true, 19 | "noImplicitReturns": false, 20 | "noFallthroughCasesInSwitch": true, 21 | "moduleResolution": "node", 22 | "baseUrl": "src", 23 | "paths": { 24 | "*": ["./*"], 25 | "@/*": ["./*"] 26 | }, 27 | "types": ["node"], 28 | "allowSyntheticDefaultImports": true, 29 | "esModuleInterop": true, 30 | "experimentalDecorators": true, 31 | "emitDecoratorMetadata": true, 32 | "forceConsistentCasingInFileNames": true 33 | }, 34 | "exclude": ["node_modules"], 35 | "include": ["./src/**/*.ts",] 36 | } 37 | -------------------------------------------------------------------------------- /LICENCE: -------------------------------------------------------------------------------- 1 | 2 | The MIT License (MIT) 3 | 4 | Copyright (c) 2013-present, Yuxi (Evan) You 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in 14 | all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | THE SOFTWARE. 23 | © 2020 GitHub, Inc. -------------------------------------------------------------------------------- /packages/api/src/errors/customErrors.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable max-classes-per-file */ 2 | 3 | type ErrorData = { [key: string]: any }; 4 | 5 | export class CustomError extends Error { 6 | constructor( 7 | public message: string = "No message", 8 | public code: string | number = "INTERNAL_ERROR", 9 | public status: number = 500, 10 | public data: ErrorData = {} 11 | ) { 12 | super(); 13 | } 14 | } 15 | 16 | export class RouteNotFoundError extends CustomError { 17 | constructor(originalUrl: string) { 18 | super(`Route '${originalUrl}' does not exist.`, "ROUTE_NOT_FOUND", 404); 19 | } 20 | } 21 | 22 | export class EntityNotFoundError extends CustomError { 23 | constructor(entityName: string) { 24 | super(`${entityName} not found.`, "ENTITY_NOT_FOUND", 404); 25 | } 26 | } 27 | 28 | export class BadUserInputError extends CustomError { 29 | constructor(errorData: ErrorData) { 30 | super("There were validation errors.", "BAD_USER_INPUT", 400, errorData); 31 | } 32 | } 33 | 34 | export class InvalidTokenError extends CustomError { 35 | constructor(message = "Authentication token is invalid.") { 36 | super(message, "INVALID_TOKEN", 401); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /packages/web/src/fonts.scss: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: 'CircularStd'; 3 | src: url('/fonts/CircularStd-Medium.woff2') format('woff2'), 4 | url('/fonts/CircularStd-Medium.woff') format('woff'); 5 | font-weight: 500; 6 | font-style: normal; 7 | font-display: swap; 8 | unicode-range: U+000-5FF; 9 | } 10 | 11 | @font-face { 12 | font-family: 'CircularStd'; 13 | src: url('/fonts/CircularStd-Bold.woff2') format('woff2'), 14 | url('/fonts/CircularStd-Bold.woff') format('woff'); 15 | font-weight: 700; 16 | font-style: normal; 17 | font-display: swap; 18 | unicode-range: U+000-5FF; 19 | } 20 | 21 | @font-face { 22 | font-family: 'CircularStd'; 23 | src: url('/fonts/CircularStd-Black.woff2') format('woff2'), 24 | url('/fonts/CircularStd-Black.woff') format('woff'); 25 | font-weight: 900; 26 | font-style: normal; 27 | font-display: swap; 28 | unicode-range: U+000-5FF; 29 | } 30 | 31 | @font-face { 32 | font-family: 'CircularStd'; 33 | src: url('/fonts/CircularStd-Book.woff2') format('woff2'), 34 | url('/fonts/CircularStd-Book.woff') format('woff'); 35 | font-weight: 400; 36 | font-style: normal; 37 | font-display: swap; 38 | unicode-range: U+000-5FF; 39 | } 40 | -------------------------------------------------------------------------------- /packages/api/src/gql/comments.ts: -------------------------------------------------------------------------------- 1 | import { Resolver, Mutation, Arg, UseMiddleware } from "type-graphql"; 2 | import { createEntity, updateEntity, deleteEntity } from "@/utils/typeorm"; 3 | import Comment from "@/models/Comment"; 4 | import { CommentInput } from "@/gql/types"; 5 | import { IsAuth } from "@/middlewares/isAuth"; 6 | import { ErrorInterceptor } from "@/middlewares/errorInterceptor"; 7 | 8 | @Resolver() 9 | class CommentResolver { 10 | @UseMiddleware([IsAuth, ErrorInterceptor]) 11 | @Mutation(() => Comment) 12 | async createComment( 13 | @Arg("comment") commentInput: CommentInput 14 | ): Promise { 15 | return await createEntity(Comment, commentInput); 16 | } 17 | @UseMiddleware([IsAuth, ErrorInterceptor]) 18 | @Mutation(() => Comment) 19 | async updateComment( 20 | @Arg("id") commentId: string, 21 | @Arg("comment") commentInput: CommentInput 22 | ): Promise { 23 | return await updateEntity(Comment, commentId, commentInput); 24 | } 25 | @UseMiddleware([IsAuth, ErrorInterceptor]) 26 | @Mutation(() => Comment) 27 | async deleteComment(@Arg("id") commentId: string): Promise { 28 | return await deleteEntity(Comment, commentId); 29 | } 30 | } 31 | 32 | export default CommentResolver; 33 | -------------------------------------------------------------------------------- /packages/web/src/utils/colors.ts: -------------------------------------------------------------------------------- 1 | import Color from 'color' 2 | import { IssuePriority, IssueStatus } from '@/types/issue' 3 | 4 | export const tuneColor = { 5 | darken: (colorValue: string, amount: number) => 6 | Color(colorValue) 7 | .darken(amount) 8 | .string(), 9 | lighten: (colorValue: string, amount: number) => 10 | Color(colorValue) 11 | .lighten(amount) 12 | .string() 13 | } 14 | 15 | export const avatarColors = [ 16 | '#DA7657', 17 | '#6ADA57', 18 | '#5784DA', 19 | '#AA57DA', 20 | '#DA5757', 21 | '#DA5792', 22 | '#57DACA', 23 | '#57A5DA' 24 | ] 25 | 26 | export const buttonVariants = { 27 | primary: '#0052cc', 28 | success: '#0B875B', 29 | danger: '#E13C3C', 30 | warning: '#F89C1C', 31 | info: '#0fb9b1', 32 | secondary: '#F4F5F7' 33 | } 34 | 35 | export const issueStatusVariants = { 36 | [IssueStatus.BACKLOG]: 'secondary', 37 | [IssueStatus.DONE]: 'success', 38 | [IssueStatus.SELECTED]: 'secondary', 39 | [IssueStatus.INPROGRESS]: 'primary' 40 | } 41 | 42 | export const issuePriorityColors = { 43 | [IssuePriority.HIGHEST]: '#CD1317', 44 | [IssuePriority.HIGH]: '#E9494A', 45 | [IssuePriority.MEDIUM]: '#E97F33', 46 | [IssuePriority.LOW]: '#2D8738', 47 | [IssuePriority.LOWEST]: '#57A55A' 48 | } 49 | -------------------------------------------------------------------------------- /packages/api/src/models/Comment.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BaseEntity, 3 | PrimaryGeneratedColumn, 4 | Column, 5 | CreateDateColumn, 6 | UpdateDateColumn, 7 | ManyToOne, 8 | Entity 9 | } from "typeorm"; 10 | import is from "@/utils/validations"; 11 | import { User, Issue } from "@/models"; 12 | import { ObjectType, Field, ID } from "type-graphql"; 13 | 14 | @Entity() 15 | @ObjectType() 16 | class Comment extends BaseEntity { 17 | static validations = { 18 | body: [is.required(), is.maxLength(50000)] 19 | }; 20 | 21 | @Field(() => ID) 22 | @PrimaryGeneratedColumn() 23 | id: number; 24 | 25 | @Field() 26 | @Column("text") 27 | body: string; 28 | 29 | @Field() 30 | @CreateDateColumn({ type: "timestamp" }) 31 | createdAt: Date; 32 | 33 | @Field() 34 | @UpdateDateColumn({ type: "timestamp" }) 35 | updatedAt: Date; 36 | 37 | @Field() 38 | @Column("uuid") 39 | userId: string; 40 | 41 | @Field() 42 | @Column("integer") 43 | issueId: number; 44 | 45 | @Field(() => User) 46 | @ManyToOne( 47 | () => User, 48 | user => user.comments 49 | ) 50 | user: User; 51 | 52 | @Field(() => Issue) 53 | @ManyToOne( 54 | () => Issue, 55 | issue => issue.comments, 56 | { onDelete: "CASCADE" } 57 | ) 58 | issue: Issue; 59 | } 60 | 61 | export default Comment; 62 | -------------------------------------------------------------------------------- /packages/web/src/components/Project/Issue/IssueDetails/Title.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 48 | 49 | 54 | -------------------------------------------------------------------------------- /packages/web/src/views/Board.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /packages/api/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "api", 3 | "version": "1.0.0", 4 | "license": "MIT", 5 | "author": { 6 | "name": "Bouaggad Moez", 7 | "email": "Mrbouaggadmoez@gmail.com" 8 | }, 9 | "scripts": { 10 | "dev": "ts-node-dev --respawn --files --transpile-only --no-notify --ignore-watch node_modules src/index.ts", 11 | "start": "node dist/index.js", 12 | "build": "tsc" 13 | }, 14 | "dependencies": { 15 | "@apollo/server": "^4.3.0", 16 | "@sentry/integrations": "7.56.0", 17 | "@sentry/node": "7.56.0", 18 | "body-parser": "^1.20.1", 19 | "cors": "^2.8.5", 20 | "dotenv": "16.3.1", 21 | "express": "^4.18.2", 22 | "graphql": "^16.7.0", 23 | "jsonwebtoken": "9.0.0", 24 | "module-alias": "2.2.3", 25 | "node-cron": "3.0.2", 26 | "pg": "8.11.0", 27 | "reflect-metadata": "0.1.13", 28 | "striptags": "3.2.0", 29 | "ts-node-dev": "2.0.0", 30 | "type-graphql": "2.0.0-beta.2", 31 | "typeorm": "0.3.17" 32 | }, 33 | "devDependencies": { 34 | "@types/cors": "2.8.13", 35 | "@types/express": "4.17.17", 36 | "@types/graphql": "14.5.0", 37 | "@types/jsonwebtoken": "9.0.2", 38 | "@types/module-alias": "2.0.1", 39 | "@types/node": "20.3.1", 40 | "@types/node-cron": "3.0.7", 41 | "class-validator": "^0.14.0", 42 | "cross-env": "7.0.3", 43 | "typescript": "5.1.3" 44 | } 45 | } -------------------------------------------------------------------------------- /packages/web/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | Jira Clone 10 | 17 | 24 | 31 | 38 | 39 | 40 | 41 | 44 |
45 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /packages/web/src/components/Project/IssueLoader.vue: -------------------------------------------------------------------------------- 1 | 26 | 27 | 35 | -------------------------------------------------------------------------------- /packages/api/src/gql/project.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Resolver, 3 | Query, 4 | Ctx, 5 | UseMiddleware, 6 | Mutation, 7 | Arg 8 | } from "type-graphql"; 9 | import { GQLContext } from "@/types/context"; 10 | import { Project } from "@/models"; 11 | import { IsAuth } from "@/middlewares/isAuth"; 12 | import { ResolveTime } from "@/middlewares/resolveTime"; 13 | import { ErrorInterceptor } from "@/middlewares/errorInterceptor"; 14 | import { findEntityOrThrow, updateEntity } from "@/utils/typeorm"; 15 | import { ProjectInput } from "@/gql/types"; 16 | 17 | @Resolver() 18 | class ProjectResolver { 19 | @UseMiddleware([ResolveTime, IsAuth, ErrorInterceptor]) 20 | @Query(() => Project) 21 | async getProjectWithUsersAndIssues(@Ctx() ctx: GQLContext): Promise { 22 | const project = await findEntityOrThrow( 23 | Project, 24 | ctx.req.currentUser.projectId, 25 | { 26 | relations: ["issues", "users"] 27 | } 28 | ); 29 | 30 | return project; 31 | } 32 | 33 | @UseMiddleware([IsAuth, ErrorInterceptor]) 34 | @Mutation(() => Project) 35 | async updateProject( 36 | @Ctx() ctx: GQLContext, 37 | @Arg("project") projectInput: ProjectInput 38 | ): Promise { 39 | const project = await updateEntity( 40 | Project, 41 | ctx.req.currentUser.projectId, 42 | projectInput 43 | ); 44 | 45 | return project; 46 | } 47 | } 48 | 49 | export default ProjectResolver; 50 | -------------------------------------------------------------------------------- /packages/web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "web", 3 | "version": "1.0.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "vite", 7 | "build": "vue-tsc && vite build", 8 | "tc": "vue-tsc --noEmit" 9 | }, 10 | "dependencies": { 11 | "@apollo/client": "^3.7.16", 12 | "@vue/apollo-composable": "^4.0.0-beta.7", 13 | "autosize": "^6.0.1", 14 | "color": "^4.2.3", 15 | "dayjs": "^1.8.21", 16 | "graphql": "^16.6.0", 17 | "graphql-tag": "^2.10.3", 18 | "lodash.omit": "^4.5.0", 19 | "lodash.pick": "^4.4.0", 20 | "lodash.xor": "^4.5.0", 21 | "mitt": "^3.0.0", 22 | "quill": "^1.3.7", 23 | "tailwindcss": "^3.3.2", 24 | "throttle-debounce": "^5.0.0", 25 | "tippy.js": "^6.0.3", 26 | "toastify-js": "^1.7.0", 27 | "vue": "^3.3.4", 28 | "vue-content-loader": "^2.0.1", 29 | "vue-router": "^4.2.2", 30 | "vue3-smooth-dnd": "^0.0.5" 31 | }, 32 | "devDependencies": { 33 | "@types/color": "^3.0.1", 34 | "@types/lodash.omit": "^4.5.6", 35 | "@types/lodash.pick": "^4.4.6", 36 | "@types/lodash.xor": "^4.5.6", 37 | "@types/quill": "^2.0.3", 38 | "@types/throttle-debounce": "^5.0.0", 39 | "@vitejs/plugin-vue": "^4.2.3", 40 | "autoprefixer": "^10.4.14", 41 | "postcss": "^8.4.24", 42 | "prettier": "^2.8.8", 43 | "resize-observer-polyfill": "^1.5.1", 44 | "sass": "^1.63.5", 45 | "typescript": "^5.0.2", 46 | "vite": "^4.3.9", 47 | "vue-tsc": "^1.8.0" 48 | } 49 | } -------------------------------------------------------------------------------- /packages/web/src/components/Project/Issue/IssueSearch/SearchResult.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /packages/api/src/gql/plugins/sentry.ts: -------------------------------------------------------------------------------- 1 | import { captureException, withScope } from "@sentry/node"; 2 | import type { ApolloServerPlugin } from "@apollo/server"; 3 | 4 | export const apolloServerSentryPlugin: ApolloServerPlugin = { 5 | async requestDidStart() { 6 | return { 7 | async didEncounterErrors(ctx) { 8 | if (!ctx.operation) { 9 | for (const err of ctx.errors) { 10 | withScope((scope) => { 11 | scope.setExtra("query", ctx.request.query); 12 | captureException(err); 13 | }); 14 | } 15 | return; 16 | } 17 | 18 | for (const err of ctx.errors) { 19 | withScope((scope) => { 20 | scope.setTag("kind", ctx.operation?.operation ?? "unknown"); 21 | 22 | scope.setExtra("query", ctx.request.query); 23 | scope.setExtra("variables", ctx.request.variables); 24 | 25 | if (err.path) { 26 | scope.setLevel("debug"); 27 | scope.addBreadcrumb({ 28 | category: "query-path", 29 | message: err.path.join(" > "), 30 | }); 31 | } 32 | 33 | const transactionId = 34 | ctx.request?.http?.headers.get("x-transaction-id"); 35 | if (transactionId) { 36 | scope.setTransactionName(transactionId); 37 | } 38 | 39 | captureException(err); 40 | }); 41 | } 42 | }, 43 | }; 44 | }, 45 | }; 46 | -------------------------------------------------------------------------------- /packages/web/src/hooks/useClipboard.ts: -------------------------------------------------------------------------------- 1 | import { ref, Ref } from 'vue' 2 | 3 | const copyToClipboard = async (text: string) => { 4 | if (navigator.clipboard) { 5 | try { 6 | return navigator.clipboard.writeText(text) 7 | } catch (err) { 8 | throw err !== undefined 9 | ? err 10 | : new DOMException('The request is not allowed', 'NotAllowedError') 11 | } 12 | } 13 | 14 | const span = document.createElement('span') 15 | span.textContent = text 16 | span.style.whiteSpace = 'pre' 17 | document.body.appendChild(span) 18 | 19 | const selection = window.getSelection() 20 | const range = window.document.createRange() 21 | selection && selection.removeAllRanges() 22 | range.selectNode(span) 23 | selection && selection.addRange(range) 24 | 25 | let success = false 26 | try { 27 | success = window.document.execCommand('copy') 28 | } catch (err) { 29 | console.error(err) 30 | } 31 | 32 | selection && selection.removeAllRanges() 33 | window.document.body.removeChild(span) 34 | 35 | return success 36 | ? Promise.resolve() 37 | : Promise.reject( 38 | new DOMException('The request is not allowed', 'NotAllowedError') 39 | ) 40 | } 41 | 42 | export const useClipboard = (): [ 43 | Ref, 44 | (text: string) => Promise 45 | ] => { 46 | const clipboard = ref('') 47 | 48 | const setClipboard = (text: string) => { 49 | clipboard.value = text 50 | return copyToClipboard(text) 51 | } 52 | 53 | return [clipboard, setClipboard] 54 | } 55 | -------------------------------------------------------------------------------- /packages/web/src/main.scss: -------------------------------------------------------------------------------- 1 | @import './fonts.scss'; 2 | 3 | @tailwind base; 4 | @tailwind components; 5 | @tailwind utilities; 6 | 7 | @import 'tippy.js/dist/tippy.css'; 8 | @import 'toastify-js/src/toastify.css'; 9 | 10 | // @import '~toastify-js/src/toastify.css'; 11 | 12 | body { 13 | line-height: 1.2; 14 | text-rendering: optimizeLegibility; 15 | } 16 | 17 | #root { 18 | position: absolute; 19 | width: 100vw; 20 | height: 100vh; 21 | overflow: hidden; 22 | display: flex; 23 | flex-direction: column; 24 | } 25 | #app-frame { 26 | position: relative; 27 | overflow: hidden; 28 | width: 100%; 29 | height: 100%; 30 | display: flex; 31 | } 32 | #content { 33 | flex: 1; 34 | min-width: 0; 35 | z-index: 0; 36 | overflow: auto; 37 | will-change: padding-left; 38 | transition: padding-left 300ms cubic-bezier(0.2, 0, 0, 1) 0s; 39 | } 40 | 41 | .page { 42 | min-width: 800px; 43 | } 44 | 45 | button, 46 | body, 47 | textarea, 48 | input { 49 | font-family: 'CircularStd', -apple-system, BlinkMacSystemFont, 'Segoe UI', 50 | Roboto, 'Helvetica Neue', Helvetica, Arial, sans-serif, 'CircularStd'; 51 | } 52 | svg { 53 | outline: none; 54 | } 55 | 56 | @keyframes fadeIn { 57 | from { 58 | opacity: 0; 59 | } 60 | 61 | to { 62 | opacity: 1; 63 | } 64 | } 65 | 66 | @keyframes spin { 67 | from { 68 | transform: rotate(0deg); 69 | } 70 | to { 71 | transform: rotate(360deg); 72 | } 73 | } 74 | 75 | .spinner { 76 | animation: spin both infinite 0.7s; 77 | } 78 | 79 | .fadeIn { 80 | animation: fadeIn 0.2s both; 81 | } 82 | -------------------------------------------------------------------------------- /packages/web/src/components/Modals/Confirm.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 64 | -------------------------------------------------------------------------------- /packages/api/src/models/User.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BaseEntity, 3 | PrimaryGeneratedColumn, 4 | Column, 5 | CreateDateColumn, 6 | UpdateDateColumn, 7 | OneToMany, 8 | ManyToMany, 9 | ManyToOne, 10 | RelationId, 11 | Entity 12 | } from "typeorm"; 13 | import { ObjectType, Field, ID } from "type-graphql"; 14 | import is from "@/utils/validations"; 15 | import { Issue, Project, Comment } from "@/models"; 16 | 17 | @ObjectType() 18 | @Entity() 19 | class User extends BaseEntity { 20 | static validations = { 21 | name: [is.required(), is.maxLength(100)], 22 | email: [is.required(), is.email(), is.maxLength(200)] 23 | }; 24 | 25 | @Field(() => ID) 26 | @PrimaryGeneratedColumn("uuid") 27 | id: string; 28 | 29 | @Field() 30 | @Column("varchar") 31 | name: string; 32 | 33 | @Field() 34 | @Column("varchar") 35 | email: string; 36 | 37 | @Field() 38 | @Column("varchar", { length: 2000 }) 39 | avatarUrl: string; 40 | 41 | @Field() 42 | @CreateDateColumn({ type: "timestamp" }) 43 | createdAt: Date; 44 | 45 | @Field() 46 | @UpdateDateColumn({ type: "timestamp" }) 47 | updatedAt: Date; 48 | 49 | @Field(() => [Comment]) 50 | @OneToMany( 51 | () => Comment, 52 | comment => comment.user 53 | ) 54 | comments: Comment[]; 55 | 56 | @Field(() => [Issue]) 57 | @ManyToMany( 58 | () => Issue, 59 | issue => issue.users 60 | ) 61 | issues: Issue[]; 62 | 63 | @Field(() => Project) 64 | @ManyToOne( 65 | () => Project, 66 | project => project.users 67 | ) 68 | project: Project; 69 | 70 | @Field() 71 | @RelationId((user: User) => user.project) 72 | projectId: number; 73 | } 74 | 75 | export default User; 76 | -------------------------------------------------------------------------------- /packages/web/.turbo/turbo-build.log: -------------------------------------------------------------------------------- 1 | 2 | > web@1.0.0 build /Users/moezbouaggad/personal/jira_clone/packages/web 3 | > vue-tsc && vite build 4 | 5 | vite v4.3.9 building for production... 6 | transforming... 7 | 8 | warn - The `purge`/`content` options have changed in Tailwind CSS v3.0. 9 | warn - Update your configuration file to eliminate this warning. 10 | warn - https://tailwindcss.com/docs/upgrade-guide#configure-content-sources 11 | ✓ 477 modules transformed. 12 | rendering chunks... 13 | computing gzip size... 14 | dist/index.html 1.31 kB │ gzip: 0.49 kB 15 | dist/assets/mountains-2f37b2ef.jpg 18.82 kB 16 | dist/assets/FullIIssueDetails-1164bd8e.css 0.04 kB │ gzip: 0.06 kB 17 | dist/assets/Settings-949cda7e.css 0.28 kB │ gzip: 0.17 kB 18 | dist/assets/Board-62c35ebb.css 0.56 kB │ gzip: 0.34 kB 19 | dist/assets/index-ce56cd0d.css 50.59 kB │ gzip: 10.09 kB 20 | dist/assets/FullIIssueDetails-48cb7d41.js 0.63 kB │ gzip: 0.42 kB 21 | dist/assets/Project-25b92695.js 1.09 kB │ gzip: 0.66 kB 22 | dist/assets/Settings-460060c3.js 4.94 kB │ gzip: 2.28 kB 23 | dist/assets/Board-39ca9999.js 51.21 kB │ gzip: 17.19 kB 24 | dist/assets/index-a4d3e490.js 629.49 kB │ gzip: 190.56 kB 25 | 26 | (!) Some chunks are larger than 500 kBs after minification. Consider: 27 | - Using dynamic import() to code-split the application 28 | - Use build.rollupOptions.output.manualChunks to improve chunking: https://rollupjs.org/configuration-options/#output-manualchunks 29 | - Adjust chunk size limit for this warning via build.chunkSizeWarningLimit. 30 | ✓ built in 2.52s 31 | -------------------------------------------------------------------------------- /packages/web/src/components/shared/Avatar/Avatar.vue: -------------------------------------------------------------------------------- 1 | 5 | 6 | 62 | -------------------------------------------------------------------------------- /packages/api/src/models/Project.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BaseEntity, 3 | PrimaryGeneratedColumn, 4 | Column, 5 | UpdateDateColumn, 6 | CreateDateColumn, 7 | OneToMany, 8 | Entity 9 | } from "typeorm"; 10 | import { ObjectType, Field, ID } from "type-graphql"; 11 | import is from "@/utils/validations"; 12 | import { ProjectCategory } from "@/constants/project"; 13 | import { User, Issue } from "@/models"; 14 | 15 | @ObjectType() 16 | @Entity() 17 | class Project extends BaseEntity { 18 | static validations = { 19 | name: [is.required(), is.maxLength(100)], 20 | url: is.url(), 21 | category: [is.required(), is.oneOf(Object.values(ProjectCategory))] 22 | }; 23 | 24 | @Field(() => ID) 25 | @PrimaryGeneratedColumn() 26 | id: number; 27 | 28 | @Field() 29 | @Column("varchar") 30 | name: string; 31 | 32 | @Field(() => String, { nullable: true }) 33 | @Column("varchar", { nullable: true }) 34 | url: string | null; 35 | 36 | @Field(() => String, { nullable: true }) 37 | @Column("text", { nullable: true }) 38 | description: string | null; 39 | 40 | @Field(() => String) 41 | @Column("varchar") 42 | category: ProjectCategory; 43 | 44 | @Field() 45 | @CreateDateColumn({ type: "timestamp" }) 46 | createdAt: Date; 47 | 48 | @Field() 49 | @UpdateDateColumn({ type: "timestamp" }) 50 | updatedAt: Date; 51 | 52 | @Field(() => [Issue], { defaultValue: [] }) 53 | @OneToMany( 54 | () => Issue, 55 | issue => issue.project 56 | ) 57 | issues: Issue[]; 58 | 59 | @Field(() => [User], { defaultValue: [] }) 60 | @OneToMany( 61 | () => User, 62 | user => user.project 63 | ) 64 | users: User[]; 65 | } 66 | 67 | export default Project; 68 | -------------------------------------------------------------------------------- /packages/web/src/types/issue.ts: -------------------------------------------------------------------------------- 1 | import { User } from './user' 2 | 3 | export enum IssueType { 4 | TASK = 'task', 5 | BUG = 'bug', 6 | STORY = 'story' 7 | } 8 | 9 | export enum IssueStatus { 10 | BACKLOG = 'backlog', 11 | SELECTED = 'selected', 12 | INPROGRESS = 'inprogress', 13 | DONE = 'done' 14 | } 15 | 16 | export enum IssuePriority { 17 | HIGHEST = '5', 18 | HIGH = '4', 19 | MEDIUM = '3', 20 | LOW = '2', 21 | LOWEST = '1' 22 | } 23 | 24 | export interface Issue { 25 | id: string 26 | title: string 27 | type: IssueType 28 | status: IssueStatus 29 | priority: IssuePriority 30 | listPosition: number 31 | description: string | null 32 | estimate: number | null 33 | timeSpent: number | null 34 | timeRemaining: number | null 35 | createdAt: Date 36 | updatedAt: Date 37 | reporterId: string 38 | userIds: string[] 39 | comments: Comment[] 40 | projectId: number | string 41 | } 42 | 43 | export interface IssueCreateDTO { 44 | type: IssueType 45 | title: string 46 | description: string | null 47 | reporterId: string 48 | userIds: string[] 49 | priority: IssuePriority 50 | status?: IssueStatus 51 | projectId?: number | string 52 | users?: Partial[] 53 | } 54 | 55 | export const IssueStatusCopy = { 56 | [IssueStatus.BACKLOG]: 'Backlog', 57 | [IssueStatus.SELECTED]: 'Selected for development', 58 | [IssueStatus.INPROGRESS]: 'In progress', 59 | [IssueStatus.DONE]: 'Done' 60 | } 61 | export const IssuePriorityCopy = { 62 | [IssuePriority.HIGH]: 'High', 63 | [IssuePriority.HIGHEST]: 'Highest', 64 | [IssuePriority.MEDIUM]: 'Medium', 65 | [IssuePriority.LOW]: 'Low', 66 | [IssuePriority.LOWEST]: 'Lowest' 67 | } 68 | 69 | export const IssueTypeCopy = { 70 | [IssueType.TASK]: 'Task', 71 | [IssueType.BUG]: 'Bug', 72 | [IssueType.STORY]: 'Story' 73 | } 74 | 75 | export default Issue 76 | -------------------------------------------------------------------------------- /packages/web/src/components/shared/Textarea/Textarea.vue: -------------------------------------------------------------------------------- 1 |