├── supabase ├── seed.sql └── .gitignore ├── .nvmrc ├── .eslintrc.json ├── src ├── app │ ├── app │ │ ├── @dialog │ │ │ ├── page.tsx │ │ │ ├── default.tsx │ │ │ ├── (.)projects │ │ │ │ ├── page.tsx │ │ │ │ ├── default.tsx │ │ │ │ ├── not-found.tsx │ │ │ │ ├── new │ │ │ │ │ └── page.tsx │ │ │ │ └── [projectId] │ │ │ │ │ └── edit │ │ │ │ │ └── page.tsx │ │ │ ├── [...catchAll] │ │ │ │ └── page.tsx │ │ │ ├── (.)tasks │ │ │ │ ├── not-found.tsx │ │ │ │ ├── new │ │ │ │ │ └── page.tsx │ │ │ │ └── [taskId] │ │ │ │ │ └── page.tsx │ │ │ ├── (.)main-menu │ │ │ │ └── page.tsx │ │ │ └── (.)settings │ │ │ │ └── account │ │ │ │ └── page.tsx │ │ ├── page.tsx │ │ ├── main-menu │ │ │ └── page.tsx │ │ ├── tasks │ │ │ ├── [taskId] │ │ │ │ ├── loading.tsx │ │ │ │ └── page.tsx │ │ │ ├── not-found.tsx │ │ │ └── new │ │ │ │ └── page.tsx │ │ ├── not-found.tsx │ │ ├── projects │ │ │ ├── [projectId] │ │ │ │ ├── loading.tsx │ │ │ │ ├── edit │ │ │ │ │ └── page.tsx │ │ │ │ └── page.tsx │ │ │ ├── not-found.tsx │ │ │ ├── new │ │ │ │ └── page.tsx │ │ │ ├── active │ │ │ │ └── page.tsx │ │ │ └── archived │ │ │ │ └── page.tsx │ │ ├── settings │ │ │ └── account │ │ │ │ └── page.tsx │ │ ├── onboarding │ │ │ └── page.tsx │ │ ├── layout.tsx │ │ └── today │ │ │ └── page.tsx │ ├── favicon.ico │ ├── error.tsx │ ├── auth │ │ ├── error.tsx │ │ ├── layout.tsx │ │ ├── callback │ │ │ └── route.ts │ │ └── sign-in │ │ │ ├── check-email-link │ │ │ └── page.tsx │ │ │ └── page.tsx │ ├── global-error.tsx │ ├── (marketing) │ │ ├── layout.tsx │ │ ├── page.tsx │ │ ├── pricing │ │ │ └── page.tsx │ │ ├── about │ │ │ └── page.mdx │ │ └── features │ │ │ └── page.tsx │ ├── not-found.tsx │ ├── ~offline │ │ └── page.tsx │ └── globals.css ├── features │ ├── app │ │ ├── tasks │ │ │ ├── ui │ │ │ │ ├── TaskCheckSize.tsx │ │ │ │ ├── formatTaskDueDate.ts │ │ │ │ ├── TaskFormSkeleton.tsx │ │ │ │ ├── TaskListSkeleton.tsx │ │ │ │ ├── DeleteTaskAlertDialog.tsx │ │ │ │ ├── TaskList.tsx │ │ │ │ ├── TaskListItem.tsx │ │ │ │ ├── TaskForm.tsx │ │ │ │ ├── TaskCheck.tsx │ │ │ │ ├── TaskDueDatePicker.tsx │ │ │ │ └── AddTask.tsx │ │ │ └── domain │ │ │ │ └── TasksDomain.ts │ │ ├── today │ │ │ └── ui │ │ │ │ └── TodayPageHeader.tsx │ │ ├── projects │ │ │ ├── ui │ │ │ │ ├── NoTasksInProject.tsx │ │ │ │ ├── ProjectFormSkeleton.tsx │ │ │ │ ├── ProjectPageSkeleton.tsx │ │ │ │ ├── ProjectListSkeleton.tsx │ │ │ │ ├── ProjectsPageHeader.tsx │ │ │ │ ├── ProjectListItem.tsx │ │ │ │ ├── DeleteProjectAlertDialog.tsx │ │ │ │ ├── ProjectPageHeader.tsx │ │ │ │ ├── ProjectsSelect.tsx │ │ │ │ ├── ArchiveProjectAlertDialog.tsx │ │ │ │ ├── ProjectList.tsx │ │ │ │ └── ProjectForm.tsx │ │ │ └── domain │ │ │ │ └── ProjectsDomain.ts │ │ ├── shared │ │ │ └── ui │ │ │ │ ├── MainMenuLink.tsx │ │ │ │ ├── Header.tsx │ │ │ │ └── MainMenu.tsx │ │ ├── users │ │ │ ├── ui │ │ │ │ └── UpdateUserTimeZone.tsx │ │ │ └── data-access │ │ │ │ └── UsersDataAccess.ts │ │ └── settings │ │ │ ├── account │ │ │ └── ui │ │ │ │ └── AccountSettings.tsx │ │ │ └── ui │ │ │ └── SettingsMenu.tsx │ ├── shared │ │ ├── data-access │ │ │ ├── cuid2.ts │ │ │ ├── prisma.ts │ │ │ └── ServerResponse.ts │ │ ├── ui │ │ │ ├── control │ │ │ │ ├── dropdown │ │ │ │ │ ├── Menu.tsx │ │ │ │ │ └── DropdownMenu.tsx │ │ │ │ ├── input │ │ │ │ │ └── inputTextClassName.tsx │ │ │ │ ├── button │ │ │ │ │ ├── RouterButton.tsx │ │ │ │ │ ├── DeleteIconButton.tsx │ │ │ │ │ ├── OAuthProviderButton.tsx │ │ │ │ │ ├── buttonClassName.tsx │ │ │ │ │ └── SubmitButton.tsx │ │ │ │ ├── switch │ │ │ │ │ └── Switch.tsx │ │ │ │ └── select │ │ │ │ │ └── Select.tsx │ │ │ ├── ClassNameProps.tsx │ │ │ ├── ChildrenProps.tsx │ │ │ ├── error │ │ │ │ ├── errorMessages.ts │ │ │ │ ├── ErrorList.tsx │ │ │ │ └── DefaultError.tsx │ │ │ ├── skeleton │ │ │ │ └── SkeletonLine.tsx │ │ │ ├── icon │ │ │ │ ├── CheckIcon.tsx │ │ │ │ ├── ExpandLessIcon.tsx │ │ │ │ ├── ExpandMoreIcon.tsx │ │ │ │ ├── PlusSignalIcon.tsx │ │ │ │ ├── HamburgerMenuIcon.tsx │ │ │ │ ├── XIcon.tsx │ │ │ │ ├── LogoutIcon.tsx │ │ │ │ ├── EditIcon.tsx │ │ │ │ ├── MailIcon.tsx │ │ │ │ ├── DeleteIcon.tsx │ │ │ │ ├── ArchiveIcon.tsx │ │ │ │ ├── WarningIcon.tsx │ │ │ │ ├── UnarchiveIcon.tsx │ │ │ │ ├── CalendarEventIcon.tsx │ │ │ │ ├── CalendarTodayIcon.tsx │ │ │ │ ├── AppleLogoIcon.tsx │ │ │ │ ├── XLogoIcon.tsx │ │ │ │ ├── ProjectsIcon.tsx │ │ │ │ ├── PersonIcon.tsx │ │ │ │ ├── MoreHorizontalIcon.tsx │ │ │ │ ├── LinkedInInLogoIcon.tsx │ │ │ │ ├── IOSShareIcon.tsx │ │ │ │ ├── IOSAddIcon.tsx │ │ │ │ ├── TwitterLogoIcon.tsx │ │ │ │ ├── SettingsIcon.tsx │ │ │ │ ├── CalendarMonthIcon.tsx │ │ │ │ ├── GitHubLogoIcon.tsx │ │ │ │ ├── GoogleLogoIcon.tsx │ │ │ │ └── FacebookLogoIcon.tsx │ │ │ ├── form │ │ │ │ ├── FormErrorList.tsx │ │ │ │ ├── useForm.ts │ │ │ │ ├── InputContentEditable.tsx │ │ │ │ └── Form.tsx │ │ │ ├── focus │ │ │ │ └── useAutoFocus.ts │ │ │ ├── pwa │ │ │ │ ├── InstallPwaProvider.tsx │ │ │ │ ├── useInstallPwa.ts │ │ │ │ └── InstallPwaDialog.tsx │ │ │ ├── feedback │ │ │ │ └── WarningFeedback.tsx │ │ │ ├── logo │ │ │ │ ├── Logo.tsx │ │ │ │ └── LogoIcon.tsx │ │ │ ├── keyboard │ │ │ │ └── useKeyboardEvent.ts │ │ │ └── dialog │ │ │ │ └── AlertDialog.tsx │ │ └── routing │ │ │ ├── useIsPathActive.ts │ │ │ ├── useRouterAction.ts │ │ │ └── RouterActions.tsx │ ├── auth │ │ └── data-access │ │ │ ├── OAuthProvider.ts │ │ │ └── AuthDataAccess.ts │ └── marketing │ │ └── shared │ │ └── ui │ │ ├── HeroHeading.tsx │ │ ├── HeroCopy.tsx │ │ ├── ShowContentTransition.tsx │ │ ├── Footer.tsx │ │ ├── MainMenuMobile.tsx │ │ ├── MainMenu.tsx │ │ └── Header.tsx ├── mdx-components.tsx └── middleware.ts ├── public ├── logo-380x380.png ├── tweet-card.png ├── logo-text-512x512.png ├── pwa-images │ ├── favicon-196.png │ ├── apple-icon-180.png │ ├── apple-splash-640-1136.jpg │ ├── apple-splash-750-1334.jpg │ ├── apple-splash-828-1792.jpg │ ├── apple-splash-1125-2436.jpg │ ├── apple-splash-1170-2532.jpg │ ├── apple-splash-1179-2556.jpg │ ├── apple-splash-1242-2208.jpg │ ├── apple-splash-1242-2688.jpg │ ├── apple-splash-1284-2778.jpg │ ├── apple-splash-1290-2796.jpg │ ├── apple-splash-1536-2048.jpg │ ├── apple-splash-1620-2160.jpg │ ├── apple-splash-1668-2224.jpg │ ├── apple-splash-1668-2388.jpg │ ├── apple-splash-2048-2732.jpg │ ├── manifest-icon-192.maskable.png │ └── manifest-icon-512.maskable.png ├── images │ └── marketing │ │ └── features │ │ ├── features-img-01.png │ │ ├── features-img-02.png │ │ ├── features-img-03.png │ │ ├── features-img-04.png │ │ ├── features-img-05.png │ │ └── features-img-06.png ├── manifest.json └── logo.svg ├── postcss.config.js ├── prisma ├── migrations │ ├── 20230813195450_user_remove_provider_unique │ │ └── migration.sql │ ├── 20230817173940_user_provider_optional │ │ └── migration.sql │ ├── 20231112201020_user_timezone_optional │ │ └── migration.sql │ ├── migration_lock.toml │ ├── 20231123225304_project_change_is_archived_to_archived_at │ │ └── migration.sql │ ├── 20230721144417_init │ │ └── migration.sql │ ├── 20231123220024_task_change_is_completed_to_completed_at │ │ └── migration.sql │ ├── 20230727193118_project │ │ └── migration.sql │ └── 20230801201849_task │ │ └── migration.sql └── schema.prisma ├── .env.local.example ├── .prettierignore ├── .prettierrc ├── next.config.js ├── tailwind.config.js ├── .env.example ├── .github └── workflows │ ├── preview-deploy.yaml │ └── production-deploy.yaml ├── .gitignore ├── tsconfig.json ├── LICENSE ├── package.json └── README.md /supabase/seed.sql: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v22.19.0 2 | -------------------------------------------------------------------------------- /supabase/.gitignore: -------------------------------------------------------------------------------- 1 | # Supabase 2 | .branches 3 | .temp -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["next/core-web-vitals", "prettier"] 3 | } 4 | -------------------------------------------------------------------------------- /src/app/app/@dialog/page.tsx: -------------------------------------------------------------------------------- 1 | export default function Page() { 2 | return null; 3 | } 4 | -------------------------------------------------------------------------------- /src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flsilva/opentask/HEAD/src/app/favicon.ico -------------------------------------------------------------------------------- /public/logo-380x380.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flsilva/opentask/HEAD/public/logo-380x380.png -------------------------------------------------------------------------------- /public/tweet-card.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flsilva/opentask/HEAD/public/tweet-card.png -------------------------------------------------------------------------------- /src/app/app/@dialog/default.tsx: -------------------------------------------------------------------------------- 1 | export default function Default() { 2 | return null; 3 | } 4 | -------------------------------------------------------------------------------- /src/app/app/@dialog/(.)projects/page.tsx: -------------------------------------------------------------------------------- 1 | export default function Page() { 2 | return null; 3 | } 4 | -------------------------------------------------------------------------------- /public/logo-text-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flsilva/opentask/HEAD/public/logo-text-512x512.png -------------------------------------------------------------------------------- /src/app/app/@dialog/(.)projects/default.tsx: -------------------------------------------------------------------------------- 1 | export default function Default() { 2 | return null; 3 | } 4 | -------------------------------------------------------------------------------- /src/app/app/@dialog/[...catchAll]/page.tsx: -------------------------------------------------------------------------------- 1 | export default function Page() { 2 | return null; 3 | } 4 | -------------------------------------------------------------------------------- /public/pwa-images/favicon-196.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flsilva/opentask/HEAD/public/pwa-images/favicon-196.png -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /prisma/migrations/20230813195450_user_remove_provider_unique/migration.sql: -------------------------------------------------------------------------------- 1 | -- DropIndex 2 | DROP INDEX "User_provider_key"; 3 | -------------------------------------------------------------------------------- /public/pwa-images/apple-icon-180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flsilva/opentask/HEAD/public/pwa-images/apple-icon-180.png -------------------------------------------------------------------------------- /src/features/app/tasks/ui/TaskCheckSize.tsx: -------------------------------------------------------------------------------- 1 | export enum TaskCheckSize { 2 | Medium = 'Medium', 3 | Large = 'Large', 4 | } 5 | -------------------------------------------------------------------------------- /public/pwa-images/apple-splash-640-1136.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flsilva/opentask/HEAD/public/pwa-images/apple-splash-640-1136.jpg -------------------------------------------------------------------------------- /public/pwa-images/apple-splash-750-1334.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flsilva/opentask/HEAD/public/pwa-images/apple-splash-750-1334.jpg -------------------------------------------------------------------------------- /public/pwa-images/apple-splash-828-1792.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flsilva/opentask/HEAD/public/pwa-images/apple-splash-828-1792.jpg -------------------------------------------------------------------------------- /.env.local.example: -------------------------------------------------------------------------------- 1 | NEXT_PUBLIC_URL=http://localhost:3000 2 | NEXT_PUBLIC_SUPABASE_URL=http://localhost:54321 3 | NEXT_PUBLIC_SUPABASE_ANON_KEY= -------------------------------------------------------------------------------- /public/pwa-images/apple-splash-1125-2436.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flsilva/opentask/HEAD/public/pwa-images/apple-splash-1125-2436.jpg -------------------------------------------------------------------------------- /public/pwa-images/apple-splash-1170-2532.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flsilva/opentask/HEAD/public/pwa-images/apple-splash-1170-2532.jpg -------------------------------------------------------------------------------- /public/pwa-images/apple-splash-1179-2556.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flsilva/opentask/HEAD/public/pwa-images/apple-splash-1179-2556.jpg -------------------------------------------------------------------------------- /public/pwa-images/apple-splash-1242-2208.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flsilva/opentask/HEAD/public/pwa-images/apple-splash-1242-2208.jpg -------------------------------------------------------------------------------- /public/pwa-images/apple-splash-1242-2688.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flsilva/opentask/HEAD/public/pwa-images/apple-splash-1242-2688.jpg -------------------------------------------------------------------------------- /public/pwa-images/apple-splash-1284-2778.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flsilva/opentask/HEAD/public/pwa-images/apple-splash-1284-2778.jpg -------------------------------------------------------------------------------- /public/pwa-images/apple-splash-1290-2796.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flsilva/opentask/HEAD/public/pwa-images/apple-splash-1290-2796.jpg -------------------------------------------------------------------------------- /public/pwa-images/apple-splash-1536-2048.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flsilva/opentask/HEAD/public/pwa-images/apple-splash-1536-2048.jpg -------------------------------------------------------------------------------- /public/pwa-images/apple-splash-1620-2160.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flsilva/opentask/HEAD/public/pwa-images/apple-splash-1620-2160.jpg -------------------------------------------------------------------------------- /public/pwa-images/apple-splash-1668-2224.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flsilva/opentask/HEAD/public/pwa-images/apple-splash-1668-2224.jpg -------------------------------------------------------------------------------- /public/pwa-images/apple-splash-1668-2388.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flsilva/opentask/HEAD/public/pwa-images/apple-splash-1668-2388.jpg -------------------------------------------------------------------------------- /public/pwa-images/apple-splash-2048-2732.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flsilva/opentask/HEAD/public/pwa-images/apple-splash-2048-2732.jpg -------------------------------------------------------------------------------- /src/features/shared/data-access/cuid2.ts: -------------------------------------------------------------------------------- 1 | import { init } from '@paralleldrive/cuid2'; 2 | 3 | export const cuid2 = init({ length: 8 }); 4 | -------------------------------------------------------------------------------- /public/pwa-images/manifest-icon-192.maskable.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flsilva/opentask/HEAD/public/pwa-images/manifest-icon-192.maskable.png -------------------------------------------------------------------------------- /public/pwa-images/manifest-icon-512.maskable.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flsilva/opentask/HEAD/public/pwa-images/manifest-icon-512.maskable.png -------------------------------------------------------------------------------- /prisma/migrations/20230817173940_user_provider_optional/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "User" ALTER COLUMN "provider" DROP NOT NULL; 3 | -------------------------------------------------------------------------------- /prisma/migrations/20231112201020_user_timezone_optional/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "User" ADD COLUMN "timeZone" VARCHAR(100); 3 | -------------------------------------------------------------------------------- /src/app/app/page.tsx: -------------------------------------------------------------------------------- 1 | import { redirect } from 'next/navigation'; 2 | 3 | export default function AppPage() { 4 | redirect('/app/today'); 5 | } 6 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | /.next 2 | /.vercel 3 | /build 4 | /out 5 | /public 6 | 7 | .DS_Store 8 | .env*.local 9 | *.pem 10 | *.tsbuildinfo 11 | next-env.d.ts -------------------------------------------------------------------------------- /public/images/marketing/features/features-img-01.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flsilva/opentask/HEAD/public/images/marketing/features/features-img-01.png -------------------------------------------------------------------------------- /public/images/marketing/features/features-img-02.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flsilva/opentask/HEAD/public/images/marketing/features/features-img-02.png -------------------------------------------------------------------------------- /public/images/marketing/features/features-img-03.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flsilva/opentask/HEAD/public/images/marketing/features/features-img-03.png -------------------------------------------------------------------------------- /public/images/marketing/features/features-img-04.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flsilva/opentask/HEAD/public/images/marketing/features/features-img-04.png -------------------------------------------------------------------------------- /public/images/marketing/features/features-img-05.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flsilva/opentask/HEAD/public/images/marketing/features/features-img-05.png -------------------------------------------------------------------------------- /public/images/marketing/features/features-img-06.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flsilva/opentask/HEAD/public/images/marketing/features/features-img-06.png -------------------------------------------------------------------------------- /src/features/shared/ui/control/dropdown/Menu.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import 'client-only'; 3 | 4 | import { Menu } from '@headlessui/react'; 5 | export { Menu }; 6 | -------------------------------------------------------------------------------- /prisma/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (i.e. Git) 3 | provider = "postgresql" -------------------------------------------------------------------------------- /src/features/auth/data-access/OAuthProvider.ts: -------------------------------------------------------------------------------- 1 | export enum OAuthProvider { 2 | Github = 'github', 3 | Google = 'google', 4 | Linkedin = 'linkedin', 5 | Twitter = 'twitter', 6 | } 7 | -------------------------------------------------------------------------------- /src/app/app/main-menu/page.tsx: -------------------------------------------------------------------------------- 1 | import { MainMenu } from '@/features/app/shared/ui/MainMenu'; 2 | 3 | export default function MainMenuPage() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /src/features/shared/ui/ClassNameProps.tsx: -------------------------------------------------------------------------------- 1 | export interface ClassNameProps { 2 | readonly className: string; 3 | } 4 | 5 | export interface ClassNamePropsOptional { 6 | readonly className?: string; 7 | } 8 | -------------------------------------------------------------------------------- /src/features/shared/ui/ChildrenProps.tsx: -------------------------------------------------------------------------------- 1 | export interface ChildrenProps { 2 | readonly children: React.ReactNode; 3 | } 4 | 5 | export interface ChildrenPropsOptional { 6 | readonly children?: React.ReactNode; 7 | } 8 | -------------------------------------------------------------------------------- /src/features/shared/ui/error/errorMessages.ts: -------------------------------------------------------------------------------- 1 | export const genericAwareOfInternalErrorMessage = 2 | "I'm sorry, but there was an error with your request. We are aware of the issue and are working to resolve it. Please try again later."; 3 | -------------------------------------------------------------------------------- /src/features/shared/ui/control/input/inputTextClassName.tsx: -------------------------------------------------------------------------------- 1 | export const inputTextClassName = 2 | 'block w-full rounded-md border border-gray-400 py-1.5 text-gray-900 ring-0 placeholder:text-gray-400 focus:border-gray-900 focus:outline-0 focus:ring-0'; 3 | -------------------------------------------------------------------------------- /src/app/app/tasks/[taskId]/loading.tsx: -------------------------------------------------------------------------------- 1 | import { TaskFormSkeletonSkeleton } from '@/features/app/tasks/ui/TaskFormSkeleton'; 2 | 3 | export default function TaskPageLoading() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /src/app/app/not-found.tsx: -------------------------------------------------------------------------------- 1 | export default function NotFound() { 2 | return ( 3 |
4 |

