├── .env.example
├── .eslintrc.cjs
├── .gitignore
├── README.md
├── components.json
├── next.config.js
├── package-lock.json
├── package.json
├── postcss.config.js
├── prettier.config.js
├── prisma
└── schema.prisma
├── public
└── favicon.ico
├── src
├── components
│ ├── layout
│ │ ├── AuthRoute.tsx
│ │ ├── GuestRoute.tsx
│ │ ├── HeadMetaData.tsx
│ │ ├── Header.tsx
│ │ ├── PageContainer.tsx
│ │ └── SectionContainer.tsx
│ ├── theme-provider.tsx
│ └── ui
│ │ ├── avatar.tsx
│ │ ├── button.tsx
│ │ ├── card.tsx
│ │ ├── checkbox.tsx
│ │ ├── form.tsx
│ │ ├── input.tsx
│ │ ├── label.tsx
│ │ ├── sonner.tsx
│ │ └── textarea.tsx
├── env.js
├── features
│ ├── auth
│ │ ├── components
│ │ │ └── RegisterFormInner.tsx
│ │ ├── forms
│ │ │ └── register.ts
│ │ └── pages
│ │ │ ├── LoginPage.tsx
│ │ │ └── RegisterPage.tsx
│ └── profile
│ │ ├── components
│ │ └── EditProfileFormInner.tsx
│ │ ├── forms
│ │ └── edit-profile.ts
│ │ └── pages
│ │ └── ProfilePage.tsx
├── lib
│ ├── supabase
│ │ ├── authErrorCodes.ts
│ │ ├── bucket.ts
│ │ ├── client.ts
│ │ └── server.ts
│ └── utils.ts
├── pages
│ ├── _app.tsx
│ ├── api
│ │ └── trpc
│ │ │ └── [trpc].ts
│ ├── index.tsx
│ ├── login.tsx
│ ├── profile.tsx
│ └── register.tsx
├── schemas
│ └── auth.ts
├── server
│ ├── api
│ │ ├── root.ts
│ │ ├── routers
│ │ │ ├── auth.ts
│ │ │ └── profile.ts
│ │ └── trpc.ts
│ └── db.ts
├── styles
│ └── globals.css
└── utils
│ └── api.ts
├── start-database.sh
├── tailwind.config.ts
└── tsconfig.json
/.env.example:
--------------------------------------------------------------------------------
1 |
2 | # Connect to Supabase via connection pooling with Supavisor.
3 | DATABASE_URL="postgresql://postgres.pzdidkaxnpkfrnpvfaeb:[YOUR-PASSWORD]@aws-0-ap-southeast-1.pooler.supabase.com:6543/postgres?pgbouncer=true"
4 |
5 | # Direct connection to the database. Used for migrations.
6 | DIRECT_URL="postgresql://postgres.pzdidkaxnpkfrnpvfaeb:[YOUR-PASSWORD]@aws-0-ap-southeast-1.pooler.supabase.com:5432/postgres"
7 |
8 | NEXT_PUBLIC_BASE_URL="http://localhost:3000"
9 |
10 | # Supabase
11 | NEXT_PUBLIC_SUPABASE_URL=""
12 | NEXT_PUBLIC_SUPABASE_ANON_KEY=""
13 | SUPABASE_SERVICE_ROLE_KEY=""
--------------------------------------------------------------------------------
/.eslintrc.cjs:
--------------------------------------------------------------------------------
1 | /** @type {import("eslint").Linter.Config} */
2 | const config = {
3 | "parser": "@typescript-eslint/parser",
4 | "parserOptions": {
5 | "project": true
6 | },
7 | "plugins": [
8 | "@typescript-eslint"
9 | ],
10 | "extends": [
11 | "next/core-web-vitals",
12 | "plugin:@typescript-eslint/recommended-type-checked",
13 | "plugin:@typescript-eslint/stylistic-type-checked"
14 | ],
15 | "rules": {
16 | "@typescript-eslint/array-type": "off",
17 | "@typescript-eslint/consistent-type-definitions": "off",
18 | "@typescript-eslint/consistent-type-imports": [
19 | "warn",
20 | {
21 | "prefer": "type-imports",
22 | "fixStyle": "inline-type-imports"
23 | }
24 | ],
25 | "@typescript-eslint/no-unused-vars": [
26 | "warn",
27 | {
28 | "argsIgnorePattern": "^_"
29 | }
30 | ],
31 | "@typescript-eslint/require-await": "off",
32 | "@typescript-eslint/no-misused-promises": [
33 | "error",
34 | {
35 | "checksVoidReturn": {
36 | "attributes": false
37 | }
38 | }
39 | ]
40 | }
41 | }
42 | module.exports = config;
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # database
12 | /prisma/db.sqlite
13 | /prisma/db.sqlite-journal
14 | db.sqlite
15 |
16 | # next.js
17 | /.next/
18 | /out/
19 | next-env.d.ts
20 |
21 | # production
22 | /build
23 |
24 | # misc
25 | .DS_Store
26 | *.pem
27 |
28 | # debug
29 | npm-debug.log*
30 | yarn-debug.log*
31 | yarn-error.log*
32 | .pnpm-debug.log*
33 |
34 | # local env files
35 | # do not commit any .env files to git, except for the .env.example file. https://create.t3.gg/en/usage/env-variables#using-environment-variables
36 | .env
37 | .env*.local
38 |
39 | # vercel
40 | .vercel
41 |
42 | # typescript
43 | *.tsbuildinfo
44 |
45 | # idea files
46 | .idea
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Qepo Lu Q\*nt\*l
--------------------------------------------------------------------------------
/components.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ui.shadcn.com/schema.json",
3 | "style": "new-york",
4 | "rsc": false,
5 | "tsx": true,
6 | "tailwind": {
7 | "config": "tailwind.config.ts",
8 | "css": "src/styles/globals.css",
9 | "baseColor": "neutral",
10 | "cssVariables": true,
11 | "prefix": ""
12 | },
13 | "aliases": {
14 | "components": "~/components",
15 | "utils": "~/lib/utils",
16 | "ui": "~/components/ui",
17 | "lib": "~/lib",
18 | "hooks": "~/hooks"
19 | },
20 | "iconLibrary": "lucide"
21 | }
--------------------------------------------------------------------------------
/next.config.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially useful
3 | * for Docker builds.
4 | */
5 | import "./src/env.js";
6 |
7 | /** @type {import("next").NextConfig} */
8 | const config = {
9 | reactStrictMode: true,
10 |
11 | /**
12 | * If you are using `appDir` then you must comment the below `i18n` config out.
13 | *
14 | * @see https://github.com/vercel/next.js/issues/41980
15 | */
16 | i18n: {
17 | locales: ["en"],
18 | defaultLocale: "en",
19 | },
20 | transpilePackages: ["geist"],
21 | };
22 |
23 | export default config;
24 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "qepo-nextjs",
3 | "version": "0.1.0",
4 | "private": true,
5 | "type": "module",
6 | "scripts": {
7 | "build": "next build",
8 | "check": "next lint && tsc --noEmit",
9 | "db:generate": "prisma migrate dev",
10 | "db:migrate": "prisma migrate deploy",
11 | "db:push": "prisma db push",
12 | "db:studio": "prisma studio",
13 | "dev": "next dev --turbo",
14 | "format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
15 | "format:write": "prettier --write \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
16 | "postinstall": "prisma generate",
17 | "lint": "next lint",
18 | "lint:fix": "next lint --fix",
19 | "preview": "next build && next start",
20 | "start": "next start",
21 | "typecheck": "tsc --noEmit"
22 | },
23 | "dependencies": {
24 | "@hookform/resolvers": "^3.10.0",
25 | "@prisma/client": "^5.14.0",
26 | "@radix-ui/react-avatar": "^1.1.2",
27 | "@radix-ui/react-checkbox": "^1.1.3",
28 | "@radix-ui/react-label": "^2.1.1",
29 | "@radix-ui/react-slot": "^1.1.1",
30 | "@supabase/ssr": "^0.5.2",
31 | "@supabase/supabase-js": "^2.48.1",
32 | "@t3-oss/env-nextjs": "^0.10.1",
33 | "@tanstack/react-query": "^5.50.0",
34 | "@trpc/client": "^11.0.0-rc.446",
35 | "@trpc/next": "^11.0.0-rc.446",
36 | "@trpc/react-query": "^11.0.0-rc.446",
37 | "@trpc/server": "^11.0.0-rc.446",
38 | "class-variance-authority": "^0.7.1",
39 | "clsx": "^2.1.1",
40 | "geist": "^1.3.0",
41 | "lucide-react": "^0.473.0",
42 | "next": "^15.0.1",
43 | "next-themes": "^0.4.4",
44 | "react": "^18.3.1",
45 | "react-dom": "^18.3.1",
46 | "react-hook-form": "^7.54.2",
47 | "react-icons": "^5.4.0",
48 | "sonner": "^1.7.2",
49 | "superjson": "^2.2.1",
50 | "tailwind-merge": "^2.6.0",
51 | "tailwindcss-animate": "^1.0.7",
52 | "unique-username-generator": "^1.4.0",
53 | "zod": "^3.24.1"
54 | },
55 | "devDependencies": {
56 | "@types/eslint": "^8.56.10",
57 | "@types/node": "^20.14.10",
58 | "@types/react": "^18.3.3",
59 | "@types/react-dom": "^18.3.0",
60 | "@typescript-eslint/eslint-plugin": "^8.1.0",
61 | "@typescript-eslint/parser": "^8.1.0",
62 | "eslint": "^8.57.0",
63 | "eslint-config-next": "^15.0.1",
64 | "postcss": "^8.4.39",
65 | "prettier": "^3.3.2",
66 | "prettier-plugin-tailwindcss": "^0.6.5",
67 | "prisma": "^5.14.0",
68 | "tailwindcss": "^3.4.3",
69 | "typescript": "^5.5.3"
70 | },
71 | "ct3aMetadata": {
72 | "initVersion": "7.38.1"
73 | },
74 | "packageManager": "npm@10.2.3"
75 | }
76 |
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | export default {
2 | plugins: {
3 | tailwindcss: {},
4 | },
5 | };
6 |
--------------------------------------------------------------------------------
/prettier.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('prettier').Config & import('prettier-plugin-tailwindcss').PluginOptions} */
2 | export default {
3 | plugins: ["prettier-plugin-tailwindcss"],
4 | };
5 |
--------------------------------------------------------------------------------
/prisma/schema.prisma:
--------------------------------------------------------------------------------
1 | // This is your Prisma schema file,
2 | // learn more about it in the docs: https://pris.ly/d/prisma-schema
3 |
4 | generator client {
5 | provider = "prisma-client-js"
6 | }
7 |
8 | datasource db {
9 | provider = "postgresql"
10 | url = env("DATABASE_URL")
11 | directUrl = env("DIRECT_URL")
12 | }
13 |
14 | model Post {
15 | id Int @id @default(autoincrement())
16 | name String
17 | createdAt DateTime @default(now())
18 | updatedAt DateTime @updatedAt
19 |
20 | @@index([name])
21 | }
22 |
23 | model Profile {
24 | userId String @id
25 |
26 | email String @unique
27 | username String @unique
28 |
29 | bio String?
30 | profilePictureUrl String?
31 | }
32 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/theodevoid/qepo-nextjs/fafc735dd857320b4ff2cb25894086f713370338/public/favicon.ico
--------------------------------------------------------------------------------
/src/components/layout/AuthRoute.tsx:
--------------------------------------------------------------------------------
1 | import { useRouter } from "next/router";
2 | import { type PropsWithChildren, useEffect } from "react";
3 | import { supabase } from "~/lib/supabase/client";
4 |
5 | export const AuthRoute = (props: PropsWithChildren) => {
6 | const router = useRouter();
7 |
8 | useEffect(() => {
9 | void (async function () {
10 | const { data } = await supabase.auth.getUser();
11 |
12 | if (!data.user) {
13 | await router.replace("/");
14 | }
15 | })();
16 | // eslint-disable-next-line react-hooks/exhaustive-deps
17 | }, []);
18 |
19 | return props.children;
20 | };
21 |
--------------------------------------------------------------------------------
/src/components/layout/GuestRoute.tsx:
--------------------------------------------------------------------------------
1 | import { useRouter } from "next/router";
2 | import { type PropsWithChildren, useEffect } from "react";
3 | import { supabase } from "~/lib/supabase/client";
4 |
5 | export const GuestRoute = (props: PropsWithChildren) => {
6 | const router = useRouter();
7 |
8 | useEffect(() => {
9 | void (async function () {
10 | const { data } = await supabase.auth.getUser();
11 |
12 | if (data.user) {
13 | await router.replace("/");
14 | }
15 | })();
16 | // eslint-disable-next-line react-hooks/exhaustive-deps
17 | }, []);
18 |
19 | return props.children;
20 | };
21 |
--------------------------------------------------------------------------------
/src/components/layout/HeadMetaData.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import Head from "next/head";
3 | import { env } from "~/env";
4 |
5 | export const HeadMetaData: React.FC<{
6 | title?: string;
7 | metaDescription?: string;
8 | // ogImageUrl?: string;
9 | pathname?: string;
10 | }> = ({
11 | title = "Konten kamu berharga",
12 | metaDescription,
13 | // ogImageUrl = env.NEXT_PUBLIC_OG_IMAGE_URL,
14 | pathname = "",
15 | }) => {
16 | const defaultTitle = "Qepo Lu";
17 |
18 | const baseUrl =
19 | process.env.NODE_ENV === "development"
20 | ? "http://localhost:3000"
21 | : env.NEXT_PUBLIC_BASE_URL;
22 |
23 | const pageUrl = new URL(pathname, baseUrl).toString();
24 |
25 | return (
26 |
27 | {title + " | " + defaultTitle}
28 |
29 |
30 | {/* metadata */}
31 |
32 |
33 | {/* */}
34 |
35 |
36 |
37 | {/* */}
38 |
39 |
40 |
41 |
42 |
43 |
44 | {/* */}
45 |
46 |
47 | );
48 | };
--------------------------------------------------------------------------------
/src/components/layout/Header.tsx:
--------------------------------------------------------------------------------
1 | import Link from "next/link";
2 |
3 | export const Header = () => {
4 | return (
5 |
13 | );
14 | };
--------------------------------------------------------------------------------
/src/components/layout/PageContainer.tsx:
--------------------------------------------------------------------------------
1 | import React, { forwardRef } from "react";
2 | import { cn } from "~/lib/utils";
3 | import { HeadMetaData } from "./HeadMetaData";
4 | import { Header } from "./Header";
5 |
6 | type PageContainerProps = {
7 | withHeader?: boolean;
8 | withFooter?: boolean;
9 | };
10 |
11 | export const PageContainer = forwardRef<
12 | HTMLElement,
13 | React.HTMLAttributes & PageContainerProps
14 | >(
15 | (
16 | { className, children, withHeader = true, withFooter = true, ...props },
17 | ref,
18 | ) => {
19 | return (
20 |
21 |
22 | {withHeader &&
}
23 |
24 | {children}
25 |
26 | {withFooter && (
27 |
32 | )}
33 |
34 | );
35 | },
36 | );
37 |
38 | PageContainer.displayName = "PageContainer";
--------------------------------------------------------------------------------
/src/components/layout/SectionContainer.tsx:
--------------------------------------------------------------------------------
1 | import React, { forwardRef } from "react";
2 | import { cn } from "~/lib/utils";
3 |
4 | type SectionContainerProps = {
5 | padded?: boolean;
6 | containerClassName?: string;
7 | minFullscreen?: boolean;
8 | };
9 |
10 | export const SectionContainer = forwardRef<
11 | HTMLElement,
12 | React.HTMLAttributes & SectionContainerProps
13 | >(({ className, children, padded, containerClassName, ...props }, ref) => {
14 | return (
15 |
16 |
29 |
30 | );
31 | });
32 |
33 | SectionContainer.displayName = "SectionContainer";
34 |
--------------------------------------------------------------------------------
/src/components/theme-provider.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { ThemeProvider as NextThemesProvider } from "next-themes"
3 |
4 | export function ThemeProvider({
5 | children,
6 | ...props
7 | }: React.ComponentProps) {
8 | return {children}
9 | }
10 |
--------------------------------------------------------------------------------
/src/components/ui/avatar.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as AvatarPrimitive from "@radix-ui/react-avatar"
3 |
4 | import { cn } from "~/lib/utils"
5 |
6 | const Avatar = React.forwardRef<
7 | React.ElementRef,
8 | React.ComponentPropsWithoutRef
9 | >(({ className, ...props }, ref) => (
10 |
18 | ))
19 | Avatar.displayName = AvatarPrimitive.Root.displayName
20 |
21 | const AvatarImage = React.forwardRef<
22 | React.ElementRef,
23 | React.ComponentPropsWithoutRef
24 | >(({ className, ...props }, ref) => (
25 |
30 | ))
31 | AvatarImage.displayName = AvatarPrimitive.Image.displayName
32 |
33 | const AvatarFallback = React.forwardRef<
34 | React.ElementRef,
35 | React.ComponentPropsWithoutRef
36 | >(({ className, ...props }, ref) => (
37 |
45 | ))
46 | AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
47 |
48 | export { Avatar, AvatarImage, AvatarFallback }
49 |
--------------------------------------------------------------------------------
/src/components/ui/button.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { Slot } from "@radix-ui/react-slot"
3 | import { cva, type VariantProps } from "class-variance-authority"
4 |
5 | import { cn } from "~/lib/utils"
6 |
7 | const buttonVariants = cva(
8 | "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
9 | {
10 | variants: {
11 | variant: {
12 | default:
13 | "bg-primary text-primary-foreground shadow hover:bg-primary/90",
14 | destructive:
15 | "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
16 | outline:
17 | "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
18 | secondary:
19 | "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
20 | ghost: "hover:bg-accent hover:text-accent-foreground",
21 | link: "text-primary underline-offset-4 hover:underline",
22 | },
23 | size: {
24 | default: "h-9 px-4 py-2",
25 | sm: "h-8 rounded-md px-3 text-xs",
26 | lg: "h-10 rounded-md px-8",
27 | icon: "h-9 w-9",
28 | },
29 | },
30 | defaultVariants: {
31 | variant: "default",
32 | size: "default",
33 | },
34 | }
35 | )
36 |
37 | export interface ButtonProps
38 | extends React.ButtonHTMLAttributes,
39 | VariantProps {
40 | asChild?: boolean
41 | }
42 |
43 | const Button = React.forwardRef(
44 | ({ className, variant, size, asChild = false, ...props }, ref) => {
45 | const Comp = asChild ? Slot : "button"
46 | return (
47 |
52 | )
53 | }
54 | )
55 | Button.displayName = "Button"
56 |
57 | export { Button, buttonVariants }
58 |
--------------------------------------------------------------------------------
/src/components/ui/card.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "~/lib/utils"
4 |
5 | const Card = React.forwardRef<
6 | HTMLDivElement,
7 | React.HTMLAttributes
8 | >(({ className, ...props }, ref) => (
9 |
17 | ))
18 | Card.displayName = "Card"
19 |
20 | const CardHeader = React.forwardRef<
21 | HTMLDivElement,
22 | React.HTMLAttributes
23 | >(({ className, ...props }, ref) => (
24 |
29 | ))
30 | CardHeader.displayName = "CardHeader"
31 |
32 | const CardTitle = React.forwardRef<
33 | HTMLDivElement,
34 | React.HTMLAttributes
35 | >(({ className, ...props }, ref) => (
36 |
41 | ))
42 | CardTitle.displayName = "CardTitle"
43 |
44 | const CardDescription = React.forwardRef<
45 | HTMLDivElement,
46 | React.HTMLAttributes
47 | >(({ className, ...props }, ref) => (
48 |
53 | ))
54 | CardDescription.displayName = "CardDescription"
55 |
56 | const CardContent = React.forwardRef<
57 | HTMLDivElement,
58 | React.HTMLAttributes
59 | >(({ className, ...props }, ref) => (
60 |
61 | ))
62 | CardContent.displayName = "CardContent"
63 |
64 | const CardFooter = React.forwardRef<
65 | HTMLDivElement,
66 | React.HTMLAttributes
67 | >(({ className, ...props }, ref) => (
68 |
73 | ))
74 | CardFooter.displayName = "CardFooter"
75 |
76 | export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
77 |
--------------------------------------------------------------------------------
/src/components/ui/checkbox.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
3 | import { Check } from "lucide-react"
4 |
5 | import { cn } from "~/lib/utils"
6 |
7 | const Checkbox = React.forwardRef<
8 | React.ElementRef,
9 | React.ComponentPropsWithoutRef
10 | >(({ className, ...props }, ref) => (
11 |
19 |
22 |
23 |
24 |
25 | ))
26 | Checkbox.displayName = CheckboxPrimitive.Root.displayName
27 |
28 | export { Checkbox }
29 |
--------------------------------------------------------------------------------
/src/components/ui/form.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as LabelPrimitive from "@radix-ui/react-label"
3 | import { Slot } from "@radix-ui/react-slot"
4 | import {
5 | Controller,
6 | ControllerProps,
7 | FieldPath,
8 | FieldValues,
9 | FormProvider,
10 | useFormContext,
11 | } from "react-hook-form"
12 |
13 | import { cn } from "~/lib/utils"
14 | import { Label } from "~/components/ui/label"
15 |
16 | const Form = FormProvider
17 |
18 | type FormFieldContextValue<
19 | TFieldValues extends FieldValues = FieldValues,
20 | TName extends FieldPath = FieldPath
21 | > = {
22 | name: TName
23 | }
24 |
25 | const FormFieldContext = React.createContext(
26 | {} as FormFieldContextValue
27 | )
28 |
29 | const FormField = <
30 | TFieldValues extends FieldValues = FieldValues,
31 | TName extends FieldPath = FieldPath
32 | >({
33 | ...props
34 | }: ControllerProps) => {
35 | return (
36 |
37 |
38 |
39 | )
40 | }
41 |
42 | const useFormField = () => {
43 | const fieldContext = React.useContext(FormFieldContext)
44 | const itemContext = React.useContext(FormItemContext)
45 | const { getFieldState, formState } = useFormContext()
46 |
47 | const fieldState = getFieldState(fieldContext.name, formState)
48 |
49 | if (!fieldContext) {
50 | throw new Error("useFormField should be used within ")
51 | }
52 |
53 | const { id } = itemContext
54 |
55 | return {
56 | id,
57 | name: fieldContext.name,
58 | formItemId: `${id}-form-item`,
59 | formDescriptionId: `${id}-form-item-description`,
60 | formMessageId: `${id}-form-item-message`,
61 | ...fieldState,
62 | }
63 | }
64 |
65 | type FormItemContextValue = {
66 | id: string
67 | }
68 |
69 | const FormItemContext = React.createContext(
70 | {} as FormItemContextValue
71 | )
72 |
73 | const FormItem = React.forwardRef<
74 | HTMLDivElement,
75 | React.HTMLAttributes
76 | >(({ className, ...props }, ref) => {
77 | const id = React.useId()
78 |
79 | return (
80 |
81 |
82 |
83 | )
84 | })
85 | FormItem.displayName = "FormItem"
86 |
87 | const FormLabel = React.forwardRef<
88 | React.ElementRef,
89 | React.ComponentPropsWithoutRef
90 | >(({ className, ...props }, ref) => {
91 | const { error, formItemId } = useFormField()
92 |
93 | return (
94 |
100 | )
101 | })
102 | FormLabel.displayName = "FormLabel"
103 |
104 | const FormControl = React.forwardRef<
105 | React.ElementRef,
106 | React.ComponentPropsWithoutRef
107 | >(({ ...props }, ref) => {
108 | const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
109 |
110 | return (
111 |
122 | )
123 | })
124 | FormControl.displayName = "FormControl"
125 |
126 | const FormDescription = React.forwardRef<
127 | HTMLParagraphElement,
128 | React.HTMLAttributes
129 | >(({ className, ...props }, ref) => {
130 | const { formDescriptionId } = useFormField()
131 |
132 | return (
133 |
139 | )
140 | })
141 | FormDescription.displayName = "FormDescription"
142 |
143 | const FormMessage = React.forwardRef<
144 | HTMLParagraphElement,
145 | React.HTMLAttributes
146 | >(({ className, children, ...props }, ref) => {
147 | const { error, formMessageId } = useFormField()
148 | const body = error ? String(error?.message) : children
149 |
150 | if (!body) {
151 | return null
152 | }
153 |
154 | return (
155 |
161 | {body}
162 |
163 | )
164 | })
165 | FormMessage.displayName = "FormMessage"
166 |
167 | export {
168 | useFormField,
169 | Form,
170 | FormItem,
171 | FormLabel,
172 | FormControl,
173 | FormDescription,
174 | FormMessage,
175 | FormField,
176 | }
177 |
--------------------------------------------------------------------------------
/src/components/ui/input.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "~/lib/utils"
4 |
5 | const Input = React.forwardRef>(
6 | ({ className, type, ...props }, ref) => {
7 | return (
8 |
17 | )
18 | }
19 | )
20 | Input.displayName = "Input"
21 |
22 | export { Input }
23 |
--------------------------------------------------------------------------------
/src/components/ui/label.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as LabelPrimitive from "@radix-ui/react-label"
3 | import { cva, type VariantProps } from "class-variance-authority"
4 |
5 | import { cn } from "~/lib/utils"
6 |
7 | const labelVariants = cva(
8 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
9 | )
10 |
11 | const Label = React.forwardRef<
12 | React.ElementRef,
13 | React.ComponentPropsWithoutRef &
14 | VariantProps
15 | >(({ className, ...props }, ref) => (
16 |
21 | ))
22 | Label.displayName = LabelPrimitive.Root.displayName
23 |
24 | export { Label }
25 |
--------------------------------------------------------------------------------
/src/components/ui/sonner.tsx:
--------------------------------------------------------------------------------
1 | import { useTheme } from "next-themes"
2 | import { Toaster as Sonner } from "sonner"
3 |
4 | type ToasterProps = React.ComponentProps
5 |
6 | const Toaster = ({ ...props }: ToasterProps) => {
7 | const { theme = "system" } = useTheme()
8 |
9 | return (
10 |
26 | )
27 | }
28 |
29 | export { Toaster }
30 |
--------------------------------------------------------------------------------
/src/components/ui/textarea.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "~/lib/utils"
4 |
5 | const Textarea = React.forwardRef<
6 | HTMLTextAreaElement,
7 | React.ComponentProps<"textarea">
8 | >(({ className, ...props }, ref) => {
9 | return (
10 |
18 | )
19 | })
20 | Textarea.displayName = "Textarea"
21 |
22 | export { Textarea }
23 |
--------------------------------------------------------------------------------
/src/env.js:
--------------------------------------------------------------------------------
1 | import { createEnv } from "@t3-oss/env-nextjs";
2 | import { z } from "zod";
3 |
4 | export const env = createEnv({
5 | /**
6 | * Specify your server-side environment variables schema here. This way you can ensure the app
7 | * isn't built with invalid env vars.
8 | */
9 | server: {
10 | DATABASE_URL: z.string().url(),
11 | NODE_ENV: z
12 | .enum(["development", "test", "production"])
13 | .default("development"),
14 | },
15 |
16 | /**
17 | * Specify your client-side environment variables schema here. This way you can ensure the app
18 | * isn't built with invalid env vars. To expose them to the client, prefix them with
19 | * `NEXT_PUBLIC_`.
20 | */
21 | client: {
22 | // NEXT_PUBLIC_CLIENTVAR: z.string(),
23 | NEXT_PUBLIC_BASE_URL: z.string().url()
24 | },
25 |
26 | /**
27 | * You can't destruct `process.env` as a regular object in the Next.js edge runtimes (e.g.
28 | * middlewares) or client-side so we need to destruct manually.
29 | */
30 | runtimeEnv: {
31 | DATABASE_URL: process.env.DATABASE_URL,
32 | NODE_ENV: process.env.NODE_ENV,
33 | NEXT_PUBLIC_BASE_URL: process.env.NEXT_PUBLIC_BASE_URL
34 | // NEXT_PUBLIC_CLIENTVAR: process.env.NEXT_PUBLIC_CLIENTVAR,
35 | },
36 | /**
37 | * Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially
38 | * useful for Docker builds.
39 | */
40 | skipValidation: !!process.env.SKIP_ENV_VALIDATION,
41 | /**
42 | * Makes it so that empty strings are treated as undefined. `SOME_VAR: z.string()` and
43 | * `SOME_VAR=''` will throw an error.
44 | */
45 | emptyStringAsUndefined: true,
46 | });
47 |
--------------------------------------------------------------------------------
/src/features/auth/components/RegisterFormInner.tsx:
--------------------------------------------------------------------------------
1 | import { useFormContext } from "react-hook-form";
2 | import { Button } from "~/components/ui/button";
3 | import { Checkbox } from "~/components/ui/checkbox";
4 | import {
5 | FormControl,
6 | FormDescription,
7 | FormField,
8 | FormItem,
9 | FormLabel,
10 | FormMessage,
11 | } from "~/components/ui/form";
12 | import { Input } from "~/components/ui/input";
13 | import { Label } from "~/components/ui/label";
14 | import { type RegisterFormSchema } from "../forms/register";
15 | import { useState } from "react";
16 |
17 | type RegisterFormInnerProps = {
18 | onRegisterSubmit: (values: RegisterFormSchema) => void;
19 | isLoading?: boolean;
20 | buttonText?: string;
21 | showPassword?: boolean;
22 | };
23 |
24 | export const RegisterFormInner = (props: RegisterFormInnerProps) => {
25 | const form = useFormContext();
26 |
27 | const [showPassword, setShowPassword] = useState(false);
28 |
29 | return (
30 |
77 | );
78 | };
79 |
--------------------------------------------------------------------------------
/src/features/auth/forms/register.ts:
--------------------------------------------------------------------------------
1 | import { z } from "zod";
2 | import { emailSchema, passwordSchema } from "~/schemas/auth";
3 |
4 | export const registerFormSchema = z.object({
5 | email: emailSchema,
6 | password: passwordSchema,
7 | });
8 |
9 | export type RegisterFormSchema = z.infer;
10 |
--------------------------------------------------------------------------------
/src/features/auth/pages/LoginPage.tsx:
--------------------------------------------------------------------------------
1 | import { zodResolver } from "@hookform/resolvers/zod";
2 | import Link from "next/link";
3 | import { useForm } from "react-hook-form";
4 | import { FcGoogle } from "react-icons/fc";
5 | import { PageContainer } from "~/components/layout/PageContainer";
6 | import { SectionContainer } from "~/components/layout/SectionContainer";
7 | import { Button } from "~/components/ui/button";
8 | import {
9 | Card,
10 | CardContent,
11 | CardFooter,
12 | CardHeader,
13 | } from "~/components/ui/card";
14 | import { Form } from "~/components/ui/form";
15 | import { RegisterFormInner } from "../components/RegisterFormInner";
16 | import { type RegisterFormSchema, registerFormSchema } from "../forms/register";
17 | import { toast } from "sonner";
18 | import { supabase } from "~/lib/supabase/client";
19 | import { type AuthError } from "@supabase/supabase-js";
20 | import { SupabaseAuthErrorCode } from "~/lib/supabase/authErrorCodes";
21 | import { useRouter } from "next/router";
22 | import { GuestRoute } from "~/components/layout/GuestRoute";
23 |
24 | const LoginPage = () => {
25 | const form = useForm({
26 | resolver: zodResolver(registerFormSchema),
27 | });
28 |
29 | const router = useRouter();
30 |
31 | const handleLoginSubmit = async (values: RegisterFormSchema) => {
32 | try {
33 | const { error } = await supabase.auth.signInWithPassword({
34 | email: values.email,
35 | password: values.password,
36 | });
37 |
38 | if (error) throw error;
39 |
40 | await router.replace("/");
41 | } catch (error) {
42 | switch ((error as AuthError).code) {
43 | case SupabaseAuthErrorCode.invalid_credentials:
44 | form.setError("email", { message: "Email atau password salah" });
45 | form.setError("password", {
46 | message: "Email atau password salah",
47 | });
48 | break;
49 | case SupabaseAuthErrorCode.email_not_confirmed:
50 | form.setError("email", { message: "Email belum diverifikasi" });
51 | break;
52 | default:
53 | toast.error("Sebuah kesalahan terjadi, coba lagi beberapa saat.");
54 | }
55 | }
56 | };
57 |
58 | return (
59 |
60 |
61 |
65 |
66 |
67 |
68 | Selamat Datang Kembali 👋
69 |
70 |
71 | Qepoin kreator favorite kamu
72 |
73 |
74 |
75 |
82 |
83 |
84 |
85 |
86 |
87 |
88 | Atau lanjut dengan
89 |
90 |
91 |
92 |
93 |
97 |
98 |
99 | Belum punya akun?{" "}
100 |
101 | Daftar dong
102 |
103 |
104 |
105 |
106 |
107 |
108 |
109 | );
110 | };
111 |
112 | export default LoginPage;
113 |
--------------------------------------------------------------------------------
/src/features/auth/pages/RegisterPage.tsx:
--------------------------------------------------------------------------------
1 | import { zodResolver } from "@hookform/resolvers/zod";
2 | import Link from "next/link";
3 | import { useForm } from "react-hook-form";
4 | import { FcGoogle } from "react-icons/fc";
5 | import { PageContainer } from "~/components/layout/PageContainer";
6 | import { SectionContainer } from "~/components/layout/SectionContainer";
7 | import { Button } from "~/components/ui/button";
8 | import {
9 | Card,
10 | CardContent,
11 | CardFooter,
12 | CardHeader,
13 | } from "~/components/ui/card";
14 | import { Form } from "~/components/ui/form";
15 | import { RegisterFormInner } from "../components/RegisterFormInner";
16 | import { type RegisterFormSchema, registerFormSchema } from "../forms/register";
17 | import { api } from "~/utils/api";
18 | import { toast } from "sonner";
19 | import { GuestRoute } from "~/components/layout/GuestRoute";
20 |
21 | const RegisterPage = () => {
22 | const form = useForm({
23 | resolver: zodResolver(registerFormSchema),
24 | });
25 |
26 | const { mutate: registerUser, isPending: registerUserIsPending } =
27 | api.auth.register.useMutation({
28 | onSuccess: () => {
29 | toast("Akun kamu berhasil dibuat!");
30 | form.setValue("email", "");
31 | form.setValue("password", "");
32 | },
33 | onError: () => {
34 | toast.error("Ada kesalahan terjadi, coba beberapa saat lagi");
35 | },
36 | });
37 |
38 | const handleRegisterSubmit = (values: RegisterFormSchema) => {
39 | registerUser(values);
40 | };
41 |
42 | return (
43 |
44 |
45 |
49 |
50 |
51 | Buat Akun
52 |
53 | Qepoin kreator favorite kamu
54 |
55 |
56 |
57 |
64 |
65 |
66 |
67 |
68 |
69 |
70 | Atau lanjut dengan
71 |
72 |
73 |
74 |
75 |
79 |
80 |
81 | Sudah punya akun?{" "}
82 |
83 | P, Login
84 |
85 |
86 |
87 |
88 |
89 |
90 |
91 | );
92 | };
93 |
94 | export default RegisterPage;
95 |
--------------------------------------------------------------------------------
/src/features/profile/components/EditProfileFormInner.tsx:
--------------------------------------------------------------------------------
1 | import { useForm, useFormContext } from "react-hook-form";
2 | import {
3 | Form,
4 | FormControl,
5 | FormField,
6 | FormItem,
7 | FormLabel,
8 | FormMessage,
9 | } from "~/components/ui/form";
10 | import { Input } from "~/components/ui/input";
11 | import { Textarea } from "~/components/ui/textarea";
12 | import { type EditProfileFormSchema } from "../forms/edit-profile";
13 |
14 | type EditProfileFormInnerProps = {
15 | defaultValues: {
16 | username?: string;
17 | bio?: string | null;
18 | };
19 | };
20 |
21 | export const EditProfileFormInner = (props: EditProfileFormInnerProps) => {
22 | const form = useFormContext();
23 |
24 | return (
25 | <>
26 | (
30 |
31 | Username
32 |
33 |
34 |
35 |
36 |
37 | )}
38 | />
39 |
40 | (
44 |
45 | Bio
46 |
47 |
48 |
49 |
50 |
51 | )}
52 | />
53 | >
54 | );
55 | };
56 |
--------------------------------------------------------------------------------
/src/features/profile/forms/edit-profile.ts:
--------------------------------------------------------------------------------
1 | import { z } from "zod";
2 |
3 | export const editProfileFormSchema = z.object({
4 | username: z
5 | .string()
6 | .min(3, { message: "Username minimal 3 karakter" })
7 | .max(16, { message: "Username maksimal 16 karakter" }),
8 | bio: z.string().optional(),
9 | });
10 |
11 | export type EditProfileFormSchema = z.infer;
12 |
--------------------------------------------------------------------------------
/src/features/profile/pages/ProfilePage.tsx:
--------------------------------------------------------------------------------
1 | import { zodResolver } from "@hookform/resolvers/zod";
2 | import { TRPCClientError } from "@trpc/client";
3 | import {
4 | type ChangeEventHandler,
5 | useEffect,
6 | useMemo,
7 | useRef,
8 | useState,
9 | } from "react";
10 | import { useForm } from "react-hook-form";
11 | import { toast } from "sonner";
12 | import { AuthRoute } from "~/components/layout/AuthRoute";
13 | import { PageContainer } from "~/components/layout/PageContainer";
14 | import { SectionContainer } from "~/components/layout/SectionContainer";
15 | import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar";
16 | import { Button } from "~/components/ui/button";
17 | import { Card, CardContent } from "~/components/ui/card";
18 | import { Form } from "~/components/ui/form";
19 | import { api } from "~/utils/api";
20 | import { EditProfileFormInner } from "../components/EditProfileFormInner";
21 | import {
22 | editProfileFormSchema,
23 | type EditProfileFormSchema,
24 | } from "../forms/edit-profile";
25 |
26 | const ProfilePage = () => {
27 | const [selectedImage, setSelectedImage] = useState(
28 | null,
29 | );
30 |
31 | const apiUtils = api.useUtils();
32 |
33 | const form = useForm({
34 | resolver: zodResolver(editProfileFormSchema),
35 | });
36 |
37 | const { data: getProfileData } = api.profile.getProfile.useQuery();
38 | const updateProfile = api.profile.updateProfile.useMutation({
39 | onSuccess: async ({ bio, username }) => {
40 | form.reset({ bio: bio ?? "", username });
41 | toast.success("Berhasil update profile");
42 | },
43 | onError: (err) => {
44 | if (err instanceof TRPCClientError) {
45 | if (err.message === "USERNAME_USED") {
46 | form.setError("username", {
47 | message: "Username sudah digunakan",
48 | });
49 | }
50 | }
51 |
52 | toast.error("Gagal update profile");
53 | },
54 | });
55 | const updateProfilePicture = api.profile.updateProfilePicture.useMutation({
56 | onSuccess: async () => {
57 | toast.success("Berhasil ganti foto profil");
58 | setSelectedImage(null);
59 | await apiUtils.profile.getProfile.invalidate();
60 | },
61 | onError: async () => {
62 | // TODO: Handle image upload errors
63 | toast.error("Gagal ganti foto profil")
64 | }
65 | });
66 |
67 | const inputFileRef = useRef(null);
68 |
69 | const handleUpdateProfileSubmit = (values: EditProfileFormSchema) => {
70 | const payload: {
71 | username?: string;
72 | bio?: string;
73 | } = {};
74 |
75 | if (values.username !== getProfileData?.username) {
76 | payload.username = values.username;
77 | }
78 |
79 | if (values.bio !== getProfileData?.bio) {
80 | payload.bio = values.bio;
81 | }
82 |
83 | updateProfile.mutate({
84 | ...payload,
85 | });
86 | };
87 |
88 | const handleOpenFileExplorer = () => {
89 | inputFileRef.current?.click();
90 | };
91 |
92 | const handleRemoveSelectedImage = () => {
93 | setSelectedImage(null);
94 | };
95 |
96 | const onPickProfilePicture: ChangeEventHandler = (e) => {
97 | if (e.target.files) {
98 | setSelectedImage(e.target.files[0]);
99 | }
100 | };
101 |
102 | const handleUpdateProfilePicture = async () => {
103 | if (selectedImage) {
104 | const reader = new FileReader();
105 |
106 | reader.onloadend = function () {
107 | const result = reader.result as string;
108 | const imageBase64 = result.substring(result.indexOf(",") + 1);
109 |
110 | updateProfilePicture.mutate(imageBase64);
111 | };
112 |
113 | reader.readAsDataURL(selectedImage);
114 | }
115 | };
116 |
117 | const selectedProfilePicturePreview = useMemo(() => {
118 | if (selectedImage) {
119 | return URL.createObjectURL(selectedImage);
120 | }
121 | }, [selectedImage]);
122 |
123 | useEffect(() => {
124 | if (getProfileData) {
125 | form.setValue("username", getProfileData.username ?? "");
126 | form.setValue("bio", getProfileData.bio ?? "");
127 | }
128 | // eslint-disable-next-line react-hooks/exhaustive-deps
129 | }, [getProfileData]);
130 |
131 | return (
132 |
133 |
134 |
135 | Profile Settings
136 |
137 |
138 |
139 |
140 |
141 | VF
142 |
149 |
150 |
151 |
158 | {!!selectedImage && (
159 | <>
160 |
167 |
170 | >
171 | )}
172 |
179 |
180 |
181 |
182 | {/* TODO: Skeleton when loading data */}
183 | {getProfileData && (
184 |
192 | )}
193 |
194 |
195 |
196 |
197 |
198 |
204 |
205 |
206 |
207 |
208 | );
209 | };
210 |
211 | export default ProfilePage;
212 |
--------------------------------------------------------------------------------
/src/lib/supabase/authErrorCodes.ts:
--------------------------------------------------------------------------------
1 | export enum SupabaseAuthErrorCode {
2 | anonymous_provider_disabled = "anonymous_provider_disabled",
3 | bad_code_verifier = "bad_code_verifier",
4 | bad_json = "bad_json",
5 | bad_jwt = "bad_jwt",
6 | bad_oauth_callback = "bad_oauth_callback",
7 | bad_oauth_state = "bad_oauth_state",
8 | captcha_failed = "captcha_failed",
9 | conflict = "conflict",
10 | email_address_not_authorized = "email_address_not_authorized",
11 | email_conflict_identity_not_deletable = "email_conflict_identity_not_deletable",
12 | email_exists = "email_exists",
13 | email_not_confirmed = "email_not_confirmed",
14 | email_provider_disabled = "email_provider_disabled",
15 | flow_state_expired = "flow_state_expired",
16 | flow_state_not_found = "flow_state_not_found",
17 | hook_payload_over_size_limit = "hook_payload_over_size_limit",
18 | hook_timeout = "hook_timeout",
19 | hook_timeout_after_retry = "hook_timeout_after_retry",
20 | identity_already_exists = "identity_already_exists",
21 | identity_not_found = "identity_not_found",
22 | insufficient_aal = "insufficient_aal",
23 | invite_not_found = "invite_not_found",
24 | invalid_credentials = "invalid_credentials",
25 | manual_linking_disabled = "manual_linking_disabled",
26 | mfa_challenge_expired = "mfa_challenge_expired",
27 | mfa_factor_name_conflict = "mfa_factor_name_conflict",
28 | mfa_factor_not_found = "mfa_factor_not_found",
29 | mfa_ip_address_mismatch = "mfa_ip_address_mismatch",
30 | mfa_verification_failed = "mfa_verification_failed",
31 | mfa_verification_rejected = "mfa_verification_rejected",
32 | mfa_verified_factor_exists = "mfa_verified_factor_exists",
33 | mfa_totp_enroll_disabled = "mfa_totp_enroll_disabled",
34 | mfa_totp_verify_disabled = "mfa_totp_verify_disabled",
35 | mfa_phone_enroll_disabled = "mfa_phone_enroll_disabled",
36 | mfa_phone_verify_disabled = "mfa_phone_verify_disabled",
37 | no_authorization = "no_authorization",
38 | not_admin = "not_admin",
39 | oauth_provider_not_supported = "oauth_provider_not_supported",
40 | otp_disabled = "otp_disabled",
41 | otp_expired = "otp_expired",
42 | over_email_send_rate_limit = "over_email_send_rate_limit",
43 | over_request_rate_limit = "over_request_rate_limit",
44 | over_sms_send_rate_limit = "over_sms_send_rate_limit",
45 | phone_exists = "phone_exists",
46 | phone_not_confirmed = "phone_not_confirmed",
47 | phone_provider_disabled = "phone_provider_disabled",
48 | provider_disabled = "provider_disabled",
49 | provider_email_needs_verification = "provider_email_needs_verification",
50 | reauthentication_needed = "reauthentication_needed",
51 | reauthentication_not_valid = "reauthentication_not_valid",
52 | request_timeout = "request_timeout",
53 | same_password = "same_password",
54 | saml_assertion_no_email = "saml_assertion_no_email",
55 | saml_assertion_no_user_id = "saml_assertion_no_user_id",
56 | saml_entity_id_mismatch = "saml_entity_id_mismatch",
57 | saml_idp_already_exists = "saml_idp_already_exists",
58 | saml_idp_not_found = "saml_idp_not_found",
59 | saml_metadata_fetch_failed = "saml_metadata_fetch_failed",
60 | saml_provider_disabled = "saml_provider_disabled",
61 | saml_relay_state_expired = "saml_relay_state_expired",
62 | saml_relay_state_not_found = "saml_relay_state_not_found",
63 | session_not_found = "session_not_found",
64 | signup_disabled = "signup_disabled",
65 | single_identity_not_deletable = "single_identity_not_deletable",
66 | sms_send_failed = "sms_send_failed",
67 | sso_domain_already_exists = "sso_domain_already_exists",
68 | sso_provider_not_found = "sso_provider_not_found",
69 | too_many_enrolled_mfa_factors = "too_many_enrolled_mfa_factors",
70 | unexpected_audience = "unexpected_audience",
71 | unexpected_failure = "unexpected_failure",
72 | user_already_exists = "user_already_exists",
73 | user_banned = "user_banned",
74 | user_not_found = "user_not_found",
75 | user_sso_managed = "user_sso_managed",
76 | validation_failed = "validation_failed",
77 | weak_password = "weak_password",
78 | }
--------------------------------------------------------------------------------
/src/lib/supabase/bucket.ts:
--------------------------------------------------------------------------------
1 | export enum SUPABASE_BUCKET {
2 | ProfilePictures = "profile-pictures"
3 | }
--------------------------------------------------------------------------------
/src/lib/supabase/client.ts:
--------------------------------------------------------------------------------
1 | import {
2 | createBrowserClient
3 | } from "@supabase/ssr";
4 | import { createClient as createDefaultClient } from "@supabase/supabase-js";
5 |
6 | function createClient() {
7 | const supabase = createBrowserClient(
8 | process.env.NEXT_PUBLIC_SUPABASE_URL!,
9 | process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
10 | );
11 |
12 | return supabase;
13 | }
14 |
15 | export const supabaseDefaultClient = createDefaultClient(
16 | process.env.NEXT_PUBLIC_SUPABASE_URL!,
17 | process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
18 | );
19 |
20 | export const supabase = createClient();
21 |
--------------------------------------------------------------------------------
/src/lib/supabase/server.ts:
--------------------------------------------------------------------------------
1 | import {
2 | createServerClient,
3 | serializeCookieHeader
4 | } from "@supabase/ssr";
5 | import { createClient as createDefaultClient } from "@supabase/supabase-js";
6 | import { type GetServerSidePropsContext } from "next";
7 |
8 | export function createSSRClient(ctx: {
9 | req: GetServerSidePropsContext["req"];
10 | res: GetServerSidePropsContext["res"];
11 | }) {
12 | const { req, res } = ctx;
13 |
14 | const supabase = createServerClient(
15 | process.env.NEXT_PUBLIC_SUPABASE_URL!,
16 | process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
17 | {
18 | cookies: {
19 | getAll() {
20 | return Object.keys(req.cookies).map((name) => ({
21 | name,
22 | value: req.cookies[name] ?? "",
23 | }));
24 | },
25 | setAll(cookiesToSet) {
26 | res.setHeader(
27 | "Set-Cookie",
28 | cookiesToSet.map(({ name, value, options }) =>
29 | serializeCookieHeader(name, value, options),
30 | ),
31 | );
32 | },
33 | },
34 | },
35 | );
36 |
37 | return supabase;
38 | }
39 |
40 | export const supabaseAdminClient = createDefaultClient(
41 | process.env.NEXT_PUBLIC_SUPABASE_URL!,
42 | process.env.SUPABASE_SERVICE_ROLE_KEY!,
43 | );
44 |
--------------------------------------------------------------------------------
/src/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { clsx, type ClassValue } from "clsx"
2 | import { twMerge } from "tailwind-merge"
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs))
6 | }
7 |
--------------------------------------------------------------------------------
/src/pages/_app.tsx:
--------------------------------------------------------------------------------
1 | import { GeistSans } from "geist/font/sans";
2 | import { type AppType } from "next/app";
3 |
4 | import { api } from "~/utils/api";
5 |
6 | import "~/styles/globals.css";
7 | import { ThemeProvider } from "~/components/theme-provider";
8 | import { Toaster } from "~/components/ui/sonner";
9 |
10 | const MyApp: AppType = ({ Component, pageProps }) => {
11 | return (
12 |
18 |
19 |
20 |
21 |
22 |
23 | );
24 | };
25 |
26 | export default api.withTRPC(MyApp);
27 |
--------------------------------------------------------------------------------
/src/pages/api/trpc/[trpc].ts:
--------------------------------------------------------------------------------
1 | import { createNextApiHandler } from "@trpc/server/adapters/next";
2 |
3 | import { env } from "~/env";
4 | import { appRouter } from "~/server/api/root";
5 | import { createTRPCContext } from "~/server/api/trpc";
6 |
7 | // export API handler
8 | export default createNextApiHandler({
9 | router: appRouter,
10 | createContext: createTRPCContext,
11 | onError:
12 | env.NODE_ENV === "development"
13 | ? ({ path, error }) => {
14 | console.error(
15 | `❌ tRPC failed on ${path ?? ""}: ${error.message}`
16 | );
17 | }
18 | : undefined,
19 | });
20 |
--------------------------------------------------------------------------------
/src/pages/index.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from "~/components/ui/button";
2 | import { Moon, Sun } from "lucide-react";
3 | import { useTheme } from "next-themes";
4 | import { supabase } from "~/lib/supabase/client";
5 |
6 | export default function Home() {
7 | const { setTheme } = useTheme();
8 |
9 | const handleLogout = async () => {
10 | await supabase.auth.signOut();
11 | alert("logout ")
12 | };
13 |
14 | return (
15 | <>
16 |
17 | Hello World
18 |
19 |
22 |
25 |
28 |
29 | >
30 | );
31 | }
32 |
--------------------------------------------------------------------------------
/src/pages/login.tsx:
--------------------------------------------------------------------------------
1 | export { default } from "~/features/auth/pages/LoginPage";
--------------------------------------------------------------------------------
/src/pages/profile.tsx:
--------------------------------------------------------------------------------
1 | export { default } from "~/features/profile/pages/ProfilePage";
--------------------------------------------------------------------------------
/src/pages/register.tsx:
--------------------------------------------------------------------------------
1 | export { default } from '~/features/auth/pages/RegisterPage'
--------------------------------------------------------------------------------
/src/schemas/auth.ts:
--------------------------------------------------------------------------------
1 | import { z } from "zod";
2 |
3 | export const passwordSchema = z
4 | .string({ message: "Password wajib diisi" })
5 | .min(8, { message: "Password minimal 8 karakter" })
6 | .regex(/[a-z]/,{ message: "Password minimal 1 huruf kecil"})
7 | .regex(/[A-Z]/, { message: "Password minimal 1 huruf besar"})
8 | .regex(/[0-9]/, { message: "Password minimal 1 angka"});
9 |
10 | export const emailSchema = z
11 | .string({ message: "Email wajib diisi" })
12 | .email({ message: "Format email tidak tepat" });
13 |
--------------------------------------------------------------------------------
/src/server/api/root.ts:
--------------------------------------------------------------------------------
1 | import { createCallerFactory, createTRPCRouter } from "~/server/api/trpc";
2 | import { authRouter } from "./routers/auth";
3 | import { profileRouter } from "./routers/profile";
4 |
5 | /**
6 | * This is the primary router for your server.
7 | *
8 | * All routers added in /api/routers should be manually added here.
9 | */
10 | export const appRouter = createTRPCRouter({
11 | auth: authRouter,
12 | profile: profileRouter
13 | });
14 |
15 | // export type definition of API
16 | export type AppRouter = typeof appRouter;
17 |
18 | /**
19 | * Create a server-side caller for the tRPC API.
20 | * @example
21 | * const trpc = createCaller(createContext);
22 | * const res = await trpc.post.all();
23 | * ^? Post[]
24 | */
25 | export const createCaller = createCallerFactory(appRouter);
26 |
--------------------------------------------------------------------------------
/src/server/api/routers/auth.ts:
--------------------------------------------------------------------------------
1 | import { z } from "zod";
2 | import { supabaseAdminClient } from "~/lib/supabase/server";
3 | import { passwordSchema } from "~/schemas/auth";
4 | import { generateFromEmail } from "unique-username-generator";
5 | import { createTRPCRouter, publicProcedure } from "~/server/api/trpc";
6 |
7 | export const authRouter = createTRPCRouter({
8 | register: publicProcedure
9 | .input(
10 | z.object({
11 | email: z.string().email().toLowerCase(),
12 | password: passwordSchema,
13 | }),
14 | )
15 | .mutation(async ({ ctx, input }) => {
16 | const { db } = ctx;
17 | const { email, password } = input;
18 |
19 | await db.$transaction(async (tx) => {
20 | let userId = "";
21 |
22 | try {
23 | const { data, error } =
24 | await supabaseAdminClient.auth.admin.createUser({
25 | email,
26 | password,
27 | });
28 |
29 | if (data.user) {
30 | userId = data.user.id;
31 | }
32 |
33 | if (error) throw error;
34 |
35 | const generatedUsername = generateFromEmail(email);
36 |
37 | await tx.profile.create({
38 | data: {
39 | email,
40 | userId: data.user.id,
41 | username: generatedUsername,
42 | },
43 | });
44 | } catch (error) {
45 | console.log(error);
46 | await supabaseAdminClient.auth.admin.deleteUser(userId);
47 | }
48 | });
49 | }),
50 | });
51 |
--------------------------------------------------------------------------------
/src/server/api/routers/profile.ts:
--------------------------------------------------------------------------------
1 | import { z } from "zod";
2 | import { createTRPCRouter, privateProcedure } from "../trpc";
3 | import { TRPCError } from "@trpc/server";
4 | import { supabase } from "~/lib/supabase/client";
5 | import { supabaseAdminClient } from "~/lib/supabase/server";
6 | import { SUPABASE_BUCKET } from "~/lib/supabase/bucket";
7 |
8 | export const profileRouter = createTRPCRouter({
9 | getProfile: privateProcedure.query(async ({ ctx }) => {
10 | const { db, user } = ctx;
11 |
12 | const profile = await db.profile.findUnique({
13 | where: {
14 | userId: user?.id,
15 | },
16 | select: {
17 | bio: true,
18 | profilePictureUrl: true,
19 | username: true,
20 | },
21 | });
22 |
23 | return profile;
24 | }),
25 |
26 | updateProfile: privateProcedure
27 | .input(
28 | z.object({
29 | // TODO: sanitize username input
30 | username: z.string().min(3).max(16).toLowerCase().optional(),
31 | bio: z.string().max(300).optional(),
32 | }),
33 | )
34 | .mutation(async ({ ctx, input }) => {
35 | const { db, user } = ctx;
36 | const { username, bio } = input;
37 |
38 | if (username) {
39 | const usernameExists = await db.profile.findUnique({
40 | where: {
41 | username,
42 | },
43 | select: {
44 | userId: true,
45 | },
46 | });
47 |
48 | if (usernameExists) {
49 | throw new TRPCError({
50 | code: "UNPROCESSABLE_CONTENT",
51 | message: "USERNAME_USED",
52 | });
53 | }
54 | }
55 |
56 | const updatedUser = await db.profile.update({
57 | where: {
58 | userId: user?.id,
59 | },
60 | data: {
61 | username,
62 | bio,
63 | },
64 | });
65 |
66 | return updatedUser;
67 | }),
68 |
69 | updateProfilePicture: privateProcedure
70 | .input(z.string().base64().optional())
71 | .mutation(async ({ ctx, input }) => {
72 | const { db, user } = ctx;
73 |
74 | const timestamp = new Date().getTime().toString();
75 |
76 | const fileName = `avatar-${user?.id}.jpeg`;
77 |
78 | if (input) {
79 | const buffer = Buffer.from(input, "base64");
80 |
81 | const { data, error } = await supabaseAdminClient.storage
82 | .from(SUPABASE_BUCKET.ProfilePictures)
83 | .upload(fileName, buffer, {
84 | contentType: "image/jpeg",
85 | upsert: true,
86 | });
87 |
88 | if (error) throw error;
89 |
90 | const profilePictureUrl = supabaseAdminClient.storage
91 | .from(SUPABASE_BUCKET.ProfilePictures)
92 | .getPublicUrl(data.path);
93 |
94 | await db.profile.update({
95 | where: {
96 | userId: user?.id,
97 | },
98 | data: {
99 | profilePictureUrl:
100 | profilePictureUrl.data.publicUrl + "?t=" + timestamp,
101 | },
102 | });
103 | }
104 | }),
105 | });
106 |
--------------------------------------------------------------------------------
/src/server/api/trpc.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * YOU PROBABLY DON'T NEED TO EDIT THIS FILE, UNLESS:
3 | * 1. You want to modify request context (see Part 1).
4 | * 2. You want to create a new middleware or type of procedure (see Part 3).
5 | *
6 | * TL;DR - This is where all the tRPC server stuff is created and plugged in. The pieces you will
7 | * need to use are documented accordingly near the end.
8 | */
9 | import { User } from "@supabase/supabase-js";
10 | import { initTRPC, TRPCError } from "@trpc/server";
11 | import { type CreateNextContextOptions } from "@trpc/server/adapters/next";
12 | import superjson from "superjson";
13 | import { ZodError } from "zod";
14 | import { createSSRClient } from "~/lib/supabase/server";
15 |
16 | import { db } from "~/server/db";
17 |
18 | /**
19 | * 1. CONTEXT
20 | *
21 | * This section defines the "contexts" that are available in the backend API.
22 | *
23 | * These allow you to access things when processing a request, like the database, the session, etc.
24 | */
25 |
26 | type CreateContextOptions = {
27 | user: User | null;
28 | };
29 |
30 | /**
31 | * This helper generates the "internals" for a tRPC context. If you need to use it, you can export
32 | * it from here.
33 | *
34 | * Examples of things you may need it for:
35 | * - testing, so we don't have to mock Next.js' req/res
36 | * - tRPC's `createSSGHelpers`, where we don't have req/res
37 | *
38 | * @see https://create.t3.gg/en/usage/trpc#-serverapitrpcts
39 | */
40 | const createInnerTRPCContext = (_opts: CreateContextOptions) => {
41 | return {
42 | db,
43 | user: _opts.user,
44 | };
45 | };
46 |
47 | /**
48 | * This is the actual context you will use in your router. It will be used to process every request
49 | * that goes through your tRPC endpoint.
50 | *
51 | * @see https://trpc.io/docs/context
52 | */
53 | export const createTRPCContext = async (_opts: CreateNextContextOptions) => {
54 | // Dapetin user yang lagi login
55 | const supabaseServerClient = createSSRClient({
56 | req: _opts.req,
57 | res: _opts.res,
58 | });
59 |
60 | const { data } = await supabaseServerClient.auth.getUser();
61 |
62 | return createInnerTRPCContext({
63 | user: data.user,
64 | });
65 | };
66 |
67 | /**
68 | * 2. INITIALIZATION
69 | *
70 | * This is where the tRPC API is initialized, connecting the context and transformer. We also parse
71 | * ZodErrors so that you get typesafety on the frontend if your procedure fails due to validation
72 | * errors on the backend.
73 | */
74 |
75 | const t = initTRPC.context().create({
76 | transformer: superjson,
77 | errorFormatter({ shape, error }) {
78 | return {
79 | ...shape,
80 | data: {
81 | ...shape.data,
82 | zodError:
83 | error.cause instanceof ZodError ? error.cause.flatten() : null,
84 | },
85 | };
86 | },
87 | });
88 |
89 | /**
90 | * Create a server-side caller.
91 | *
92 | * @see https://trpc.io/docs/server/server-side-calls
93 | */
94 | export const createCallerFactory = t.createCallerFactory;
95 |
96 | /**
97 | * 3. ROUTER & PROCEDURE (THE IMPORTANT BIT)
98 | *
99 | * These are the pieces you use to build your tRPC API. You should import these a lot in the
100 | * "/src/server/api/routers" directory.
101 | */
102 |
103 | /**
104 | * This is how you create new routers and sub-routers in your tRPC API.
105 | *
106 | * @see https://trpc.io/docs/router
107 | */
108 | export const createTRPCRouter = t.router;
109 |
110 | /**
111 | * Middleware for timing procedure execution and adding an artificial delay in development.
112 | *
113 | * You can remove this if you don't like it, but it can help catch unwanted waterfalls by simulating
114 | * network latency that would occur in production but not in local development.
115 | */
116 | const timingMiddleware = t.middleware(async ({ next, path }) => {
117 | const start = Date.now();
118 |
119 | if (t._config.isDev) {
120 | // artificial delay in dev
121 | const waitMs = Math.floor(Math.random() * 400) + 100;
122 | await new Promise((resolve) => setTimeout(resolve, waitMs));
123 | }
124 |
125 | const result = await next();
126 |
127 | const end = Date.now();
128 | console.log(`[TRPC] ${path} took ${end - start}ms to execute`);
129 |
130 | return result;
131 | });
132 |
133 | const authMiddleware = t.middleware(async ({ ctx, next }) => {
134 | if (!ctx.user)
135 | throw new TRPCError({ code: "UNAUTHORIZED", message: "user unauthorized" });
136 |
137 | return await next();
138 | });
139 |
140 | /**
141 | * Public (unauthenticated) procedure
142 | *
143 | * This is the base piece you use to build new queries and mutations on your tRPC API. It does not
144 | * guarantee that a user querying is authorized, but you can still access user session data if they
145 | * are logged in.
146 | */
147 | export const publicProcedure = t.procedure.use(timingMiddleware);
148 |
149 | export const privateProcedure = t.procedure.use(authMiddleware);
150 |
--------------------------------------------------------------------------------
/src/server/db.ts:
--------------------------------------------------------------------------------
1 | import { PrismaClient } from "@prisma/client";
2 |
3 | import { env } from "~/env";
4 |
5 | const createPrismaClient = () =>
6 | new PrismaClient({
7 | log:
8 | env.NODE_ENV === "development" ? ["query", "error", "warn"] : ["error"],
9 | });
10 |
11 | const globalForPrisma = globalThis as unknown as {
12 | prisma: ReturnType | undefined;
13 | };
14 |
15 | export const db = globalForPrisma.prisma ?? createPrismaClient();
16 |
17 | if (env.NODE_ENV !== "production") globalForPrisma.prisma = db;
18 |
--------------------------------------------------------------------------------
/src/styles/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 | @layer base {
5 | :root {
6 | --background: 0 0% 100%;
7 | --foreground: 0 0% 3.9%;
8 | --card: 0 0% 100%;
9 | --card-foreground: 0 0% 3.9%;
10 | --popover: 0 0% 100%;
11 | --popover-foreground: 0 0% 3.9%;
12 | --primary: 0 0% 9%;
13 | --primary-foreground: 0 0% 98%;
14 | --secondary: 0 0% 96.1%;
15 | --secondary-foreground: 0 0% 9%;
16 | --muted: 0 0% 96.1%;
17 | --muted-foreground: 0 0% 45.1%;
18 | --accent: 0 0% 96.1%;
19 | --accent-foreground: 0 0% 9%;
20 | --destructive: 0 84.2% 60.2%;
21 | --destructive-foreground: 0 0% 98%;
22 | --border: 0 0% 89.8%;
23 | --input: 0 0% 89.8%;
24 | --ring: 0 0% 3.9%;
25 | --chart-1: 12 76% 61%;
26 | --chart-2: 173 58% 39%;
27 | --chart-3: 197 37% 24%;
28 | --chart-4: 43 74% 66%;
29 | --chart-5: 27 87% 67%;
30 | --radius: 0.5rem
31 | }
32 | .dark {
33 | --background: 0 0% 3.9%;
34 | --foreground: 0 0% 98%;
35 | --card: 0 0% 3.9%;
36 | --card-foreground: 0 0% 98%;
37 | --popover: 0 0% 3.9%;
38 | --popover-foreground: 0 0% 98%;
39 | --primary: 0 0% 98%;
40 | --primary-foreground: 0 0% 9%;
41 | --secondary: 0 0% 14.9%;
42 | --secondary-foreground: 0 0% 98%;
43 | --muted: 0 0% 14.9%;
44 | --muted-foreground: 0 0% 63.9%;
45 | --accent: 0 0% 14.9%;
46 | --accent-foreground: 0 0% 98%;
47 | --destructive: 0 62.8% 30.6%;
48 | --destructive-foreground: 0 0% 98%;
49 | --border: 0 0% 14.9%;
50 | --input: 0 0% 14.9%;
51 | --ring: 0 0% 83.1%;
52 | --chart-1: 220 70% 50%;
53 | --chart-2: 160 60% 45%;
54 | --chart-3: 30 80% 55%;
55 | --chart-4: 280 65% 60%;
56 | --chart-5: 340 75% 55%
57 | }
58 | }
59 | @layer base {
60 | * {
61 | @apply border-border;
62 | }
63 | body {
64 | @apply bg-background text-foreground;
65 | }
66 | }
67 |
--------------------------------------------------------------------------------
/src/utils/api.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * This is the client-side entrypoint for your tRPC API. It is used to create the `api` object which
3 | * contains the Next.js App-wrapper, as well as your type-safe React Query hooks.
4 | *
5 | * We also create a few inference helpers for input and output types.
6 | */
7 | import { httpBatchLink, loggerLink } from "@trpc/client";
8 | import { createTRPCNext } from "@trpc/next";
9 | import { type inferRouterInputs, type inferRouterOutputs } from "@trpc/server";
10 | import superjson from "superjson";
11 |
12 | import { type AppRouter } from "~/server/api/root";
13 |
14 | const getBaseUrl = () => {
15 | if (typeof window !== "undefined") return ""; // browser should use relative url
16 | if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`; // SSR should use vercel url
17 | return `http://localhost:${process.env.PORT ?? 3000}`; // dev SSR should use localhost
18 | };
19 |
20 | /** A set of type-safe react-query hooks for your tRPC API. */
21 | export const api = createTRPCNext({
22 | config() {
23 | return {
24 | /**
25 | * Links used to determine request flow from client to server.
26 | *
27 | * @see https://trpc.io/docs/links
28 | */
29 | links: [
30 | loggerLink({
31 | enabled: (opts) =>
32 | process.env.NODE_ENV === "development" ||
33 | (opts.direction === "down" && opts.result instanceof Error),
34 | }),
35 | httpBatchLink({
36 | /**
37 | * Transformer used for data de-serialization from the server.
38 | *
39 | * @see https://trpc.io/docs/data-transformers
40 | */
41 | transformer: superjson,
42 | url: `${getBaseUrl()}/api/trpc`,
43 | }),
44 | ],
45 | };
46 | },
47 | /**
48 | * Whether tRPC should await queries when server rendering pages.
49 | *
50 | * @see https://trpc.io/docs/nextjs#ssr-boolean-default-false
51 | */
52 | ssr: false,
53 | transformer: superjson,
54 | });
55 |
56 | /**
57 | * Inference helper for inputs.
58 | *
59 | * @example type HelloInput = RouterInputs['example']['hello']
60 | */
61 | export type RouterInputs = inferRouterInputs;
62 |
63 | /**
64 | * Inference helper for outputs.
65 | *
66 | * @example type HelloOutput = RouterOutputs['example']['hello']
67 | */
68 | export type RouterOutputs = inferRouterOutputs;
69 |
--------------------------------------------------------------------------------
/start-database.sh:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 | # Use this script to start a docker container for a local development database
3 |
4 | # TO RUN ON WINDOWS:
5 | # 1. Install WSL (Windows Subsystem for Linux) - https://learn.microsoft.com/en-us/windows/wsl/install
6 | # 2. Install Docker Desktop for Windows - https://docs.docker.com/docker-for-windows/install/
7 | # 3. Open WSL - `wsl`
8 | # 4. Run this script - `./start-database.sh`
9 |
10 | # On Linux and macOS you can run this script directly - `./start-database.sh`
11 |
12 | DB_CONTAINER_NAME="qepo-nextjs-postgres"
13 |
14 | if ! [ -x "$(command -v docker)" ]; then
15 | echo -e "Docker is not installed. Please install docker and try again.\nDocker install guide: https://docs.docker.com/engine/install/"
16 | exit 1
17 | fi
18 |
19 | if ! docker info > /dev/null 2>&1; then
20 | echo "Docker daemon is not running. Please start Docker and try again."
21 | exit 1
22 | fi
23 |
24 | if [ "$(docker ps -q -f name=$DB_CONTAINER_NAME)" ]; then
25 | echo "Database container '$DB_CONTAINER_NAME' already running"
26 | exit 0
27 | fi
28 |
29 | if [ "$(docker ps -q -a -f name=$DB_CONTAINER_NAME)" ]; then
30 | docker start "$DB_CONTAINER_NAME"
31 | echo "Existing database container '$DB_CONTAINER_NAME' started"
32 | exit 0
33 | fi
34 |
35 | # import env variables from .env
36 | set -a
37 | source .env
38 |
39 | DB_PASSWORD=$(echo "$DATABASE_URL" | awk -F':' '{print $3}' | awk -F'@' '{print $1}')
40 | DB_PORT=$(echo "$DATABASE_URL" | awk -F':' '{print $4}' | awk -F'\/' '{print $1}')
41 |
42 | if [ "$DB_PASSWORD" = "password" ]; then
43 | echo "You are using the default database password"
44 | read -p "Should we generate a random password for you? [y/N]: " -r REPLY
45 | if ! [[ $REPLY =~ ^[Yy]$ ]]; then
46 | echo "Please change the default password in the .env file and try again"
47 | exit 1
48 | fi
49 | # Generate a random URL-safe password
50 | DB_PASSWORD=$(openssl rand -base64 12 | tr '+/' '-_')
51 | sed -i -e "s#:password@#:$DB_PASSWORD@#" .env
52 | fi
53 |
54 | docker run -d \
55 | --name $DB_CONTAINER_NAME \
56 | -e POSTGRES_USER="postgres" \
57 | -e POSTGRES_PASSWORD="$DB_PASSWORD" \
58 | -e POSTGRES_DB=qepo-nextjs \
59 | -p "$DB_PORT":5432 \
60 | docker.io/postgres && echo "Database container '$DB_CONTAINER_NAME' was successfully created"
61 |
--------------------------------------------------------------------------------
/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import { type Config } from "tailwindcss";
2 | import { fontFamily } from "tailwindcss/defaultTheme";
3 |
4 | export default {
5 | darkMode: ["class"],
6 | content: ["./src/**/*.tsx"],
7 | theme: {
8 | extend: {
9 | container: {
10 | center: true
11 | },
12 | fontFamily: {
13 | sans: ["var(--font-geist-sans)", ...fontFamily.sans],
14 | },
15 | borderRadius: {
16 | lg: "var(--radius)",
17 | md: "calc(var(--radius) - 2px)",
18 | sm: "calc(var(--radius) - 4px)",
19 | },
20 | colors: {
21 | background: "hsl(var(--background))",
22 | foreground: "hsl(var(--foreground))",
23 | card: {
24 | DEFAULT: "hsl(var(--card))",
25 | foreground: "hsl(var(--card-foreground))",
26 | },
27 | popover: {
28 | DEFAULT: "hsl(var(--popover))",
29 | foreground: "hsl(var(--popover-foreground))",
30 | },
31 | primary: {
32 | DEFAULT: "hsl(var(--primary))",
33 | foreground: "hsl(var(--primary-foreground))",
34 | },
35 | secondary: {
36 | DEFAULT: "hsl(var(--secondary))",
37 | foreground: "hsl(var(--secondary-foreground))",
38 | },
39 | muted: {
40 | DEFAULT: "hsl(var(--muted))",
41 | foreground: "hsl(var(--muted-foreground))",
42 | },
43 | accent: {
44 | DEFAULT: "hsl(var(--accent))",
45 | foreground: "hsl(var(--accent-foreground))",
46 | },
47 | destructive: {
48 | DEFAULT: "hsl(var(--destructive))",
49 | foreground: "hsl(var(--destructive-foreground))",
50 | },
51 | border: "hsl(var(--border))",
52 | input: "hsl(var(--input))",
53 | ring: "hsl(var(--ring))",
54 | chart: {
55 | "1": "hsl(var(--chart-1))",
56 | "2": "hsl(var(--chart-2))",
57 | "3": "hsl(var(--chart-3))",
58 | "4": "hsl(var(--chart-4))",
59 | "5": "hsl(var(--chart-5))",
60 | },
61 | },
62 | },
63 | },
64 | // eslint-disable-next-line @typescript-eslint/no-require-imports
65 | plugins: [require("tailwindcss-animate")],
66 | } satisfies Config;
67 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | /* Base Options: */
4 | "esModuleInterop": true,
5 | "skipLibCheck": true,
6 | "target": "es2022",
7 | "allowJs": true,
8 | "resolveJsonModule": true,
9 | "moduleDetection": "force",
10 | "isolatedModules": true,
11 |
12 | /* Strictness */
13 | "strict": true,
14 | "noUncheckedIndexedAccess": true,
15 | "checkJs": true,
16 |
17 | /* Bundled projects */
18 | "lib": ["dom", "dom.iterable", "ES2022"],
19 | "noEmit": true,
20 | "module": "ESNext",
21 | "moduleResolution": "Bundler",
22 | "jsx": "preserve",
23 | "plugins": [{ "name": "next" }],
24 | "incremental": true,
25 |
26 | /* Path Aliases */
27 | "baseUrl": ".",
28 | "paths": {
29 | "~/*": ["./src/*"]
30 | }
31 | },
32 | "include": [
33 | ".eslintrc.cjs",
34 | "next-env.d.ts",
35 | "**/*.ts",
36 | "**/*.tsx",
37 | "**/*.cjs",
38 | "**/*.js",
39 | ".next/types/**/*.ts"
40 | ],
41 | "exclude": ["node_modules"]
42 | }
43 |
--------------------------------------------------------------------------------