├── .editorconfig ├── .env.example ├── .gitignore ├── .prettierrc.json ├── .vscode └── extensions.json ├── README.md ├── auto-imports.d.ts ├── components.d.ts ├── components.json ├── database ├── seed.js └── types.ts ├── env.d.ts ├── eslint.config.js ├── formkit.config.ts ├── formkit.theme.ts ├── index.html ├── package-lock.json ├── package.json ├── public └── favicon.ico ├── src ├── App.vue ├── assets │ └── index.css ├── components │ ├── AppError │ │ ├── AppErrorDevSection.vue │ │ ├── AppErrorPage.vue │ │ └── AppErrorProdSection.vue │ ├── AppInPlaceEdit │ │ ├── AppInPlaceEditStatus.vue │ │ ├── AppInPlaceEditText.vue │ │ └── AppInPlaceEditTextarea.vue │ ├── AppNew │ │ └── AppNewTask.vue │ ├── Layout │ │ ├── Sidebar.vue │ │ ├── SidebarLinks.vue │ │ ├── TopNavbar.vue │ │ └── main │ │ │ ├── AuthLayout.vue │ │ │ └── GuestLayout.vue │ └── ui │ │ ├── avatar │ │ ├── Avatar.vue │ │ ├── AvatarFallback.vue │ │ ├── AvatarImage.vue │ │ └── index.ts │ │ ├── button │ │ ├── Button.vue │ │ └── index.ts │ │ ├── card │ │ ├── Card.vue │ │ ├── CardContent.vue │ │ ├── CardDescription.vue │ │ ├── CardFooter.vue │ │ ├── CardHeader.vue │ │ ├── CardTitle.vue │ │ └── index.ts │ │ ├── data-table │ │ └── DataTable.vue │ │ ├── dropdown-menu │ │ ├── DropdownMenu.vue │ │ ├── DropdownMenuCheckboxItem.vue │ │ ├── DropdownMenuContent.vue │ │ ├── DropdownMenuGroup.vue │ │ ├── DropdownMenuItem.vue │ │ ├── DropdownMenuLabel.vue │ │ ├── DropdownMenuRadioGroup.vue │ │ ├── DropdownMenuRadioItem.vue │ │ ├── DropdownMenuSeparator.vue │ │ ├── DropdownMenuShortcut.vue │ │ ├── DropdownMenuSub.vue │ │ ├── DropdownMenuSubContent.vue │ │ ├── DropdownMenuSubTrigger.vue │ │ ├── DropdownMenuTrigger.vue │ │ └── index.ts │ │ ├── input │ │ ├── Input.vue │ │ └── index.ts │ │ ├── label │ │ ├── Label.vue │ │ └── index.ts │ │ ├── separator │ │ ├── Separator.vue │ │ └── index.ts │ │ ├── sheet │ │ ├── Sheet.vue │ │ ├── SheetClose.vue │ │ ├── SheetContent.vue │ │ ├── SheetDescription.vue │ │ ├── SheetFooter.vue │ │ ├── SheetHeader.vue │ │ ├── SheetTitle.vue │ │ ├── SheetTrigger.vue │ │ └── index.ts │ │ └── table │ │ ├── Table.vue │ │ ├── TableBody.vue │ │ ├── TableCaption.vue │ │ ├── TableCell.vue │ │ ├── TableEmpty.vue │ │ ├── TableFooter.vue │ │ ├── TableHead.vue │ │ ├── TableHeader.vue │ │ ├── TableRow.vue │ │ └── index.ts ├── composables │ ├── collabs.ts │ └── formErrors.ts ├── lib │ ├── supabaseClient.ts │ └── utils.ts ├── main.ts ├── pages │ ├── [...catchAll].vue │ ├── index.vue │ ├── login.vue │ ├── projects │ │ ├── [slug].vue │ │ └── index.vue │ ├── register.vue │ ├── tasks │ │ ├── [id].vue │ │ └── index.vue │ └── users │ │ └── [username].vue ├── router │ └── index.ts ├── stores │ ├── auth.ts │ ├── error.ts │ ├── loaders │ │ ├── projects.ts │ │ └── tasks.ts │ └── page.ts ├── types │ ├── AuthForm.ts │ ├── CreateNewForm.ts │ ├── Error.ts │ └── GroupedCollabs.ts └── utils │ ├── formValidations.ts │ ├── injectionKeys.ts │ ├── supaAuth.ts │ ├── supaQueries.ts │ └── tableColumns │ ├── projectsColumns.ts │ └── tasksColumns.ts ├── supabase ├── .gitignore ├── config.toml ├── migrations │ ├── 20240607090723_projects-schema.sql │ ├── 20240905083244_profiles-schema.sql │ └── 20240926141009_tasks-schema.sql └── seed.sql ├── tailwind.config.js ├── tsconfig.app.json ├── tsconfig.json ├── tsconfig.node.json ├── typed-router.d.ts └── vite.config.ts /.editorconfig: -------------------------------------------------------------------------------- 1 | [*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue}] 2 | charset = utf-8 3 | indent_size = 2 4 | indent_style = space 5 | insert_final_newline = true 6 | trim_trailing_whitespace = true -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | VITE_SUPABASE_URL= 2 | VITE_SUPABASE_KEY= 3 | SERVICE_ROLE_KEY= 4 | TESTING_USER_EMAIL= -------------------------------------------------------------------------------- /.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 | 30 | *.tsbuildinfo 31 | 32 | .env -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/prettierrc", 3 | "semi": false, 4 | "tabWidth": 2, 5 | "singleQuote": true, 6 | "trailingComma": "none" 7 | } 8 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "Vue.volar", 4 | "dbaeumer.vscode-eslint", 5 | "esbenp.prettier-vscode" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # vuejs-masterclass-2024-Edition 2 | 3 | This respository contains the source code for the Vue.js [Master Class 2024 Edition course](https://vueschool.io/courses/the-vuejs-3-master-class). 4 | 5 | ## About The Masterclass 6 | 7 | The Vue.js Master Class is our signature course and THE most popular source online for learning how to build a real world Vue.js application from scratch. This 2024 Edition of the course is a complete re-write with the latest and great official and community Vue.js technologies. 8 | 9 | The Vue.js Masterclass 2024 Edition is so comprehensive that we can not cover everything on this page. Thus we’ve created a separate page where you can learn more about it. 10 | 11 | During this video course, we focus on learning practical application and strategies for Vue.js by building a feature-rich product management app together. The goal of this course is to teach you Vue.js along with Best Practices, Modern Javascript, and other exciting technologies, by building a Real World application. 12 | 13 | **We cover the fundamentals, like:** 14 | 15 | - Setting up Vue 3 project using Vite 16 | - Integrating VueDevTools with Vue js 3 17 | - Routing with Vue Router and File Based Routing with unplugin-vue-router 18 | - Vue Component and Composable Design with the Composition API 19 | - State management with Pinia 20 | - Modern Javascript (ES2023/ESNext) 21 | - User permissions & Route Guards 22 | - Data and File Storage, plus Authentication with Supabase 23 | - Automatic code review with ESLint and Formatting with Prettier 24 | - Consuming REST APIs 25 | - Application architecture and best practices 26 | - Error handling and monitoring 27 | - Supabase Row Level Security 28 | - Database migrations and seeding 29 | 30 | **We also dive into practical real world features and how to implement them quickly:** 31 | 32 | - Robust and beautiful components with TailwindCSS and ShadCN Vue 33 | - SEO, Sitemaps, schema.org and Metadata 34 | - Transactional emails for dynamic app notifications 35 | - Data filtering and searching strategies across multiple resources (projects, tasks, etc) 36 | - Forms and Validation with Formkit 37 | - Auto saving on edits to inline content 38 | - Pagination and Infinite scroll support 39 | - Real time commenting 40 | - Analytics and events tracking with Google Analytics 4, Google Tag Manager, and Sentry. 41 | 42 | By completing the Vue.js Masterclass, you will be able to land any Vue.js related job or optimize/improve your own projects! 43 | 44 | Requirements You should be familiar with JavaScript, HTML, basic CSS, and have fundamental knowledge of Vue.js (specifically with the Composition API). 45 | 46 | If you are just starting out with Vue.js, we suggest that you watch our free course [Vue.js Fundamentals with the Composition API](https://vueschool.io/courses/vue-js-fundamentals-with-the-composition-api) along with [the Vue Component Fundamentals with the Composition API](https://vueschool.io/courses/vue-component-fundamentals-with-the-composition-api) course. These courses will help you learn Vue.js fundamentals and prepare for the journey ahead. 47 | 48 | ## Project Setup 49 | 50 | ```sh 51 | npm install 52 | ``` 53 | 54 | ### Compile and Hot-Reload for Development 55 | 56 | ```sh 57 | npm run dev 58 | ``` 59 | 60 | ### ENV Variables 61 | 62 | Make sure to provide the env variables listed in the `.env.example` file along with their values. 63 | -------------------------------------------------------------------------------- /auto-imports.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /* prettier-ignore */ 3 | // @ts-nocheck 4 | // noinspection JSUnusedGlobalSymbols 5 | // Generated by unplugin-auto-import 6 | // biome-ignore lint: disable 7 | export {} 8 | declare global { 9 | const EffectScope: typeof import('vue')['EffectScope'] 10 | const acceptHMRUpdate: typeof import('pinia')['acceptHMRUpdate'] 11 | const computed: typeof import('vue')['computed'] 12 | const createApp: typeof import('vue')['createApp'] 13 | const customRef: typeof import('vue')['customRef'] 14 | const defineAsyncComponent: typeof import('vue')['defineAsyncComponent'] 15 | const defineComponent: typeof import('vue')['defineComponent'] 16 | const defineStore: typeof import('pinia')['defineStore'] 17 | const effectScope: typeof import('vue')['effectScope'] 18 | const getCurrentInstance: typeof import('vue')['getCurrentInstance'] 19 | const getCurrentScope: typeof import('vue')['getCurrentScope'] 20 | const h: typeof import('vue')['h'] 21 | const inject: typeof import('vue')['inject'] 22 | const isProxy: typeof import('vue')['isProxy'] 23 | const isReactive: typeof import('vue')['isReactive'] 24 | const isReadonly: typeof import('vue')['isReadonly'] 25 | const isRef: typeof import('vue')['isRef'] 26 | const markRaw: typeof import('vue')['markRaw'] 27 | const nextTick: typeof import('vue')['nextTick'] 28 | const onActivated: typeof import('vue')['onActivated'] 29 | const onBeforeMount: typeof import('vue')['onBeforeMount'] 30 | const onBeforeRouteLeave: typeof import('vue-router')['onBeforeRouteLeave'] 31 | const onBeforeRouteUpdate: typeof import('vue-router')['onBeforeRouteUpdate'] 32 | const onBeforeUnmount: typeof import('vue')['onBeforeUnmount'] 33 | const onBeforeUpdate: typeof import('vue')['onBeforeUpdate'] 34 | const onDeactivated: typeof import('vue')['onDeactivated'] 35 | const onErrorCaptured: typeof import('vue')['onErrorCaptured'] 36 | const onMounted: typeof import('vue')['onMounted'] 37 | const onRenderTracked: typeof import('vue')['onRenderTracked'] 38 | const onRenderTriggered: typeof import('vue')['onRenderTriggered'] 39 | const onScopeDispose: typeof import('vue')['onScopeDispose'] 40 | const onServerPrefetch: typeof import('vue')['onServerPrefetch'] 41 | const onUnmounted: typeof import('vue')['onUnmounted'] 42 | const onUpdated: typeof import('vue')['onUpdated'] 43 | const onWatcherCleanup: typeof import('vue')['onWatcherCleanup'] 44 | const provide: typeof import('vue')['provide'] 45 | const reactive: typeof import('vue')['reactive'] 46 | const readonly: typeof import('vue')['readonly'] 47 | const ref: typeof import('vue')['ref'] 48 | const resolveComponent: typeof import('vue')['resolveComponent'] 49 | const shallowReactive: typeof import('vue')['shallowReactive'] 50 | const shallowReadonly: typeof import('vue')['shallowReadonly'] 51 | const shallowRef: typeof import('vue')['shallowRef'] 52 | const storeToRefs: typeof import('pinia')['storeToRefs'] 53 | const toRaw: typeof import('vue')['toRaw'] 54 | const toRef: typeof import('vue')['toRef'] 55 | const toRefs: typeof import('vue')['toRefs'] 56 | const toValue: typeof import('vue')['toValue'] 57 | const triggerRef: typeof import('vue')['triggerRef'] 58 | const unref: typeof import('vue')['unref'] 59 | const useAttrs: typeof import('vue')['useAttrs'] 60 | const useAuthStore: typeof import('./src/stores/auth')['useAuthStore'] 61 | const useCollabs: typeof import('./src/composables/collabs')['useCollabs'] 62 | const useCssModule: typeof import('vue')['useCssModule'] 63 | const useCssVars: typeof import('vue')['useCssVars'] 64 | const useErrorStore: typeof import('./src/stores/error')['useErrorStore'] 65 | const useFormErrors: typeof import('./src/composables/formErrors')['useFormErrors'] 66 | const useId: typeof import('vue')['useId'] 67 | const useLink: typeof import('vue-router')['useLink'] 68 | const useMenu: typeof import('./src/composables/manu')['useMenu'] 69 | const useMeta: typeof import('vue-meta')['useMeta'] 70 | const useModel: typeof import('vue')['useModel'] 71 | const usePageStore: typeof import('./src/stores/page')['usePageStore'] 72 | const useProjectsStore: typeof import('./src/stores/loaders/projects')['useProjectsStore'] 73 | const useRoute: typeof import('vue-router')['useRoute'] 74 | const useRouter: typeof import('vue-router')['useRouter'] 75 | const useSlots: typeof import('vue')['useSlots'] 76 | const useTasksStore: typeof import('./src/stores/loaders/tasks')['useTasksStore'] 77 | const useTemplateRef: typeof import('vue')['useTemplateRef'] 78 | const watch: typeof import('vue')['watch'] 79 | const watchEffect: typeof import('vue')['watchEffect'] 80 | const watchPostEffect: typeof import('vue')['watchPostEffect'] 81 | const watchSyncEffect: typeof import('vue')['watchSyncEffect'] 82 | } 83 | // for type re-export 84 | declare global { 85 | // @ts-ignore 86 | export type { Component, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue' 87 | import('vue') 88 | } 89 | -------------------------------------------------------------------------------- /components.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | // @ts-nocheck 3 | // Generated by unplugin-vue-components 4 | // Read more: https://github.com/vuejs/core/pull/3399 5 | export {} 6 | 7 | /* prettier-ignore */ 8 | declare module 'vue' { 9 | export interface GlobalComponents { 10 | AppErrorDevSection: typeof import('./src/components/AppError/AppErrorDevSection.vue')['default'] 11 | AppErrorPage: typeof import('./src/components/AppError/AppErrorPage.vue')['default'] 12 | AppErrorProdSection: typeof import('./src/components/AppError/AppErrorProdSection.vue')['default'] 13 | AppInPlaceEditStatus: typeof import('./src/components/AppInPlaceEdit/AppInPlaceEditStatus.vue')['default'] 14 | AppInPlaceEditText: typeof import('./src/components/AppInPlaceEdit/AppInPlaceEditText.vue')['default'] 15 | AppInPlaceEditTextarea: typeof import('./src/components/AppInPlaceEdit/AppInPlaceEditTextarea.vue')['default'] 16 | AppNewTask: typeof import('./src/components/AppNew/AppNewTask.vue')['default'] 17 | AuthLayout: typeof import('./src/components/Layout/main/AuthLayout.vue')['default'] 18 | Avatar: typeof import('./src/components/ui/avatar/Avatar.vue')['default'] 19 | AvatarFallback: typeof import('./src/components/ui/avatar/AvatarFallback.vue')['default'] 20 | AvatarImage: typeof import('./src/components/ui/avatar/AvatarImage.vue')['default'] 21 | Button: typeof import('./src/components/ui/button/Button.vue')['default'] 22 | Card: typeof import('./src/components/ui/card/Card.vue')['default'] 23 | CardContent: typeof import('./src/components/ui/card/CardContent.vue')['default'] 24 | CardDescription: typeof import('./src/components/ui/card/CardDescription.vue')['default'] 25 | CardFooter: typeof import('./src/components/ui/card/CardFooter.vue')['default'] 26 | CardHeader: typeof import('./src/components/ui/card/CardHeader.vue')['default'] 27 | CardTitle: typeof import('./src/components/ui/card/CardTitle.vue')['default'] 28 | copy: typeof import('./src/components/AppInPlaceEdit/AppInPlaceEditText copy.vue')['default'] 29 | DataTable: typeof import('./src/components/ui/data-table/DataTable.vue')['default'] 30 | DropdownMenu: typeof import('./src/components/ui/dropdown-menu/DropdownMenu.vue')['default'] 31 | DropdownMenuCheckboxItem: typeof import('./src/components/ui/dropdown-menu/DropdownMenuCheckboxItem.vue')['default'] 32 | DropdownMenuContent: typeof import('./src/components/ui/dropdown-menu/DropdownMenuContent.vue')['default'] 33 | DropdownMenuGroup: typeof import('./src/components/ui/dropdown-menu/DropdownMenuGroup.vue')['default'] 34 | DropdownMenuItem: typeof import('./src/components/ui/dropdown-menu/DropdownMenuItem.vue')['default'] 35 | DropdownMenuLabel: typeof import('./src/components/ui/dropdown-menu/DropdownMenuLabel.vue')['default'] 36 | DropdownMenuRadioGroup: typeof import('./src/components/ui/dropdown-menu/DropdownMenuRadioGroup.vue')['default'] 37 | DropdownMenuRadioItem: typeof import('./src/components/ui/dropdown-menu/DropdownMenuRadioItem.vue')['default'] 38 | DropdownMenuSeparator: typeof import('./src/components/ui/dropdown-menu/DropdownMenuSeparator.vue')['default'] 39 | DropdownMenuShortcut: typeof import('./src/components/ui/dropdown-menu/DropdownMenuShortcut.vue')['default'] 40 | DropdownMenuSub: typeof import('./src/components/ui/dropdown-menu/DropdownMenuSub.vue')['default'] 41 | DropdownMenuSubContent: typeof import('./src/components/ui/dropdown-menu/DropdownMenuSubContent.vue')['default'] 42 | DropdownMenuSubTrigger: typeof import('./src/components/ui/dropdown-menu/DropdownMenuSubTrigger.vue')['default'] 43 | DropdownMenuTrigger: typeof import('./src/components/ui/dropdown-menu/DropdownMenuTrigger.vue')['default'] 44 | GuestLayout: typeof import('./src/components/Layout/main/GuestLayout.vue')['default'] 45 | Input: typeof import('./src/components/ui/input/Input.vue')['default'] 46 | Label: typeof import('./src/components/ui/label/Label.vue')['default'] 47 | RouterLink: typeof import('vue-router')['RouterLink'] 48 | RouterView: typeof import('vue-router')['RouterView'] 49 | Separator: typeof import('./src/components/ui/separator/Separator.vue')['default'] 50 | Sheet: typeof import('./src/components/ui/sheet/Sheet.vue')['default'] 51 | SheetClose: typeof import('./src/components/ui/sheet/SheetClose.vue')['default'] 52 | SheetContent: typeof import('./src/components/ui/sheet/SheetContent.vue')['default'] 53 | SheetDescription: typeof import('./src/components/ui/sheet/SheetDescription.vue')['default'] 54 | SheetFooter: typeof import('./src/components/ui/sheet/SheetFooter.vue')['default'] 55 | SheetHeader: typeof import('./src/components/ui/sheet/SheetHeader.vue')['default'] 56 | SheetTitle: typeof import('./src/components/ui/sheet/SheetTitle.vue')['default'] 57 | SheetTrigger: typeof import('./src/components/ui/sheet/SheetTrigger.vue')['default'] 58 | Sidebar: typeof import('./src/components/Layout/Sidebar.vue')['default'] 59 | SidebarLinks: typeof import('./src/components/Layout/SidebarLinks.vue')['default'] 60 | Table: typeof import('./src/components/ui/table/Table.vue')['default'] 61 | TableBody: typeof import('./src/components/ui/table/TableBody.vue')['default'] 62 | TableCaption: typeof import('./src/components/ui/table/TableCaption.vue')['default'] 63 | TableCell: typeof import('./src/components/ui/table/TableCell.vue')['default'] 64 | TableEmpty: typeof import('./src/components/ui/table/TableEmpty.vue')['default'] 65 | TableFooter: typeof import('./src/components/ui/table/TableFooter.vue')['default'] 66 | TableHead: typeof import('./src/components/ui/table/TableHead.vue')['default'] 67 | TableHeader: typeof import('./src/components/ui/table/TableHeader.vue')['default'] 68 | TableRow: typeof import('./src/components/ui/table/TableRow.vue')['default'] 69 | TopNavbar: typeof import('./src/components/Layout/TopNavbar.vue')['default'] 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://shadcn-vue.com/schema.json", 3 | "style": "default", 4 | "typescript": true, 5 | "tsConfigPath": "./tsconfig.json", 6 | "tailwind": { 7 | "config": "tailwind.config.js", 8 | "css": "src/assets/index.css", 9 | "baseColor": "slate", 10 | "cssVariables": true 11 | }, 12 | "framework": "vite", 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils" 16 | } 17 | } -------------------------------------------------------------------------------- /database/seed.js: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | 3 | import { fakerEN_US as faker } from '@faker-js/faker' 4 | import { createClient } from '@supabase/supabase-js' 5 | 6 | const supabase = createClient( 7 | process.env.VITE_SUPABASE_URL, 8 | process.env.SERVICE_ROLE_KEY 9 | ) 10 | 11 | const testingUserEmail = process.env.TESTING_USER_EMAIL 12 | if (!testingUserEmail) { 13 | console.error('Have you forgot to add TESTING_USER_EMAIL to your .env file?') 14 | process.exit() 15 | } 16 | 17 | const logErrorAndExit = (tableName, error) => { 18 | console.error( 19 | `An error occurred in table '${tableName}' with code ${error.code}: ${error.message}` 20 | ) 21 | process.exit(1) 22 | } 23 | 24 | const logStep = (stepMessage) => { 25 | console.log(stepMessage) 26 | } 27 | 28 | const PrimaryTestUserExists = async () => { 29 | logStep('Checking if primary test user exists...') 30 | const { data, error } = await supabase 31 | .from('profiles') 32 | .select('id, username') 33 | .eq('username', 'testaccount1') 34 | .single() 35 | 36 | if (error) { 37 | console.log('Primary test user not found. Will create one.') 38 | return false 39 | } 40 | 41 | logStep('Primary test user is found.') 42 | return data?.id 43 | } 44 | 45 | const createPrimaryTestUser = async () => { 46 | logStep('Creating primary test user...') 47 | const firstName = 'Test' 48 | const lastName = 'Account' 49 | const userName = 'testaccount1' 50 | const email = testingUserEmail 51 | const { data, error } = await supabase.auth.signUp({ 52 | email: email, 53 | password: 'password', 54 | options: { 55 | data: { 56 | first_name: firstName, 57 | last_name: lastName, 58 | full_name: firstName + ' ' + lastName, 59 | username: userName 60 | } 61 | } 62 | }) 63 | 64 | if (error) { 65 | logErrorAndExit('Users', error) 66 | } 67 | 68 | if (data) { 69 | const userId = data.user.id 70 | await supabase.from('profiles').insert({ 71 | id: userId, 72 | full_name: firstName + ' ' + lastName, 73 | username: userName, 74 | bio: 'The main testing account', 75 | avatar_url: `https://i.pravatar.cc/150?u=${data.user.id}` 76 | }) 77 | 78 | logStep('Primary test user created successfully.') 79 | return userId 80 | } 81 | } 82 | 83 | const seedProjects = async (numEntries, userId) => { 84 | logStep('Seeding projects...') 85 | const projects = [] 86 | 87 | for (let i = 0; i < numEntries; i++) { 88 | const name = faker.lorem.words(3) 89 | 90 | projects.push({ 91 | name: name, 92 | slug: name.toLocaleLowerCase().replace(/ /g, '-'), 93 | description: faker.lorem.paragraphs(2), 94 | status: faker.helpers.arrayElement(['in-progress', 'completed']), 95 | collaborators: faker.helpers.arrayElements([userId]) 96 | }) 97 | } 98 | 99 | const { data, error } = await supabase 100 | .from('projects') 101 | .insert(projects) 102 | .select('id') 103 | 104 | if (error) return logErrorAndExit('Projects', error) 105 | 106 | logStep('Projects seeded successfully.') 107 | 108 | return data 109 | } 110 | 111 | const seedTasks = async (numEntries, projectsIds, userId) => { 112 | logStep('Seeding tasks...') 113 | const tasks = [] 114 | 115 | for (let i = 0; i < numEntries; i++) { 116 | tasks.push({ 117 | name: faker.lorem.words(3), 118 | status: faker.helpers.arrayElement(['in-progress', 'completed']), 119 | description: faker.lorem.paragraph(), 120 | due_date: faker.date.future(), 121 | profile_id: userId, 122 | project_id: faker.helpers.arrayElement(projectsIds), 123 | collaborators: faker.helpers.arrayElements([userId]) 124 | }) 125 | } 126 | 127 | const { data, error } = await supabase 128 | .from('tasks') 129 | .insert(tasks) 130 | .select('id') 131 | 132 | if (error) return logErrorAndExit('Tasks', error) 133 | 134 | logStep('Tasks seeded successfully.') 135 | 136 | return data 137 | } 138 | 139 | const seedDatabase = async (numEntriesPerTable) => { 140 | let userId 141 | 142 | const testUserId = await PrimaryTestUserExists() 143 | 144 | if (!testUserId) { 145 | const primaryTestUserId = await createPrimaryTestUser() 146 | userId = primaryTestUserId 147 | } else { 148 | userId = testUserId 149 | } 150 | 151 | const projectsIds = (await seedProjects(numEntriesPerTable, userId)).map( 152 | (project) => project.id 153 | ) 154 | await seedTasks(numEntriesPerTable, projectsIds, userId) 155 | } 156 | 157 | const numEntriesPerTable = 10 158 | 159 | seedDatabase(numEntriesPerTable) 160 | -------------------------------------------------------------------------------- /database/types.ts: -------------------------------------------------------------------------------- 1 | export type Json = 2 | | string 3 | | number 4 | | boolean 5 | | null 6 | | { [key: string]: Json | undefined } 7 | | Json[] 8 | 9 | export type Database = { 10 | public: { 11 | Tables: { 12 | profiles: { 13 | Row: { 14 | avatar_url: string | null 15 | bio: string | null 16 | created_at: string 17 | full_name: string 18 | id: string 19 | mode: string 20 | username: string 21 | } 22 | Insert: { 23 | avatar_url?: string | null 24 | bio?: string | null 25 | created_at?: string 26 | full_name: string 27 | id: string 28 | mode?: string 29 | username: string 30 | } 31 | Update: { 32 | avatar_url?: string | null 33 | bio?: string | null 34 | created_at?: string 35 | full_name?: string 36 | id?: string 37 | mode?: string 38 | username?: string 39 | } 40 | Relationships: [] 41 | } 42 | projects: { 43 | Row: { 44 | collaborators: string[] 45 | created_at: string 46 | description: string 47 | id: number 48 | name: string 49 | slug: string 50 | status: Database["public"]["Enums"]["current_status"] 51 | } 52 | Insert: { 53 | collaborators?: string[] 54 | created_at?: string 55 | description?: string 56 | id?: never 57 | name: string 58 | slug: string 59 | status?: Database["public"]["Enums"]["current_status"] 60 | } 61 | Update: { 62 | collaborators?: string[] 63 | created_at?: string 64 | description?: string 65 | id?: never 66 | name?: string 67 | slug?: string 68 | status?: Database["public"]["Enums"]["current_status"] 69 | } 70 | Relationships: [] 71 | } 72 | tasks: { 73 | Row: { 74 | collaborators: string[] 75 | created_at: string 76 | description: string 77 | due_date: string | null 78 | id: number 79 | name: string 80 | profile_id: string 81 | project_id: number | null 82 | status: Database["public"]["Enums"]["current_status"] 83 | } 84 | Insert: { 85 | collaborators?: string[] 86 | created_at?: string 87 | description: string 88 | due_date?: string | null 89 | id?: never 90 | name: string 91 | profile_id: string 92 | project_id?: number | null 93 | status?: Database["public"]["Enums"]["current_status"] 94 | } 95 | Update: { 96 | collaborators?: string[] 97 | created_at?: string 98 | description?: string 99 | due_date?: string | null 100 | id?: never 101 | name?: string 102 | profile_id?: string 103 | project_id?: number | null 104 | status?: Database["public"]["Enums"]["current_status"] 105 | } 106 | Relationships: [ 107 | { 108 | foreignKeyName: "tasks_profile_id_fkey" 109 | columns: ["profile_id"] 110 | isOneToOne: false 111 | referencedRelation: "profiles" 112 | referencedColumns: ["id"] 113 | }, 114 | { 115 | foreignKeyName: "tasks_project_id_fkey" 116 | columns: ["project_id"] 117 | isOneToOne: false 118 | referencedRelation: "projects" 119 | referencedColumns: ["id"] 120 | }, 121 | ] 122 | } 123 | } 124 | Views: { 125 | [_ in never]: never 126 | } 127 | Functions: { 128 | [_ in never]: never 129 | } 130 | Enums: { 131 | current_status: "in-progress" | "completed" 132 | } 133 | CompositeTypes: { 134 | [_ in never]: never 135 | } 136 | } 137 | } 138 | 139 | type PublicSchema = Database[Extract] 140 | 141 | export type Tables< 142 | PublicTableNameOrOptions extends 143 | | keyof (PublicSchema["Tables"] & PublicSchema["Views"]) 144 | | { schema: keyof Database }, 145 | TableName extends PublicTableNameOrOptions extends { schema: keyof Database } 146 | ? keyof (Database[PublicTableNameOrOptions["schema"]]["Tables"] & 147 | Database[PublicTableNameOrOptions["schema"]]["Views"]) 148 | : never = never, 149 | > = PublicTableNameOrOptions extends { schema: keyof Database } 150 | ? (Database[PublicTableNameOrOptions["schema"]]["Tables"] & 151 | Database[PublicTableNameOrOptions["schema"]]["Views"])[TableName] extends { 152 | Row: infer R 153 | } 154 | ? R 155 | : never 156 | : PublicTableNameOrOptions extends keyof (PublicSchema["Tables"] & 157 | PublicSchema["Views"]) 158 | ? (PublicSchema["Tables"] & 159 | PublicSchema["Views"])[PublicTableNameOrOptions] extends { 160 | Row: infer R 161 | } 162 | ? R 163 | : never 164 | : never 165 | 166 | export type TablesInsert< 167 | PublicTableNameOrOptions extends 168 | | keyof PublicSchema["Tables"] 169 | | { schema: keyof Database }, 170 | TableName extends PublicTableNameOrOptions extends { schema: keyof Database } 171 | ? keyof Database[PublicTableNameOrOptions["schema"]]["Tables"] 172 | : never = never, 173 | > = PublicTableNameOrOptions extends { schema: keyof Database } 174 | ? Database[PublicTableNameOrOptions["schema"]]["Tables"][TableName] extends { 175 | Insert: infer I 176 | } 177 | ? I 178 | : never 179 | : PublicTableNameOrOptions extends keyof PublicSchema["Tables"] 180 | ? PublicSchema["Tables"][PublicTableNameOrOptions] extends { 181 | Insert: infer I 182 | } 183 | ? I 184 | : never 185 | : never 186 | 187 | export type TablesUpdate< 188 | PublicTableNameOrOptions extends 189 | | keyof PublicSchema["Tables"] 190 | | { schema: keyof Database }, 191 | TableName extends PublicTableNameOrOptions extends { schema: keyof Database } 192 | ? keyof Database[PublicTableNameOrOptions["schema"]]["Tables"] 193 | : never = never, 194 | > = PublicTableNameOrOptions extends { schema: keyof Database } 195 | ? Database[PublicTableNameOrOptions["schema"]]["Tables"][TableName] extends { 196 | Update: infer U 197 | } 198 | ? U 199 | : never 200 | : PublicTableNameOrOptions extends keyof PublicSchema["Tables"] 201 | ? PublicSchema["Tables"][PublicTableNameOrOptions] extends { 202 | Update: infer U 203 | } 204 | ? U 205 | : never 206 | : never 207 | 208 | export type Enums< 209 | PublicEnumNameOrOptions extends 210 | | keyof PublicSchema["Enums"] 211 | | { schema: keyof Database }, 212 | EnumName extends PublicEnumNameOrOptions extends { schema: keyof Database } 213 | ? keyof Database[PublicEnumNameOrOptions["schema"]]["Enums"] 214 | : never = never, 215 | > = PublicEnumNameOrOptions extends { schema: keyof Database } 216 | ? Database[PublicEnumNameOrOptions["schema"]]["Enums"][EnumName] 217 | : PublicEnumNameOrOptions extends keyof PublicSchema["Enums"] 218 | ? PublicSchema["Enums"][PublicEnumNameOrOptions] 219 | : never 220 | 221 | export type CompositeTypes< 222 | PublicCompositeTypeNameOrOptions extends 223 | | keyof PublicSchema["CompositeTypes"] 224 | | { schema: keyof Database }, 225 | CompositeTypeName extends PublicCompositeTypeNameOrOptions extends { 226 | schema: keyof Database 227 | } 228 | ? keyof Database[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"] 229 | : never = never, 230 | > = PublicCompositeTypeNameOrOptions extends { schema: keyof Database } 231 | ? Database[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"][CompositeTypeName] 232 | : PublicCompositeTypeNameOrOptions extends keyof PublicSchema["CompositeTypes"] 233 | ? PublicSchema["CompositeTypes"][PublicCompositeTypeNameOrOptions] 234 | : never 235 | -------------------------------------------------------------------------------- /env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import pluginVue from 'eslint-plugin-vue' 2 | import vueTsEslintConfig from '@vue/eslint-config-typescript' 3 | import skipFormatting from '@vue/eslint-config-prettier/skip-formatting' 4 | 5 | export default [ 6 | { 7 | name: 'app/files-to-lint', 8 | files: ['**/*.{ts,mts,tsx,vue}'] 9 | }, 10 | 11 | { 12 | name: 'app/files-to-ignore', 13 | ignores: ['**/dist/**', '**/dist-ssr/**', '**/coverage/**'] 14 | }, 15 | ...pluginVue.configs['flat/essential'], 16 | { 17 | rules: { 18 | 'vue/multi-word-component-names': 0 19 | } 20 | }, 21 | ...vueTsEslintConfig(), 22 | skipFormatting 23 | ] 24 | -------------------------------------------------------------------------------- /formkit.config.ts: -------------------------------------------------------------------------------- 1 | import { defaultConfig } from '@formkit/vue' 2 | import { rootClasses } from './formkit.theme' 3 | 4 | export default defaultConfig({ 5 | config: { 6 | rootClasses 7 | } 8 | }) 9 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Vite App 9 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vuejs-masterclass-2024", 3 | "version": "0.0.0", 4 | "private": true, 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "run-p type-check \"build-only {@}\" --", 9 | "preview": "vite preview", 10 | "build-only": "vite build", 11 | "type-check": "vue-tsc --build --force", 12 | "lint": "eslint . --fix", 13 | "format": "prettier --write src/", 14 | "supabase:init": "supabase init", 15 | "supabase:login": "supabase login", 16 | "supabase:link": "supabase link --project-ref baavizkjepbcaklxvgvd", 17 | "supabase:types": "npx supabase gen types typescript --project-id baavizkjepbcaklxvgvd --schema public > database/types.ts", 18 | "db:migrate:new": "supabase migration new", 19 | "db:reset": "supabase db reset --linked", 20 | "db:seed": "node --env-file=.env database/seed.js" 21 | }, 22 | "dependencies": { 23 | "@formkit/vue": "^1.6.9", 24 | "@supabase/supabase-js": "^2.43.4", 25 | "@tanstack/vue-table": "^8.19.3", 26 | "@vueuse/core": "^10.11.1", 27 | "class-variance-authority": "^0.7.0", 28 | "clsx": "^2.1.1", 29 | "iconify-icon": "^2.1.0", 30 | "lucide-vue-next": "^0.397.0", 31 | "pinia": "^2.1.7", 32 | "radix-vue": "^1.8.5", 33 | "tailwind-merge": "^2.3.0", 34 | "tailwindcss-animate": "^1.0.7", 35 | "vue": "^3.5.12", 36 | "vue-meta": "^3.0.0-alpha.10", 37 | "vue-router": "^4.4.0" 38 | }, 39 | "devDependencies": { 40 | "@faker-js/faker": "^8.4.1", 41 | "@rushstack/eslint-patch": "^1.8.0", 42 | "@tsconfig/node20": "^20.1.4", 43 | "@types/node": "^20.12.5", 44 | "@vitejs/plugin-vue": "^5.1.4", 45 | "@vue/eslint-config-prettier": "^10.1.0", 46 | "@vue/eslint-config-typescript": "^14.1.3", 47 | "@vue/tsconfig": "^0.5.1", 48 | "autoprefixer": "^10.4.19", 49 | "eslint": "^9.13.0", 50 | "eslint-plugin-vue": "^9.30.0", 51 | "npm-run-all2": "^6.1.2", 52 | "prettier": "^3.3.3", 53 | "supabase": "^1.172.2", 54 | "tailwindcss": "^3.4.4", 55 | "typescript": "~5.6.3", 56 | "unplugin-auto-import": "^0.18.2", 57 | "unplugin-vue-components": "^0.27.3", 58 | "unplugin-vue-router": "^0.10.1", 59 | "vite": "^5.2.8", 60 | "vue-tsc": "^2.1.10" 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vueschool/vuejs-masterclass-2024-edition/4c7c44c7f274132ed7bbfe6b0385b9c33d31a7a5/public/favicon.ico -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 31 | 32 | 60 | -------------------------------------------------------------------------------- /src/assets/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | :root { 7 | --background: 0 0% 100%; 8 | --foreground: 222.2 84% 4.9%; 9 | 10 | --muted: 210 40% 96.1%; 11 | --muted-foreground: 215.4 16.3% 46.9%; 12 | 13 | --popover: 0 0% 100%; 14 | --popover-foreground: 222.2 84% 4.9%; 15 | 16 | --card: 0 0% 100%; 17 | --card-foreground: 222.2 84% 4.9%; 18 | 19 | --border: 214.3 31.8% 91.4%; 20 | --input: 214.3 31.8% 91.4%; 21 | 22 | --primary: 222.2 47.4% 11.2%; 23 | --primary-foreground: 210 40% 98%; 24 | 25 | --secondary: 210 40% 96.1%; 26 | --secondary-foreground: 222.2 47.4% 11.2%; 27 | 28 | --accent: 210 40% 96.1%; 29 | --accent-foreground: 222.2 47.4% 11.2%; 30 | 31 | --destructive: 0 84.2% 60.2%; 32 | --destructive-foreground: 210 40% 98%; 33 | 34 | --ring: 222.2 84% 4.9%; 35 | 36 | --radius: 0.5rem; 37 | } 38 | 39 | .dark { 40 | --background: 222.2 84% 4.9%; 41 | --foreground: 210 40% 98%; 42 | 43 | --muted: 217.2 32.6% 17.5%; 44 | --muted-foreground: 215 20.2% 65.1%; 45 | 46 | --popover: 222.2 84% 4.9%; 47 | --popover-foreground: 210 40% 98%; 48 | 49 | --card: 222.2 84% 4.9%; 50 | --card-foreground: 210 40% 98%; 51 | 52 | --border: 217.2 32.6% 17.5%; 53 | --input: 217.2 32.6% 17.5%; 54 | 55 | --primary: 210 40% 98%; 56 | --primary-foreground: 222.2 47.4% 11.2%; 57 | 58 | --secondary: 217.2 32.6% 17.5%; 59 | --secondary-foreground: 210 40% 98%; 60 | 61 | --accent: 217.2 32.6% 17.5%; 62 | --accent-foreground: 210 40% 98%; 63 | 64 | --destructive: 0 62.8% 30.6%; 65 | --destructive-foreground: 210 40% 98%; 66 | 67 | --ring: 212.7 26.8% 83.9%; 68 | } 69 | } 70 | 71 | @layer base { 72 | * { 73 | @apply border-border; 74 | } 75 | body { 76 | @apply bg-background text-foreground; 77 | } 78 | } 79 | 80 | .scale-enter-active, 81 | .scale-leave-active { 82 | transition: transform 0.1s; 83 | } 84 | 85 | .scale-enter-from, 86 | .scale-leave-to { 87 | transform: scale(0.3); 88 | } 89 | 90 | .fade-enter-active, 91 | .fade-leave-active { 92 | transition: opacity 0.5s ease; 93 | } 94 | 95 | .fade-enter-from, 96 | .fade-leave-to { 97 | opacity: 0; 98 | } 99 | -------------------------------------------------------------------------------- /src/components/AppError/AppErrorDevSection.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 28 | -------------------------------------------------------------------------------- /src/components/AppError/AppErrorPage.vue: -------------------------------------------------------------------------------- 1 | 36 | 37 | 50 | 51 | 80 | -------------------------------------------------------------------------------- /src/components/AppError/AppErrorProdSection.vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | 40 | -------------------------------------------------------------------------------- /src/components/AppInPlaceEdit/AppInPlaceEditStatus.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 30 | -------------------------------------------------------------------------------- /src/components/AppInPlaceEdit/AppInPlaceEditText.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 16 | -------------------------------------------------------------------------------- /src/components/AppInPlaceEdit/AppInPlaceEditTextarea.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 134 | 135 | 148 | -------------------------------------------------------------------------------- /src/pages/tasks/index.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 22 | -------------------------------------------------------------------------------- /src/pages/users/[username].vue: -------------------------------------------------------------------------------- 1 | 23 | 24 | 41 | -------------------------------------------------------------------------------- /src/router/index.ts: -------------------------------------------------------------------------------- 1 | import { createRouter, createWebHistory } from 'vue-router/auto' 2 | import { routes } from 'vue-router/auto-routes' 3 | 4 | const router = createRouter({ 5 | history: createWebHistory(import.meta.env.BASE_URL), 6 | routes 7 | }) 8 | 9 | router.beforeEach(async (to, from) => { 10 | const authStore = useAuthStore() 11 | await authStore.getSession() 12 | 13 | const isAuthPage = ['/login', '/register'].includes(to.path) 14 | 15 | if (!authStore.user && !isAuthPage) { 16 | return { 17 | name: '/login' 18 | } 19 | } 20 | 21 | if (authStore.user && isAuthPage) { 22 | return { 23 | name: '/' 24 | } 25 | } 26 | }) 27 | 28 | export default router 29 | -------------------------------------------------------------------------------- /src/stores/auth.ts: -------------------------------------------------------------------------------- 1 | import { supabase } from '@/lib/supabaseClient' 2 | import { profileQuery } from '@/utils/supaQueries' 3 | import type { Session, User } from '@supabase/supabase-js' 4 | import type { Tables } from 'database/types' 5 | 6 | export const useAuthStore = defineStore('auth-store', () => { 7 | const user = ref(null) 8 | const profile = ref>(null) 9 | const isTrackingAuthChanges = ref(false) 10 | 11 | const setProfile = async () => { 12 | if (!user.value) { 13 | profile.value = null 14 | return 15 | } 16 | 17 | if (!profile.value || profile.value.id !== user.value.id) { 18 | const { data } = await profileQuery({ 19 | column: 'id', 20 | value: user.value.id 21 | }) 22 | 23 | profile.value = data || null 24 | } 25 | } 26 | 27 | const setAuth = async (userSession: null | Session = null) => { 28 | if (!userSession) { 29 | user.value = null 30 | profile.value = null 31 | return 32 | } 33 | 34 | user.value = userSession.user 35 | await setProfile() 36 | } 37 | 38 | const getSession = async () => { 39 | const { data } = await supabase.auth.getSession() 40 | if (data.session?.user) await setAuth(data.session) 41 | } 42 | 43 | const trackAuthChanges = () => { 44 | if (isTrackingAuthChanges.value) return 45 | 46 | isTrackingAuthChanges.value = true 47 | supabase.auth.onAuthStateChange((event, session) => { 48 | setTimeout(async () => { 49 | await setAuth(session) 50 | }, 0) 51 | }) 52 | } 53 | 54 | return { 55 | user, 56 | profile, 57 | setAuth, 58 | getSession, 59 | trackAuthChanges 60 | } 61 | }) 62 | 63 | if (import.meta.hot) { 64 | import.meta.hot.accept(acceptHMRUpdate(useAuthStore, import.meta.hot)) 65 | } 66 | -------------------------------------------------------------------------------- /src/stores/error.ts: -------------------------------------------------------------------------------- 1 | import type { CustomError, ExtendedPostgrestError } from '@/types/Error' 2 | import type { PostgrestError } from '@supabase/supabase-js' 3 | 4 | export const useErrorStore = defineStore('error-store', () => { 5 | const activeError = ref(null) 6 | const isCustomError = ref(false) 7 | 8 | const setError = ({ 9 | error, 10 | customCode 11 | }: { 12 | error: string | PostgrestError | Error 13 | customCode?: number 14 | }) => { 15 | if (typeof error === 'string') isCustomError.value = true 16 | 17 | if (typeof error === 'string' || error instanceof Error) { 18 | activeError.value = typeof error === 'string' ? Error(error) : error 19 | activeError.value.customCode = customCode || 500 20 | return 21 | } 22 | 23 | activeError.value = error 24 | activeError.value.statusCode = customCode || 500 25 | } 26 | 27 | const clearError = () => { 28 | activeError.value = null 29 | isCustomError.value = false 30 | } 31 | 32 | return { 33 | activeError, 34 | setError, 35 | isCustomError, 36 | clearError 37 | } 38 | }) 39 | 40 | if (import.meta.hot) { 41 | import.meta.hot.accept(acceptHMRUpdate(useErrorStore, import.meta.hot)) 42 | } 43 | -------------------------------------------------------------------------------- /src/stores/loaders/projects.ts: -------------------------------------------------------------------------------- 1 | import { 2 | projectQuery, 3 | projectsQuery, 4 | updateProjectQuery 5 | } from '@/utils/supaQueries' 6 | import { useMemoize } from '@vueuse/core' 7 | import type { Project, Projects } from '@/utils/supaQueries' 8 | 9 | export const useProjectsStore = defineStore('projects-store', () => { 10 | const projects = ref(null) 11 | const project = ref(null) 12 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 13 | const loadProjects = useMemoize(async (key: string) => await projectsQuery) 14 | const loadProject = useMemoize( 15 | async (slug: string) => await projectQuery(slug) 16 | ) 17 | 18 | interface ValidateCacheParams { 19 | ref: typeof projects | typeof project 20 | query: typeof projectsQuery | typeof projectQuery 21 | key: string 22 | loaderFn: typeof loadProjects | typeof loadProject 23 | } 24 | 25 | const validateCache = ({ 26 | ref, 27 | query, 28 | key, 29 | loaderFn 30 | }: ValidateCacheParams) => { 31 | if (ref.value) { 32 | const finalQuery = typeof query === 'function' ? query(key) : query 33 | 34 | finalQuery.then(({ data, error }) => { 35 | if (JSON.stringify(ref.value) === JSON.stringify(data)) { 36 | return 37 | } else { 38 | loaderFn.delete(key) 39 | if (!error && data) ref.value = data 40 | } 41 | }) 42 | } 43 | } 44 | 45 | const getProjects = async () => { 46 | projects.value = null 47 | 48 | const { data, error, status } = await loadProjects('projects') 49 | 50 | if (error) useErrorStore().setError({ error, customCode: status }) 51 | 52 | if (data) projects.value = data 53 | 54 | validateCache({ 55 | ref: projects, 56 | query: projectsQuery, 57 | key: 'projects', 58 | loaderFn: loadProjects 59 | }) 60 | } 61 | 62 | const getProject = async (slug: string) => { 63 | project.value = null 64 | 65 | const { data, error, status } = await loadProject(slug) 66 | 67 | if (error) useErrorStore().setError({ error, customCode: status }) 68 | 69 | if (data) project.value = data 70 | 71 | validateCache({ 72 | ref: project, 73 | query: projectQuery, 74 | key: slug, 75 | loaderFn: loadProject 76 | }) 77 | } 78 | 79 | const updateProject = async () => { 80 | if (!project.value) return 81 | 82 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 83 | const { tasks, id, ...projectProperties } = project.value 84 | 85 | await updateProjectQuery(projectProperties, project.value.id) 86 | } 87 | 88 | return { 89 | projects, 90 | getProjects, 91 | getProject, 92 | project, 93 | updateProject 94 | } 95 | }) 96 | -------------------------------------------------------------------------------- /src/stores/loaders/tasks.ts: -------------------------------------------------------------------------------- 1 | import { 2 | deleteTaskQuery, 3 | taskQuery, 4 | tasksWithProjectsQuery, 5 | updateTaskQuery 6 | } from '@/utils/supaQueries' 7 | import { useMemoize } from '@vueuse/core' 8 | import type { Task, TasksWithProjects } from '@/utils/supaQueries' 9 | 10 | export const useTasksStore = defineStore('tasks-store', () => { 11 | const tasks = ref(null) 12 | const task = ref(null) 13 | const loadTasks = useMemoize( 14 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 15 | async (id: string) => await tasksWithProjectsQuery 16 | ) 17 | const loadTask = useMemoize(async (slug: string) => await taskQuery(slug)) 18 | 19 | interface ValidateCacheParams { 20 | ref: typeof tasks | typeof task 21 | query: typeof tasksWithProjectsQuery | typeof taskQuery 22 | key: string 23 | loaderFn: typeof loadTasks | typeof loadTask 24 | } 25 | 26 | const validateCache = ({ 27 | ref, 28 | query, 29 | key, 30 | loaderFn 31 | }: ValidateCacheParams) => { 32 | if (ref.value) { 33 | const finalQuery = typeof query === 'function' ? query(key) : query 34 | 35 | finalQuery.then(({ data, error }) => { 36 | if (JSON.stringify(ref.value) === JSON.stringify(data)) { 37 | return 38 | } else { 39 | loaderFn.delete(key) 40 | if (!error && data) ref.value = data 41 | } 42 | }) 43 | } 44 | } 45 | 46 | const getTasks = async () => { 47 | tasks.value = null 48 | 49 | const { data, error, status } = await loadTasks('tasks') 50 | 51 | if (error) useErrorStore().setError({ error, customCode: status }) 52 | 53 | if (data) tasks.value = data 54 | 55 | validateCache({ 56 | ref: tasks, 57 | query: tasksWithProjectsQuery, 58 | key: 'tasks', 59 | loaderFn: loadTasks 60 | }) 61 | } 62 | 63 | const getTask = async (id: string) => { 64 | task.value = null 65 | 66 | const { data, error, status } = await loadTask(id) 67 | 68 | if (error) useErrorStore().setError({ error, customCode: status }) 69 | 70 | if (data) task.value = data 71 | 72 | validateCache({ 73 | ref: task, 74 | query: taskQuery, 75 | key: id, 76 | loaderFn: loadTask 77 | }) 78 | } 79 | 80 | const updateTask = async () => { 81 | if (!task.value) return 82 | 83 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 84 | const { projects, id, ...taskProperties } = task.value 85 | 86 | await updateTaskQuery(taskProperties, task.value.id) 87 | } 88 | 89 | const deleteTask = async () => { 90 | if (!task.value) return 91 | 92 | await deleteTaskQuery(task.value.id) 93 | } 94 | 95 | return { 96 | tasks, 97 | getTasks, 98 | getTask, 99 | task, 100 | updateTask, 101 | deleteTask 102 | } 103 | }) 104 | -------------------------------------------------------------------------------- /src/stores/page.ts: -------------------------------------------------------------------------------- 1 | export const usePageStore = defineStore('page-store', () => { 2 | const pageData = ref({ 3 | title: '' 4 | }) 5 | 6 | return { 7 | pageData 8 | } 9 | }) 10 | 11 | if (import.meta.hot) { 12 | import.meta.hot.accept(acceptHMRUpdate(usePageStore, import.meta.hot)) 13 | } 14 | -------------------------------------------------------------------------------- /src/types/AuthForm.ts: -------------------------------------------------------------------------------- 1 | export interface LoginForm { 2 | email: string 3 | password: string 4 | } 5 | 6 | export interface RegisterForm extends LoginForm { 7 | confirmPassword: string 8 | username: string 9 | firstName: string 10 | lastName: string 11 | } 12 | -------------------------------------------------------------------------------- /src/types/CreateNewForm.ts: -------------------------------------------------------------------------------- 1 | export interface CreateNewTask { 2 | name: string 3 | description: string 4 | project_id: number 5 | profile_id: string 6 | } 7 | -------------------------------------------------------------------------------- /src/types/Error.ts: -------------------------------------------------------------------------------- 1 | import type { PostgrestError } from '@supabase/supabase-js' 2 | 3 | export interface CustomError extends Error { 4 | customCode?: number 5 | } 6 | export interface ExtendedPostgrestError extends PostgrestError { 7 | statusCode?: number 8 | } 9 | -------------------------------------------------------------------------------- /src/types/GroupedCollabs.ts: -------------------------------------------------------------------------------- 1 | import type { Collabs } from '@/utils/supaQueries' 2 | 3 | export type GroupedCollabs = { 4 | [key: string]: Collabs 5 | } 6 | -------------------------------------------------------------------------------- /src/utils/formValidations.ts: -------------------------------------------------------------------------------- 1 | export const validateEmail = (email: string) => { 2 | const trimmedEmail = email.trim() 3 | if (!trimmedEmail) return [] 4 | 5 | const errors = [] 6 | 7 | const emailRegex = /^((?!\.)[\w\-_.]*[^.])(@\w+)(\.\w+(\.\w+)?[^.\W])$/ 8 | const isValidEmailFormat = emailRegex.test(trimmedEmail) 9 | 10 | if (!isValidEmailFormat) errors.push('Not a valid email format') 11 | 12 | return errors 13 | } 14 | 15 | export const validatePassword = (password: string) => { 16 | if (!password) return [] 17 | 18 | const errors = [] 19 | 20 | if (password.length <= 6) 21 | errors.push('Password must be more than 6 characters') 22 | 23 | return errors 24 | } 25 | -------------------------------------------------------------------------------- /src/utils/injectionKeys.ts: -------------------------------------------------------------------------------- 1 | import type { InjectionKey, Ref } from 'vue' 2 | 3 | export interface MenuInjectionOptions { 4 | menuOpen: Ref 5 | toggleMenu: () => void 6 | } 7 | 8 | export const menuKey = Symbol() as InjectionKey 9 | -------------------------------------------------------------------------------- /src/utils/supaAuth.ts: -------------------------------------------------------------------------------- 1 | import { supabase } from '@/lib/supabaseClient' 2 | import type { LoginForm, RegisterForm } from '@/types/AuthForm' 3 | 4 | export const register = async (formData: RegisterForm) => { 5 | const { data, error } = await supabase.auth.signUp({ 6 | email: formData.email, 7 | password: formData.password 8 | }) 9 | 10 | if (error) return console.log(error) 11 | 12 | if (data.user) { 13 | const { error } = await supabase.from('profiles').insert({ 14 | id: data.user.id, 15 | username: formData.username, 16 | full_name: formData.firstName.concat(' ', formData.lastName) 17 | }) 18 | 19 | if (error) return console.log('Profiles err: ', error) 20 | } 21 | 22 | return true 23 | } 24 | 25 | export const login = async (formData: LoginForm) => { 26 | const { error } = await supabase.auth.signInWithPassword({ 27 | email: formData.email, 28 | password: formData.password 29 | }) 30 | 31 | return { error } 32 | } 33 | 34 | export const logout = async () => { 35 | const { error } = await supabase.auth.signOut() 36 | 37 | if (error) return console.log(error) 38 | 39 | return true 40 | } 41 | -------------------------------------------------------------------------------- /src/utils/supaQueries.ts: -------------------------------------------------------------------------------- 1 | import { supabase } from '@/lib/supabaseClient' 2 | import type { CreateNewTask } from '@/types/CreateNewForm' 3 | import type { QueryData } from '@supabase/supabase-js' 4 | 5 | export const tasksWithProjectsQuery = supabase.from('tasks').select(` 6 | *, 7 | projects ( 8 | id, 9 | name, 10 | slug 11 | ) 12 | `) 13 | export type TasksWithProjects = QueryData 14 | 15 | export const projectsQuery = supabase.from('projects').select() 16 | export type Projects = QueryData 17 | 18 | export const projectQuery = (slug: string) => 19 | supabase 20 | .from('projects') 21 | .select( 22 | ` 23 | *, 24 | tasks ( 25 | id, 26 | name, 27 | status, 28 | due_date 29 | ) 30 | ` 31 | ) 32 | .eq('slug', slug) 33 | .single() 34 | 35 | export type Project = QueryData> 36 | 37 | export const updateProjectQuery = (updatedProject = {}, id: number) => { 38 | return supabase.from('projects').update(updatedProject).eq('id', id) 39 | } 40 | 41 | export const taskQuery = (id: string) => { 42 | return supabase 43 | .from('tasks') 44 | .select( 45 | ` 46 | *, 47 | projects ( 48 | id, 49 | name, 50 | slug 51 | ) 52 | ` 53 | ) 54 | .eq('id', id) 55 | .single() 56 | } 57 | export type Task = QueryData> 58 | 59 | export const updateTaskQuery = (updatedTask = {}, id: number) => { 60 | return supabase.from('tasks').update(updatedTask).eq('id', id) 61 | } 62 | 63 | export const deleteTaskQuery = (id: number) => { 64 | return supabase.from('tasks').delete().eq('id', id) 65 | } 66 | 67 | export const profileQuery = ({ 68 | column, 69 | value 70 | }: { 71 | column: string 72 | value: string 73 | }) => { 74 | return supabase.from('profiles').select().eq(column, value).single() 75 | } 76 | 77 | export const profilesQuery = supabase.from('profiles').select(`id, full_name`) 78 | 79 | export const groupedProfilesQuery = (userIds: string[]) => 80 | supabase 81 | .from('profiles') 82 | .select('username, avatar_url, id, full_name') 83 | .in('id', userIds) 84 | export type Collabs = QueryData> 85 | 86 | export const createNewTaskQuery = (newTask: CreateNewTask) => { 87 | return supabase.from('tasks').insert(newTask) 88 | } 89 | -------------------------------------------------------------------------------- /src/utils/tableColumns/projectsColumns.ts: -------------------------------------------------------------------------------- 1 | import { RouterLink } from 'vue-router' 2 | import type { ColumnDef } from '@tanstack/vue-table' 3 | import type { Projects } from '../supaQueries' 4 | import type { Ref } from 'vue' 5 | import type { GroupedCollabs } from '@/types/GroupedCollabs' 6 | import Avatar from '@/components/ui/avatar/Avatar.vue' 7 | import AvatarImage from '@/components/ui/avatar/AvatarImage.vue' 8 | import AvatarFallback from '@/components/ui/avatar/AvatarFallback.vue' 9 | import AppInPlaceEditStatus from '@/components/AppInPlaceEdit/AppInPlaceEditStatus.vue' 10 | 11 | export const columns = ( 12 | collabs: Ref 13 | ): ColumnDef[] => [ 14 | { 15 | accessorKey: 'name', 16 | header: () => h('div', { class: 'text-left' }, 'Name'), 17 | cell: ({ row }) => { 18 | return h( 19 | RouterLink, 20 | { 21 | to: `/projects/${row.original.slug}`, 22 | class: 'text-left font-medium hover:bg-muted block w-full' 23 | }, 24 | () => row.getValue('name') 25 | ) 26 | } 27 | }, 28 | { 29 | accessorKey: 'status', 30 | header: () => h('div', { class: 'text-left' }, 'Status'), 31 | cell: ({ row }) => { 32 | return h( 33 | 'div', 34 | { class: 'text-left font-medium' }, 35 | h(AppInPlaceEditStatus, { 36 | modelValue: row.original.status, 37 | readonly: true 38 | }) 39 | ) 40 | } 41 | }, 42 | { 43 | accessorKey: 'collaborators', 44 | header: () => h('div', { class: 'text-left' }, 'Collaborators'), 45 | cell: ({ row }) => { 46 | return h( 47 | 'div', 48 | { class: 'text-left font-medium h-20 flex items-center' }, 49 | collabs.value[row.original.id] 50 | ? collabs.value[row.original.id].map((collab) => { 51 | return h(RouterLink, { to: `/users/${collab.username}` }, () => { 52 | return h( 53 | Avatar, 54 | { class: 'hover:scale-110 transition-transform' }, 55 | () => h(AvatarImage, { src: collab.avatar_url || '' }) 56 | ) 57 | }) 58 | }) 59 | : row.original.collaborators.map(() => { 60 | return h(Avatar, { class: 'animate-pulse' }, () => 61 | h(AvatarFallback) 62 | ) 63 | }) 64 | ) 65 | } 66 | } 67 | ] 68 | -------------------------------------------------------------------------------- /src/utils/tableColumns/tasksColumns.ts: -------------------------------------------------------------------------------- 1 | import type { ColumnDef } from '@tanstack/vue-table' 2 | import type { TasksWithProjects } from '../supaQueries' 3 | import { RouterLink } from 'vue-router' 4 | import type { GroupedCollabs } from '@/types/GroupedCollabs' 5 | import Avatar from '@/components/ui/avatar/Avatar.vue' 6 | import AvatarImage from '@/components/ui/avatar/AvatarImage.vue' 7 | import AvatarFallback from '@/components/ui/avatar/AvatarFallback.vue' 8 | import AppInPlaceEditStatus from '@/components/AppInPlaceEdit/AppInPlaceEditStatus.vue' 9 | 10 | export const columns = ( 11 | collabs: Ref 12 | ): ColumnDef[] => [ 13 | { 14 | accessorKey: 'name', 15 | header: () => h('div', { class: 'text-left' }, 'Name'), 16 | cell: ({ row }) => { 17 | return h( 18 | RouterLink, 19 | { 20 | to: `/tasks/${row.original.id}`, 21 | class: 'text-left font-medium hover:bg-muted block w-full' 22 | }, 23 | () => row.getValue('name') 24 | ) 25 | } 26 | }, 27 | { 28 | accessorKey: 'status', 29 | header: () => h('div', { class: 'text-left' }, 'Status'), 30 | cell: ({ row }) => { 31 | return h( 32 | 'div', 33 | { class: 'text-left font-medium' }, 34 | h(AppInPlaceEditStatus, { 35 | modelValue: row.original.status, 36 | readonly: true 37 | }) 38 | ) 39 | } 40 | }, 41 | { 42 | accessorKey: 'due_date', 43 | header: () => h('div', { class: 'text-left' }, 'Due Date'), 44 | cell: ({ row }) => { 45 | return h( 46 | 'div', 47 | { class: 'text-left font-medium' }, 48 | row.getValue('due_date') 49 | ) 50 | } 51 | }, 52 | { 53 | accessorKey: 'projects', 54 | header: () => h('div', { class: 'text-left' }, 'Project'), 55 | cell: ({ row }) => { 56 | return row.original.projects 57 | ? h( 58 | RouterLink, 59 | { 60 | to: `/projects/${row.original.projects.slug}`, 61 | class: 'text-left font-medium hover:bg-muted block w-full' 62 | }, 63 | () => row.original.projects?.name 64 | ) 65 | : '' 66 | } 67 | }, 68 | { 69 | accessorKey: 'collaborators', 70 | header: () => h('div', { class: 'text-left' }, 'Collaborators'), 71 | cell: ({ row }) => { 72 | return h( 73 | 'div', 74 | { class: 'text-left font-medium h-20 flex items-center' }, 75 | collabs.value[row.original.id] 76 | ? collabs.value[row.original.id].map((collab) => { 77 | return h(RouterLink, { to: `/users/${collab.username}` }, () => { 78 | return h( 79 | Avatar, 80 | { class: 'hover:scale-110 transition-transform' }, 81 | () => h(AvatarImage, { src: collab.avatar_url || '' }) 82 | ) 83 | }) 84 | }) 85 | : row.original.collaborators.map(() => { 86 | return h(Avatar, { class: 'animate-pulse' }, () => 87 | h(AvatarFallback) 88 | ) 89 | }) 90 | ) 91 | } 92 | } 93 | ] 94 | -------------------------------------------------------------------------------- /supabase/.gitignore: -------------------------------------------------------------------------------- 1 | # Supabase 2 | .branches 3 | .temp 4 | .env 5 | -------------------------------------------------------------------------------- /supabase/config.toml: -------------------------------------------------------------------------------- 1 | # A string used to distinguish different Supabase projects on the same host. Defaults to the 2 | # working directory name when running `supabase init`. 3 | project_id = "vuejs-masterclass-2024" 4 | 5 | [api] 6 | enabled = true 7 | # Port to use for the API URL. 8 | port = 54321 9 | # Schemas to expose in your API. Tables, views and stored procedures in this schema will get API 10 | # endpoints. `public` is always included. 11 | schemas = ["public", "graphql_public"] 12 | # Extra schemas to add to the search_path of every request. `public` is always included. 13 | extra_search_path = ["public", "extensions"] 14 | # The maximum number of rows returns from a view, table, or stored procedure. Limits payload size 15 | # for accidental or malicious requests. 16 | max_rows = 1000 17 | 18 | [db] 19 | # Port to use for the local database URL. 20 | port = 54322 21 | # Port used by db diff command to initialize the shadow database. 22 | shadow_port = 54320 23 | # The database major version to use. This has to be the same as your remote database's. Run `SHOW 24 | # server_version;` on the remote database to check. 25 | major_version = 15 26 | 27 | [db.pooler] 28 | enabled = false 29 | # Port to use for the local connection pooler. 30 | port = 54329 31 | # Specifies when a server connection can be reused by other clients. 32 | # Configure one of the supported pooler modes: `transaction`, `session`. 33 | pool_mode = "transaction" 34 | # How many server connections to allow per user/database pair. 35 | default_pool_size = 20 36 | # Maximum number of client connections allowed. 37 | max_client_conn = 100 38 | 39 | [realtime] 40 | enabled = true 41 | # Bind realtime via either IPv4 or IPv6. (default: IPv4) 42 | # ip_version = "IPv6" 43 | # The maximum length in bytes of HTTP request headers. (default: 4096) 44 | # max_header_length = 4096 45 | 46 | [studio] 47 | enabled = true 48 | # Port to use for Supabase Studio. 49 | port = 54323 50 | # External URL of the API server that frontend connects to. 51 | api_url = "http://127.0.0.1" 52 | # OpenAI API Key to use for Supabase AI in the Supabase Studio. 53 | openai_api_key = "env(OPENAI_API_KEY)" 54 | 55 | # Email testing server. Emails sent with the local dev setup are not actually sent - rather, they 56 | # are monitored, and you can view the emails that would have been sent from the web interface. 57 | [inbucket] 58 | enabled = true 59 | # Port to use for the email testing server web interface. 60 | port = 54324 61 | # Uncomment to expose additional ports for testing user applications that send emails. 62 | # smtp_port = 54325 63 | # pop3_port = 54326 64 | 65 | [storage] 66 | enabled = true 67 | # The maximum file size allowed (e.g. "5MB", "500KB"). 68 | file_size_limit = "50MiB" 69 | 70 | [storage.image_transformation] 71 | enabled = true 72 | 73 | [auth] 74 | enabled = true 75 | # The base URL of your website. Used as an allow-list for redirects and for constructing URLs used 76 | # in emails. 77 | site_url = "http://127.0.0.1:3000" 78 | # A list of *exact* URLs that auth providers are permitted to redirect to post authentication. 79 | additional_redirect_urls = ["https://127.0.0.1:3000"] 80 | # How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week). 81 | jwt_expiry = 3600 82 | # If disabled, the refresh token will never expire. 83 | enable_refresh_token_rotation = true 84 | # Allows refresh tokens to be reused after expiry, up to the specified interval in seconds. 85 | # Requires enable_refresh_token_rotation = true. 86 | refresh_token_reuse_interval = 10 87 | # Allow/disallow new user signups to your project. 88 | enable_signup = true 89 | # Allow/disallow anonymous sign-ins to your project. 90 | enable_anonymous_sign_ins = false 91 | # Allow/disallow testing manual linking of accounts 92 | enable_manual_linking = false 93 | 94 | [auth.email] 95 | # Allow/disallow new user signups via email to your project. 96 | enable_signup = true 97 | # If enabled, a user will be required to confirm any email change on both the old, and new email 98 | # addresses. If disabled, only the new email is required to confirm. 99 | double_confirm_changes = true 100 | # If enabled, users need to confirm their email address before signing in. 101 | enable_confirmations = false 102 | # Controls the minimum amount of time that must pass before sending another signup confirmation or password reset email. 103 | max_frequency = "1s" 104 | 105 | # Uncomment to customize email template 106 | # [auth.email.template.invite] 107 | # subject = "You have been invited" 108 | # content_path = "./supabase/templates/invite.html" 109 | 110 | [auth.sms] 111 | # Allow/disallow new user signups via SMS to your project. 112 | enable_signup = true 113 | # If enabled, users need to confirm their phone number before signing in. 114 | enable_confirmations = false 115 | # Template for sending OTP to users 116 | template = "Your code is {{ .Code }} ." 117 | # Controls the minimum amount of time that must pass before sending another sms otp. 118 | max_frequency = "5s" 119 | 120 | # Use pre-defined map of phone number to OTP for testing. 121 | # [auth.sms.test_otp] 122 | # 4152127777 = "123456" 123 | 124 | # This hook runs before a token is issued and allows you to add additional claims based on the authentication method used. 125 | # [auth.hook.custom_access_token] 126 | # enabled = true 127 | # uri = "pg-functions:////" 128 | 129 | # Configure one of the supported SMS providers: `twilio`, `twilio_verify`, `messagebird`, `textlocal`, `vonage`. 130 | [auth.sms.twilio] 131 | enabled = false 132 | account_sid = "" 133 | message_service_sid = "" 134 | # DO NOT commit your Twilio auth token to git. Use environment variable substitution instead: 135 | auth_token = "env(SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN)" 136 | 137 | # Use an external OAuth provider. The full list of providers are: `apple`, `azure`, `bitbucket`, 138 | # `discord`, `facebook`, `github`, `gitlab`, `google`, `keycloak`, `linkedin_oidc`, `notion`, `twitch`, 139 | # `twitter`, `slack`, `spotify`, `workos`, `zoom`. 140 | [auth.external.apple] 141 | enabled = false 142 | client_id = "" 143 | # DO NOT commit your OAuth provider secret to git. Use environment variable substitution instead: 144 | secret = "env(SUPABASE_AUTH_EXTERNAL_APPLE_SECRET)" 145 | # Overrides the default auth redirectUrl. 146 | redirect_uri = "" 147 | # Overrides the default auth provider URL. Used to support self-hosted gitlab, single-tenant Azure, 148 | # or any other third-party OIDC providers. 149 | url = "" 150 | # If enabled, the nonce check will be skipped. Required for local sign in with Google auth. 151 | skip_nonce_check = false 152 | 153 | [edge_runtime] 154 | enabled = true 155 | # Configure one of the supported request policies: `oneshot`, `per_worker`. 156 | # Use `oneshot` for hot reload, or `per_worker` for load testing. 157 | policy = "oneshot" 158 | inspector_port = 8083 159 | 160 | [analytics] 161 | enabled = false 162 | port = 54327 163 | vector_port = 54328 164 | # Configure one of the supported backends: `postgres`, `bigquery`. 165 | backend = "postgres" 166 | 167 | # Experimental features may be deprecated any time 168 | [experimental] 169 | # Configures Postgres storage engine to use OrioleDB (S3) 170 | orioledb_version = "" 171 | # Configures S3 bucket URL, eg. .s3-.amazonaws.com 172 | s3_host = "env(S3_HOST)" 173 | # Configures S3 bucket region, eg. us-east-1 174 | s3_region = "env(S3_REGION)" 175 | # Configures AWS_ACCESS_KEY_ID for S3 bucket 176 | s3_access_key = "env(S3_ACCESS_KEY)" 177 | # Configures AWS_SECRET_ACCESS_KEY for S3 bucket 178 | s3_secret_key = "env(S3_SECRET_KEY)" 179 | -------------------------------------------------------------------------------- /supabase/migrations/20240607090723_projects-schema.sql: -------------------------------------------------------------------------------- 1 | drop table if exists projects; 2 | 3 | drop type if exists current_status; 4 | create type current_status as enum ('in-progress', 'completed'); 5 | 6 | create table 7 | projects ( 8 | id bigint primary key generated always as identity not null, 9 | created_at timestamptz default now() not null, 10 | name text not null, 11 | slug text unique not null, 12 | description text not null default '', 13 | status current_status default 'in-progress' not null, 14 | collaborators text array default array[]::varchar[] not null 15 | ); 16 | 17 | -------------------------------------------------------------------------------- /supabase/migrations/20240905083244_profiles-schema.sql: -------------------------------------------------------------------------------- 1 | drop table if exists profiles; 2 | TRUNCATE auth.users cascade; 3 | 4 | create table 5 | profiles ( 6 | id uuid references auth.users on delete cascade not null, 7 | created_at timestamptz default now() not null, 8 | username text unique not null, 9 | full_name text not null, 10 | bio text default null, 11 | mode text default 'dark' not null, 12 | avatar_url text default null, 13 | 14 | primary key (id) 15 | ); -------------------------------------------------------------------------------- /supabase/migrations/20240926141009_tasks-schema.sql: -------------------------------------------------------------------------------- 1 | drop table if exists tasks; 2 | 3 | create table 4 | tasks ( 5 | id bigint primary key generated always as identity not null, 6 | created_at timestamptz default now() not null, 7 | name text not null, 8 | status current_status default 'in-progress' not null, 9 | description text not null, 10 | due_date date default null, 11 | profile_id uuid references profiles (id) on delete cascade not null, 12 | project_id bigint references projects (id) default null, 13 | collaborators text array default array[]::varchar[] not null 14 | ); -------------------------------------------------------------------------------- /supabase/seed.sql: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vueschool/vuejs-masterclass-2024-edition/4c7c44c7f274132ed7bbfe6b0385b9c33d31a7a5/supabase/seed.sql -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | const animate = require('tailwindcss-animate') 2 | 3 | /** @type {import('tailwindcss').Config} */ 4 | module.exports = { 5 | darkMode: ['class'], 6 | safelist: ['dark'], 7 | prefix: '', 8 | 9 | content: [ 10 | './pages/**/*.{ts,tsx,vue}', 11 | './components/**/*.{ts,tsx,vue}', 12 | './app/**/*.{ts,tsx,vue}', 13 | './src/**/*.{ts,tsx,vue}', 14 | './formkit.theme.ts' 15 | ], 16 | 17 | theme: { 18 | container: { 19 | center: true, 20 | padding: '2rem', 21 | screens: { 22 | '2xl': '1400px' 23 | } 24 | }, 25 | extend: { 26 | colors: { 27 | border: 'hsl(var(--border))', 28 | input: 'hsl(var(--input))', 29 | ring: 'hsl(var(--ring))', 30 | background: 'hsl(var(--background))', 31 | foreground: 'hsl(var(--foreground))', 32 | primary: { 33 | DEFAULT: 'hsl(var(--primary))', 34 | foreground: 'hsl(var(--primary-foreground))' 35 | }, 36 | secondary: { 37 | DEFAULT: 'hsl(var(--secondary))', 38 | foreground: 'hsl(var(--secondary-foreground))' 39 | }, 40 | destructive: { 41 | DEFAULT: 'hsl(var(--destructive))', 42 | foreground: 'hsl(var(--destructive-foreground))' 43 | }, 44 | muted: { 45 | DEFAULT: 'hsl(var(--muted))', 46 | foreground: 'hsl(var(--muted-foreground))' 47 | }, 48 | accent: { 49 | DEFAULT: 'hsl(var(--accent))', 50 | foreground: 'hsl(var(--accent-foreground))' 51 | }, 52 | popover: { 53 | DEFAULT: 'hsl(var(--popover))', 54 | foreground: 'hsl(var(--popover-foreground))' 55 | }, 56 | card: { 57 | DEFAULT: 'hsl(var(--card))', 58 | foreground: 'hsl(var(--card-foreground))' 59 | } 60 | }, 61 | borderRadius: { 62 | xl: 'calc(var(--radius) + 4px)', 63 | lg: 'var(--radius)', 64 | md: 'calc(var(--radius) - 2px)', 65 | sm: 'calc(var(--radius) - 4px)' 66 | }, 67 | keyframes: { 68 | 'accordion-down': { 69 | from: { height: 0 }, 70 | to: { height: 'var(--radix-accordion-content-height)' } 71 | }, 72 | 'accordion-up': { 73 | from: { height: 'var(--radix-accordion-content-height)' }, 74 | to: { height: 0 } 75 | }, 76 | 'collapsible-down': { 77 | from: { height: 0 }, 78 | to: { height: 'var(--radix-collapsible-content-height)' } 79 | }, 80 | 'collapsible-up': { 81 | from: { height: 'var(--radix-collapsible-content-height)' }, 82 | to: { height: 0 } 83 | } 84 | }, 85 | animation: { 86 | 'accordion-down': 'accordion-down 0.2s ease-out', 87 | 'accordion-up': 'accordion-up 0.2s ease-out', 88 | 'collapsible-down': 'collapsible-down 0.2s ease-in-out', 89 | 'collapsible-up': 'collapsible-up 0.2s ease-in-out' 90 | } 91 | } 92 | }, 93 | plugins: [animate] 94 | } 95 | -------------------------------------------------------------------------------- /tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@vue/tsconfig/tsconfig.dom.json", 3 | "include": [ 4 | "env.d.ts", 5 | "src/**/*", 6 | "src/**/*.vue", 7 | "typed-router.d.ts", 8 | "database/types.ts", 9 | "auto-imports.d.ts", 10 | "components.d.ts", 11 | "formkit.config.ts", 12 | "formkit.theme.ts" 13 | ], 14 | "exclude": ["src/**/__tests__/*"], 15 | "compilerOptions": { 16 | "composite": true, 17 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", 18 | "moduleResolution": "Bundler", 19 | "baseUrl": ".", 20 | "paths": { 21 | "@/*": ["./src/*"] 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { 5 | "path": "./tsconfig.node.json" 6 | }, 7 | { 8 | "path": "./tsconfig.app.json" 9 | } 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/node20/tsconfig.json", 3 | "include": [ 4 | "vite.config.*", 5 | "vitest.config.*", 6 | "cypress.config.*", 7 | "nightwatch.conf.*", 8 | "playwright.config.*" 9 | ], 10 | "compilerOptions": { 11 | "composite": true, 12 | "noEmit": true, 13 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", 14 | 15 | "module": "ESNext", 16 | "moduleResolution": "Bundler", 17 | "types": ["node"] 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /typed-router.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /* prettier-ignore */ 3 | // @ts-nocheck 4 | // Generated by unplugin-vue-router. ‼️ DO NOT MODIFY THIS FILE ‼️ 5 | // It's recommended to commit this file. 6 | // Make sure to add this file to your tsconfig.json file as an "includes" or "files" entry. 7 | 8 | declare module 'vue-router/auto-routes' { 9 | import type { 10 | RouteRecordInfo, 11 | ParamValue, 12 | ParamValueOneOrMore, 13 | ParamValueZeroOrMore, 14 | ParamValueZeroOrOne, 15 | } from 'vue-router' 16 | 17 | /** 18 | * Route name map generated by unplugin-vue-router 19 | */ 20 | export interface RouteNamedMap { 21 | '/': RouteRecordInfo<'/', '/', Record, Record>, 22 | '/[...catchAll]': RouteRecordInfo<'/[...catchAll]', '/:catchAll(.*)', { catchAll: ParamValue }, { catchAll: ParamValue }>, 23 | '/login': RouteRecordInfo<'/login', '/login', Record, Record>, 24 | '/projects/': RouteRecordInfo<'/projects/', '/projects', Record, Record>, 25 | '/projects/[slug]': RouteRecordInfo<'/projects/[slug]', '/projects/:slug', { slug: ParamValue }, { slug: ParamValue }>, 26 | '/register': RouteRecordInfo<'/register', '/register', Record, Record>, 27 | '/tasks/': RouteRecordInfo<'/tasks/', '/tasks', Record, Record>, 28 | '/tasks/[id]': RouteRecordInfo<'/tasks/[id]', '/tasks/:id', { id: ParamValue }, { id: ParamValue }>, 29 | '/users/[username]': RouteRecordInfo<'/users/[username]', '/users/:username', { username: ParamValue }, { username: ParamValue }>, 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { fileURLToPath, URL } from 'node:url' 2 | import VueRouter from 'unplugin-vue-router/vite' 3 | import AutoImport from 'unplugin-auto-import/vite' 4 | import { VueRouterAutoImports } from 'unplugin-vue-router' 5 | 6 | import Components from 'unplugin-vue-components/vite' 7 | 8 | import tailwind from 'tailwindcss' 9 | import autoprefixer from 'autoprefixer' 10 | 11 | import { defineConfig } from 'vite' 12 | import vue from '@vitejs/plugin-vue' 13 | 14 | // https://vitejs.dev/config/ 15 | export default defineConfig({ 16 | plugins: [ 17 | VueRouter(), 18 | Components({ 19 | /* options */ 20 | }), 21 | AutoImport({ 22 | include: [ 23 | /\.[tj]sx?$/, // .ts, .tsx, .js, .jsx 24 | /\.vue$/, 25 | /\.vue\?vue/, // .vue 26 | /\.md$/ // .md 27 | ], 28 | imports: [ 29 | 'vue', 30 | VueRouterAutoImports, 31 | { 32 | pinia: ['defineStore', 'storeToRefs', 'acceptHMRUpdate'] 33 | }, 34 | { 35 | 'vue-meta': ['useMeta'] 36 | } 37 | ], 38 | dts: true, 39 | viteOptimizeDeps: true, 40 | dirs: ['src/stores/**', 'src/composables/**'] 41 | }), 42 | vue({ 43 | template: { 44 | compilerOptions: { 45 | isCustomElement: (element) => element.startsWith('iconify-icon') 46 | } 47 | } 48 | }) 49 | ], 50 | css: { 51 | postcss: { 52 | plugins: [tailwind(), autoprefixer()] 53 | } 54 | }, 55 | resolve: { 56 | alias: { 57 | '@': fileURLToPath(new URL('./src', import.meta.url)) 58 | } 59 | } 60 | }) 61 | --------------------------------------------------------------------------------