404

5 |

This page could not be found.

6 |
7 | ); 8 | } 9 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "singleQuote": true, 4 | "tabWidth": 2, 5 | "trailingComma": "all", 6 | "overrides": [ 7 | { 8 | "files": "*.mdx", 9 | "options": { 10 | "parser": "mdx" 11 | } 12 | } 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /src/app/app/projects/[projectId]/loading.tsx: -------------------------------------------------------------------------------- 1 | import { ProjectPageSkeleton } from '@/features/app/projects/ui/ProjectPageSkeleton'; 2 | 3 | export default function ProjectPageLoading() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /src/app/error.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { DefaultError } from '@/features/shared/ui/error/DefaultError'; 4 | 5 | export default function RootError({ error, reset }: { error: Error; reset: () => void }) { 6 | return ; 7 | } 8 | -------------------------------------------------------------------------------- /src/app/auth/error.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { DefaultError } from '@/features/shared/ui/error/DefaultError'; 4 | 5 | export default function AuthError({ error, reset }: { error: Error; reset: () => void }) { 6 | return ; 7 | } 8 | -------------------------------------------------------------------------------- /src/features/marketing/shared/ui/HeroHeading.tsx: -------------------------------------------------------------------------------- 1 | import { ChildrenProps } from '@/features/shared/ui/ChildrenProps'; 2 | 3 | export const HeroHeading = ({ children }: ChildrenProps) => ( 4 |

5 | {children} 6 |

7 | ); 8 | -------------------------------------------------------------------------------- /src/features/shared/routing/useIsPathActive.ts: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { usePathname } from 'next/navigation'; 4 | 5 | export const useIsPathActive = (path: string) => { 6 | const pathname = usePathname(); 7 | if (!path || path === '') return false; 8 | return pathname.lastIndexOf(path) !== -1; 9 | }; 10 | -------------------------------------------------------------------------------- /src/features/shared/ui/skeleton/SkeletonLine.tsx: -------------------------------------------------------------------------------- 1 | import { twMerge } from 'tailwind-merge'; 2 | import { ClassNamePropsOptional } from '@/features/shared/ui/ClassNameProps'; 3 | 4 | export const SkeletonLine = ({ className }: ClassNamePropsOptional) => { 5 | return
; 6 | }; 7 | -------------------------------------------------------------------------------- /src/app/app/tasks/not-found.tsx: -------------------------------------------------------------------------------- 1 | export default function NotFound() { 2 | return ( 3 |
4 |

404

5 |

6 | We couldn't find that task. 7 |

8 |
9 | ); 10 | } 11 | -------------------------------------------------------------------------------- /src/app/app/projects/not-found.tsx: -------------------------------------------------------------------------------- 1 | export default function NotFound() { 2 | return ( 3 |
4 |

404

5 |

6 | We couldn't find that project. 7 |

8 |
9 | ); 10 | } 11 | -------------------------------------------------------------------------------- /src/app/app/@dialog/(.)tasks/not-found.tsx: -------------------------------------------------------------------------------- 1 | export default function NotFound() { 2 | return ( 3 |
4 |

404

5 |

6 | We couldn't find that task. 7 |

8 |
9 | ); 10 | } 11 | -------------------------------------------------------------------------------- /src/app/app/projects/new/page.tsx: -------------------------------------------------------------------------------- 1 | import { ProjectForm } from '@/features/app/projects/ui/ProjectForm'; 2 | 3 | export default function NewProjectPage() { 4 | return ( 5 |
6 |

Create project

7 | 8 |
9 | ); 10 | } 11 | -------------------------------------------------------------------------------- /src/app/app/@dialog/(.)projects/not-found.tsx: -------------------------------------------------------------------------------- 1 | export default function NotFound() { 2 | return ( 3 |
4 |

404

5 |

6 | We couldn't find that project. 7 |

8 |
9 | ); 10 | } 11 | -------------------------------------------------------------------------------- /src/app/app/tasks/[taskId]/page.tsx: -------------------------------------------------------------------------------- 1 | import { TaskForm } from '@/features/app/tasks/ui/TaskForm'; 2 | 3 | interface TaskPageProps { 4 | readonly params: { readonly taskId: string }; 5 | } 6 | 7 | export default function TaskPage({ params: { taskId } }: TaskPageProps) { 8 | return ; 9 | } 10 | -------------------------------------------------------------------------------- /src/features/shared/ui/icon/CheckIcon.tsx: -------------------------------------------------------------------------------- 1 | export const CheckIcon = (props: React.SVGProps) => ( 2 | 9 | 10 | 11 | ); 12 | -------------------------------------------------------------------------------- /src/app/app/settings/account/page.tsx: -------------------------------------------------------------------------------- 1 | import { AccountSettings } from '@/features/app/settings/account/ui/AccountSettings'; 2 | 3 | export default function AccountSettingsPage() { 4 | return ( 5 |
6 |

Settings

7 | 8 |
9 | ); 10 | } 11 | -------------------------------------------------------------------------------- /src/features/shared/ui/icon/ExpandLessIcon.tsx: -------------------------------------------------------------------------------- 1 | export const ExpandLessIcon = (props: React.SVGProps) => ( 2 | 9 | 10 | 11 | ); 12 | -------------------------------------------------------------------------------- /src/features/shared/ui/icon/ExpandMoreIcon.tsx: -------------------------------------------------------------------------------- 1 | export const ExpandMoreIcon = (props: React.SVGProps) => ( 2 | 9 | 10 | 11 | ); 12 | -------------------------------------------------------------------------------- /src/features/shared/ui/icon/PlusSignalIcon.tsx: -------------------------------------------------------------------------------- 1 | export const PlusSignalIcon = (props: React.SVGProps) => ( 2 | 9 | 10 | 11 | ); 12 | -------------------------------------------------------------------------------- /src/features/shared/ui/icon/HamburgerMenuIcon.tsx: -------------------------------------------------------------------------------- 1 | export const HamburgerMenuIcon = (props: React.SVGProps) => ( 2 | 9 | 10 | 11 | ); 12 | -------------------------------------------------------------------------------- /src/features/shared/ui/icon/XIcon.tsx: -------------------------------------------------------------------------------- 1 | export const XIcon = (props: React.SVGProps) => ( 2 | 9 | 10 | 11 | ); 12 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | pageExtensions: ['js', 'jsx', 'mdx', 'ts', 'tsx'], 4 | }; 5 | 6 | const withPWA = require('@ducanh2912/next-pwa').default({ 7 | aggressiveFrontEndNavCaching: true, 8 | cacheOnFrontEndNav: true, 9 | dest: 'public', 10 | }); 11 | 12 | const withMDX = require('@next/mdx')(); 13 | 14 | module.exports = withMDX(withPWA(nextConfig)); 15 | -------------------------------------------------------------------------------- /src/features/marketing/shared/ui/HeroCopy.tsx: -------------------------------------------------------------------------------- 1 | import { twMerge } from 'tailwind-merge'; 2 | import { ChildrenProps } from '@/features/shared/ui/ChildrenProps'; 3 | import { ClassNamePropsOptional } from '@/features/shared/ui/ClassNameProps'; 4 | 5 | export const HeroCopy = ({ children, className }: ChildrenProps & ClassNamePropsOptional) => ( 6 |

{children}

7 | ); 8 | -------------------------------------------------------------------------------- /src/features/app/today/ui/TodayPageHeader.tsx: -------------------------------------------------------------------------------- 1 | import { format } from 'date-fns'; 2 | 3 | export const TodayPageHeader = () => ( 4 |
5 |
6 |

Today

7 |

{format(new Date(), 'iii MMM d')}

8 |
9 |
10 | ); 11 | -------------------------------------------------------------------------------- /src/app/app/@dialog/(.)main-menu/page.tsx: -------------------------------------------------------------------------------- 1 | import { Dialog } from '@/features/shared/ui/dialog/Dialog'; 2 | import { MainMenu } from '@/features/app/shared/ui/MainMenu'; 3 | import { RouterActions } from '@/features/shared/routing/RouterActions'; 4 | 5 | export default function MainMenuDialogPage() { 6 | return ( 7 | 8 | 9 | 10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /src/features/shared/ui/form/FormErrorList.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import 'client-only'; 4 | import { useContext } from 'react'; 5 | import { ErrorList } from '@/features/shared/ui/error/ErrorList'; 6 | import { FormContext } from './Form'; 7 | 8 | export const FormErrorList = () => { 9 | const { response } = useContext(FormContext); 10 | if (!response || !response.errors) return null; 11 | 12 | return ; 13 | }; 14 | -------------------------------------------------------------------------------- /src/features/shared/ui/icon/LogoutIcon.tsx: -------------------------------------------------------------------------------- 1 | export const LogoutIcon = (props: React.SVGProps) => ( 2 | 9 | 10 | 11 | ); 12 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: [ 4 | './src/pages/**/*.{js,ts,jsx,tsx,mdx}', 5 | './src/components/**/*.{js,ts,jsx,tsx,mdx}', 6 | './src/app/**/*.{js,ts,jsx,tsx,mdx}', 7 | './src/features/**/*.{js,ts,jsx,tsx,mdx}', 8 | ], 9 | future: { 10 | hoverOnlyWhenSupported: true, 11 | }, 12 | plugins: [require('@tailwindcss/forms'), require('@tailwindcss/typography')], 13 | }; 14 | -------------------------------------------------------------------------------- /src/features/shared/ui/focus/useAutoFocus.ts: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import 'client-only'; 4 | import { useEffect, useRef } from 'react'; 5 | 6 | export const useAutoFocus = void }>( 7 | autoFocus: boolean = true, 8 | ) => { 9 | const inputRef = useRef(null); 10 | 11 | useEffect(() => { 12 | if (!autoFocus || !inputRef.current) return; 13 | inputRef.current.focus(); 14 | }, [autoFocus]); 15 | 16 | return inputRef; 17 | }; 18 | -------------------------------------------------------------------------------- /src/features/shared/ui/icon/EditIcon.tsx: -------------------------------------------------------------------------------- 1 | export const EditIcon = (props: React.SVGProps) => ( 2 | 9 | 10 | 11 | ); 12 | -------------------------------------------------------------------------------- /src/features/shared/ui/icon/MailIcon.tsx: -------------------------------------------------------------------------------- 1 | export const MailIcon = (props: React.SVGProps) => ( 2 | 9 | 10 | 11 | ); 12 | -------------------------------------------------------------------------------- /src/app/app/@dialog/(.)projects/new/page.tsx: -------------------------------------------------------------------------------- 1 | import { RouterActions } from '@/features/shared/routing/RouterActions'; 2 | import { Dialog } from '@/features/shared/ui/dialog/Dialog'; 3 | import { ProjectForm } from '@/features/app/projects/ui/ProjectForm'; 4 | 5 | export default function NewProjectDialogPage() { 6 | return ( 7 | 8 | 9 | 10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /src/app/app/@dialog/(.)settings/account/page.tsx: -------------------------------------------------------------------------------- 1 | import { Dialog } from '@/features/shared/ui/dialog/Dialog'; 2 | import { RouterActions } from '@/features/shared/routing/RouterActions'; 3 | import { AccountSettings } from '@/features/app/settings/account/ui/AccountSettings'; 4 | 5 | export default function AccountSettingsDialogPage() { 6 | return ( 7 | 8 | 9 | 10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /src/features/shared/ui/icon/DeleteIcon.tsx: -------------------------------------------------------------------------------- 1 | export const DeleteIcon = (props: React.SVGProps) => ( 2 | 9 | 10 | 11 | ); 12 | -------------------------------------------------------------------------------- /src/app/global-error.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useEffect } from 'react'; 4 | 5 | export default function GlobalError({ error, reset }: { error: Error; reset: () => void }) { 6 | useEffect(() => { 7 | // Log the error to an error reporting service 8 | console.error(error); 9 | }, [error]); 10 | 11 | return ( 12 | 13 | 14 |

Something went wrong!

15 | 16 | 17 | 18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /prisma/migrations/20231123225304_project_change_is_archived_to_archived_at/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - You are about to drop the column `isArchived` on the `Project` table. All the data in the column will be lost. 5 | 6 | */ 7 | -- DropIndex 8 | DROP INDEX "Project_isArchived_authorId_idx"; 9 | 10 | -- AlterTable 11 | ALTER TABLE "Project" DROP COLUMN "isArchived", 12 | ADD COLUMN "archivedAt" TIMESTAMP(3); 13 | 14 | -- CreateIndex 15 | CREATE INDEX "Project_archivedAt_authorId_idx" ON "Project"("archivedAt", "authorId"); 16 | -------------------------------------------------------------------------------- /src/app/app/tasks/new/page.tsx: -------------------------------------------------------------------------------- 1 | import { TaskForm } from '@/features/app/tasks/ui/TaskForm'; 2 | 3 | interface NewTaskPageProps { 4 | readonly searchParams: { readonly projectId: string }; 5 | } 6 | 7 | export default function NewTaskPage({ searchParams: { projectId } }: NewTaskPageProps) { 8 | return ( 9 |
10 |

Create task

11 | 12 |
13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | DATABASE_URL=postgresql://postgres:postgres@localhost:54322/postgres 2 | DATABASE_DIRECT_URL=postgresql://postgres:postgres@localhost:54322/postgres 3 | 4 | SUPABASE_AUTH_EXTERNAL_GITHUB_CLIENT_ID="" 5 | SUPABASE_AUTH_EXTERNAL_GITHUB_SECRET="" 6 | 7 | SUPABASE_AUTH_EXTERNAL_GOOGLE_CLIENT_ID="" 8 | SUPABASE_AUTH_EXTERNAL_GOOGLE_SECRET="" 9 | 10 | SUPABASE_AUTH_EXTERNAL_LINKEDIN_CLIENT_ID="" 11 | SUPABASE_AUTH_EXTERNAL_LINKEDIN_SECRET="" 12 | 13 | SUPABASE_AUTH_EXTERNAL_TWITTER_CLIENT_ID="" 14 | SUPABASE_AUTH_EXTERNAL_TWITTER_SECRET="" 15 | -------------------------------------------------------------------------------- /src/features/shared/ui/pwa/InstallPwaProvider.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { createContext } from 'react'; 4 | import { ChildrenProps } from '@/features/shared/ui/ChildrenProps'; 5 | import { useInstallPwa } from './useInstallPwa'; 6 | 7 | export const InstallPwaContext = createContext<(() => void) | null>(null); 8 | 9 | export const InstallPwaProvider = ({ children }: ChildrenProps) => { 10 | const promptFunction = useInstallPwa(); 11 | return {children}; 12 | }; 13 | -------------------------------------------------------------------------------- /src/features/app/tasks/ui/formatTaskDueDate.ts: -------------------------------------------------------------------------------- 1 | import { differenceInCalendarDays, format } from 'date-fns'; 2 | 3 | export const formatTaskDueDate = (date: Date | null | undefined, now: Date) => { 4 | if (!date || !now) return 'Due date'; 5 | const diffDays = differenceInCalendarDays(date, now); 6 | 7 | if (diffDays === -1) return 'Yesterday'; 8 | if (diffDays === 0) return 'Today'; 9 | if (diffDays === 1) return 'Tomorrow'; 10 | 11 | if (diffDays > 1 && diffDays < 7) return format(date, 'EEEE'); 12 | return format(date, 'MMM dd'); 13 | }; 14 | -------------------------------------------------------------------------------- /src/features/shared/ui/icon/ArchiveIcon.tsx: -------------------------------------------------------------------------------- 1 | export const ArchiveIcon = (props: React.SVGProps) => ( 2 | 9 | 10 | 11 | ); 12 | -------------------------------------------------------------------------------- /prisma/migrations/20230721144417_init/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "User" ( 3 | "id" UUID NOT NULL, 4 | "email" VARCHAR(254) NOT NULL, 5 | "name" VARCHAR(500), 6 | "provider" VARCHAR(100) NOT NULL, 7 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 8 | "updatedAt" TIMESTAMP(3), 9 | 10 | CONSTRAINT "User_pkey" PRIMARY KEY ("id") 11 | ); 12 | 13 | -- CreateIndex 14 | CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); 15 | 16 | -- CreateIndex 17 | CREATE UNIQUE INDEX "User_provider_key" ON "User"("provider"); 18 | -------------------------------------------------------------------------------- /src/features/shared/ui/icon/WarningIcon.tsx: -------------------------------------------------------------------------------- 1 | export const WarningIcon = (props: React.SVGProps) => ( 2 | 9 | 10 | 11 | ); 12 | -------------------------------------------------------------------------------- /prisma/migrations/20231123220024_task_change_is_completed_to_completed_at/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - You are about to drop the column `isCompleted` on the `Task` table. All the data in the column will be lost. 5 | 6 | */ 7 | -- DropIndex 8 | DROP INDEX "Task_dueDate_isCompleted_authorId_projectId_idx"; 9 | 10 | -- AlterTable 11 | ALTER TABLE "Task" DROP COLUMN "isCompleted", 12 | ADD COLUMN "completedAt" TIMESTAMP(3); 13 | 14 | -- CreateIndex 15 | CREATE INDEX "Task_authorId_completedAt_dueDate_projectId_idx" ON "Task"("authorId", "completedAt", "dueDate", "projectId"); 16 | -------------------------------------------------------------------------------- /src/features/shared/ui/icon/UnarchiveIcon.tsx: -------------------------------------------------------------------------------- 1 | export const UnarchiveIcon = (props: React.SVGProps) => ( 2 | 9 | 10 | 11 | ); 12 | -------------------------------------------------------------------------------- /src/features/shared/ui/icon/CalendarEventIcon.tsx: -------------------------------------------------------------------------------- 1 | export const CalendarEventIcon = (props: React.SVGProps) => ( 2 | 9 | 10 | 11 | ); 12 | -------------------------------------------------------------------------------- /src/features/shared/ui/icon/CalendarTodayIcon.tsx: -------------------------------------------------------------------------------- 1 | export const CalendarTodayIcon = (props: React.SVGProps) => ( 2 | 9 | 10 | 11 | ); 12 | -------------------------------------------------------------------------------- /src/features/shared/ui/icon/AppleLogoIcon.tsx: -------------------------------------------------------------------------------- 1 | export const AppleLogoIcon = (props: React.SVGProps) => ( 2 | 13 | ); 14 | -------------------------------------------------------------------------------- /src/features/app/projects/ui/NoTasksInProject.tsx: -------------------------------------------------------------------------------- 1 | import { ErrorList } from '@/features/shared/ui/error/ErrorList'; 2 | import { getTasks } from '@/features/app/tasks/data-access/TasksDataAccess'; 3 | 4 | interface NoTasksInProjectProps { 5 | readonly id: string; 6 | } 7 | 8 | export const NoTasksInProject = async ({ id }: NoTasksInProjectProps) => { 9 | const { data: tasks, errors } = await getTasks({ 10 | byProject: id, 11 | }); 12 | 13 | if (errors) return ; 14 | if (!tasks || tasks.length > 0) return null; 15 | 16 | return

No tasks in this project.

; 17 | }; 18 | -------------------------------------------------------------------------------- /src/app/(marketing)/layout.tsx: -------------------------------------------------------------------------------- 1 | import '../globals.css'; 2 | import { Header } from '@/features/marketing/shared/ui/Header'; 3 | import { Footer } from '@/features/marketing/shared/ui/Footer'; 4 | 5 | export default function MarketingLayout({ children }: { children: React.ReactNode }) { 6 | return ( 7 | <> 8 |
9 |
10 |
11 |
12 |
{children}
13 |
14 |
15 |
16 |
60 | ); 61 | }; 62 | -------------------------------------------------------------------------------- /src/app/app/today/page.tsx: -------------------------------------------------------------------------------- 1 | import { Suspense } from 'react'; 2 | import { redirect } from 'next/navigation'; 3 | import { subDays } from 'date-fns'; 4 | import { ErrorList } from '@/features/shared/ui/error/ErrorList'; 5 | import { getProjects } from '@/features/app/projects/data-access/ProjectsDataAccess'; 6 | import { AddTask } from '@/features/app/tasks/ui/AddTask'; 7 | import { TaskForm } from '@/features/app/tasks/ui/TaskForm'; 8 | import { TaskList } from '@/features/app/tasks/ui/TaskList'; 9 | import { TaskListSkeleton } from '@/features/app/tasks/ui/TaskListSkeleton'; 10 | import { TodayPageHeader } from '@/features/app/today/ui/TodayPageHeader'; 11 | 12 | export default async function TodayPage() { 13 | const { data: projects, errors } = await getProjects(); 14 | if (errors) return ; 15 | if (!projects || projects.length <= 0) redirect('/app/onboarding'); 16 | 17 | const yesterday = subDays(new Date(), 1); 18 | const today = new Date(); 19 | 20 | return ( 21 | <> 22 | 23 | }> 24 | 25 | {({ list: listOverdue, tasks: tasksOverdue }) => ( 26 | <> 27 | {tasksOverdue.length > 0 &&

Overdue

} 28 | {listOverdue} 29 | {tasksOverdue.length > 0 &&

Today

} 30 | 31 | 32 | {({ list: listDueToday, tasks: tasksDueToday }) => ( 33 | <> 34 | {listDueToday} 35 | {tasksDueToday.length < 1 && ( 36 |

37 | No tasks due today. {tasksOverdue.length < 1 && 'Enjoy your day!'} 38 |

39 | )} 40 | 41 | )} 42 |
43 | 44 | )} 45 |
46 | 47 | 52 | 53 |
54 | 55 | ); 56 | } 57 | -------------------------------------------------------------------------------- /src/features/app/tasks/ui/TaskListItem.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link'; 2 | import { sanitize } from 'isomorphic-dompurify'; 3 | import { utcToZonedTime } from 'date-fns-tz'; 4 | import { CalendarEventIcon } from '@/features/shared/ui/icon/CalendarEventIcon'; 5 | import { ClassNamePropsOptional } from '@/features/shared/ui/ClassNameProps'; 6 | import { TaskCheck } from './TaskCheck'; 7 | import { TaskCheckSize } from './TaskCheckSize'; 8 | import { twJoin, twMerge } from 'tailwind-merge'; 9 | import { formatTaskDueDate } from './formatTaskDueDate'; 10 | 11 | export interface TaskListItemProps extends ClassNamePropsOptional { 12 | readonly completedAt: Date | null | undefined; 13 | readonly description: string; 14 | readonly dueDate: Date | null | undefined; 15 | readonly id: string; 16 | readonly name: string; 17 | readonly timeZone: string; 18 | } 19 | 20 | export const TaskListItem = ({ 21 | className, 22 | completedAt, 23 | description, 24 | dueDate, 25 | id, 26 | name, 27 | timeZone, 28 | }: TaskListItemProps) => { 29 | return ( 30 |
36 | 42 | 43 |
44 |
48 | {description && ( 49 |
53 | )} 54 | {dueDate && ( 55 |
56 | 57 |

58 | {formatTaskDueDate( 59 | utcToZonedTime(dueDate, timeZone), 60 | utcToZonedTime(new Date(), timeZone), 61 | )} 62 |

63 |
64 | )} 65 |
66 | 67 |
68 | ); 69 | }; 70 | -------------------------------------------------------------------------------- /src/features/app/projects/ui/ProjectForm.tsx: -------------------------------------------------------------------------------- 1 | 'use server'; 2 | 3 | import 'server-only'; 4 | import { notFound } from 'next/navigation'; 5 | import { twMerge } from 'tailwind-merge'; 6 | import { ClassNamePropsOptional } from '@/features/shared/ui/ClassNameProps'; 7 | import { buttonGreenClassName } from '@/features/shared/ui/control/button/buttonClassName'; 8 | import { SubmitButton } from '@/features/shared/ui/control/button/SubmitButton'; 9 | import { inputTextClassName } from '@/features/shared/ui/control/input/inputTextClassName'; 10 | import { ServerError } from '@/features/shared/data-access/ServerResponse'; 11 | import { ErrorList } from '@/features/shared/ui/error/ErrorList'; 12 | import { Form } from '@/features/shared/ui/form/Form'; 13 | import { FormErrorList } from '@/features/shared/ui/form/FormErrorList'; 14 | import { 15 | ProjectDto, 16 | createProject, 17 | getProjectById, 18 | updateProject, 19 | } from '../data-access/ProjectsDataAccess'; 20 | 21 | export interface ProjectFormProps extends ClassNamePropsOptional { 22 | readonly projectId?: string; 23 | } 24 | 25 | export const ProjectForm = async ({ className, projectId }: ProjectFormProps) => { 26 | let project: ProjectDto | undefined | null; 27 | let errors: Array | undefined; 28 | 29 | if (projectId) ({ data: project, errors } = await getProjectById(projectId)); 30 | if (errors) return ; 31 | if (projectId && !project) notFound(); 32 | 33 | const name = (project && project.name) ?? ''; 34 | const description = (project && project.description) ?? ''; 35 | const formAction = project ? updateProject : createProject; 36 | 37 | return ( 38 |
39 | {project && } 40 | 52 | 60 | 61 | Save 62 | 63 | ); 64 | }; 65 | -------------------------------------------------------------------------------- /src/features/shared/routing/RouterActions.tsx: -------------------------------------------------------------------------------- 1 | import { NavigateOptions } from 'next/dist/shared/lib/app-router-context.shared-runtime'; 2 | 3 | export enum RouterActionType { 4 | Back = 'Back', 5 | BackAndRefresh = 'BackAndRefresh', 6 | Forward = 'Forward', 7 | ForwardAndRefresh = 'ForwardAndRefresh', 8 | Push = 'Push', 9 | PushAndRefresh = 'PushAndRefresh', 10 | Replace = 'Replace', 11 | ReplaceAndRefresh = 'ReplaceAndRefresh', 12 | } 13 | 14 | export interface RouterActionWithNoArgs { 15 | readonly type: 16 | | RouterActionType.Back 17 | | RouterActionType.BackAndRefresh 18 | | RouterActionType.Forward 19 | | RouterActionType.ForwardAndRefresh; 20 | } 21 | 22 | export interface RouterActionPushOrReplace { 23 | readonly href: string; 24 | readonly options?: NavigateOptions; 25 | readonly type: 26 | | RouterActionType.Push 27 | | RouterActionType.PushAndRefresh 28 | | RouterActionType.Replace 29 | | RouterActionType.ReplaceAndRefresh; 30 | } 31 | 32 | export type RouterAction = RouterActionWithNoArgs | RouterActionPushOrReplace; 33 | 34 | export const RouterActions: { 35 | Back: RouterActionWithNoArgs; 36 | BackAndRefresh: RouterActionWithNoArgs; 37 | Forward: RouterActionWithNoArgs; 38 | ForwardAndRefresh: RouterActionWithNoArgs; 39 | Push: (href: string, options?: NavigateOptions) => RouterActionPushOrReplace; 40 | PushAndRefresh: (href: string, options?: NavigateOptions) => RouterActionPushOrReplace; 41 | Replace: (href: string, options?: NavigateOptions) => RouterActionPushOrReplace; 42 | ReplaceAndRefresh: (href: string, options?: NavigateOptions) => RouterActionPushOrReplace; 43 | } = { 44 | Back: { type: RouterActionType.Back }, 45 | BackAndRefresh: { type: RouterActionType.BackAndRefresh }, 46 | Forward: { type: RouterActionType.Forward }, 47 | ForwardAndRefresh: { type: RouterActionType.ForwardAndRefresh }, 48 | Push: (href: string, options?: NavigateOptions): RouterActionPushOrReplace => ({ 49 | href, 50 | options, 51 | type: RouterActionType.Push, 52 | }), 53 | PushAndRefresh: (href: string, options?: NavigateOptions): RouterActionPushOrReplace => ({ 54 | href, 55 | options, 56 | type: RouterActionType.PushAndRefresh, 57 | }), 58 | Replace: (href: string, options?: NavigateOptions): RouterActionPushOrReplace => ({ 59 | href, 60 | options, 61 | type: RouterActionType.Replace, 62 | }), 63 | ReplaceAndRefresh: (href: string, options?: NavigateOptions): RouterActionPushOrReplace => ({ 64 | href, 65 | options, 66 | type: RouterActionType.ReplaceAndRefresh, 67 | }), 68 | }; 69 | -------------------------------------------------------------------------------- /src/features/marketing/shared/ui/Header.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useState } from 'react'; 4 | import Link from 'next/link'; 5 | import { HamburgerMenuIcon } from '@/features/shared/ui/icon/HamburgerMenuIcon'; 6 | import { XIcon } from '@/features/shared/ui/icon/XIcon'; 7 | import { Logo } from '@/features/shared/ui/logo/Logo'; 8 | import { MainMenu } from './MainMenu'; 9 | import { MainMenuMobile } from './MainMenuMobile'; 10 | 11 | export const Header = () => { 12 | const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); 13 | return ( 14 |
15 | 62 |
63 | ); 64 | }; 65 | -------------------------------------------------------------------------------- /src/features/app/tasks/ui/TaskForm.tsx: -------------------------------------------------------------------------------- 1 | import 'server-only'; 2 | import { Suspense } from 'react'; 3 | import { notFound } from 'next/navigation'; 4 | import { twMerge } from 'tailwind-merge'; 5 | import { ClassNamePropsOptional } from '@/features/shared/ui/ClassNameProps'; 6 | import { ServerError } from '@/features/shared/data-access/ServerResponse'; 7 | import { ErrorList } from '@/features/shared/ui/error/ErrorList'; 8 | import { Form } from '@/features/shared/ui/form/Form'; 9 | import { FormErrorList } from '@/features/shared/ui/form/FormErrorList'; 10 | import { ProjectsSelect } from '@/features/app/projects/ui/ProjectsSelect'; 11 | import { TaskFormFields } from './TaskFormFields'; 12 | import { createTask, updateTask, TaskDto, getTaskById } from '../data-access/TasksDataAccess'; 13 | 14 | export interface TaskFormProps extends ClassNamePropsOptional { 15 | readonly defaultDueDate?: Date | undefined; 16 | readonly projectId?: string; 17 | readonly startOnEditingMode?: boolean; 18 | readonly taskId?: string; 19 | readonly taskNameClassName?: string; 20 | } 21 | 22 | export const TaskForm = async ({ 23 | className, 24 | defaultDueDate, 25 | projectId, 26 | startOnEditingMode = false, 27 | taskId, 28 | taskNameClassName, 29 | }: TaskFormProps) => { 30 | let task: TaskDto | undefined | null; 31 | let errors: Array | undefined; 32 | 33 | if (taskId) ({ data: task, errors } = await getTaskById(taskId)); 34 | if (errors) return ; 35 | if (taskId && !task) notFound(); 36 | 37 | const updateTaskProject = async (value: string) => { 38 | 'use server'; 39 | if (!taskId) return; 40 | 41 | const formData = new FormData(); 42 | formData.append('id', taskId); 43 | formData.append('projectId', value); 44 | 45 | const { errors } = await updateTask(undefined, formData); 46 | 47 | if (errors) { 48 | // TODO: Display error (in a toast?) 49 | } 50 | }; 51 | 52 | return ( 53 |
57 | {task && } 58 | 59 | 69 | } 70 | startOnEditingMode={startOnEditingMode} 71 | task={task} 72 | taskNameClassName={taskNameClassName} 73 | /> 74 | 75 | 76 | 77 | ); 78 | }; 79 | -------------------------------------------------------------------------------- /src/app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | :root { 6 | --foreground-rgb: 0, 0, 0; 7 | --background-rgb: 255, 255, 255; 8 | } 9 | 10 | @media (prefers-color-scheme: dark) { 11 | /* 12 | :root { 13 | --foreground-rgb: 255, 255, 255; 14 | --background-rgb: 0, 0, 0; 15 | } 16 | */ 17 | } 18 | 19 | body { 20 | background-color: rgb(var(--background-rgb)); 21 | color: rgb(var(--foreground-rgb)); 22 | } 23 | 24 | button:focus, a:focus { 25 | outline: 2px solid transparent; 26 | } 27 | 28 | @layer components { 29 | /* 30 | * Dialog animations 31 | */ 32 | .animation-dialog-content-show-sm { 33 | animation-name: dialog-content-show-sm; 34 | animation-duration: 350ms; 35 | animation-timing-function: cubic-bezier(0.000, 0.980, 0.665, 0.980); /* custom ease-in-out */ 36 | } 37 | 38 | @keyframes dialog-content-show-sm { 39 | from { 40 | transform: translate3d(0, 85%, 0); 41 | } 42 | to { 43 | transform: translate3d(0, 10%, 0); 44 | } 45 | } 46 | 47 | .animation-dialog-content-hide-sm { 48 | animation-name: dialog-content-hide-sm; 49 | animation-duration: 250ms; 50 | animation-timing-function: linear; 51 | } 52 | 53 | @keyframes dialog-content-hide-sm { 54 | from { 55 | transform: translate3d(0, 10%, 0); 56 | } 57 | to { 58 | transform: translate3d(0, 100%, 0); 59 | } 60 | } 61 | 62 | @keyframes dialog-content-show-md { 63 | from { 64 | opacity: 0; 65 | transform: scale(0.95); 66 | } 67 | to { 68 | opacity: 1; 69 | transform: scale(1); 70 | } 71 | } 72 | 73 | @keyframes dialog-content-hide-md { 74 | from { 75 | opacity: 1; 76 | transform: scale(1); 77 | } 78 | to { 79 | opacity: 0; 80 | transform: scale(0.95); 81 | } 82 | } 83 | 84 | @keyframes fake-fade { 85 | from { 86 | opacity: 1; 87 | } 88 | to { 89 | opacity: 1; 90 | } 91 | } 92 | /**/ 93 | 94 | /* 95 | Spinner 96 | */ 97 | .spinner { 98 | width: 1rem; 99 | height: 1rem; 100 | border: 0.125rem solid #fff; 101 | border-bottom-color: transparent; 102 | border-radius: 50%; 103 | display: flex; 104 | box-sizing: border-box; 105 | animation: rotation 800ms linear infinite; 106 | } 107 | 108 | @keyframes rotation { 109 | 0% { 110 | transform: rotate(0deg); 111 | } 112 | 100% { 113 | transform: rotate(360deg); 114 | } 115 | } 116 | /**/ 117 | 118 | /* 119 | * Simple reusable animations 120 | */ 121 | @keyframes fade-in { 122 | from { 123 | opacity: 0; 124 | } 125 | to { 126 | opacity: 1; 127 | } 128 | } 129 | 130 | @keyframes fade-out { 131 | from { 132 | opacity: 1; 133 | } 134 | to { 135 | opacity: 0; 136 | } 137 | } 138 | /**/ 139 | 140 | } 141 | 142 | -------------------------------------------------------------------------------- /src/features/app/tasks/ui/TaskCheck.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import 'client-only'; 4 | import { useRouter } from 'next/navigation'; 5 | import { twJoin, twMerge } from 'tailwind-merge'; 6 | import { ClassNamePropsOptional } from '@/features/shared/ui/ClassNameProps'; 7 | import { CheckIcon } from '@/features/shared/ui/icon/CheckIcon'; 8 | import { ServerResponse } from '@/features/shared/data-access/ServerResponse'; 9 | import { TaskDto, updateTask } from '../data-access/TasksDataAccess'; 10 | import { TaskCheckSize } from './TaskCheckSize'; 11 | 12 | export interface TaskCheckProps extends ClassNamePropsOptional { 13 | readonly completedAt: Date | null | undefined; 14 | readonly onClick?: (response: ServerResponse) => void; 15 | readonly size: TaskCheckSize; 16 | readonly taskId?: string; 17 | } 18 | 19 | /* 20 | * Flavio Silva on Nov. 22: 21 | * 22 | * revalidatePath() and revalidateTag() break the app when used in Intercepting Routes: 23 | * 24 | * GitHub issue: 25 | * https://github.com/vercel/next.js/issues/58772 26 | * 27 | * So I made TaskCheck a Client Component to be able to use router.refresh() after updating the task, 28 | * but it turns out router.refresh() doesn't work either on Intercepting Routes, as described below. 29 | * 30 | */ 31 | export const TaskCheck = ({ className, completedAt, onClick, size, taskId }: TaskCheckProps) => { 32 | const router = useRouter(); 33 | 34 | const onCheckClick = async () => { 35 | if (!taskId) return; 36 | 37 | const formData = new FormData(); 38 | formData.append('id', taskId); 39 | formData.append('completedAt', completedAt ? 'null' : new Date().toString()); 40 | 41 | const response = await updateTask(undefined, formData); 42 | 43 | /* 44 | * Flavio Silva on Nov. 22: 45 | * The following router.refresh() works when we're completing / undoing tasks in a task list, 46 | * but it doesn't work as expected when we're completing a task in a Dialog in the 47 | * Intercepting Route "app/tasks/[taskId]". 48 | * The Server Component of the page segment does rerender (TaskDialogPage) and 49 | * refetches the updated task, the Server Component does rerender, but 50 | * Client Components don't rerender. I saw this behavior in Next.js 13.5.x. 51 | * 52 | * GitHub issue: 53 | * https://github.com/vercel/next.js/issues/51310 54 | */ 55 | // router.refresh() is necessary to refetch and rerender mutated data. 56 | router.refresh(); 57 | 58 | /* 59 | * This is a workaround for this issue, so can update itself 60 | * according to the updated completedAt data. 61 | */ 62 | if (onClick) onClick(response); 63 | /**/ 64 | }; 65 | 66 | return ( 67 | 84 | ); 85 | }; 86 | -------------------------------------------------------------------------------- /src/features/shared/ui/control/select/Select.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import 'client-only'; 4 | import { forwardRef } from 'react'; 5 | import * as SelectPrimitive from '@radix-ui/react-select'; 6 | import { twMerge } from 'tailwind-merge'; 7 | import { ExpandMoreIcon } from '@/features/shared/ui/icon/ExpandMoreIcon'; 8 | import { ExpandLessIcon } from '@/features/shared/ui/icon/ExpandLessIcon'; 9 | import { CheckIcon } from '@/features/shared/ui/icon/CheckIcon'; 10 | 11 | export interface SelectItemProps extends SelectPrimitive.SelectItemProps { 12 | readonly value: string; 13 | } 14 | 15 | export const SelectItem = forwardRef( 16 | ({ children, className, ...props }: SelectPrimitive.SelectItemProps, forwardedRef) => { 17 | return ( 18 | 28 | {children} 29 | 30 | 31 | 32 | 33 | ); 34 | }, 35 | ); 36 | 37 | SelectItem.displayName = 'SelectItem'; 38 | 39 | export interface SelectProps extends SelectPrimitive.SelectProps { 40 | readonly ariaLabel: string; 41 | readonly items: Array; 42 | readonly placeholder: string; 43 | } 44 | 45 | const scrollButtonClassName = 'absolute left-0 inline-flex items-center justify-center w-8'; 46 | 47 | export const Select = ({ ariaLabel, items, placeholder, ...props }: SelectProps) => ( 48 | 49 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | {items.map((item) => ( 65 | 66 | {item.children} 67 | 68 | ))} 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | ); 77 | -------------------------------------------------------------------------------- /src/features/shared/ui/pwa/InstallPwaDialog.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import 'client-only'; 4 | import { useContext, useEffect, useState } from 'react'; 5 | import { UAParser } from 'ua-parser-js'; 6 | import { twMerge } from 'tailwind-merge'; 7 | import { buttonGreenClassName } from '@/features/shared/ui/control/button/buttonClassName'; 8 | import { IOSAddIcon } from '@/features/shared/ui/icon/IOSAddIcon'; 9 | import { IOSShareIcon } from '@/features/shared/ui/icon/IOSShareIcon'; 10 | import { Dialog } from '@/features/shared/ui/dialog/Dialog'; 11 | import { InstallPwaContext } from './InstallPwaProvider'; 12 | 13 | export const InstallPwaDialog = () => { 14 | const [isOpen, setIsOpen] = useState(false); 15 | const [os, setOS] = useState(); 16 | const installPrompt = useContext(InstallPwaContext); 17 | 18 | useEffect(() => { 19 | if (!window || !localStorage) return; 20 | 21 | const parser = new UAParser(window.navigator.userAgent); 22 | const browser = parser.getBrowser().name; 23 | const _os = parser.getOS().name; 24 | setOS(_os); 25 | 26 | let showedDate: string | null = null; 27 | let isRunningAsPwa: boolean = false; 28 | 29 | try { 30 | isRunningAsPwa = window.matchMedia('(display-mode: fullscreen)').matches; 31 | showedDate = localStorage.getItem('InstallPwaDialogShowedDate'); 32 | } catch {} 33 | 34 | /* 35 | * We only want to show this Dialog once, if the web app is not running as a PWA already, 36 | * and if users are on Safari on iOS or Chrome on Android. 37 | */ 38 | if ( 39 | !showedDate && 40 | !isRunningAsPwa && 41 | ((_os === 'iOS' && browser === 'Mobile Safari') || 42 | (_os === 'Android' && browser === 'Chrome' && installPrompt)) 43 | ) { 44 | setIsOpen(true); 45 | /* 46 | * We want to set this here, so we can make sure we showed the Dialog, 47 | * and now it's safe to not show it anymore. 48 | */ 49 | try { 50 | localStorage.setItem('InstallPwaDialogShowedDate', new Date().toString()); 51 | } catch {} 52 | /**/ 53 | } 54 | /**/ 55 | }, [installPrompt]); 56 | 57 | return ( 58 | 59 |
60 |

61 | Add OpenTask to your home screen to have a better user experience and use it in 62 | fullscreen. 63 |

64 | {os === 'iOS' && ( 65 | <> 66 |
67 | 68 |

69 | 1) Tap the "Share" button 70 |

71 |
72 |
73 | 74 |

75 | 2) Tap "Add to Home Screen" button 76 |

77 |
78 | 79 | )} 80 | {os === 'Android' && ( 81 | 91 | )} 92 |
93 |
94 | ); 95 | }; 96 | -------------------------------------------------------------------------------- /src/features/app/tasks/ui/TaskDueDatePicker.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import 'client-only'; 4 | import { useEffect, useState } from 'react'; 5 | import { DayPicker, DayPickerSingleProps } from 'react-day-picker'; 6 | import 'react-day-picker/dist/style.css'; 7 | import { ServerResponse } from '@/features/shared/data-access/ServerResponse'; 8 | import { Dialog } from '@/features/shared/ui/dialog/Dialog'; 9 | import { CalendarMonthIcon } from '@/features/shared/ui/icon/CalendarMonthIcon'; 10 | import { XIcon } from '@/features/shared/ui/icon/XIcon'; 11 | import { formatTaskDueDate } from './formatTaskDueDate'; 12 | import { TaskDto, updateTask } from '../data-access/TasksDataAccess'; 13 | 14 | export interface TaskDueDatePickerProps { 15 | readonly defaultDate?: Date | undefined; 16 | readonly name?: string; 17 | readonly onChange: (response: ServerResponse) => void; 18 | readonly taskId?: string; 19 | } 20 | 21 | export const TaskDueDatePicker = ({ 22 | defaultDate, 23 | name, 24 | onChange, 25 | taskId, 26 | }: TaskDueDatePickerProps) => { 27 | const [isDialogOpen, setIsDialogOpen] = useState(false); 28 | const [selectedDate, setSelectedDate] = useState(); 29 | 30 | useEffect(() => { 31 | setSelectedDate(defaultDate); 32 | }, [defaultDate]); 33 | 34 | const handleChange = async (date: Date | undefined) => { 35 | setSelectedDate(date); 36 | setIsDialogOpen(false); 37 | 38 | if (!taskId) return; 39 | 40 | date?.setHours(new Date().getHours()); 41 | date?.setMinutes(new Date().getMinutes()); 42 | 43 | const formData = new FormData(); 44 | formData.append('id', taskId); 45 | formData.append('dueDate', date === undefined ? 'null' : String(date)); 46 | 47 | const response = await updateTask(undefined, formData); 48 | 49 | if (onChange) onChange(response); 50 | }; 51 | 52 | const datePickerProps: DayPickerSingleProps = { 53 | captionLayout: 'dropdown-buttons', 54 | disabled: { before: new Date() }, 55 | fixedWeeks: true, 56 | fromMonth: new Date(), 57 | fromYear: new Date().getFullYear(), 58 | ISOWeek: true, 59 | mode: 'single', 60 | modifiersClassNames: { 61 | selected: '!bg-green-600 !text-white !opacity-100', 62 | today: 'my-today', 63 | }, 64 | onSelect: handleChange, 65 | selected: selectedDate, 66 | showOutsideDays: true, 67 | toYear: new Date().getFullYear() + 2, 68 | }; 69 | 70 | const openDialogButton = ( 71 | 76 | ); 77 | 78 | return ( 79 |
80 | {name && ( 81 | 82 | )} 83 | event.preventDefault()} 87 | trigger={openDialogButton} 88 | noCloseButton 89 | > 90 |
91 | 92 |
93 |
94 | {selectedDate && ( 95 | 103 | )} 104 |
105 | ); 106 | }; 107 | -------------------------------------------------------------------------------- /src/app/auth/sign-in/page.tsx: -------------------------------------------------------------------------------- 1 | import { redirect } from 'next/navigation'; 2 | import Link from 'next/link'; 3 | import { buttonGreenClassName } from '@/features/shared/ui/control/button/buttonClassName'; 4 | import { SubmitButton } from '@/features/shared/ui/control/button/SubmitButton'; 5 | import { GitHubLogoIcon } from '@/features/shared/ui/icon/GitHubLogoIcon'; 6 | import { GoogleLogoIcon } from '@/features/shared/ui/icon/GoogleLogoIcon'; 7 | import { LinkedInInLogoIcon } from '@/features/shared/ui/icon/LinkedInInLogoIcon'; 8 | import { XLogoIcon } from '@/features/shared/ui/icon/XLogoIcon'; 9 | import { OAuthProviderButton } from '@/features/shared/ui/control/button/OAuthProviderButton'; 10 | import { signInWithEmail, signInWithOAuth } from '@/features/auth/data-access/AuthDataAccess'; 11 | import { OAuthProvider } from '@/features/auth/data-access/OAuthProvider'; 12 | import { isUserAuthenticated } from '@/features/app/users/data-access/UsersDataAccess'; 13 | 14 | export default async function SignInPage() { 15 | /* 16 | * Redirect authenticated users to the app. 17 | */ 18 | const isAuthenticated = await isUserAuthenticated(); 19 | if (isAuthenticated) redirect('/app/today'); 20 | /**/ 21 | 22 | return ( 23 |
24 |

Sign in

25 |
26 | 27 | 28 | Continue with Google 29 | 30 | 31 | 32 | Continue with GitHub 33 | 34 | 35 | 36 | Continue with X 37 | 38 | 39 | 40 | Continue with LinkedIn 41 | 42 | {process.env.NEXT_PUBLIC_URL !== 'https://www.opentask.app' && ( 43 | <> 44 |
45 |
46 |

Or

47 |
48 |
49 |
50 | 60 | 61 | Continue with Email 62 | 63 |
64 | 65 | )} 66 |

67 | You agree to our{' '} 68 | 69 | Terms of Service 70 | {' '} 71 | and{' '} 72 | 73 | Privacy Policy 74 | {' '} 75 | by continuing with any option above. 76 |

77 |
78 |
79 | ); 80 | } 81 | -------------------------------------------------------------------------------- /src/features/shared/ui/form/InputContentEditable.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import 'client-only'; 4 | import { forwardRef, useCallback, useContext, useEffect, useRef, useState } from 'react'; 5 | import ContentEditable from 'react-contenteditable'; 6 | import { ClassNamePropsOptional } from '@/features/shared/ui/ClassNameProps'; 7 | import { useKeyboardEvent } from '@/features/shared/ui/keyboard/useKeyboardEvent'; 8 | import { FormContext } from './Form'; 9 | 10 | export interface InputContentEditableProps extends ClassNamePropsOptional { 11 | readonly autoFocus?: boolean; 12 | readonly defaultValue?: string | null; 13 | readonly name?: string; 14 | readonly onBlur?: (event: React.FocusEvent) => void; 15 | readonly onFocus?: (event: React.FocusEvent) => void; 16 | readonly onKeyDown?: (event: KeyboardEvent) => void; 17 | readonly placeholder?: string; 18 | readonly placeholderClassName?: string; 19 | readonly submitOnEnter?: boolean; 20 | } 21 | 22 | export const InputContentEditable = forwardRef( 23 | ( 24 | { 25 | autoFocus, 26 | className, 27 | defaultValue, 28 | name, 29 | onBlur, 30 | onFocus, 31 | onKeyDown, 32 | placeholder, 33 | placeholderClassName, 34 | submitOnEnter, 35 | }, 36 | ref, 37 | ) => { 38 | const [content, setContent] = useState(defaultValue || placeholder || ''); 39 | const [isEditingContent, setIsEditingContent] = useState(false); 40 | const _ref = useRef(null); 41 | const { formRef } = useContext(FormContext); 42 | 43 | useEffect(() => { 44 | if (autoFocus) _ref.current?.focus(); 45 | }, [autoFocus]); 46 | 47 | const _onFocus = (event: React.FocusEvent) => { 48 | setIsEditingContent(true); 49 | if (onFocus) onFocus(event); 50 | if (!event.target) return; 51 | if (event.target.innerHTML !== '' && event.target.innerHTML !== placeholder) return; 52 | setContent(''); 53 | }; 54 | 55 | const _onBlur = (event: React.FocusEvent) => { 56 | setIsEditingContent(false); 57 | if (onBlur) onBlur(event); 58 | if (!event.target || event.target.innerHTML !== '' || !placeholder) return; 59 | setContent(placeholder); 60 | }; 61 | 62 | const onChange = (event: { target: { value: string } }) => { 63 | if (!event.target) return; 64 | setContent(event.target.value); 65 | }; 66 | 67 | /* 68 | * Flavio Silva on Aug. 14th, 2023: 69 | * It seems ContentEditable's shouldComponentUpdate() logic ignores event listeners 70 | * (like onKeydown), preventing it from being used in this case, where the listener 71 | * depends on reactive values and needs to be updated on rerender. 72 | * 73 | * So for now I'll use my custom useKeyboardEvent() hook below. 74 | */ 75 | const _onKeyDown = useCallback( 76 | (event: KeyboardEvent) => { 77 | if (!isEditingContent) return; 78 | if (onKeyDown) onKeyDown(event); 79 | if (submitOnEnter && event.key === 'Enter') { 80 | event.preventDefault(); 81 | formRef?.current?.requestSubmit(); 82 | } 83 | }, 84 | [formRef, isEditingContent, onKeyDown, submitOnEnter], 85 | ); 86 | 87 | useKeyboardEvent('keydown', [{ key: '*', listener: _onKeyDown }]); 88 | /**/ 89 | 90 | return ( 91 | <> 92 | ) ?? _ref} 96 | onBlur={_onBlur} 97 | onFocus={_onFocus} 98 | onChange={onChange} 99 | /> 100 | {name && } 101 | 102 | ); 103 | }, 104 | ); 105 | 106 | InputContentEditable.displayName = 'InputContentEditable'; 107 | -------------------------------------------------------------------------------- /src/features/shared/ui/dialog/AlertDialog.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useState } from 'react'; 4 | import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog'; 5 | import { twMerge } from 'tailwind-merge'; 6 | import { 7 | buttonGreenClassName, 8 | buttonWhiteClassName, 9 | } from '@/features/shared/ui/control/button/buttonClassName'; 10 | import { SubmitButton } from '@/features/shared/ui/control/button/SubmitButton'; 11 | import { RouterAction } from '@/features/shared/routing/RouterActions'; 12 | import { useRouterAction } from '@/features/shared/routing/useRouterAction'; 13 | import { 14 | dialogContentClassNames, 15 | invisibleOverlayClassNames, 16 | visibleOverlayClassNames, 17 | } from './Dialog'; 18 | 19 | export interface AlertDialogBodyProps { 20 | readonly cancelButtonLabel?: string; 21 | readonly confirmButtonLabel: string; 22 | readonly confirmButtonLabelSubmitting?: React.ReactNode; 23 | readonly message: string | React.ReactNode; 24 | readonly onConfirm: (() => void) | 'submit'; 25 | } 26 | 27 | export const AlertDialogBody = ({ 28 | cancelButtonLabel = 'Cancel', 29 | confirmButtonLabel, 30 | confirmButtonLabelSubmitting, 31 | message, 32 | onConfirm, 33 | }: AlertDialogBodyProps) => { 34 | const submitButton = 35 | onConfirm === 'submit' ? ( 36 | 37 | 38 | {confirmButtonLabel} 39 | 40 | 41 | ) : ( 42 | 43 | 46 | 47 | ); 48 | 49 | return ( 50 |
51 | 52 | {message} 53 | 54 |
55 | 56 | {cancelButtonLabel} 57 | 58 | {submitButton} 59 |
60 |
61 | ); 62 | }; 63 | 64 | export interface AlertDialogProps { 65 | readonly body: React.ReactNode; 66 | readonly defaultOpen?: boolean; 67 | readonly onOpenChange?: (open: boolean) => void; 68 | readonly open?: boolean; 69 | readonly routerActionOnClose?: RouterAction; 70 | readonly title: string | React.ReactNode; 71 | readonly trigger?: React.ReactNode; 72 | } 73 | 74 | export const AlertDialog = ({ 75 | body, 76 | defaultOpen, 77 | onOpenChange, 78 | open, 79 | routerActionOnClose, 80 | title, 81 | trigger, 82 | }: AlertDialogProps) => { 83 | const [isOpen, setIsOpen] = useState(open || defaultOpen); 84 | const routerAction = useRouterAction(routerActionOnClose); 85 | 86 | const _onOpenChange = (_open: boolean) => { 87 | setIsOpen(_open); 88 | 89 | if (_open) { 90 | if (onOpenChange) onOpenChange(_open); 91 | } else { 92 | setTimeout(() => { 93 | routerAction(); 94 | if (onOpenChange) onOpenChange(_open); 95 | }, 300); 96 | } 97 | }; 98 | 99 | return ( 100 | 106 | {trigger && {trigger}} 107 | 108 | 109 |