├── .eslintrc.json ├── .vscode └── settings.json ├── src ├── app │ ├── [locale] │ │ ├── loaders.ts │ │ ├── signOut │ │ │ └── action.ts │ │ ├── admin │ │ │ └── page.tsx │ │ ├── signIn │ │ │ ├── action.tsx │ │ │ ├── page.tsx │ │ │ └── form.tsx │ │ ├── [id] │ │ │ ├── edit │ │ │ │ ├── action.tsx │ │ │ │ ├── page.tsx │ │ │ │ └── form.tsx │ │ │ ├── delete │ │ │ │ ├── action.tsx │ │ │ │ ├── page.tsx │ │ │ │ └── form.tsx │ │ │ └── page.tsx │ │ ├── register │ │ │ ├── action.tsx │ │ │ ├── page.tsx │ │ │ └── form.tsx │ │ ├── page.tsx │ │ ├── new │ │ │ ├── action.tsx │ │ │ ├── page.tsx │ │ │ └── form.tsx │ │ ├── layout.tsx │ │ ├── pokemons.tsx │ │ ├── pokemon-list-item.tsx │ │ ├── globals.css │ │ └── main-layout.tsx │ ├── favicon.ico │ ├── api │ │ └── auth │ │ │ └── [...nextauth] │ │ │ └── route.ts │ └── i18n.ts ├── db │ ├── schema │ │ ├── index.ts │ │ ├── pokemons.ts │ │ └── users.ts │ ├── migrations │ │ ├── meta │ │ │ ├── _journal.json │ │ │ └── 0000_snapshot.json │ │ └── 0000_amused_stephen_strange.sql │ ├── index.ts │ └── seed.ts ├── i18nConfig.ts ├── lib │ └── utils.ts ├── locales │ ├── en-US │ │ ├── pokemons.json │ │ ├── delete.json │ │ ├── main-layout.json │ │ ├── signIn.json │ │ ├── register.json │ │ ├── edit.json │ │ └── new.json │ └── tr-TR │ │ ├── pokemons.json │ │ ├── delete.json │ │ ├── main-layout.json │ │ ├── register.json │ │ ├── signIn.json │ │ ├── edit.json │ │ └── new.json ├── context │ └── NextAuthProvider.tsx ├── theme │ └── ThemeProvider.tsx ├── middleware.ts ├── components │ ├── SignInMenuItem.tsx │ ├── SignOutMenuItem.tsx │ ├── TranslationsProvider.tsx │ ├── ui │ │ ├── label.tsx │ │ ├── input.tsx │ │ ├── checkbox.tsx │ │ ├── button.tsx │ │ ├── card.tsx │ │ ├── sheet.tsx │ │ ├── form.tsx │ │ ├── select.tsx │ │ └── dropdown-menu.tsx │ ├── ThemeToggleButton.tsx │ └── LanguageChanger.tsx ├── env.mjs └── auth.ts ├── art └── screens.png ├── next.config.mjs ├── postcss.config.mjs ├── .env.sample ├── drizzle.config.ts ├── components.json ├── .gitignore ├── public ├── vercel.svg ├── next.svg └── placeholder.svg ├── db.json ├── tsconfig.json ├── README.md ├── package.json └── tailwind.config.ts /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "i18n-ally.localesPaths": ["src/locales"] 3 | } 4 | -------------------------------------------------------------------------------- /src/app/[locale]/loaders.ts: -------------------------------------------------------------------------------- 1 | export const API_URL = "http://localhost:3001"; 2 | -------------------------------------------------------------------------------- /src/db/schema/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./pokemons"; 2 | export * from "./users"; 3 | -------------------------------------------------------------------------------- /art/screens.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ozcanzaferayan/next-workshop/HEAD/art/screens.png -------------------------------------------------------------------------------- /src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ozcanzaferayan/next-workshop/HEAD/src/app/favicon.ico -------------------------------------------------------------------------------- /src/i18nConfig.ts: -------------------------------------------------------------------------------- 1 | const i18nConfig = { 2 | locales: ["en-US", "tr-TR"], 3 | defaultLocale: "en-US", 4 | }; 5 | 6 | export default i18nConfig; 7 | -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | import "./src/env.mjs"; 2 | 3 | /** @type {import('next').NextConfig} */ 4 | const nextConfig = {}; 5 | 6 | export default nextConfig; 7 | -------------------------------------------------------------------------------- /src/app/[locale]/signOut/action.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | import { signOut } from "@/auth"; 3 | 4 | export async function signOutAction() { 5 | await signOut(); 6 | } 7 | -------------------------------------------------------------------------------- /src/app/api/auth/[...nextauth]/route.ts: -------------------------------------------------------------------------------- 1 | import { handlers } from "@/auth"; // Referring to the auth.ts we just created 2 | export const { GET, POST } = handlers; 3 | -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('postcss-load-config').Config} */ 2 | const config = { 3 | plugins: { 4 | tailwindcss: {}, 5 | }, 6 | }; 7 | 8 | export default config; 9 | -------------------------------------------------------------------------------- /.env.sample: -------------------------------------------------------------------------------- 1 | # Do not expose your Neon credentials to the browser 2 | 3 | PGHOST='yourhost.eu-central-1.aws.neon.tech' 4 | PGDATABASE='yourdb' 5 | PGUSER='yourdb_owner' 6 | PGPASSWORD='yourpassword' 7 | -------------------------------------------------------------------------------- /src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { type ClassValue, clsx } from "clsx" 2 | import { twMerge } from "tailwind-merge" 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)) 6 | } 7 | -------------------------------------------------------------------------------- /src/locales/en-US/pokemons.json: -------------------------------------------------------------------------------- 1 | { 2 | "card": { 3 | "title": "Pokemons", 4 | "description": "You have {{count}} pokemons in Pokedex." 5 | }, 6 | "button": { 7 | "add_new": "Add new" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/locales/tr-TR/pokemons.json: -------------------------------------------------------------------------------- 1 | { 2 | "card": { 3 | "title": "Pokemonlar", 4 | "description": "Pokedex'te {{count}} adet pokemonunuz var." 5 | }, 6 | "button": { 7 | "add_new": "Yeni Ekle" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/app/[locale]/admin/page.tsx: -------------------------------------------------------------------------------- 1 | import { auth } from "@/auth"; 2 | 3 | const AdminPage = async () => { 4 | const session = await auth(); 5 | if (!session) return
Not authenticated
; 6 | 7 | return

Secret page

