├── .env ├── .eslintrc.cjs ├── .gitignore ├── .npmrc ├── .prettierignore ├── .prettierrc.js ├── .vscode └── extensions.json ├── README.md ├── components.d.ts ├── cypress.config.ts ├── cypress.d.ts ├── cypress ├── e2e │ ├── example.cy.ts │ └── tsconfig.json ├── fixtures │ └── example.json └── support │ ├── commands.ts │ ├── component-index.html │ ├── component.ts │ └── e2e.ts ├── env.d.ts ├── graphql.d.ts ├── index.html ├── package-lock.json ├── package.json ├── postcss.config.js ├── public └── favicon.ico ├── src ├── App.vue ├── assets │ └── base.css ├── common.ts ├── components │ ├── AppButton.cy.tsx │ ├── AppButton.vue │ ├── AppColorInput.vue │ ├── AppImage.vue │ ├── AppImageDropzone.vue │ ├── AppLabelsPicker.vue │ ├── AppLoader.vue │ ├── AppPageHeading.vue │ ├── BoardCard.vue │ ├── BoardDragAndDrop.vue │ ├── BoardMenu.vue │ ├── TaskCard.vue │ ├── TaskCreator.vue │ ├── TaskLabel.cy.tsx │ ├── TaskLabel.vue │ ├── TheAlerts.vue │ ├── TheDrawer.vue │ └── TheNavbar.vue ├── composables │ └── use8baseStorage.ts ├── graphql │ ├── apolloClient.ts │ ├── fragments │ │ └── board.fragment.gql │ ├── mutations │ │ ├── addTaskToBoard.mutation.gql │ │ ├── attachImageToBoard.mutation.gql │ │ ├── createBoard.mutation.gql │ │ ├── deleteBoard.mutation.gql │ │ ├── updateBoard.mutation.gql │ │ └── userSignUp.mutation.gql │ └── queries │ │ ├── board.query.gql │ │ ├── boards.query.gql │ │ ├── currentUser.query.gql │ │ └── task.query.gql ├── helpers │ └── 8baseAuth.ts ├── main.ts ├── pages │ ├── auth │ │ └── callback.vue │ ├── boards │ │ ├── [id].vue │ │ ├── [id] │ │ │ └── tasks │ │ │ │ └── [taskId].vue │ │ └── index.vue │ ├── index.vue │ ├── login.vue │ ├── logout.vue │ ├── settings │ │ └── index.vue │ └── templates │ │ └── index.vue ├── router │ └── index.ts ├── stores │ ├── AuthUserStore.ts │ └── alerts.ts └── types │ └── index.ts ├── tailwind.config.js ├── tsconfig.app.json ├── tsconfig.config.json ├── tsconfig.cypress.json ├── tsconfig.json ├── tsconfig.vitest.json ├── vite.config.ts └── windi.config.ts /.env: -------------------------------------------------------------------------------- 1 | VITE_TWICPICS_URL="https://2bl1vae5.twic.pics/" 2 | VITE_AUTH_DOMAIN="https://62ce08c7ab465500090d481a.auth.us-east-1.amazoncognito.com" 3 | VITE_AUTH_CLIENT_ID="7aku5ej4dc8tn3c7fb74etfp0e" 4 | VITE_AUTH_PROFILE_ID="cl5ld1pli09cz09mm11hi9fpv" -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | require("@rushstack/eslint-patch/modern-module-resolution"); 3 | 4 | module.exports = { 5 | root: true, 6 | extends: [ 7 | "plugin:vue/vue3-essential", 8 | "eslint:recommended", 9 | "@vue/eslint-config-typescript/recommended", 10 | "@vue/eslint-config-prettier", 11 | ], 12 | overrides: [ 13 | { 14 | files: ["cypress/e2e/**.{cy,spec}.{js,ts,jsx,tsx}"], 15 | extends: ["plugin:cypress/recommended"], 16 | }, 17 | ], 18 | rules: { 19 | "vue/multi-word-component-names": "off", 20 | }, 21 | ignorePatterns: ["*.config.js"], 22 | }; 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | .DS_Store 12 | dist 13 | dist-ssr 14 | coverage 15 | *.local 16 | 17 | /cypress/videos/ 18 | /cypress/screenshots/ 19 | 20 | # Editor directories and files 21 | .vscode/* 22 | !.vscode/extensions.json 23 | .idea 24 | *.suo 25 | *.ntvs* 26 | *.njsproj 27 | *.sln 28 | *.sw? 29 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict=true -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | __build__ 2 | dist 3 | coverage 4 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | semi: true, 3 | trailingComma: 'es5', 4 | singleQuote: false, 5 | } 6 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["Vue.volar", "Vue.vscode-typescript-vue-plugin"] 3 | } 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Vue.js Forge Logo](https://vuejsforge.com/images/logo.svg) 2 | 3 | # Vue.js Forge Official Project Repo 4 | 5 | We're super excited to be teaming up with the community to build a SaaS Project Management App together! 6 | 7 | Use this repo to keep up with the code as it's being worked on live by event speakers. 8 | 9 | You can merge changes from this repo into your own working project by adding it as a remote: 10 | 11 | ``` 12 | git remote add speakers git@github.com:vueschool/vuejs-forge-the-project.git 13 | ``` 14 | 15 | and then pulling and merging (if you've made updates to your own codebase, you may need to resolve any resulting merge conflicts) 16 | 17 | ``` 18 | git pull speakers main 19 | ``` 20 | 21 | # Project Setup 22 | 23 | This template should help get you started developing with Vue 3 in Vite. 24 | 25 | ## Recommended IDE Setup 26 | 27 | [VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur) + [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin). 28 | 29 | ## Type Support for `.vue` Imports in TS 30 | 31 | TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin) to make the TypeScript language service aware of `.vue` types. 32 | 33 | If the standalone TypeScript plugin doesn't feel fast enough to you, Volar has also implemented a [Take Over Mode](https://github.com/johnsoncodehk/volar/discussions/471#discussioncomment-1361669) that is more performant. You can enable it by the following steps: 34 | 35 | 1. Disable the built-in TypeScript Extension 36 | 1) Run `Extensions: Show Built-in Extensions` from VSCode's command palette 37 | 2) Find `TypeScript and JavaScript Language Features`, right click and select `Disable (Workspace)` 38 | 2. Reload the VSCode window by running `Developer: Reload Window` from the command palette. 39 | 40 | ## Customize configuration 41 | 42 | See [Vite Configuration Reference](https://vitejs.dev/config/). 43 | 44 | ## Project Setup 45 | 46 | ```sh 47 | npm install 48 | ``` 49 | 50 | ### Compile and Hot-Reload for Development 51 | 52 | ```sh 53 | npm run dev 54 | ``` 55 | 56 | ### Type-Check, Compile and Minify for Production 57 | 58 | ```sh 59 | npm run build 60 | ``` 61 | 62 | ### Run Unit Tests with [Vitest](https://vitest.dev/) 63 | 64 | ```sh 65 | npm run test:unit 66 | ``` 67 | 68 | ### Run End-to-End Tests with [Cypress](https://www.cypress.io/) 69 | 70 | ```sh 71 | npm run build 72 | npm run test:e2e # or `npm run test:e2e:ci` for headless testing 73 | ``` 74 | 75 | ### Lint with [ESLint](https://eslint.org/) 76 | 77 | ```sh 78 | npm run lint 79 | ``` 80 | -------------------------------------------------------------------------------- /components.d.ts: -------------------------------------------------------------------------------- 1 | // generated by unplugin-vue-components 2 | // We suggest you to commit this file into source control 3 | // Read more: https://github.com/vuejs/core/pull/3399 4 | import '@vue/runtime-core' 5 | 6 | declare module '@vue/runtime-core' { 7 | export interface GlobalComponents { 8 | AppButton: typeof import('./src/components/AppButton.vue')['default'] 9 | AppColorInput: typeof import('./src/components/AppColorInput.vue')['default'] 10 | AppImage: typeof import('./src/components/AppImage.vue')['default'] 11 | AppImageDropzone: typeof import('./src/components/AppImageDropzone.vue')['default'] 12 | AppLabelsPicker: typeof import('./src/components/AppLabelsPicker.vue')['default'] 13 | AppLoader: typeof import('./src/components/AppLoader.vue')['default'] 14 | AppPageHeading: typeof import('./src/components/AppPageHeading.vue')['default'] 15 | BoardCard: typeof import('./src/components/BoardCard.vue')['default'] 16 | BoardDragAndDrop: typeof import('./src/components/BoardDragAndDrop.vue')['default'] 17 | BoardMenu: typeof import('./src/components/BoardMenu.vue')['default'] 18 | RouterLink: typeof import('vue-router')['RouterLink'] 19 | RouterView: typeof import('vue-router')['RouterView'] 20 | TaskCard: typeof import('./src/components/TaskCard.vue')['default'] 21 | TaskCreator: typeof import('./src/components/TaskCreator.vue')['default'] 22 | TaskLabel: typeof import('./src/components/TaskLabel.vue')['default'] 23 | TheAlerts: typeof import('./src/components/TheAlerts.vue')['default'] 24 | TheDrawer: typeof import('./src/components/TheDrawer.vue')['default'] 25 | TheNavbar: typeof import('./src/components/TheNavbar.vue')['default'] 26 | } 27 | } 28 | 29 | export {} 30 | -------------------------------------------------------------------------------- /cypress.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "cypress"; 2 | 3 | export default defineConfig({ 4 | e2e: { 5 | specPattern: "cypress/e2e/**/*.{cy,spec}.{js,jsx,ts,tsx}", 6 | baseUrl: "http://localhost:4173", 7 | }, 8 | 9 | component: { 10 | devServer: { 11 | framework: "vue", 12 | bundler: "vite", 13 | }, 14 | }, 15 | }); 16 | -------------------------------------------------------------------------------- /cypress.d.ts: -------------------------------------------------------------------------------- 1 | import type { mount } from "cypress/vue"; 2 | 3 | declare global { 4 | namespace Cypress { 5 | interface Chainable { 6 | mount: typeof mount; 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /cypress/e2e/example.cy.ts: -------------------------------------------------------------------------------- 1 | // https://docs.cypress.io/api/introduction/api.html 2 | 3 | describe('My First Test', () => { 4 | it('visits the app root url', () => { 5 | cy.visit('/') 6 | cy.contains('h1', 'You did it!') 7 | }) 8 | }) 9 | -------------------------------------------------------------------------------- /cypress/e2e/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@vue/tsconfig/tsconfig.web.json", 3 | "include": ["./**/*", "../support/**/*"], 4 | "compilerOptions": { 5 | "isolatedModules": false, 6 | "target": "es5", 7 | "lib": ["es5", "dom"], 8 | "types": ["cypress"] 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /cypress/fixtures/example.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Using fixtures to represent data", 3 | "email": "hello@cypress.io", 4 | "body": "Fixtures are a great way to mock data for responses to routes" 5 | } 6 | -------------------------------------------------------------------------------- /cypress/support/commands.ts: -------------------------------------------------------------------------------- 1 | /// 2 | // *********************************************** 3 | // This example commands.ts shows you how to 4 | // create various custom commands and overwrite 5 | // existing commands. 6 | // 7 | // For more comprehensive examples of custom 8 | // commands please read more here: 9 | // https://on.cypress.io/custom-commands 10 | // *********************************************** 11 | // 12 | // 13 | // -- This is a parent command -- 14 | // Cypress.Commands.add('login', (email, password) => { ... }) 15 | // 16 | // 17 | // -- This is a child command -- 18 | // Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... }) 19 | // 20 | // 21 | // -- This is a dual command -- 22 | // Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... }) 23 | // 24 | // 25 | // -- This will overwrite an existing command -- 26 | // Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... }) 27 | // 28 | // declare global { 29 | // namespace Cypress { 30 | // interface Chainable { 31 | // login(email: string, password: string): Chainable 32 | // drag(subject: string, options?: Partial): Chainable 33 | // dismiss(subject: string, options?: Partial): Chainable 34 | // visit(originalFn: CommandOriginalFn, url: string, options: Partial): Chainable 35 | // } 36 | // } 37 | // } 38 | -------------------------------------------------------------------------------- /cypress/support/component-index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Components App 8 | 9 | 10 |
11 | 12 | -------------------------------------------------------------------------------- /cypress/support/component.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-namespace */ 2 | // *********************************************************** 3 | // This example support/component.ts is processed and 4 | // loaded automatically before your test files. 5 | // 6 | // This is a great place to put global configuration and 7 | // behavior that modifies Cypress. 8 | // 9 | // You can change the location of this file or turn off 10 | // automatically serving support files with the 11 | // 'supportFile' configuration option. 12 | // 13 | // You can read more here: 14 | // https://on.cypress.io/configuration 15 | // *********************************************************** 16 | 17 | // Import commands.js using ES2015 syntax: 18 | 19 | // Alternatively you can use CommonJS syntax: 20 | // require('./commands') 21 | 22 | import "../../src/common"; 23 | import { mount } from "cypress/vue"; 24 | 25 | Cypress.Commands.add("mount", mount); 26 | 27 | // Example use: 28 | // cy.mount(MyComponent) 29 | -------------------------------------------------------------------------------- /cypress/support/e2e.ts: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/index.js is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | // Import commands.js using ES2015 syntax: 17 | import './commands' 18 | 19 | // Alternatively you can use CommonJS syntax: 20 | // require('./commands') 21 | -------------------------------------------------------------------------------- /env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | /// 4 | -------------------------------------------------------------------------------- /graphql.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.gql' { 2 | import { DocumentNode } from 'graphql' 3 | const Schema: DocumentNode 4 | 5 | export = Schema 6 | } 7 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Vite App 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vue-forge-boilerplate", 3 | "version": "0.0.0", 4 | "engines": { 5 | "npm": ">=8.0.0", 6 | "node": ">=18.0.0" 7 | }, 8 | "scripts": { 9 | "dev": "vite", 10 | "build": "run-p type-check build-only", 11 | "preview": "vite preview --port 4173", 12 | "test:unit": "vitest --environment jsdom", 13 | "test:component": "cypress open --component --browser=canary", 14 | "test:component:ci": "cypress run --component --browser=canary", 15 | "test:e2e": "start-server-and-test preview http://127.0.0.1:4173/ 'cypress open --e2e'", 16 | "test:e2e:ci": "start-server-and-test preview http://127.0.0.1:4173/ 'cypress run --e2e'", 17 | "build-only": "vite build", 18 | "type-check": "vue-tsc --noEmit -p tsconfig.vitest.json --composite false", 19 | "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore" 20 | }, 21 | "dependencies": { 22 | "@8base/auth": "^2.6.6", 23 | "@apollo/client": "^3.6.9", 24 | "@progress/kendo-vue-dateinputs": "^3.4.3", 25 | "@twicpics/components": "^0.8.2", 26 | "@vue/apollo-composable": "^4.0.0-alpha.18", 27 | "@vueuse/core": "^8.9.2", 28 | "graphql": "^16.5.0", 29 | "graphql-request": "^4.3.0", 30 | "graphql-tag": "^2.12.6", 31 | "lodash-es": "^4.17.21", 32 | "pinia": "^2.0.16", 33 | "unplugin-vue-components": "^0.20.1", 34 | "uuid": "^8.3.2", 35 | "vite-plugin-pages": "^0.24.3", 36 | "vue": "^3.2.37", 37 | "vue-router": "^4.0.16", 38 | "vuedraggable": "^4.1.0" 39 | }, 40 | "devDependencies": { 41 | "@progress/kendo-licensing": "^1.2.2", 42 | "@progress/kendo-theme-default": "^5.5.0", 43 | "@progress/kendo-vue-animation": "^3.4.0", 44 | "@progress/kendo-vue-buttons": "^3.4.0", 45 | "@progress/kendo-vue-dialogs": "^3.4.0", 46 | "@progress/kendo-vue-editor": "^3.4.0", 47 | "@progress/kendo-vue-form": "^3.4.0", 48 | "@progress/kendo-vue-indicators": "^3.4.0", 49 | "@progress/kendo-vue-inputs": "^3.4.0", 50 | "@progress/kendo-vue-intl": "^3.4.3", 51 | "@progress/kendo-vue-layout": "^3.4.0", 52 | "@progress/kendo-vue-notification": "^3.4.0", 53 | "@progress/kendo-vue-popup": "^3.4.0", 54 | "@progress/kendo-vue-progressbars": "^3.4.3", 55 | "@rollup/plugin-graphql": "^1.1.0", 56 | "@rushstack/eslint-patch": "^1.1.0", 57 | "@types/jsdom": "^16.2.14", 58 | "@types/lodash-es": "^4.17.6", 59 | "@types/node": "^16.11.41", 60 | "@types/uuid": "^8.3.4", 61 | "@vitejs/plugin-vue": "^2.3.3", 62 | "@vitejs/plugin-vue-jsx": "^1.3.10", 63 | "@vue/eslint-config-prettier": "^7.0.0", 64 | "@vue/eslint-config-typescript": "^11.0.0", 65 | "@vue/test-utils": "^2.0.0", 66 | "@vue/tsconfig": "^0.1.3", 67 | "autoprefixer": "^10.4.7", 68 | "cypress": "^10.1.0", 69 | "eslint": "^8.5.0", 70 | "eslint-plugin-cypress": "^2.12.1", 71 | "eslint-plugin-vue": "^9.0.0", 72 | "jsdom": "^20.0.0", 73 | "npm-run-all": "^4.1.5", 74 | "postcss": "^8.4.14", 75 | "prettier": "^2.5.1", 76 | "start-server-and-test": "^1.14.0", 77 | "tailwindcss": "^3.1.4", 78 | "typescript": "~4.7.4", 79 | "vite": "^2.9.12", 80 | "vite-plugin-windicss": "^1.8.6", 81 | "vitest": "^0.15.1", 82 | "vue-tsc": "^0.38.1", 83 | "windicss": "^3.5.6" 84 | }, 85 | "overrides": { 86 | "graphql-prettier": { 87 | "graphql": ">=15.0.0" 88 | } 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vueschool/vuejs-forge-the-project/16cdd34f7e6067d0d1d3402142d7857852c52955/public/favicon.ico -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 13 | -------------------------------------------------------------------------------- /src/assets/base.css: -------------------------------------------------------------------------------- 1 | .aspect-video { 2 | aspect-ratio: 16 / 9; 3 | } 4 | -------------------------------------------------------------------------------- /src/common.ts: -------------------------------------------------------------------------------- 1 | import "virtual:windi-base.css"; 2 | import "virtual:windi-components.css"; 3 | import "virtual:windi-utilities.css"; 4 | import "./assets/base.css"; 5 | import "@progress/kendo-theme-default/dist/all.css"; 6 | -------------------------------------------------------------------------------- /src/components/AppButton.cy.tsx: -------------------------------------------------------------------------------- 1 | import AppButton from "./AppButton.vue"; 2 | 3 | describe("AppButton.cy.ts", () => { 4 | it("renders an app button", () => { 5 | cy.mount(() => Hello World!) 6 | .get("button") 7 | .click(); 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /src/components/AppButton.vue: -------------------------------------------------------------------------------- 1 | 4 | 9 | -------------------------------------------------------------------------------- /src/components/AppColorInput.vue: -------------------------------------------------------------------------------- 1 | 10 | 26 | -------------------------------------------------------------------------------- /src/components/AppImage.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 25 | -------------------------------------------------------------------------------- /src/components/AppImageDropzone.vue: -------------------------------------------------------------------------------- 1 | 41 | 42 | 64 | -------------------------------------------------------------------------------- /src/components/AppLabelsPicker.vue: -------------------------------------------------------------------------------- 1 | 89 | 142 | -------------------------------------------------------------------------------- /src/components/AppLoader.vue: -------------------------------------------------------------------------------- 1 | 7 | 12 | 13 | 20 | -------------------------------------------------------------------------------- /src/components/AppPageHeading.vue: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /src/components/BoardCard.vue: -------------------------------------------------------------------------------- 1 | 12 | 31 | -------------------------------------------------------------------------------- /src/components/BoardDragAndDrop.vue: -------------------------------------------------------------------------------- 1 | 58 | 59 | 117 | -------------------------------------------------------------------------------- /src/components/BoardMenu.vue: -------------------------------------------------------------------------------- 1 | 49 | 106 | 115 | -------------------------------------------------------------------------------- /src/components/TaskCard.vue: -------------------------------------------------------------------------------- 1 | 17 | 47 | -------------------------------------------------------------------------------- /src/components/TaskCreator.vue: -------------------------------------------------------------------------------- 1 | 26 | 27 | 47 | 48 | 49 | -------------------------------------------------------------------------------- /src/components/TaskLabel.cy.tsx: -------------------------------------------------------------------------------- 1 | import TaskLabel from "./TaskLabel.vue"; 2 | 3 | it('renders', () => { 4 | cy.mount(TaskLabel) 5 | }) -------------------------------------------------------------------------------- /src/components/TaskLabel.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 8 | -------------------------------------------------------------------------------- /src/components/TheAlerts.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 43 | -------------------------------------------------------------------------------- /src/components/TheDrawer.vue: -------------------------------------------------------------------------------- 1 | 59 | 60 | 77 | -------------------------------------------------------------------------------- /src/components/TheNavbar.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 48 | -------------------------------------------------------------------------------- /src/composables/use8baseStorage.ts: -------------------------------------------------------------------------------- 1 | import gql from "graphql-tag"; 2 | import { useMutation, useQuery } from "@vue/apollo-composable"; 3 | 4 | const IMAGE_UPLOAD_QUERY = gql` 5 | query { 6 | fileUploadInfo { 7 | policy 8 | signature 9 | apiKey 10 | path 11 | } 12 | } 13 | `; 14 | 15 | const FILE_CREATE_MUTATION = gql` 16 | mutation CREATE_FILE($fileId: String!, $filename: String!) { 17 | fileCreate(data: { fileId: $fileId, filename: $filename }) { 18 | id 19 | } 20 | } 21 | `; 22 | 23 | export function useStorage() { 24 | const { result } = useQuery(IMAGE_UPLOAD_QUERY); 25 | const { mutate: createFileIn8base } = useMutation(FILE_CREATE_MUTATION); 26 | 27 | async function uploadAsset(file: File) { 28 | if (!result.value || !result.value.fileUploadInfo) { 29 | throw new Error("File Upload info not yet available"); 30 | } 31 | const res = await fetch( 32 | `https://www.filestackapi.com/api/store/S3?key=${result.value.fileUploadInfo.apiKey}&policy=${result.value.fileUploadInfo.policy}&signature=${result.value.fileUploadInfo.signature}&path=${result.value.fileUploadInfo.path}`, 33 | { 34 | method: "POST", 35 | headers: { 36 | "Content-Type": file.type, 37 | }, 38 | body: file, 39 | } 40 | ); 41 | const data = await res.json(); 42 | return createFileIn8base({ 43 | fileId: data.url.split("/").at(-1), 44 | filename: data.filename, 45 | }); 46 | } 47 | return { 48 | uploadAsset, 49 | }; 50 | } 51 | export default useStorage; 52 | -------------------------------------------------------------------------------- /src/graphql/apolloClient.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ApolloClient, 3 | createHttpLink, 4 | InMemoryCache, 5 | } from "@apollo/client/core"; 6 | import { useAuthUserStore } from "@/stores/AuthUserStore"; 7 | import { setContext } from "@apollo/client/link/context"; 8 | import { onError } from "@apollo/client/link/error"; 9 | 10 | // HTTP connection to the API 11 | const httpLink = createHttpLink({ 12 | uri: "https://api.8base.com/cl5ittcmt05gm09kz812y1b3t", 13 | }); 14 | 15 | // Authorization Link 16 | const setAuthorizationLink = setContext((request, previousContext) => { 17 | const store = useAuthUserStore(); 18 | return store.authenticated 19 | ? { 20 | ...previousContext, 21 | headers: { 22 | authorization: `Bearer ${store.idToken}`, 23 | }, 24 | } 25 | : previousContext; 26 | }); 27 | 28 | // Error handling 29 | const setErrorHandler = onError((error) => { 30 | const badToken = !!error.response?.errors?.find( 31 | (e: { code: string }) => 32 | e.code === "TokenExpiredError" || e.code === "InvalidTokenError" 33 | ); 34 | if (badToken) { 35 | const store = useAuthUserStore(); 36 | store.login(); 37 | } 38 | }); 39 | 40 | const cache = new InMemoryCache(); 41 | 42 | export const apolloClient = new ApolloClient({ 43 | link: setAuthorizationLink.concat(setErrorHandler).concat(httpLink), 44 | cache, 45 | }); 46 | -------------------------------------------------------------------------------- /src/graphql/fragments/board.fragment.gql: -------------------------------------------------------------------------------- 1 | fragment Board on Board { 2 | id 3 | title 4 | image { 5 | downloadUrl 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/graphql/mutations/addTaskToBoard.mutation.gql: -------------------------------------------------------------------------------- 1 | mutation addTaskToBoard($boardId: ID!, $title: String!, $type: String) { 2 | boardUpdate( 3 | filter: { id: $boardId } 4 | data: { tasks: { create: [{ title: $title, type: $type }] } } 5 | ) { 6 | id 7 | tasks(last: 1) { 8 | items { 9 | id 10 | title 11 | createdAt 12 | updatedAt 13 | dueAt 14 | } 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/graphql/mutations/attachImageToBoard.mutation.gql: -------------------------------------------------------------------------------- 1 | mutation attachImageToBoard($id: ID!, $imageId: ID!) { 2 | boardUpdate( 3 | filter: { id: $id } 4 | data: { image: { reconnect: { id: $imageId } } } 5 | ) { 6 | id 7 | image { 8 | id 9 | downloadUrl 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/graphql/mutations/createBoard.mutation.gql: -------------------------------------------------------------------------------- 1 | #import "../fragments/board.fragment.gql" 2 | 3 | mutation createBoard($data: BoardCreateInput!) { 4 | boardCreate(data: $data) { 5 | ...Board 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/graphql/mutations/deleteBoard.mutation.gql: -------------------------------------------------------------------------------- 1 | mutation deleteBoard($id: ID!) { 2 | boardDelete(filter: { id: $id }, force: true) { 3 | success 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/graphql/mutations/updateBoard.mutation.gql: -------------------------------------------------------------------------------- 1 | mutation updateBoard($id: ID, $order: JSON, $title: String) { 2 | boardUpdate(filter: { id: $id }, data: { order: $order, title: $title }) { 3 | id 4 | title 5 | order 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/graphql/mutations/userSignUp.mutation.gql: -------------------------------------------------------------------------------- 1 | mutation UserSignUp($user: UserCreateInput!, $authProfileId: ID) { 2 | userSignUpWithToken(user: $user, authProfileId: $authProfileId) { 3 | id 4 | email 5 | firstName 6 | lastName 7 | team { 8 | items { 9 | id 10 | name 11 | } 12 | } 13 | roles { 14 | items { 15 | name 16 | } 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/graphql/queries/board.query.gql: -------------------------------------------------------------------------------- 1 | query getBoard($id: ID) { 2 | board(id: $id) { 3 | id 4 | title 5 | order 6 | createdAt 7 | updatedAt 8 | image { 9 | id 10 | downloadUrl 11 | } 12 | tasks { 13 | items { 14 | id 15 | title 16 | description 17 | createdAt 18 | updatedAt 19 | dueAt 20 | } 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/graphql/queries/boards.query.gql: -------------------------------------------------------------------------------- 1 | #import "../fragments/board.fragment.gql" 2 | 3 | query BoardsList { 4 | boardsList { 5 | items { 6 | ...Board 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/graphql/queries/currentUser.query.gql: -------------------------------------------------------------------------------- 1 | query currentUser { 2 | user { 3 | id 4 | email 5 | firstName 6 | lastName 7 | team { 8 | items { 9 | id 10 | name 11 | } 12 | } 13 | roles { 14 | items { 15 | name 16 | } 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/graphql/queries/task.query.gql: -------------------------------------------------------------------------------- 1 | query task($id: ID!) { 2 | task(id: $id) { 3 | title 4 | description 5 | dueAt 6 | labels { 7 | items { 8 | id 9 | label 10 | color 11 | } 12 | } 13 | comments { 14 | items { 15 | message 16 | } 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/helpers/8baseAuth.ts: -------------------------------------------------------------------------------- 1 | import { Auth, AUTH_STRATEGIES } from "@8base/auth"; 2 | 3 | const domain = import.meta.env.VITE_AUTH_DOMAIN; 4 | const clientId = import.meta.env.VITE_AUTH_CLIENT_ID; 5 | 6 | export const authClient = Auth.createClient( 7 | { 8 | strategy: AUTH_STRATEGIES.WEB_COGNITO, 9 | }, 10 | { 11 | domain, 12 | clientId, 13 | logoutRedirectUri: `${window.location.origin}/logout`, 14 | redirectUri: `${window.location.origin}/auth/callback`, 15 | } 16 | ); 17 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import "./common"; 2 | import { createApp, provide, h } from "vue"; 3 | import { createPinia } from "pinia"; 4 | import TwicPics from "@twicpics/components/vue3"; 5 | import App from "./App.vue"; 6 | import router from "./router"; 7 | import "@/assets/base.css"; 8 | import "@progress/kendo-theme-default/dist/all.css"; 9 | import "@twicpics/components/style.css"; 10 | import { DefaultApolloClient } from "@vue/apollo-composable"; 11 | import { apolloClient } from "@/graphql/apolloClient"; 12 | 13 | const app = createApp({ 14 | setup() { 15 | provide(DefaultApolloClient, apolloClient); 16 | }, 17 | 18 | render: () => h(App), 19 | }); 20 | 21 | app 22 | .use(router) 23 | .use(createPinia()) 24 | .use(TwicPics, { 25 | domain: `${import.meta.env.VITE_TWICPICS_URL}`, 26 | }); 27 | 28 | app.mount("#app"); 29 | -------------------------------------------------------------------------------- /src/pages/auth/callback.vue: -------------------------------------------------------------------------------- 1 | 11 | 14 | -------------------------------------------------------------------------------- /src/pages/boards/[id].vue: -------------------------------------------------------------------------------- 1 | 104 | 130 | -------------------------------------------------------------------------------- /src/pages/boards/[id]/tasks/[taskId].vue: -------------------------------------------------------------------------------- 1 | 58 | 59 | 153 | -------------------------------------------------------------------------------- /src/pages/boards/index.vue: -------------------------------------------------------------------------------- 1 | 58 | 59 | 79 | -------------------------------------------------------------------------------- /src/pages/index.vue: -------------------------------------------------------------------------------- 1 | 10 | -------------------------------------------------------------------------------- /src/pages/login.vue: -------------------------------------------------------------------------------- 1 | 6 | 9 | -------------------------------------------------------------------------------- /src/pages/logout.vue: -------------------------------------------------------------------------------- 1 | 6 | 9 | -------------------------------------------------------------------------------- /src/pages/settings/index.vue: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /src/pages/templates/index.vue: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /src/router/index.ts: -------------------------------------------------------------------------------- 1 | import { createRouter, createWebHistory } from "vue-router"; 2 | 3 | // this works with the vite plugin to support file based routing 4 | import routes from "~pages"; 5 | 6 | const router = createRouter({ 7 | history: createWebHistory(import.meta.env.BASE_URL), 8 | routes, 9 | }); 10 | 11 | export default router; 12 | -------------------------------------------------------------------------------- /src/stores/AuthUserStore.ts: -------------------------------------------------------------------------------- 1 | import { defineStore, acceptHMRUpdate } from "pinia"; 2 | import { authClient } from "@/helpers/8baseAuth"; 3 | import currentUserQuery from "@/graphql/queries/currentUser.query.gql"; 4 | import userSignUpMutation from "@/graphql/mutations/userSignUp.mutation.gql"; 5 | import { apolloClient } from "@/graphql/apolloClient"; 6 | import type { User } from "@/types"; 7 | 8 | const localStorageKey = "id_token"; 9 | const idToken = localStorage.getItem(localStorageKey); 10 | 11 | export const useAuthUserStore = defineStore("AuthUserStore", { 12 | state: () => { 13 | return { 14 | authenticated: !!idToken, 15 | idToken, 16 | user: null as User | null, 17 | }; 18 | }, 19 | actions: { 20 | login() { 21 | authClient.authorize(); 22 | }, 23 | 24 | async initUser() { 25 | if (!this.idToken) return; 26 | try { 27 | const res = await this.fetchUser(this.idToken as string); 28 | this.user = res.data.user; 29 | } catch (error) { 30 | console.log("no existing user matching id token"); 31 | } 32 | }, 33 | fetchUser(idToken: string) { 34 | const context = { 35 | headers: { 36 | authorization: `Bearer ${idToken}`, 37 | }, 38 | }; 39 | return apolloClient.query({ 40 | query: currentUserQuery, 41 | context, 42 | }); 43 | }, 44 | logout() { 45 | authClient.logout(); 46 | this.authenticated = false; 47 | this.idToken = null; 48 | localStorage.removeItem(localStorageKey); 49 | }, 50 | async handleAuthentication() { 51 | const authResult = await authClient.getAuthorizedData(); 52 | 53 | /** 54 | * Check if user exists in 8base. 55 | */ 56 | try { 57 | await this.fetchUser(authResult.idToken); 58 | } catch { 59 | /** 60 | * If user doesn't exist, an error will be 61 | * thrown, which then the new user can be 62 | * created using the authResult values. 63 | */ 64 | try { 65 | await apolloClient.mutate({ 66 | mutation: userSignUpMutation, 67 | variables: { 68 | user: { 69 | email: authResult.email, 70 | firstName: authResult.firstName, 71 | lastName: authResult.lastName, 72 | team: { 73 | create: { 74 | name: `${authResult.firstName}'s team`, 75 | }, 76 | }, 77 | }, 78 | authProfileId: import.meta.env.VITE_AUTH_PROFILE_ID, 79 | }, 80 | context: { 81 | headers: { 82 | authorization: `Bearer ${authResult.idToken}`, 83 | }, 84 | }, 85 | }); 86 | // for some reason 8base is throwing an error here even though the signup is successful 87 | // refreshing the page makes everything work... 88 | } catch (err) { 89 | window.location.pathname = "/"; 90 | } 91 | } 92 | 93 | this.authenticated = true; 94 | this.idToken = authResult.idToken; 95 | localStorage.setItem(localStorageKey, authResult.idToken); 96 | }, 97 | }, 98 | }); 99 | 100 | if (import.meta.hot) { 101 | import.meta.hot.accept(acceptHMRUpdate(useAuthUserStore, import.meta.hot)); 102 | } 103 | -------------------------------------------------------------------------------- /src/stores/alerts.ts: -------------------------------------------------------------------------------- 1 | import { acceptHMRUpdate, defineStore } from "pinia"; 2 | import { v4 as uuid } from "uuid"; 3 | 4 | export type AlertStyle = "error" | "success" | "warning" | "info" | "none"; 5 | 6 | export interface AlertOptions { 7 | html?: boolean; 8 | closable?: boolean; 9 | timeout?: number | false; 10 | style?: AlertStyle; 11 | } 12 | 13 | const defaultOptions: Required = { 14 | closable: true, 15 | html: false, 16 | timeout: 3000, 17 | style: "info", 18 | }; 19 | 20 | export interface Alert extends AlertOptions { 21 | id: string; 22 | message: string; 23 | } 24 | 25 | export const useAlerts = defineStore("alerts", { 26 | state: () => ({ 27 | items: [] as Alert[], 28 | }), 29 | 30 | actions: { 31 | notify(message: string, style: AlertStyle, options?: AlertOptions) { 32 | options = { ...defaultOptions, style, ...options }; 33 | 34 | const id = uuid(); 35 | this.items.push({ 36 | message, 37 | id, 38 | ...options, 39 | }); 40 | 41 | if (options.timeout !== false) { 42 | setTimeout(() => { 43 | this.remove(id); 44 | }, options.timeout); 45 | } 46 | }, 47 | 48 | success(message: string, options?: AlertOptions) { 49 | this.notify(message, "success", options); 50 | }, 51 | 52 | error(message: string, options?: AlertOptions) { 53 | this.notify(message, "error", options); 54 | }, 55 | 56 | warning(message: string, options?: AlertOptions) { 57 | this.notify(message, "warning", options); 58 | }, 59 | 60 | info(message: string, options?: AlertOptions) { 61 | this.notify(message, "info", options); 62 | }, 63 | 64 | remove(id: string) { 65 | const index = this.items.findIndex((item) => item.id === id); 66 | if (index > -1) { 67 | this.items.splice(index, 1); 68 | } 69 | }, 70 | }, 71 | }); 72 | 73 | if (import.meta.hot) { 74 | import.meta.hot.accept(acceptHMRUpdate(useAlerts, import.meta.hot)); 75 | } 76 | -------------------------------------------------------------------------------- /src/types/index.ts: -------------------------------------------------------------------------------- 1 | type ID = string; 2 | 3 | export interface Resource8base { 4 | id: ID; 5 | createdAt: Date; 6 | updatedAt: Date; 7 | deletedAt: Date; 8 | } 9 | 10 | export interface Team extends Resource8base { 11 | name: string; 12 | } 13 | 14 | export interface User extends Resource8base { 15 | email: string; 16 | roles: { items: Role[] }; 17 | team: { 18 | items: Team[]; 19 | }; 20 | } 21 | export interface Role { 22 | name: string; 23 | } 24 | 25 | export interface Board extends Resource8base { 26 | title: string; 27 | 28 | // Board order JSON encoded in DB and thus can be a string 29 | // when decoded it's an array of Columns 30 | order: string | Column[]; 31 | 32 | // relationships 33 | image?: Partial; 34 | tasks?: Partial[]; 35 | } 36 | 37 | export interface Column { 38 | id: ID; 39 | title: string; 40 | taskIds: ID[]; 41 | } 42 | 43 | export interface Task extends Resource8base { 44 | title: string; 45 | description: string; 46 | labels: Label[]; 47 | dueAt: Date; 48 | 49 | // relationships 50 | board?: Partial; 51 | comments?: Partial[]; 52 | } 53 | 54 | export interface Comment extends Resource8base { 55 | message: string; 56 | 57 | // relationships 58 | task?: Partial; 59 | } 60 | 61 | type LabelColor = 62 | | "red" 63 | | "orange" 64 | | "yellow" 65 | | "green" 66 | | "blue" 67 | | "purple" 68 | | "pink"; 69 | 70 | export interface Label extends Resource8base { 71 | label: string; 72 | color: LabelColor; 73 | 74 | // relationships 75 | board?: Partial; 76 | tasks?: Partial[]; 77 | } 78 | 79 | export interface File extends Resource8base { 80 | downloadStorageUrl: string; 81 | downloadUrl: string; 82 | filename: string; 83 | meta: { 84 | path: string; 85 | size: number; 86 | mimetype: string; 87 | workspaceId: string; 88 | }; 89 | previewUrl: string; 90 | provider: string; 91 | public: boolean; 92 | shareUrl: string; 93 | uploadUrl: string; 94 | uploaded: boolean; 95 | } 96 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: ["./index.html", "./src/**/*.{vue,js,ts,jsx,tsx}"], 4 | theme: { 5 | extend: {}, 6 | }, 7 | safelist: [ 8 | "bg-red-500", 9 | "bg-orange-500", 10 | "bg-yellow-500", 11 | "bg-green-500", 12 | "bg-blue-500", 13 | "bg-purple-500", 14 | "bg-pink-500", 15 | ], 16 | }; 17 | -------------------------------------------------------------------------------- /tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@vue/tsconfig/tsconfig.web.json", 3 | "files": ["graphql.d.ts"], 4 | "include": ["env.d.ts", "src/**/*", "src/**/*.vue", "src/**/*.gql"], 5 | "exclude": ["src/**/__tests__/*"], 6 | "compilerOptions": { 7 | "composite": true, 8 | "jsx": "preserve", 9 | "baseUrl": ".", 10 | "paths": { 11 | "@/*": ["./src/*"] 12 | }, 13 | "allowJs": true // 👈 add this line 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /tsconfig.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@vue/tsconfig/tsconfig.node.json", 3 | "include": ["vite.config.*", "vitest.config.*", "cypress.config.*"], 4 | "compilerOptions": { 5 | "composite": true, 6 | "types": ["node"] 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /tsconfig.cypress.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.app.json", 3 | "include": ["src", "**/*.cy.*", "cypress/support/component.ts"], 4 | "exclude": [], 5 | "files": ["./cypress.d.ts"], 6 | "compilerOptions": { 7 | "isolatedModules": false, 8 | "composite": true, 9 | "lib": ["DOM"], 10 | "jsx": "preserve", 11 | "jsxFactory": "h", 12 | "types": ["node", "cypress"], 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "compilerOptions": { 4 | "allowJs": true 5 | }, 6 | "references": [ 7 | { 8 | "path": "./tsconfig.config.json" 9 | }, 10 | { 11 | "path": "./tsconfig.app.json" 12 | }, 13 | { 14 | "path": "./tsconfig.vitest.json" 15 | }, 16 | { 17 | "path": "./tsconfig.cypress.json" 18 | } 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /tsconfig.vitest.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.app.json", 3 | "exclude": [], 4 | "compilerOptions": { 5 | "composite": true, 6 | "lib": [], 7 | "types": ["node", "jsdom"] 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { fileURLToPath, URL } from "url"; 2 | 3 | import { defineConfig } from "vite"; 4 | import graphql from "@rollup/plugin-graphql"; 5 | import WindiCSS from "vite-plugin-windicss"; 6 | 7 | // plugins 8 | import vueJsx from "@vitejs/plugin-vue-jsx"; 9 | import vue from "@vitejs/plugin-vue"; 10 | import Pages from "vite-plugin-pages"; 11 | import Components from "unplugin-vue-components/vite"; 12 | 13 | // https://vitejs.dev/config/ 14 | export default defineConfig({ 15 | plugins: [vue(), vueJsx(), graphql(), Pages(), Components(), WindiCSS()], 16 | resolve: { 17 | alias: { 18 | "@": fileURLToPath(new URL("./src", import.meta.url)), 19 | }, 20 | }, 21 | }); 22 | -------------------------------------------------------------------------------- /windi.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite-plugin-windicss"; 2 | 3 | export default defineConfig({ 4 | extract: { 5 | include: [ 6 | "./**/*.html", 7 | "./**/*.vue", 8 | "./**/*.ts", 9 | "./**/*.js", 10 | "./**/*.jsx", 11 | "./**/*.tsx", 12 | ], 13 | }, 14 | safelist: [ 15 | "bg-red-500", 16 | "bg-orange-500", 17 | "bg-yellow-500", 18 | "bg-green-500", 19 | "bg-blue-500", 20 | "bg-purple-500", 21 | "bg-pink-500", 22 | ], 23 | }); 24 | --------------------------------------------------------------------------------