; 8 | }; 9 | 10 | export default AdminPage; 11 | -------------------------------------------------------------------------------- /src/db/migrations/meta/_journal.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "5", 3 | "dialect": "pg", 4 | "entries": [ 5 | { 6 | "idx": 0, 7 | "version": "5", 8 | "when": 1714925052625, 9 | "tag": "0000_amused_stephen_strange", 10 | "breakpoints": true 11 | } 12 | ] 13 | } -------------------------------------------------------------------------------- /drizzle.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "drizzle-kit"; 2 | import { env } from "./src/env.mjs"; 3 | 4 | export default { 5 | schema: "./src/db/schema", 6 | driver: "pg", 7 | out: "./src/db/migrations", 8 | dbCredentials: { 9 | connectionString: env.DATABASE_URL, 10 | }, 11 | } satisfies Config; 12 | -------------------------------------------------------------------------------- /src/db/schema/pokemons.ts: -------------------------------------------------------------------------------- 1 | import { pgTable, serial, text } from "drizzle-orm/pg-core"; 2 | 3 | export const pokemons = pgTable("pokemons", { 4 | id: serial("id").primaryKey(), 5 | name: text("name").notNull(), 6 | type: text("type").notNull(), 7 | }); 8 | 9 | export type Pokemon = typeof pokemons.$inferSelect; 10 | -------------------------------------------------------------------------------- /src/db/schema/users.ts: -------------------------------------------------------------------------------- 1 | import { pgTable, serial, text, varchar } from "drizzle-orm/pg-core"; 2 | 3 | export const users = pgTable("users", { 4 | id: serial("id").primaryKey(), 5 | username: text("username"), 6 | email: varchar("email", { length: 256 }), 7 | }); 8 | 9 | export type User = typeof users.$inferSelect; 10 | -------------------------------------------------------------------------------- /src/context/NextAuthProvider.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { SessionProvider } from "next-auth/react"; 4 | import { ReactNode } from "react"; 5 | 6 | export default function NextAuthProvider({ 7 | children, 8 | }: { 9 | children: ReactNode; 10 | }) { 11 | return {children}; 12 | } 13 | -------------------------------------------------------------------------------- /src/locales/en-US/delete.json: -------------------------------------------------------------------------------- 1 | { 2 | "form": { 3 | "title": "Delete {{pokemonName}}?", 4 | "description": "Are you sure you want to delete {{pokemonName}}? This action cannot be undone.", 5 | "buttons": { 6 | "cancel": "Cancel", 7 | "delete": "Delete", 8 | "pending": "Pending" 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/locales/tr-TR/delete.json: -------------------------------------------------------------------------------- 1 | { 2 | "form": { 3 | "title": "{{pokemonName}} silinsin mi?", 4 | "description": "{{pokemonName}} silmek istediğinizden emin misiniz? Bu işlem geri alınamaz.", 5 | "buttons": { 6 | "cancel": "İptal", 7 | "delete": "Sil", 8 | "pending": "Bekleniyor" 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/theme/ThemeProvider.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { ThemeProvider as NextThemesProvider } from "next-themes"; 4 | import { type ThemeProviderProps } from "next-themes/dist/types"; 5 | 6 | export function ThemeProvider({ children, ...props }: ThemeProviderProps) { 7 | return {children}; 8 | } 9 | -------------------------------------------------------------------------------- /src/db/migrations/0000_amused_stephen_strange.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS "pokemons" ( 2 | "id" serial PRIMARY KEY NOT NULL, 3 | "name" text NOT NULL, 4 | "type" text NOT NULL 5 | ); 6 | --> statement-breakpoint 7 | CREATE TABLE IF NOT EXISTS "users" ( 8 | "id" serial PRIMARY KEY NOT NULL, 9 | "username" text, 10 | "email" varchar(256) 11 | ); 12 | -------------------------------------------------------------------------------- /src/app/[locale]/signIn/action.tsx: -------------------------------------------------------------------------------- 1 | "use server"; 2 | import { signIn } from "@/auth"; 3 | import { redirect } from "next/navigation"; 4 | 5 | export async function signInAction( 6 | prevState: any, 7 | user: { 8 | email: string; 9 | password: string; 10 | } 11 | ) { 12 | await signIn("credentials", user); 13 | 14 | redirect("/"); 15 | } 16 | -------------------------------------------------------------------------------- /src/middleware.ts: -------------------------------------------------------------------------------- 1 | import { i18nRouter } from "next-i18n-router"; 2 | import { NextRequest } from "next/server"; 3 | import i18nConfig from "./i18nConfig"; 4 | 5 | export function middleware(request: NextRequest) { 6 | return i18nRouter(request, i18nConfig); 7 | } 8 | 9 | export const config = { 10 | matcher: "/((?!api|static|.*\\..*|_next).*)", 11 | }; 12 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "default", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "src/app/globals.css", 9 | "baseColor": "slate", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils" 16 | } 17 | } -------------------------------------------------------------------------------- /src/locales/en-US/main-layout.json: -------------------------------------------------------------------------------- 1 | { 2 | "navigation": { 3 | "toggle_menu": "Toggle navigation menu", 4 | "toggle_user_menu": "Toggle user menu" 5 | }, 6 | "links": { 7 | "acme_inc": "Acme Inc", 8 | "pokemons": "Pokemons", 9 | "add": "Add" 10 | }, 11 | "user_menu": { 12 | "my_account": "My Account", 13 | "settings": "Settings", 14 | "support": "Support", 15 | "signOut": "Logout", 16 | "signIn": "SignIn" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/components/SignInMenuItem.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { DropdownMenuItem } from "@/components/ui/dropdown-menu"; 3 | import Link from "next/link"; 4 | import { useTranslation } from "react-i18next"; 5 | 6 | const SignInMenuItem = () => { 7 | const { t } = useTranslation(); 8 | return ( 9 | 10 | {t("user_menu.signIn")} 11 | 12 | ); 13 | }; 14 | 15 | export default SignInMenuItem; 16 | -------------------------------------------------------------------------------- /src/locales/tr-TR/main-layout.json: -------------------------------------------------------------------------------- 1 | { 2 | "navigation": { 3 | "toggle_menu": "Navigasyon menüsünü aç/kapa", 4 | "toggle_user_menu": "Kullanıcı menüsünü aç/kapa" 5 | }, 6 | "links": { 7 | "acme_inc": "Acme A.Ş.", 8 | "pokemons": "Pokemonlar", 9 | "add": "Ekle" 10 | }, 11 | "user_menu": { 12 | "my_account": "Hesabım", 13 | "settings": "Ayarlar", 14 | "support": "Destek", 15 | "signOut": "Çıkış Yap", 16 | "signIn": "Giriş Yap" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/app/[locale]/[id]/edit/action.tsx: -------------------------------------------------------------------------------- 1 | "use server"; 2 | import { db } from "@/db"; 3 | import { Pokemon, pokemons } from "@/db/schema/pokemons"; 4 | import { eq } from "drizzle-orm"; 5 | import { revalidatePath } from "next/cache"; 6 | import { redirect } from "next/navigation"; 7 | 8 | export async function editPokemon(prevState: any, pokemon: Pokemon) { 9 | await db.update(pokemons).set(pokemon).where(eq(pokemons.id, pokemon.id)); 10 | 11 | revalidatePath("/", "layout"); 12 | redirect("/"); 13 | } 14 | -------------------------------------------------------------------------------- /src/components/SignOutMenuItem.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { signOutAction } from "@/app/[locale]/signOut/action"; 3 | import { DropdownMenuItem } from "@/components/ui/dropdown-menu"; 4 | import { useTranslation } from "react-i18next"; 5 | 6 | const SignOutMenuItem = () => { 7 | const { t } = useTranslation(); 8 | return ( 9 | signOutAction()}> 10 | {t("user_menu.signOut")} 11 | 12 | ); 13 | }; 14 | 15 | export default SignOutMenuItem; 16 | -------------------------------------------------------------------------------- /src/app/[locale]/register/action.tsx: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { API_URL } from "@/app/[locale]/loaders"; 4 | import { signIn } from "@/auth"; 5 | import { redirect } from "next/navigation"; 6 | 7 | export async function registerAction(prevState: any, user: any) { 8 | await fetch(`${API_URL}/register`, { 9 | method: "POST", 10 | headers: { 11 | "Content-Type": "application/json", 12 | }, 13 | body: JSON.stringify(user), 14 | }); 15 | 16 | await signIn("credentials", user); 17 | 18 | redirect("/"); 19 | } 20 | -------------------------------------------------------------------------------- /.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 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | .env 31 | 32 | # vercel 33 | .vercel 34 | 35 | # typescript 36 | *.tsbuildinfo 37 | next-env.d.ts 38 | -------------------------------------------------------------------------------- /src/app/[locale]/page.tsx: -------------------------------------------------------------------------------- 1 | import Pokemons from "@/app/[locale]/pokemons"; 2 | import initTranslations from "@/app/i18n"; 3 | import { db } from "@/db"; 4 | import { pokemons } from "@/db/schema/pokemons"; 5 | 6 | type Props = { 7 | params: { 8 | locale: string; 9 | }; 10 | }; 11 | 12 | const i18nNamespaces = ["pokemons"]; 13 | 14 | const page = async ({ params: { locale } }: Props) => { 15 | const data = await db.select().from(pokemons); 16 | const { t } = await initTranslations(locale, i18nNamespaces); 17 | return ; 18 | }; 19 | 20 | export default page; 21 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/TranslationsProvider.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import initTranslations from "@/app/i18n"; 4 | import { createInstance } from "i18next"; 5 | import { I18nextProvider } from "react-i18next"; 6 | 7 | export default function TranslationsProvider({ 8 | children, 9 | locale, 10 | namespaces, 11 | resources, 12 | }: { 13 | children: React.ReactNode; 14 | locale: string; 15 | namespaces: string[]; 16 | resources: any; 17 | }) { 18 | const i18n = createInstance(); 19 | 20 | initTranslations(locale, namespaces, i18n, resources); 21 | 22 | return {children}; 23 | } 24 | -------------------------------------------------------------------------------- /src/app/[locale]/new/action.tsx: -------------------------------------------------------------------------------- 1 | "use server"; 2 | import { db } from "@/db"; 3 | import { Pokemon, pokemons } from "@/db/schema/pokemons"; 4 | import { revalidatePath } from "next/cache"; 5 | import { redirect } from "next/navigation"; 6 | 7 | export async function newPokemon(prevState: any, pokemon: Pokemon) { 8 | // await fetch(`${API_URL}/pokemons`, { 9 | // method: "POST", 10 | // headers: { 11 | // "Content-Type": "application/json", 12 | // }, 13 | // body: JSON.stringify(pokemon), 14 | // }); 15 | 16 | await db.insert(pokemons).values(pokemon); 17 | 18 | revalidatePath("/", "layout"); 19 | redirect("/"); 20 | } 21 | -------------------------------------------------------------------------------- /src/locales/tr-TR/register.json: -------------------------------------------------------------------------------- 1 | { 2 | "form": { 3 | "title": "Kayıt Ol", 4 | "description": "Hesabınıza giriş yapmak için aşağıya e-postanızı girin.", 5 | "labels": { 6 | "email": "E-posta", 7 | "password": "Şifre" 8 | }, 9 | "placeholders": { 10 | "email": "ornek@ornek.com", 11 | "password": "Şifre" 12 | }, 13 | "buttons": { 14 | "signIn": "Kayıt Ol", 15 | "cancel": "İptal" 16 | }, 17 | "errors": { 18 | "email": "Geçerli bir e-posta adresi girin", 19 | "password_length": "Şifre en az {{min}} karakter olmalıdır", 20 | "password": "Şifre alanı boş bırakılamaz" 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/locales/tr-TR/signIn.json: -------------------------------------------------------------------------------- 1 | { 2 | "form": { 3 | "title": "Giriş Yap", 4 | "description": "Hesabınıza giriş yapmak için aşağıya e-postanızı girin.", 5 | "labels": { 6 | "email": "E-posta", 7 | "password": "Şifre" 8 | }, 9 | "placeholders": { 10 | "email": "ornek@ornek.com", 11 | "password": "Şifre" 12 | }, 13 | "buttons": { 14 | "signIn": "Giriş Yap", 15 | "cancel": "İptal" 16 | }, 17 | "errors": { 18 | "email": "Geçerli bir e-posta adresi girin", 19 | "password_length": "Şifre en az {{min}} karakter olmalıdır", 20 | "password": "Şifre alanı boş bırakılamaz" 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/locales/en-US/signIn.json: -------------------------------------------------------------------------------- 1 | { 2 | "form": { 3 | "title": "SignIn", 4 | "description": "Enter your email below to signIn to your account.", 5 | "labels": { 6 | "email": "Email", 7 | "password": "Password" 8 | }, 9 | "placeholders": { 10 | "email": "m@example.com", 11 | "password": "Password" 12 | }, 13 | "buttons": { 14 | "signIn": "Sign in", 15 | "cancel": "Cancel" 16 | }, 17 | "errors": { 18 | "email": "Please enter a valid email address", 19 | "password": "Please enter a password", 20 | "password_length": "Password must be at least {{min}} characters long" 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/locales/en-US/register.json: -------------------------------------------------------------------------------- 1 | { 2 | "form": { 3 | "title": "Register", 4 | "description": "Enter your email below to signIn to your account.", 5 | "labels": { 6 | "email": "Email", 7 | "password": "Password" 8 | }, 9 | "placeholders": { 10 | "email": "m@example.com", 11 | "password": "Password" 12 | }, 13 | "buttons": { 14 | "signIn": "Register", 15 | "cancel": "Cancel" 16 | }, 17 | "errors": { 18 | "email": "Please enter a valid email address", 19 | "password": "Please enter a password", 20 | "password_length": "Password must be at least {{min}} characters long" 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /db.json: -------------------------------------------------------------------------------- 1 | { 2 | "users": [ 3 | { 4 | "email": "olivier@mail.com", 5 | "password": "$2a$10$2qbdwaO.bpOv90.lmElwGOydYuUbA55/Msue0BUHQ57oz5ccMEdmO", 6 | "id": 1 7 | }, 8 | { 9 | "email": "zafer@gmail.com", 10 | "password": "$2a$10$LNxTMYdvr3iyf7.vclOHKOBsd1xQkVO7jnAUfuXYbvi0ljzlVxVr.", 11 | "id": 2 12 | } 13 | ], 14 | "pokemons": [ 15 | { 16 | "id": "1", 17 | "name": "Bulbasaur", 18 | "type": "grass" 19 | }, 20 | { 21 | "id": "4", 22 | "name": "Charmander", 23 | "type": "fire" 24 | }, 25 | { 26 | "id": "7", 27 | "name": "Squirtle", 28 | "type": "water" 29 | } 30 | ] 31 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["dom", "dom.iterable", "esnext"], 4 | "allowJs": true, 5 | "skipLibCheck": true, 6 | "strict": true, 7 | "noEmit": true, 8 | "esModuleInterop": true, 9 | "module": "esnext", 10 | "moduleResolution": "bundler", 11 | "resolveJsonModule": true, 12 | "isolatedModules": true, 13 | "jsx": "preserve", 14 | "incremental": true, 15 | "plugins": [ 16 | { 17 | "name": "next" 18 | } 19 | ], 20 | "paths": { 21 | "@/*": ["./src/*"] 22 | } 23 | }, 24 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 25 | "exclude": ["node_modules"] 26 | } 27 | -------------------------------------------------------------------------------- /src/db/index.ts: -------------------------------------------------------------------------------- 1 | let { PGHOST, PGDATABASE, PGUSER, PGPASSWORD } = process.env; 2 | import { env } from "@/env.mjs"; 3 | import { neon } from "@neondatabase/serverless"; 4 | import { type Logger } from "drizzle-orm/logger"; 5 | import { drizzle } from "drizzle-orm/neon-http"; 6 | 7 | class QueryLogger implements Logger { 8 | logQuery(query: string, params: unknown[]): void { 9 | console.debug("___QUERY___"); 10 | console.debug(query); 11 | console.debug(params); 12 | console.debug("___END_QUERY___"); 13 | } 14 | } 15 | 16 | const sql = neon(env.DATABASE_URL); 17 | 18 | const db = drizzle(sql, { logger: new QueryLogger() }); 19 | 20 | export * from "./schema"; 21 | 22 | export { db }; 23 | -------------------------------------------------------------------------------- /src/app/[locale]/new/page.tsx: -------------------------------------------------------------------------------- 1 | import NewPokemonForm from "@/app/[locale]/new/form"; 2 | import initTranslations from "@/app/i18n"; 3 | import TranslationsProvider from "@/components/TranslationsProvider"; 4 | 5 | type Props = { 6 | params: { 7 | locale: string; 8 | }; 9 | }; 10 | 11 | const i18nNamespaces = ["new"]; 12 | 13 | const page = async ({ params: { locale } }: Props) => { 14 | const { resources } = await initTranslations(locale, i18nNamespaces); 15 | return ( 16 | 21 | 22 | 23 | ); 24 | }; 25 | 26 | export default page; 27 | -------------------------------------------------------------------------------- /src/components/ui/label.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as LabelPrimitive from "@radix-ui/react-label" 5 | import { cva, type VariantProps } from "class-variance-authority" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const labelVariants = cva( 10 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" 11 | ) 12 | 13 | const Label = React.forwardRef< 14 | React.ElementRef, 15 | React.ComponentPropsWithoutRef & 16 | VariantProps 17 | >(({ className, ...props }, ref) => ( 18 | 23 | )) 24 | Label.displayName = LabelPrimitive.Root.displayName 25 | 26 | export { Label } 27 | -------------------------------------------------------------------------------- /src/app/[locale]/[id]/delete/action.tsx: -------------------------------------------------------------------------------- 1 | "use server"; 2 | import { db } from "@/db"; 3 | import { pokemons } from "@/db/schema/pokemons"; 4 | import { eq } from "drizzle-orm"; 5 | import { revalidatePath } from "next/cache"; 6 | import { redirect } from "next/navigation"; 7 | import { z } from "zod"; 8 | 9 | export async function deletePokemon(prevState: any, formData: FormData) { 10 | const schema = z.object({ 11 | id: z.string({ 12 | message: "ID is required", 13 | }), 14 | }); 15 | 16 | const parse = schema.safeParse({ 17 | id: formData.get("id"), 18 | }); 19 | 20 | if (!parse.success) { 21 | return { errors: parse.error.errors }; 22 | } 23 | 24 | const pokemon = parse.data; 25 | 26 | await db.delete(pokemons).where(eq(pokemons.id, parseInt(pokemon.id))); 27 | 28 | revalidatePath("/", "layout"); 29 | redirect("/"); 30 | } 31 | -------------------------------------------------------------------------------- /src/app/[locale]/signIn/page.tsx: -------------------------------------------------------------------------------- 1 | import SignInForm from "@/app/[locale]/signIn/form"; 2 | import initTranslations from "@/app/i18n"; 3 | import { auth } from "@/auth"; 4 | import TranslationsProvider from "@/components/TranslationsProvider"; 5 | import { redirect } from "next/navigation"; 6 | 7 | type Props = { 8 | params: { 9 | locale: string; 10 | }; 11 | }; 12 | 13 | const i18nNamespaces = ["signIn"]; 14 | 15 | const SignIn = async ({ params: { locale } }: Props) => { 16 | const { resources } = await initTranslations(locale, i18nNamespaces); 17 | const session = await auth(); 18 | if (session) { 19 | redirect("/"); 20 | } 21 | return ( 22 | 27 | 28 | 29 | ); 30 | }; 31 | export default SignIn; 32 | -------------------------------------------------------------------------------- /src/app/[locale]/register/page.tsx: -------------------------------------------------------------------------------- 1 | import RegisterForm from "@/app/[locale]/register/form"; 2 | import initTranslations from "@/app/i18n"; 3 | import { auth } from "@/auth"; 4 | import TranslationsProvider from "@/components/TranslationsProvider"; 5 | import { redirect } from "next/navigation"; 6 | 7 | type Props = { 8 | params: { 9 | locale: string; 10 | }; 11 | }; 12 | 13 | const i18nNamespaces = ["register"]; 14 | 15 | const SignIn = async ({ params: { locale } }: Props) => { 16 | const { resources } = await initTranslations(locale, i18nNamespaces); 17 | const session = await auth(); 18 | if (session) { 19 | redirect("/"); 20 | } 21 | return ( 22 | 27 | 28 | 29 | ); 30 | }; 31 | export default SignIn; 32 | -------------------------------------------------------------------------------- /src/components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | export interface InputProps 6 | extends React.InputHTMLAttributes {} 7 | 8 | const Input = React.forwardRef( 9 | ({ className, type, ...props }, ref) => { 10 | return ( 11 | 20 | ) 21 | } 22 | ) 23 | Input.displayName = "Input" 24 | 25 | export { Input } 26 | -------------------------------------------------------------------------------- /src/env.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import { createEnv } from "@t3-oss/env-nextjs"; 3 | import { z } from "zod"; 4 | 5 | export const env = createEnv({ 6 | /* 7 | * Serverside Environment variables, not available on the client. 8 | * Will throw if you access these variables on the client. 9 | */ 10 | server: { 11 | DATABASE_URL: z.string().url(), 12 | AUTH_SECRET: z.string().min(1), 13 | }, 14 | /* 15 | * Environment variables available on the client (and server). 16 | * 17 | * 💡 You'll get type errors if these are not prefixed with NEXT_PUBLIC_. 18 | */ 19 | client: {}, 20 | /* 21 | * Due to how Next.js bundles environment variables on Edge and Client, 22 | * we need to manually destructure them to make sure all are included in bundle. 23 | * 24 | * 💡 You'll get type errors if not all variables from `server` & `client` are included here. 25 | */ 26 | runtimeEnv: { 27 | DATABASE_URL: process.env.DATABASE_URL, 28 | AUTH_SECRET: process.env.AUTH_SECRET, 29 | }, 30 | }); 31 | -------------------------------------------------------------------------------- /src/app/[locale]/[id]/edit/page.tsx: -------------------------------------------------------------------------------- 1 | import EditForm from "@/app/[locale]/[id]/edit/form"; 2 | import initTranslations from "@/app/i18n"; 3 | import TranslationsProvider from "@/components/TranslationsProvider"; 4 | import { db } from "@/db"; 5 | import { pokemons } from "@/db/schema/pokemons"; 6 | import { eq } from "drizzle-orm"; 7 | 8 | type Props = { 9 | params: { 10 | id: string; 11 | locale: string; 12 | }; 13 | }; 14 | 15 | const i18nNamespaces = ["edit"]; 16 | 17 | const Edit = async ({ params: { id, locale } }: Props) => { 18 | const pokemon = ( 19 | await db 20 | .select() 21 | .from(pokemons) 22 | .where(eq(pokemons.id, parseInt(id))) 23 | )[0]; 24 | 25 | const { resources } = await initTranslations(locale, i18nNamespaces); 26 | 27 | return ( 28 | 33 | 34 | 35 | ); 36 | }; 37 | 38 | export default Edit; 39 | -------------------------------------------------------------------------------- /src/app/[locale]/[id]/delete/page.tsx: -------------------------------------------------------------------------------- 1 | import DeleteForm from "@/app/[locale]/[id]/delete/form"; 2 | import initTranslations from "@/app/i18n"; 3 | import TranslationsProvider from "@/components/TranslationsProvider"; 4 | import { db } from "@/db"; 5 | import { pokemons } from "@/db/schema/pokemons"; 6 | import { eq } from "drizzle-orm"; 7 | 8 | export type Props = { 9 | params: { 10 | id: string; 11 | locale: string; 12 | }; 13 | }; 14 | 15 | const i18nNamespaces = ["delete"]; 16 | 17 | const DeletePokemon = async ({ params: { id, locale } }: Props) => { 18 | const { resources } = await initTranslations(locale, i18nNamespaces); 19 | const pokemon = ( 20 | await db 21 | .select() 22 | .from(pokemons) 23 | .where(eq(pokemons.id, parseInt(id))) 24 | )[0]; 25 | 26 | return ( 27 | 32 | 33 | 34 | ); 35 | }; 36 | 37 | export default DeletePokemon; 38 | -------------------------------------------------------------------------------- /src/app/[locale]/[id]/page.tsx: -------------------------------------------------------------------------------- 1 | import PokemonListItem from "@/app/[locale]/pokemon-list-item"; 2 | import { 3 | Card, 4 | CardContent, 5 | CardDescription, 6 | CardHeader, 7 | CardTitle, 8 | } from "@/components/ui/card"; 9 | import { db } from "@/db"; 10 | import { pokemons } from "@/db/schema/pokemons"; 11 | import { eq } from "drizzle-orm"; 12 | 13 | export type Props = { 14 | params: { 15 | id: string; 16 | }; 17 | }; 18 | 19 | const PokemonDetail = async ({ params: { id } }: Props) => { 20 | const pokemon = ( 21 | await db 22 | .select() 23 | .from(pokemons) 24 | .where(eq(pokemons.id, parseInt(id))) 25 | )[0]; 26 | 27 | return ( 28 | 29 | 30 | Pokemon Detail 31 | This is the detail of the Pokemon. 32 | 33 | 34 | 35 | 36 | 37 | ); 38 | }; 39 | 40 | export default PokemonDetail; 41 | -------------------------------------------------------------------------------- /src/locales/en-US/edit.json: -------------------------------------------------------------------------------- 1 | { 2 | "form": { 3 | "title": "Edit pokemon", 4 | "description": "Edit the details of the pokemon", 5 | "labels": { 6 | "pokemon_name": "Pokemon name", 7 | "type": "Type" 8 | }, 9 | "placeholders": { 10 | "enter_pokemon": "Enter a new pokemon", 11 | "select_type": "Select" 12 | }, 13 | "type_options": { 14 | "fire": "Fire", 15 | "water": "Water", 16 | "grass": "Grass", 17 | "electric": "Electric", 18 | "psychic": "Psychic", 19 | "ice": "Ice", 20 | "dragon": "Dragon", 21 | "dark": "Dark", 22 | "fairy": "Fairy", 23 | "fighting": "Fighting", 24 | "flying": "Flying", 25 | "poison": "Poison", 26 | "ground": "Ground", 27 | "rock": "Rock", 28 | "bug": "Bug", 29 | "ghost": "Ghost", 30 | "steel": "Steel", 31 | "normal": "Normal" 32 | }, 33 | "buttons": { 34 | "cancel": "Cancel", 35 | "save": "Save", 36 | "pending": "Pending" 37 | }, 38 | "errors": { 39 | "name_length": "Name must be at least 3 characters", 40 | "select_type": "Please select a type" 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/locales/tr-TR/edit.json: -------------------------------------------------------------------------------- 1 | { 2 | "form": { 3 | "title": "Pokemon Düzenle", 4 | "description": "Pokemon detaylarını düzenleyin", 5 | "labels": { 6 | "pokemon_name": "Pokemon Adı", 7 | "type": "Tür" 8 | }, 9 | "placeholders": { 10 | "enter_pokemon": "Yeni bir pokemon girin", 11 | "select_type": "Seçiniz" 12 | }, 13 | "type_options": { 14 | "fire": "Ateş", 15 | "water": "Su", 16 | "grass": "Çim", 17 | "electric": "Elektrik", 18 | "psychic": "Psişik", 19 | "ice": "Buz", 20 | "dragon": "Ejderha", 21 | "dark": "Karanlık", 22 | "fairy": "Peri", 23 | "fighting": "Dövüş", 24 | "flying": "Uçan", 25 | "poison": "Zehir", 26 | "ground": "Toprak", 27 | "rock": "Kaya", 28 | "bug": "Böcek", 29 | "ghost": "Hayalet", 30 | "steel": "Çelik", 31 | "normal": "Normal" 32 | }, 33 | "buttons": { 34 | "cancel": "İptal", 35 | "save": "Kaydet", 36 | "pending": "Bekleniyor" 37 | }, 38 | "errors": { 39 | "name_length": "Ad en az 3 karakter olmalıdır", 40 | "select_type": "Lütfen bir tür seçiniz" 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/locales/en-US/new.json: -------------------------------------------------------------------------------- 1 | { 2 | "form": { 3 | "title": "Create pokemon", 4 | "description": "Fill the form to create a new pokemon.", 5 | "labels": { 6 | "pokemon_name": "Pokemon name", 7 | "type": "Type" 8 | }, 9 | "placeholders": { 10 | "enter_pokemon": "Enter a new pokemon", 11 | "select_type": "Select" 12 | }, 13 | "type_options": { 14 | "fire": "Fire", 15 | "water": "Water", 16 | "grass": "Grass", 17 | "electric": "Electric", 18 | "psychic": "Psychic", 19 | "ice": "Ice", 20 | "dragon": "Dragon", 21 | "dark": "Dark", 22 | "fairy": "Fairy", 23 | "fighting": "Fighting", 24 | "flying": "Flying", 25 | "poison": "Poison", 26 | "ground": "Ground", 27 | "rock": "Rock", 28 | "bug": "Bug", 29 | "ghost": "Ghost", 30 | "steel": "Steel", 31 | "normal": "Normal" 32 | }, 33 | "buttons": { 34 | "cancel": "Cancel", 35 | "save": "Save", 36 | "pending": "Pending" 37 | }, 38 | "errors": { 39 | "name_length": "Name must be at least 3 characters", 40 | "select_type": "Please select a type" 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/locales/tr-TR/new.json: -------------------------------------------------------------------------------- 1 | { 2 | "form": { 3 | "title": "Pokemon Oluştur", 4 | "description": "Yeni bir pokemon oluşturmak için formu doldurun.", 5 | "labels": { 6 | "pokemon_name": "Pokemon Adı", 7 | "type": "Tür" 8 | }, 9 | "placeholders": { 10 | "enter_pokemon": "Yeni bir pokemon girin", 11 | "select_type": "Seçiniz" 12 | }, 13 | "type_options": { 14 | "fire": "Ateş", 15 | "water": "Su", 16 | "grass": "Çim", 17 | "electric": "Elektrik", 18 | "psychic": "Psişik", 19 | "ice": "Buz", 20 | "dragon": "Ejderha", 21 | "dark": "Karanlık", 22 | "fairy": "Peri", 23 | "fighting": "Dövüş", 24 | "flying": "Uçan", 25 | "poison": "Zehir", 26 | "ground": "Toprak", 27 | "rock": "Kaya", 28 | "bug": "Böcek", 29 | "ghost": "Hayalet", 30 | "steel": "Çelik", 31 | "normal": "Normal" 32 | }, 33 | "buttons": { 34 | "cancel": "İptal", 35 | "save": "Kaydet", 36 | "pending": "Bekleniyor" 37 | }, 38 | "errors": { 39 | "name_length": "Ad en az 3 karakter olmalıdır", 40 | "select_type": "Lütfen bir tür seçiniz" 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/app/[locale]/layout.tsx: -------------------------------------------------------------------------------- 1 | import MainLayout from "@/app/[locale]/main-layout"; 2 | import NextAuthProvider from "@/context/NextAuthProvider"; 3 | import { ThemeProvider } from "@/theme/ThemeProvider"; 4 | import type { Metadata } from "next"; 5 | import { Inter } from "next/font/google"; 6 | import "./globals.css"; 7 | 8 | const inter = Inter({ subsets: ["latin"] }); 9 | 10 | export const metadata: Metadata = { 11 | title: "Create Next App", 12 | description: "Generated by create next app", 13 | }; 14 | 15 | export default function RootLayout({ 16 | children, 17 | params: { locale }, 18 | }: Readonly<{ 19 | children: React.ReactNode; 20 | params: { 21 | locale: string; 22 | }; 23 | }>) { 24 | return ( 25 | 26 | 27 | 33 | 34 | {children} 35 | 36 | 37 | 38 | 39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /src/components/ui/checkbox.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as CheckboxPrimitive from "@radix-ui/react-checkbox" 5 | import { Check } from "lucide-react" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const Checkbox = React.forwardRef< 10 | React.ElementRef, 11 | React.ComponentPropsWithoutRef 12 | >(({ className, ...props }, ref) => ( 13 | 21 | 24 | 25 | 26 | 27 | )) 28 | Checkbox.displayName = CheckboxPrimitive.Root.displayName 29 | 30 | export { Checkbox } 31 | -------------------------------------------------------------------------------- /src/db/seed.ts: -------------------------------------------------------------------------------- 1 | import { faker } from "@faker-js/faker"; 2 | import { neon } from "@neondatabase/serverless"; 3 | import * as dotenv from "dotenv"; 4 | import { drizzle } from "drizzle-orm/neon-http"; 5 | import { Pokemon, pokemons, users } from "./schema"; 6 | 7 | dotenv.config({ path: "./.env" }); 8 | 9 | const seed = async () => { 10 | const sql = neon(process.env.DATABASE_URL as string); 11 | const db = drizzle(sql); 12 | 13 | const usersData = Array.from({ length: 20 }).map(() => ({ 14 | username: faker.internet.userName(), 15 | email: faker.internet.email(), 16 | })); 17 | 18 | const pokemonsData: Pokemon[] = [ 19 | { 20 | id: 1, 21 | name: "Bulbasaur", 22 | type: "grass", 23 | }, 24 | { 25 | id: 4, 26 | name: "Charmander", 27 | type: "fire", 28 | }, 29 | { 30 | id: 7, 31 | name: "Squirtle", 32 | type: "water", 33 | }, 34 | ]; 35 | 36 | console.log("Seed start"); 37 | let res = await db.insert(users).values(usersData); 38 | console.log(`Inserted ${res.rowCount} users`); 39 | res = await db.insert(pokemons).values(pokemonsData); 40 | console.log(`Inserted ${res.rowCount} pokemons`); 41 | console.log("Seed done"); 42 | }; 43 | 44 | seed(); 45 | -------------------------------------------------------------------------------- /src/app/i18n.ts: -------------------------------------------------------------------------------- 1 | import i18nConfig from "@/i18nConfig"; 2 | import { createInstance, i18n } from "i18next"; 3 | import resourcesToBackend from "i18next-resources-to-backend"; 4 | import { initReactI18next } from "react-i18next/initReactI18next"; 5 | 6 | export default async function initTranslations( 7 | locale: string, 8 | namespaces: string[], 9 | i18nInstance?: i18n, 10 | resources?: Record> 11 | ) { 12 | i18nInstance = i18nInstance || createInstance(); 13 | 14 | i18nInstance.use(initReactI18next); 15 | 16 | if (!resources) { 17 | i18nInstance.use( 18 | resourcesToBackend( 19 | (language: string, namespace: string) => 20 | import(`@/locales/${language}/${namespace}.json`) 21 | ) 22 | ); 23 | } 24 | 25 | await i18nInstance.init({ 26 | lng: locale, 27 | resources, 28 | fallbackLng: i18nConfig.defaultLocale, 29 | supportedLngs: i18nConfig.locales, 30 | defaultNS: namespaces[0], 31 | fallbackNS: namespaces[0], 32 | ns: namespaces, 33 | preload: resources ? [] : i18nConfig.locales, 34 | }); 35 | 36 | return { 37 | i18n: i18nInstance, 38 | resources: i18nInstance.services.resourceStore.data, 39 | t: i18nInstance.t, 40 | }; 41 | } 42 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Next Workshop 2 | 3 | ![Screens](art/screens.png) 4 | 5 | # Commands 6 | 7 | ```bash 8 | pnpm i 9 | pnpm dev 10 | ``` 11 | 12 | # Features 13 | 14 | - [x] [App router navigation](https://nextjs.org) 15 | - [x] [Shadcn components](https://ui.shadcn.com/docs/installation/next) 16 | - [x] TailwindCSS 17 | - [x] [JSON Server](https://github.com/typicode/json-server) 18 | - [x] CRUD operations 19 | - [x] [Server Actions and Mutations](https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions-and-mutations) 20 | - [x] [Cache revalidation](https://nextjs.org/docs/app/building-your-application/data-fetching/fetching-caching-and-revalidating) 21 | - [x] [Form validations](https://ui.shadcn.com/docs/components/form) 22 | - [x] [Next Theme](https://ui.shadcn.com/docs/dark-mode/next) 23 | - [x] [i18n](https://i18nexus.com/tutorials/nextjs/react-i18next) 24 | - [x] [Next Auth](https://authjs.dev/getting-started/installation?framework=next.js) 25 | - [ ] Drizzle 26 | - [ ] Corepack 27 | - [ ] VSCode Extensions (e.g. i18n-ally) 28 | - [ ] .env instead .env.sample 29 | - [ ] db.json to data/db.json 30 | - [ ] Create NEXT_DATA_SOURCE and use Strategy pattern to fetch data 31 | - [ ] Create connectionString (drizzle and db) into one file db/conns.ts 32 | -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/ThemeToggleButton.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Moon, Sun } from "lucide-react"; 4 | import { useTheme } from "next-themes"; 5 | 6 | import { Button } from "@/components/ui/button"; 7 | import { 8 | DropdownMenu, 9 | DropdownMenuContent, 10 | DropdownMenuItem, 11 | DropdownMenuTrigger, 12 | } from "@/components/ui/dropdown-menu"; 13 | 14 | export function ThemeToggleButton() { 15 | const { setTheme } = useTheme(); 16 | 17 | return ( 18 | 19 | 20 | 25 | 26 | 27 | setTheme("light")}> 28 | Light 29 | 30 | setTheme("dark")}> 31 | Dark 32 | 33 | setTheme("system")}> 34 | System 35 | 36 | 37 | 38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /src/app/[locale]/pokemons.tsx: -------------------------------------------------------------------------------- 1 | import PokemonListItem from "@/app/[locale]/pokemon-list-item"; 2 | import { Button } from "@/components/ui/button"; 3 | import { 4 | Card, 5 | CardContent, 6 | CardDescription, 7 | CardFooter, 8 | CardHeader, 9 | CardTitle, 10 | } from "@/components/ui/card"; 11 | import { Pokemon } from "@/db/schema/pokemons"; 12 | import { cn } from "@/lib/utils"; 13 | import { PlusIcon } from "lucide-react"; 14 | import Link from "next/link"; 15 | 16 | type Props = { 17 | t: (key: string, values?: Record) => string; 18 | pokemons: Pokemon[]; 19 | }; 20 | 21 | const Pokemons = ({ pokemons, t }: Props) => { 22 | return ( 23 | 24 | 25 | {t("card.title")} 26 | 27 | {t("card.description", { count: pokemons.length })} 28 | 29 | 30 | 31 | {pokemons.map((pokemon) => ( 32 | 33 | ))} 34 | 35 | 36 | 37 | 40 | 41 | 42 | 43 | ); 44 | }; 45 | 46 | export default Pokemons; 47 | -------------------------------------------------------------------------------- /src/app/[locale]/pokemon-list-item.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@/components/ui/button"; 2 | import { Pokemon } from "@/db/schema/pokemons"; 3 | import { EditIcon, TrashIcon } from "lucide-react"; 4 | import Image from "next/image"; 5 | import Link from "next/link"; 6 | 7 | type Props = { 8 | pokemon: Pokemon; 9 | hasActions?: boolean; 10 | }; 11 | 12 | const PokemonListItem = ({ pokemon, hasActions = true }: Props) => { 13 | return ( 14 |
18 |
19 | {pokemon.name} 30 |
31 |

{pokemon.name}

32 |

33 | {pokemon.type} Pokemon 34 |

35 |
36 | {hasActions && ( 37 | <> 38 | 39 | 42 | 43 | 44 | 47 | 48 | 49 | )} 50 |
51 |
52 | ); 53 | }; 54 | 55 | export default PokemonListItem; 56 | -------------------------------------------------------------------------------- /src/db/migrations/meta/0000_snapshot.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "8c1a2b4e-bc3c-4eb2-84ad-bbf0f62208a1", 3 | "prevId": "00000000-0000-0000-0000-000000000000", 4 | "version": "5", 5 | "dialect": "pg", 6 | "tables": { 7 | "pokemons": { 8 | "name": "pokemons", 9 | "schema": "", 10 | "columns": { 11 | "id": { 12 | "name": "id", 13 | "type": "serial", 14 | "primaryKey": true, 15 | "notNull": true 16 | }, 17 | "name": { 18 | "name": "name", 19 | "type": "text", 20 | "primaryKey": false, 21 | "notNull": true 22 | }, 23 | "type": { 24 | "name": "type", 25 | "type": "text", 26 | "primaryKey": false, 27 | "notNull": true 28 | } 29 | }, 30 | "indexes": {}, 31 | "foreignKeys": {}, 32 | "compositePrimaryKeys": {}, 33 | "uniqueConstraints": {} 34 | }, 35 | "users": { 36 | "name": "users", 37 | "schema": "", 38 | "columns": { 39 | "id": { 40 | "name": "id", 41 | "type": "serial", 42 | "primaryKey": true, 43 | "notNull": true 44 | }, 45 | "username": { 46 | "name": "username", 47 | "type": "text", 48 | "primaryKey": false, 49 | "notNull": false 50 | }, 51 | "email": { 52 | "name": "email", 53 | "type": "varchar(256)", 54 | "primaryKey": false, 55 | "notNull": false 56 | } 57 | }, 58 | "indexes": {}, 59 | "foreignKeys": {}, 60 | "compositePrimaryKeys": {}, 61 | "uniqueConstraints": {} 62 | } 63 | }, 64 | "enums": {}, 65 | "schemas": {}, 66 | "_meta": { 67 | "columns": {}, 68 | "schemas": {}, 69 | "tables": {} 70 | } 71 | } -------------------------------------------------------------------------------- /src/app/[locale]/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | :root { 7 | --background: 0 0% 100%; 8 | --foreground: 222.2 84% 4.9%; 9 | 10 | --card: 0 0% 100%; 11 | --card-foreground: 222.2 84% 4.9%; 12 | 13 | --popover: 0 0% 100%; 14 | --popover-foreground: 222.2 84% 4.9%; 15 | 16 | --primary: 222.2 47.4% 11.2%; 17 | --primary-foreground: 210 40% 98%; 18 | 19 | --secondary: 210 40% 96.1%; 20 | --secondary-foreground: 222.2 47.4% 11.2%; 21 | 22 | --muted: 210 40% 96.1%; 23 | --muted-foreground: 215.4 16.3% 46.9%; 24 | 25 | --accent: 210 40% 96.1%; 26 | --accent-foreground: 222.2 47.4% 11.2%; 27 | 28 | --destructive: 0 84.2% 60.2%; 29 | --destructive-foreground: 210 40% 98%; 30 | 31 | --border: 214.3 31.8% 91.4%; 32 | --input: 214.3 31.8% 91.4%; 33 | --ring: 222.2 84% 4.9%; 34 | 35 | --radius: 0.5rem; 36 | } 37 | 38 | .dark { 39 | --background: 222.2 84% 4.9%; 40 | --foreground: 210 40% 98%; 41 | 42 | --card: 222.2 84% 4.9%; 43 | --card-foreground: 210 40% 98%; 44 | 45 | --popover: 222.2 84% 4.9%; 46 | --popover-foreground: 210 40% 98%; 47 | 48 | --primary: 210 40% 98%; 49 | --primary-foreground: 222.2 47.4% 11.2%; 50 | 51 | --secondary: 217.2 32.6% 17.5%; 52 | --secondary-foreground: 210 40% 98%; 53 | 54 | --muted: 217.2 32.6% 17.5%; 55 | --muted-foreground: 215 20.2% 65.1%; 56 | 57 | --accent: 217.2 32.6% 17.5%; 58 | --accent-foreground: 210 40% 98%; 59 | 60 | --destructive: 0 62.8% 30.6%; 61 | --destructive-foreground: 210 40% 98%; 62 | 63 | --border: 217.2 32.6% 17.5%; 64 | --input: 217.2 32.6% 17.5%; 65 | --ring: 212.7 26.8% 83.9%; 66 | } 67 | } 68 | 69 | @layer base { 70 | * { 71 | @apply border-border; 72 | } 73 | body { 74 | @apply bg-background text-foreground; 75 | } 76 | } -------------------------------------------------------------------------------- /src/auth.ts: -------------------------------------------------------------------------------- 1 | import { API_URL } from "@/app/[locale]/loaders"; 2 | import NextAuth, { DefaultSession } from "next-auth"; 3 | import Credentials from "next-auth/providers/credentials"; 4 | import { z } from "zod"; 5 | 6 | declare module "next-auth" { 7 | interface Session { 8 | user: { 9 | email: string; 10 | } & DefaultSession["user"]; 11 | } 12 | } 13 | 14 | export const signInSchema = z.object({ 15 | email: z 16 | .string({ required_error: "Email is required" }) 17 | .min(1, "Email is required") 18 | .email("Invalid email"), 19 | password: z 20 | .string({ required_error: "Password is required" }) 21 | .min(1, "Password is required") 22 | .min(8, "Password must be more than 8 characters") 23 | .max(32, "Password must be less than 32 characters"), 24 | }); 25 | 26 | export const { handlers, signIn, signOut, auth } = NextAuth({ 27 | session: { 28 | strategy: "jwt", 29 | }, 30 | pages: { 31 | signIn: "/signIn", 32 | }, 33 | providers: [ 34 | Credentials({ 35 | credentials: { 36 | email: {}, 37 | password: {}, 38 | }, 39 | authorize: async (credentials) => { 40 | let user = null; 41 | 42 | const { email, password } = await signInSchema.parseAsync(credentials); 43 | 44 | // logic to verify if user exists 45 | const res = await fetch(`${API_URL}/signIn`, { 46 | method: "POST", 47 | headers: { 48 | "Content-Type": "application/json", 49 | }, 50 | body: JSON.stringify({ 51 | email, 52 | password, 53 | }), 54 | }); 55 | 56 | user = (await res.json()).user; 57 | 58 | if (!user) { 59 | // No user found, so this is their first attempt to signIn 60 | // meaning this is also the place you could do registration 61 | throw new Error("User not found."); 62 | } 63 | 64 | // return user object with the their profile data 65 | return user; 66 | }, 67 | }), 68 | ], 69 | }); 70 | -------------------------------------------------------------------------------- /src/components/LanguageChanger.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { usePathname, useRouter } from "next/navigation"; 4 | 5 | import { Button } from "@/components/ui/button"; 6 | import { 7 | DropdownMenu, 8 | DropdownMenuContent, 9 | DropdownMenuItem, 10 | DropdownMenuTrigger, 11 | } from "@/components/ui/dropdown-menu"; 12 | import i18nConfig from "@/i18nConfig"; 13 | import { useTranslation } from "react-i18next"; 14 | 15 | export default function LanguageChanger() { 16 | const { i18n } = useTranslation(); 17 | const currentLocale = i18n.language; 18 | const router = useRouter(); 19 | const currentPathname = usePathname(); 20 | 21 | const handleChange = (newLocale: string) => { 22 | const days = 30; 23 | const date = new Date(); 24 | date.setTime(date.getTime() + days * 24 * 60 * 60 * 1000); 25 | const expires = date.toUTCString(); 26 | document.cookie = `NEXT_LOCALE=${newLocale};expires=${expires};path=/`; 27 | // redirect to the new locale path 28 | if (currentLocale === i18nConfig.defaultLocale) { 29 | router.push("/" + newLocale + currentPathname); 30 | } else { 31 | router.push( 32 | currentPathname.replace(`/${currentLocale}`, `/${newLocale}`) 33 | ); 34 | } 35 | router.refresh(); 36 | }; 37 | 38 | return ( 39 | 40 | 41 | 47 | 48 | 49 | handleChange("en-US")}> 50 | English 51 | 52 | handleChange("tr-TR")}> 53 | Türkçe 54 | 55 | 56 | 57 | ); 58 | } 59 | -------------------------------------------------------------------------------- /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 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", 9 | { 10 | variants: { 11 | variant: { 12 | default: "bg-primary text-primary-foreground hover:bg-primary/90", 13 | destructive: 14 | "bg-destructive text-destructive-foreground hover:bg-destructive/90", 15 | outline: 16 | "border border-input bg-background hover:bg-accent hover:text-accent-foreground", 17 | secondary: 18 | "bg-secondary text-secondary-foreground hover:bg-secondary/80", 19 | ghost: "hover:bg-accent hover:text-accent-foreground", 20 | link: "text-primary underline-offset-4 hover:underline", 21 | }, 22 | size: { 23 | default: "h-10 px-4 py-2", 24 | sm: "h-9 rounded-md px-3", 25 | lg: "h-11 rounded-md px-8", 26 | icon: "h-10 w-10", 27 | }, 28 | }, 29 | defaultVariants: { 30 | variant: "default", 31 | size: "default", 32 | }, 33 | } 34 | ) 35 | 36 | export interface ButtonProps 37 | extends React.ButtonHTMLAttributes, 38 | VariantProps { 39 | asChild?: boolean 40 | } 41 | 42 | const Button = React.forwardRef( 43 | ({ className, variant, size, asChild = false, ...props }, ref) => { 44 | const Comp = asChild ? Slot : "button" 45 | return ( 46 | 51 | ) 52 | } 53 | ) 54 | Button.displayName = "Button" 55 | 56 | export { Button, buttonVariants } 57 | -------------------------------------------------------------------------------- /src/app/[locale]/[id]/delete/form.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import Link from "next/link"; 3 | import { useFormState, useFormStatus } from "react-dom"; 4 | import { useTranslation } from "react-i18next"; 5 | 6 | import { deletePokemon } from "@/app/[locale]/[id]/delete/action"; 7 | import PokemonListItem from "@/app/[locale]/pokemon-list-item"; 8 | import { Button } from "@/components/ui/button"; 9 | import { 10 | Card, 11 | CardContent, 12 | CardDescription, 13 | CardFooter, 14 | CardHeader, 15 | CardTitle, 16 | } from "@/components/ui/card"; 17 | import { Pokemon } from "@/db/schema/pokemons"; 18 | import { TrashIcon } from "lucide-react"; 19 | 20 | type Props = { 21 | pokemon: Pokemon; 22 | }; 23 | 24 | const DeleteButton = () => { 25 | const { t } = useTranslation(); 26 | const { pending } = useFormStatus(); 27 | 28 | return ( 29 | 32 | ); 33 | }; 34 | 35 | const DeleteForm = ({ pokemon }: Props) => { 36 | const { t } = useTranslation(); 37 | const [state, formAction] = useFormState(deletePokemon, null); 38 | 39 | return ( 40 |
41 | 42 | 43 | 44 | {t("form.title", { pokemonName: pokemon.name })} 45 | 46 | 47 | {t("form.description", { pokemonName: pokemon.name })} 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 60 | 61 | 62 | 63 |
64 | ); 65 | }; 66 | 67 | export default DeleteForm; 68 | -------------------------------------------------------------------------------- /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 | HTMLParagraphElement, 34 | React.HTMLAttributes 35 | >(({ className, ...props }, ref) => ( 36 |

44 | )) 45 | CardTitle.displayName = "CardTitle" 46 | 47 | const CardDescription = React.forwardRef< 48 | HTMLParagraphElement, 49 | React.HTMLAttributes 50 | >(({ className, ...props }, ref) => ( 51 |

56 | )) 57 | CardDescription.displayName = "CardDescription" 58 | 59 | const CardContent = React.forwardRef< 60 | HTMLDivElement, 61 | React.HTMLAttributes 62 | >(({ className, ...props }, ref) => ( 63 |

64 | )) 65 | CardContent.displayName = "CardContent" 66 | 67 | const CardFooter = React.forwardRef< 68 | HTMLDivElement, 69 | React.HTMLAttributes 70 | >(({ className, ...props }, ref) => ( 71 |
76 | )) 77 | CardFooter.displayName = "CardFooter" 78 | 79 | export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } 80 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "test-next-auth-2", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "build": "next build", 7 | "start": "next start", 8 | "lint": "next lint", 9 | "db": "json-server-auth --watch db.json --port 3001", 10 | "server": "next dev", 11 | "dev2": "concurrently \"npm run server\" \"npm run db\"", 12 | "dev": "npm run server", 13 | "drizzle:studio": "drizzle-kit studio --verbose", 14 | "drizzle:drop": "drizzle-kit drop", 15 | "drizzle:generate": "drizzle-kit generate:pg", 16 | "drizzle:push": "drizzle-kit push:pg", 17 | "drizzle:seed": "node --loader esbuild-register/loader -r esbuild-register ./src/db/seed.ts" 18 | }, 19 | "dependencies": { 20 | "@hookform/resolvers": "^3.3.4", 21 | "@neondatabase/serverless": "^0.9.1", 22 | "@radix-ui/react-checkbox": "^1.0.4", 23 | "@radix-ui/react-dialog": "^1.0.5", 24 | "@radix-ui/react-dropdown-menu": "^2.0.6", 25 | "@radix-ui/react-label": "^2.0.2", 26 | "@radix-ui/react-select": "^2.0.0", 27 | "@radix-ui/react-slot": "^1.0.2", 28 | "@t3-oss/env-nextjs": "^0.10.1", 29 | "class-variance-authority": "^0.7.0", 30 | "clsx": "^2.1.1", 31 | "drizzle-kit": "^0.20.17", 32 | "drizzle-orm": "^0.30.10", 33 | "i18next": "^23.11.3", 34 | "i18next-resources-to-backend": "^1.2.1", 35 | "lucide-react": "^0.376.0", 36 | "next": "14.2.3", 37 | "next-auth": "5.0.0-beta.17", 38 | "next-i18n-router": "^5.4.2", 39 | "next-themes": "^0.3.0", 40 | "react": "^18", 41 | "react-dom": "^18", 42 | "react-hook-form": "^7.51.3", 43 | "react-i18next": "^14.1.1", 44 | "tailwind-merge": "^2.3.0", 45 | "tailwindcss-animate": "^1.0.7", 46 | "zod": "^3.23.4" 47 | }, 48 | "devDependencies": { 49 | "@faker-js/faker": "^8.4.1", 50 | "@types/node": "^20", 51 | "@types/react": "^18", 52 | "@types/react-dom": "^18", 53 | "concurrently": "^8.2.2", 54 | "dotenv": "^16.4.5", 55 | "esbuild-register": "^3.5.0", 56 | "eslint": "^8", 57 | "eslint-config-next": "14.2.3", 58 | "express": "^4.19.2", 59 | "json-server": "0.17.4", 60 | "json-server-auth": "^2.1.0", 61 | "pg": "^8.11.5", 62 | "postcss": "^8", 63 | "tailwindcss": "^3.4.1", 64 | "ts-node": "^10.9.2", 65 | "tsx": "^4.9.1", 66 | "typescript": "^5" 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "tailwindcss" 2 | 3 | const config = { 4 | darkMode: ["class"], 5 | content: [ 6 | './pages/**/*.{ts,tsx}', 7 | './components/**/*.{ts,tsx}', 8 | './app/**/*.{ts,tsx}', 9 | './src/**/*.{ts,tsx}', 10 | ], 11 | prefix: "", 12 | theme: { 13 | container: { 14 | center: true, 15 | padding: "2rem", 16 | screens: { 17 | "2xl": "1400px", 18 | }, 19 | }, 20 | extend: { 21 | colors: { 22 | border: "hsl(var(--border))", 23 | input: "hsl(var(--input))", 24 | ring: "hsl(var(--ring))", 25 | background: "hsl(var(--background))", 26 | foreground: "hsl(var(--foreground))", 27 | primary: { 28 | DEFAULT: "hsl(var(--primary))", 29 | foreground: "hsl(var(--primary-foreground))", 30 | }, 31 | secondary: { 32 | DEFAULT: "hsl(var(--secondary))", 33 | foreground: "hsl(var(--secondary-foreground))", 34 | }, 35 | destructive: { 36 | DEFAULT: "hsl(var(--destructive))", 37 | foreground: "hsl(var(--destructive-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 | popover: { 48 | DEFAULT: "hsl(var(--popover))", 49 | foreground: "hsl(var(--popover-foreground))", 50 | }, 51 | card: { 52 | DEFAULT: "hsl(var(--card))", 53 | foreground: "hsl(var(--card-foreground))", 54 | }, 55 | }, 56 | borderRadius: { 57 | lg: "var(--radius)", 58 | md: "calc(var(--radius) - 2px)", 59 | sm: "calc(var(--radius) - 4px)", 60 | }, 61 | keyframes: { 62 | "accordion-down": { 63 | from: { height: "0" }, 64 | to: { height: "var(--radix-accordion-content-height)" }, 65 | }, 66 | "accordion-up": { 67 | from: { height: "var(--radix-accordion-content-height)" }, 68 | to: { height: "0" }, 69 | }, 70 | }, 71 | animation: { 72 | "accordion-down": "accordion-down 0.2s ease-out", 73 | "accordion-up": "accordion-up 0.2s ease-out", 74 | }, 75 | }, 76 | }, 77 | plugins: [require("tailwindcss-animate")], 78 | } satisfies Config 79 | 80 | export default config -------------------------------------------------------------------------------- /public/placeholder.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/[locale]/signIn/form.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { signInAction } from "@/app/[locale]/signIn/action"; 3 | import { Button } from "@/components/ui/button"; 4 | import { 5 | Card, 6 | CardContent, 7 | CardDescription, 8 | CardFooter, 9 | CardHeader, 10 | CardTitle, 11 | } from "@/components/ui/card"; 12 | import { 13 | Form, 14 | FormControl, 15 | FormField, 16 | FormItem, 17 | FormLabel, 18 | FormMessage, 19 | } from "@/components/ui/form"; 20 | import { Input } from "@/components/ui/input"; 21 | import { zodResolver } from "@hookform/resolvers/zod"; 22 | import Link from "next/link"; 23 | import { useFormState, useFormStatus } from "react-dom"; 24 | import { useForm } from "react-hook-form"; 25 | import { useTranslation } from "react-i18next"; 26 | import { z } from "zod"; 27 | 28 | const SignInButton = () => { 29 | const { t } = useTranslation(); 30 | const { pending } = useFormStatus(); 31 | 32 | return ( 33 | 36 | ); 37 | }; 38 | 39 | const SignInForm = () => { 40 | const { t } = useTranslation(); 41 | const schema = z.object({ 42 | email: z.string().email(t("form.errors.email")), 43 | password: z.string().min(8, t("form.errors.password_length", { min: 8 })), 44 | }); 45 | 46 | const [state, formAction] = useFormState(signInAction, null); 47 | const form = useForm>({ 48 | resolver: zodResolver(schema), 49 | defaultValues: { 50 | email: "olivier@mail.com", 51 | password: "bestPassw0rd", 52 | }, 53 | }); 54 | return ( 55 |
56 | 57 | 58 | 59 | {t("form.title")} 60 | {t("form.description")} 61 | 62 | 63 |
64 | ( 68 | 69 | {t("form.labels.email")} 70 | 71 | 75 | 76 | 77 | 78 | )} 79 | /> 80 | ( 84 | 85 | {t("form.labels.password")} 86 | 87 | 91 | 92 | 93 | 94 | )} 95 | /> 96 |
97 |
98 | 99 | 100 | 101 | 102 | 103 | 104 |
105 |
106 | 107 | ); 108 | }; 109 | 110 | export default SignInForm; 111 | -------------------------------------------------------------------------------- /src/app/[locale]/register/form.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { registerAction } from "@/app/[locale]/register/action"; 3 | import { Button } from "@/components/ui/button"; 4 | import { 5 | Card, 6 | CardContent, 7 | CardDescription, 8 | CardFooter, 9 | CardHeader, 10 | CardTitle, 11 | } from "@/components/ui/card"; 12 | import { 13 | Form, 14 | FormControl, 15 | FormField, 16 | FormItem, 17 | FormLabel, 18 | FormMessage, 19 | } from "@/components/ui/form"; 20 | import { Input } from "@/components/ui/input"; 21 | import { zodResolver } from "@hookform/resolvers/zod"; 22 | import Link from "next/link"; 23 | import { useFormState, useFormStatus } from "react-dom"; 24 | import { useForm } from "react-hook-form"; 25 | import { useTranslation } from "react-i18next"; 26 | import { z } from "zod"; 27 | 28 | const SignInButton = () => { 29 | const { t } = useTranslation(); 30 | const { pending } = useFormStatus(); 31 | 32 | return ( 33 | 36 | ); 37 | }; 38 | 39 | const RegisterForm = () => { 40 | const { t } = useTranslation(); 41 | const schema = z.object({ 42 | email: z.string().email(t("form.errors.email")), 43 | password: z.string().min(8, t("form.errors.password_length", { min: 8 })), 44 | }); 45 | 46 | const [state, formAction] = useFormState(registerAction, null); 47 | const form = useForm>({ 48 | resolver: zodResolver(schema), 49 | defaultValues: { 50 | email: "zafer@gmail.com", 51 | password: "12345678", 52 | }, 53 | }); 54 | return ( 55 |
56 | 57 | 58 | 59 | {t("form.title")} 60 | {t("form.description")} 61 | 62 | 63 |
64 | ( 68 | 69 | {t("form.labels.email")} 70 | 71 | 75 | 76 | 77 | 78 | )} 79 | /> 80 | ( 84 | 85 | {t("form.labels.password")} 86 | 87 | 91 | 92 | 93 | 94 | )} 95 | /> 96 |
97 |
98 | 99 | 100 | 101 | 102 | 103 | 104 |
105 |
106 | 107 | ); 108 | }; 109 | 110 | export default RegisterForm; 111 | -------------------------------------------------------------------------------- /src/app/[locale]/new/form.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { newPokemon } from "@/app/[locale]/new/action"; 3 | import { Button } from "@/components/ui/button"; 4 | import { 5 | Card, 6 | CardContent, 7 | CardDescription, 8 | CardFooter, 9 | CardHeader, 10 | CardTitle, 11 | } from "@/components/ui/card"; 12 | import { 13 | Form, 14 | FormControl, 15 | FormField, 16 | FormItem, 17 | FormLabel, 18 | FormMessage, 19 | } from "@/components/ui/form"; 20 | import { Input } from "@/components/ui/input"; 21 | import { 22 | Select, 23 | SelectContent, 24 | SelectItem, 25 | SelectTrigger, 26 | SelectValue, 27 | } from "@/components/ui/select"; 28 | import { zodResolver } from "@hookform/resolvers/zod"; 29 | import Link from "next/link"; 30 | import { useFormState, useFormStatus } from "react-dom"; 31 | import { useForm } from "react-hook-form"; 32 | import { useTranslation } from "react-i18next"; 33 | import { z } from "zod"; 34 | 35 | const CreateButton = () => { 36 | const { pending } = useFormStatus(); 37 | return ( 38 | 41 | ); 42 | }; 43 | 44 | const NewPokemonForm = () => { 45 | const { t } = useTranslation(); 46 | 47 | const schema = z.object({ 48 | name: z.string().min(3, t("form.errors.name_length", { min: 3 })), 49 | type: z.string().min(1, t("form.errors.select_type")), 50 | }); 51 | 52 | const [state, formAction] = useFormState(newPokemon, null); 53 | const form = useForm>({ 54 | resolver: zodResolver(schema), 55 | defaultValues: { 56 | name: "", 57 | type: "", 58 | }, 59 | }); 60 | return ( 61 |
62 | 63 | 64 | 65 | {t("form.title")} 66 | {t("form.description")} 67 | 68 | 69 |
70 | ( 74 | 75 | {t("form.labels.pokemon_name")} 76 | 77 | 81 | 82 | 83 | 84 | )} 85 | /> 86 | ( 90 | 91 | {t("form.labels.type")} 92 | 113 | 114 | 115 | )} 116 | /> 117 |
118 |
119 | 120 | 121 | 122 | 123 | 124 | 125 |
126 |
127 | 128 | ); 129 | }; 130 | 131 | export default NewPokemonForm; 132 | -------------------------------------------------------------------------------- /src/app/[locale]/[id]/edit/form.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { editPokemon } from "@/app/[locale]/[id]/edit/action"; 3 | import { Button } from "@/components/ui/button"; 4 | import { 5 | Card, 6 | CardContent, 7 | CardDescription, 8 | CardFooter, 9 | CardHeader, 10 | CardTitle, 11 | } from "@/components/ui/card"; 12 | import { 13 | Form, 14 | FormControl, 15 | FormField, 16 | FormItem, 17 | FormLabel, 18 | FormMessage, 19 | } from "@/components/ui/form"; 20 | import { Input } from "@/components/ui/input"; 21 | import { 22 | Select, 23 | SelectContent, 24 | SelectItem, 25 | SelectTrigger, 26 | SelectValue, 27 | } from "@/components/ui/select"; 28 | import { Pokemon } from "@/db/schema/pokemons"; 29 | import { zodResolver } from "@hookform/resolvers/zod"; 30 | import Link from "next/link"; 31 | import { useFormState, useFormStatus } from "react-dom"; 32 | import { useForm } from "react-hook-form"; 33 | import { useTranslation } from "react-i18next"; 34 | import { z } from "zod"; 35 | 36 | type Props = { 37 | pokemon: Pokemon; 38 | }; 39 | 40 | const EditButton = () => { 41 | const { pending } = useFormStatus(); 42 | 43 | return ( 44 | 47 | ); 48 | }; 49 | 50 | const EditForm = ({ pokemon }: Props) => { 51 | const { t } = useTranslation(); 52 | const schema = z.object({ 53 | id: z.number(), 54 | name: z.string().min(3, t("form.errors.name_length", { min: 3 })), 55 | type: z.string().min(1, t("form.errors.select_type")), 56 | }); 57 | 58 | const [state, formAction] = useFormState(editPokemon, null); 59 | const form = useForm>({ 60 | resolver: zodResolver(schema), 61 | defaultValues: { 62 | id: pokemon.id, 63 | name: pokemon.name, 64 | type: pokemon.type, 65 | }, 66 | }); 67 | return ( 68 |
69 | 70 | 71 | 72 | {t("form.title")} 73 | {t("form.description")} 74 | 75 | 76 |
77 | ( 81 | 82 | {t("form.labels.pokemon_name")} 83 | 84 | 88 | 89 | 90 | 91 | )} 92 | /> 93 | ( 97 | 98 | {t("form.labels.type")} 99 | 120 | 121 | 122 | )} 123 | /> 124 |
125 |
126 | 127 | 128 | 129 | 130 | 131 | 132 |
133 |
134 | 135 | ); 136 | }; 137 | 138 | export default EditForm; 139 | -------------------------------------------------------------------------------- /src/components/ui/sheet.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as SheetPrimitive from "@radix-ui/react-dialog" 5 | import { cva, type VariantProps } from "class-variance-authority" 6 | import { X } from "lucide-react" 7 | 8 | import { cn } from "@/lib/utils" 9 | 10 | const Sheet = SheetPrimitive.Root 11 | 12 | const SheetTrigger = SheetPrimitive.Trigger 13 | 14 | const SheetClose = SheetPrimitive.Close 15 | 16 | const SheetPortal = SheetPrimitive.Portal 17 | 18 | const SheetOverlay = React.forwardRef< 19 | React.ElementRef, 20 | React.ComponentPropsWithoutRef 21 | >(({ className, ...props }, ref) => ( 22 | 30 | )) 31 | SheetOverlay.displayName = SheetPrimitive.Overlay.displayName 32 | 33 | const sheetVariants = cva( 34 | "fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500", 35 | { 36 | variants: { 37 | side: { 38 | top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top", 39 | bottom: 40 | "inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom", 41 | left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm", 42 | right: 43 | "inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm", 44 | }, 45 | }, 46 | defaultVariants: { 47 | side: "right", 48 | }, 49 | } 50 | ) 51 | 52 | interface SheetContentProps 53 | extends React.ComponentPropsWithoutRef, 54 | VariantProps {} 55 | 56 | const SheetContent = React.forwardRef< 57 | React.ElementRef, 58 | SheetContentProps 59 | >(({ side = "right", className, children, ...props }, ref) => ( 60 | 61 | 62 | 67 | {children} 68 | 69 | 70 | Close 71 | 72 | 73 | 74 | )) 75 | SheetContent.displayName = SheetPrimitive.Content.displayName 76 | 77 | const SheetHeader = ({ 78 | className, 79 | ...props 80 | }: React.HTMLAttributes) => ( 81 |
88 | ) 89 | SheetHeader.displayName = "SheetHeader" 90 | 91 | const SheetFooter = ({ 92 | className, 93 | ...props 94 | }: React.HTMLAttributes) => ( 95 |
102 | ) 103 | SheetFooter.displayName = "SheetFooter" 104 | 105 | const SheetTitle = React.forwardRef< 106 | React.ElementRef, 107 | React.ComponentPropsWithoutRef 108 | >(({ className, ...props }, ref) => ( 109 | 114 | )) 115 | SheetTitle.displayName = SheetPrimitive.Title.displayName 116 | 117 | const SheetDescription = React.forwardRef< 118 | React.ElementRef, 119 | React.ComponentPropsWithoutRef 120 | >(({ className, ...props }, ref) => ( 121 | 126 | )) 127 | SheetDescription.displayName = SheetPrimitive.Description.displayName 128 | 129 | export { 130 | Sheet, 131 | SheetPortal, 132 | SheetOverlay, 133 | SheetTrigger, 134 | SheetClose, 135 | SheetContent, 136 | SheetHeader, 137 | SheetFooter, 138 | SheetTitle, 139 | SheetDescription, 140 | } 141 | -------------------------------------------------------------------------------- /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 |