├── .prettierrc ├── .eslintrc.json ├── .vscode └── settings.json ├── bun.lockb ├── src ├── app │ ├── (routes) │ │ ├── (app) │ │ │ ├── @sidebar │ │ │ │ ├── default.tsx │ │ │ │ ├── page.tsx │ │ │ │ └── contacts │ │ │ │ │ ├── [id] │ │ │ │ │ ├── page.tsx │ │ │ │ │ └── edit │ │ │ │ │ │ └── page.tsx │ │ │ │ │ └── new │ │ │ │ │ └── page.tsx │ │ │ ├── contacts │ │ │ │ ├── [id] │ │ │ │ │ ├── not-found.tsx │ │ │ │ │ ├── layout.tsx │ │ │ │ │ ├── edit │ │ │ │ │ │ └── page.tsx │ │ │ │ │ └── page.tsx │ │ │ │ └── new │ │ │ │ │ ├── page.tsx │ │ │ │ │ └── layout.tsx │ │ │ ├── page.tsx │ │ │ └── layout.tsx │ │ ├── not-found.tsx │ │ ├── layout.tsx │ │ ├── global-error.tsx │ │ └── globals.css │ ├── (components) │ │ ├── sidebar-page.tsx │ │ ├── logo.tsx │ │ ├── favorite-form.tsx │ │ ├── nav-link.tsx │ │ ├── new-contact-form.tsx │ │ ├── delete-form.tsx │ │ ├── sidebar.tsx │ │ ├── sidebar-form.tsx │ │ └── edit-form.tsx │ ├── (actions) │ │ └── contacts.ts │ └── (models) │ │ └── contact.ts ├── lib │ ├── constants.ts │ ├── types.ts │ ├── db.ts │ ├── validators.ts │ ├── schema │ │ └── contact.sql.ts │ ├── functions.ts │ └── server-utils.ts └── env.mjs ├── public ├── favicon.ico ├── vercel.svg ├── nextjs.svg └── nextjs-dark.svg ├── .env.example ├── postcss.config.cjs ├── tailwind.config.cjs ├── drizzle.config.ts ├── global.d.ts ├── .gitignore ├── next.config.mjs ├── tsconfig.json ├── package.json └── README.md /.prettierrc: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["next/core-web-vitals", "next/babel"] 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib" 3 | } -------------------------------------------------------------------------------- /bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Fredkiss3/nextjs-13-react-router-contacts/HEAD/bun.lockb -------------------------------------------------------------------------------- /src/app/(routes)/(app)/@sidebar/default.tsx: -------------------------------------------------------------------------------- 1 | export default function Default() { 2 | return null; 3 | } 4 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Fredkiss3/nextjs-13-react-router-contacts/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /src/app/(routes)/(app)/@sidebar/page.tsx: -------------------------------------------------------------------------------- 1 | export { SidebarPage as default } from "~/app/(components)/sidebar-page"; 2 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | TURSO_DB_TOKEN="" 2 | TURSO_DB_URL="libsql://-.turso.io" -------------------------------------------------------------------------------- /src/app/(routes)/(app)/@sidebar/contacts/[id]/page.tsx: -------------------------------------------------------------------------------- 1 | export { SidebarPage as default } from "~/app/(components)/sidebar-page"; 2 | -------------------------------------------------------------------------------- /src/app/(routes)/(app)/@sidebar/contacts/new/page.tsx: -------------------------------------------------------------------------------- 1 | export { SidebarPage as default } from "~/app/(components)/sidebar-page"; 2 | -------------------------------------------------------------------------------- /postcss.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /src/app/(routes)/(app)/@sidebar/contacts/[id]/edit/page.tsx: -------------------------------------------------------------------------------- 1 | export { SidebarPage as default } from "~/app/(components)/sidebar-page"; 2 | -------------------------------------------------------------------------------- /src/app/(routes)/not-found.tsx: -------------------------------------------------------------------------------- 1 | export default function NotFoundPage() { 2 | return ( 3 |
4 |

Oops!

5 |

This page could not be found.

6 |
7 | ); 8 | } 9 | -------------------------------------------------------------------------------- /src/app/(routes)/(app)/contacts/[id]/not-found.tsx: -------------------------------------------------------------------------------- 1 | export default function NotFoundPage() { 2 | return ( 3 |
4 |

Oops!

5 |

This page could not be found.

6 |
7 | ); 8 | } 9 | -------------------------------------------------------------------------------- /src/app/(components)/sidebar-page.tsx: -------------------------------------------------------------------------------- 1 | import { Sidebar } from "~/app/(components)/sidebar"; 2 | 3 | export function SidebarPage(props: { 4 | searchParams: { q: string } | undefined; 5 | }) { 6 | return ; 7 | } 8 | -------------------------------------------------------------------------------- /src/lib/constants.ts: -------------------------------------------------------------------------------- 1 | export const contactKeys = { 2 | all: () => [contactKeys.allKey()], 3 | detail: (id: number) => [contactKeys.singleKey(id)], 4 | // keys 5 | singleKey: (id: number) => `contact-${id}` as const, 6 | allKey: () => "contacts" as const, 7 | } as const; 8 | -------------------------------------------------------------------------------- /src/app/(routes)/(app)/contacts/new/page.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { EditForm } from "~/app/(components)/edit-form"; 3 | 4 | export type NewContactProps = {}; 5 | 6 | export default function NewContact({}: NewContactProps) { 7 | return ; 8 | } 9 | -------------------------------------------------------------------------------- /tailwind.config.cjs: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: [ 4 | // Or if using `src` directory: 5 | "./src/**/*.{js,ts,jsx,tsx,mdx}", 6 | ], 7 | theme: { 8 | extend: {}, 9 | }, 10 | plugins: [], 11 | }; 12 | -------------------------------------------------------------------------------- /drizzle.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "drizzle-kit"; 2 | 3 | export default { 4 | schema: "./src/lib/schema/*.sql.ts", 5 | out: "./drizzle", 6 | driver: "turso", 7 | dbCredentials: { 8 | url: process.env.TURSO_DB_URL!, 9 | authToken: process.env.TURSO_DB_TOKEN!, 10 | }, 11 | } satisfies Config; 12 | -------------------------------------------------------------------------------- /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 | server: { 7 | TURSO_DB_TOKEN: z.string(), 8 | TURSO_DB_URL: z.string().url(), 9 | }, 10 | client: {}, 11 | runtimeEnv: { 12 | TURSO_DB_TOKEN: process.env.TURSO_DB_TOKEN, 13 | TURSO_DB_URL: process.env.TURSO_DB_URL, 14 | }, 15 | }); 16 | -------------------------------------------------------------------------------- /src/lib/types.ts: -------------------------------------------------------------------------------- 1 | export type PageParams = Record; 2 | export interface PageProps { 3 | params?: PageParams; 4 | searchParams?: Record; 5 | } 6 | export type Contact = { 7 | id: number; 8 | createdAt: number; // timestamp 9 | first?: string; 10 | last?: string; 11 | favorite?: boolean; 12 | avatar?: string; 13 | twitter?: string; 14 | notes?: string; 15 | }; 16 | -------------------------------------------------------------------------------- /global.d.ts: -------------------------------------------------------------------------------- 1 | import * as ReactDOM from "react-dom"; 2 | // globals.d.ts 3 | 4 | declare module "react-dom" { 5 | function experimental_useFormState( 6 | action: (state: S, payload: P) => Promise, 7 | initialState?: S, 8 | url?: string 9 | ): initialState extends undefined 10 | ? [S | undefined, (payload: P) => Promise] 11 | : [S, (payload: P) => Promise]; 12 | } 13 | -------------------------------------------------------------------------------- /src/app/(routes)/(app)/contacts/new/layout.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | 3 | export default function ContactLayout({ 4 | children, 5 | }: { 6 | children: React.ReactNode; 7 | }) { 8 | return ( 9 | <> 10 |
11 | 12 | {"<"} Go home 13 | 14 |
15 |
{children}
16 | 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /src/lib/db.ts: -------------------------------------------------------------------------------- 1 | import "server-only"; 2 | import { contacts } from "~/lib/schema/contact.sql"; 3 | import { drizzle } from "drizzle-orm/libsql"; 4 | import { createClient } from "@libsql/client/http"; 5 | import { env } from "~/env.mjs"; 6 | 7 | export const db = drizzle( 8 | createClient({ 9 | url: env.TURSO_DB_URL, 10 | authToken: env.TURSO_DB_TOKEN, 11 | }), 12 | { 13 | logger: true, 14 | schema: { 15 | contacts, 16 | }, 17 | } 18 | ); 19 | -------------------------------------------------------------------------------- /src/app/(routes)/(app)/contacts/[id]/layout.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | 3 | export default function ContactLayout({ 4 | children, 5 | }: { 6 | children: React.ReactNode; 7 | }) { 8 | return ( 9 | <> 10 |
11 | 12 | {"<"} Go home 13 | 14 |
15 |
{children}
16 | 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /src/app/(routes)/(app)/page.tsx: -------------------------------------------------------------------------------- 1 | import { Logo } from "~/app/(components)/logo"; 2 | 3 | export default function Home() { 4 | return ( 5 |
6 |

7 | 8 |
9 | This is a demo for NextJS 13 10 |
11 | Check out the docs at  12 | 13 | nextjs.org/docs 14 | 15 | . 16 |

17 |
18 | ); 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 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | .pnpm-debug.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 | 39 | .vscode 40 | -------------------------------------------------------------------------------- /src/app/(components)/logo.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { unstable_getImgProps as getImgProps } from "next/image"; 3 | 4 | export function Logo() { 5 | const common = { alt: "NextJS Logo", width: 72, height: 16 }; 6 | 7 | const { 8 | props: { srcSet: light, ...rest }, 9 | } = getImgProps({ ...common, src: "/nextjs.svg" }); 10 | 11 | return ( 12 | 13 | 14 | 15 | 16 | 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | import { env } from "./src/env.mjs"; 2 | /** @type {import('next').NextConfig} */ 3 | const nextConfig = { 4 | reactStrictMode: true, 5 | experimental: { 6 | serverActions: true, 7 | logging: { 8 | level: "verbose", 9 | // @ts-expect-error this is normally a boolean 10 | fullUrl: true, 11 | }, 12 | }, 13 | images: { 14 | remotePatterns: [ 15 | { 16 | protocol: "https", 17 | hostname: "avatars.githubusercontent.com", 18 | }, 19 | ], 20 | }, 21 | }; 22 | 23 | export default nextConfig; 24 | -------------------------------------------------------------------------------- /src/app/(routes)/layout.tsx: -------------------------------------------------------------------------------- 1 | import "./globals.css"; 2 | import * as React from "react"; 3 | 4 | export const metadata = { 5 | title: "Contacts App With Next 13", 6 | description: "A reproduction of the app in remix tutorial", 7 | }; 8 | 9 | // export const runtime = "edge"; 10 | export const fetchCache = "default-no-store"; 11 | 12 | export default async function RootLayout({ 13 | children, 14 | }: { 15 | children: React.ReactNode; 16 | }) { 17 | return ( 18 | 19 | 20 | {children} 21 | 22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /src/app/(routes)/(app)/layout.tsx: -------------------------------------------------------------------------------- 1 | import { Logo } from "~/app/(components)/logo"; 2 | 3 | export default async function MainLayout({ 4 | children, 5 | sidebar, 6 | }: { 7 | children: React.ReactNode; 8 | sidebar: React.ReactNode; 9 | }) { 10 | return ( 11 |
17 | 25 |
{children}
26 |
27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /src/lib/validators.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | export const contactSchema = z.object({ 4 | first: z.string().trim(), 5 | last: z.string().trim(), 6 | avatar_url: z 7 | .string() 8 | .trim() 9 | .url() 10 | .nonempty() 11 | .regex(/^(https:\/\/)?avatars\.githubusercontent\.com/, { 12 | message: "Only Github avatar URL are supported", 13 | }), 14 | github_handle: z 15 | .string() 16 | .trim() 17 | .nonempty() 18 | .regex(/^[a-zA-Z][a-zA-Z0-9_\-]+/), 19 | notes: z.string().trim(), 20 | }); 21 | 22 | export type ContactPayload = z.TypeOf; 23 | -------------------------------------------------------------------------------- /src/app/(components)/favorite-form.tsx: -------------------------------------------------------------------------------- 1 | import { favoriteContact } from "~/app/(actions)/contacts"; 2 | 3 | export function FavoriteForm({ 4 | contactId, 5 | isFavorite, 6 | }: { 7 | contactId: number; 8 | isFavorite: boolean; 9 | }) { 10 | return ( 11 |
12 | 13 | 21 |
22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /src/lib/schema/contact.sql.ts: -------------------------------------------------------------------------------- 1 | import { sql, InferSelectModel } from "drizzle-orm"; 2 | import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core"; 3 | 4 | export const contacts = sqliteTable("contacts", { 5 | id: integer("id").primaryKey({ autoIncrement: true }), 6 | createdAt: integer("createdAt", { mode: "timestamp" }) 7 | .default(sql`(strftime('%s', 'now'))`) 8 | .notNull(), 9 | favorite: integer("favorite", { mode: "boolean" }).default(false).notNull(), 10 | first: text("first"), 11 | last: text("last"), 12 | avatar_url: text("avatar_url"), 13 | github_handle: text("github_handle").unique(), 14 | notes: text("notes"), 15 | }); 16 | 17 | export type Contact = InferSelectModel; 18 | -------------------------------------------------------------------------------- /src/app/(components)/nav-link.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Link, { type LinkProps } from "next/link"; 4 | import { usePathname } from "next/navigation"; 5 | 6 | function linkWithSlash(href: string) { 7 | if (href.endsWith("/")) { 8 | return href; 9 | } 10 | return href + "/"; 11 | } 12 | 13 | function useActiveLink(href: string) { 14 | const path = usePathname(); 15 | 16 | return linkWithSlash(path).includes(linkWithSlash(href)); 17 | } 18 | 19 | export function NavLink({ 20 | href, 21 | children, 22 | ...props 23 | }: LinkProps & { children: React.ReactNode }) { 24 | const isActive = useActiveLink(href.toString()); 25 | 26 | return ( 27 | 28 | {children} 29 | 30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /src/lib/functions.ts: -------------------------------------------------------------------------------- 1 | export function wait(ms: number): Promise { 2 | // Wait for the specified amount of time 3 | return new Promise((resolve) => setTimeout(resolve, ms)); 4 | } 5 | 6 | /** 7 | * @example 8 | * const fn = debounce(() => { console.log(...) }) 9 | * 10 | * // Only the second call will be executed 11 | * fn() 12 | * fn() 13 | * 14 | * @param callback 15 | * @param delay 16 | */ 17 | 18 | export function debounce( 19 | callback: T, 20 | delay: number = 500 21 | ): T { 22 | let timer: any; 23 | const fn = (...args: unknown[]) => { 24 | clearTimeout(timer); 25 | timer = setTimeout(() => { 26 | // @ts-ignore 27 | callback.apply(this, args); 28 | }, delay); 29 | }; 30 | return fn as unknown as T; 31 | } 32 | -------------------------------------------------------------------------------- /src/app/(routes)/global-error.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useEffect } from "react"; 4 | 5 | export default function Error({ 6 | error, 7 | reset, 8 | }: { 9 | error: Error & { digest?: string }; 10 | reset: () => void; 11 | }) { 12 | useEffect(() => { 13 | // Log the error to an error reporting service 14 | console.error(error); 15 | }, [error]); 16 | 17 | return ( 18 | 19 | 20 | 21 | 22 | Something went wrong ! 23 | 24 | 25 |
26 |

Something went wrong!

27 | 28 |
29 | 30 | 31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /src/app/(components)/new-contact-form.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import { useRouter } from "next/navigation"; 5 | import { newContact } from "~/app/(actions)/contacts"; 6 | 7 | export function NewContactForm() { 8 | const router = useRouter(); 9 | const [_, startTransition] = React.useTransition(); 10 | 11 | return ( 12 | <> 13 |
{ 16 | e.preventDefault(); 17 | // FIXME: until this issue is fixed : https://github.com/vercel/next.js/issues/52075 18 | startTransition(() => 19 | newContact().then((newContactId) => { 20 | router.refresh(); 21 | router.push(`/contacts/${newContactId}/edit`); 22 | }) 23 | ); 24 | }} 25 | > 26 | 29 |
30 | 31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "incremental": true, 17 | "plugins": [ 18 | { 19 | "name": "next" 20 | } 21 | ], 22 | "paths": { 23 | "~/*": ["./src/*"] 24 | } 25 | }, 26 | "include": [ 27 | "next-env.d.ts", 28 | "./src/env.mjs", 29 | "./global.d.ts", 30 | "**/*.ts", 31 | "**/*.tsx", 32 | ".next/types/**/*.ts" 33 | ], 34 | "exclude": ["node_modules"] 35 | } 36 | -------------------------------------------------------------------------------- /src/app/(routes)/(app)/contacts/[id]/edit/page.tsx: -------------------------------------------------------------------------------- 1 | import { EditForm } from "~/app/(components)/edit-form"; 2 | import { getContactDetail } from "~/app/(models)/contact"; 3 | import { notFound } from "next/navigation"; 4 | import { editContact } from "~/app/(actions)/contacts"; 5 | 6 | import type { Metadata } from "next"; 7 | 8 | export async function generateMetadata({ 9 | params, 10 | }: { 11 | params: { 12 | id: string; 13 | }; 14 | }): Promise { 15 | const contact = await getContactDetail(Number(params.id)); 16 | 17 | if (!contact) { 18 | notFound(); 19 | } 20 | 21 | return { 22 | title: `Edit contact : ${contact.twitter ?? "empty"}`, 23 | }; 24 | } 25 | 26 | export default async function EditFormPage({ 27 | params, 28 | }: { 29 | params: { 30 | id: string; 31 | }; 32 | }) { 33 | const contact = await getContactDetail(Number(params.id)); 34 | 35 | if (!contact) { 36 | notFound(); 37 | } 38 | 39 | return ; 40 | } 41 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | -------------------------------------------------------------------------------- /src/lib/server-utils.ts: -------------------------------------------------------------------------------- 1 | import "server-only"; 2 | import { Remarkable } from "remarkable"; 3 | import { linkify } from "remarkable/linkify"; 4 | import { headers } from "next/headers"; 5 | import { unstable_cache } from "next/cache"; 6 | import { redirect } from "next/navigation"; 7 | import { cache } from "react"; 8 | 9 | export function isSSR() { 10 | return headers().get("accept")?.includes("text/html"); 11 | } 12 | 13 | export function ssrRedirect(path: string) { 14 | // FIXME: until this issue is fixed : https://github.com/vercel/next.js/issues/52075 15 | if (isSSR()) { 16 | redirect(path); 17 | } 18 | } 19 | 20 | type Callback = (...args: any[]) => Promise; 21 | export function nextCache( 22 | cb: T, 23 | options: { 24 | tags: string[]; 25 | } 26 | ) { 27 | return cache(unstable_cache(cb, options.tags, options)); 28 | } 29 | 30 | export function renderMarkdown(markdown: string): string { 31 | return new Remarkable("full", { 32 | html: true, 33 | breaks: true, 34 | typographer: true, 35 | }) 36 | .use(linkify) 37 | .render(markdown); 38 | } 39 | -------------------------------------------------------------------------------- /src/app/(components)/delete-form.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import { useRouter } from "next/navigation"; 5 | import { removeContact } from "~/app/(actions)/contacts"; 6 | 7 | export function DeleteForm({ contactId }: { contactId: number }) { 8 | const router = useRouter(); 9 | const [isPending, startTransition] = React.useTransition(); 10 | 11 | return ( 12 |
{ 15 | e.preventDefault(); 16 | if (!confirm("Please confirm you want to delete this record.")) { 17 | return; 18 | } 19 | 20 | startTransition(() => 21 | removeContact(new FormData(e.currentTarget)).then(() => { 22 | // FIXME: until this issue is fixed : https://github.com/vercel/next.js/issues/52075 23 | router.replace("/"); 24 | router.refresh(); 25 | }) 26 | ); 27 | }} 28 | > 29 | 30 | 33 |
34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@next13-test/webapp", 3 | "version": "0.1.0", 4 | "private": true, 5 | "type": "module", 6 | "scripts": { 7 | "dev": "next dev", 8 | "clean-dev": "rm -rf .next && next dev", 9 | "build": "next build", 10 | "start": "next start", 11 | "lint": "next lint", 12 | "info": "next info", 13 | "db:push": "set -a; source .env; set +a && drizzle-kit push:sqlite", 14 | "db:studio": "set -a; source .env; set +a && drizzle-kit studio" 15 | }, 16 | "dependencies": { 17 | "@libsql/client": "0.3.5", 18 | "@t3-oss/env-nextjs": "0.6.1", 19 | "drizzle-orm": "0.28.6", 20 | "next": "13.5.4-canary.2", 21 | "react": "18.2.0", 22 | "react-dom": "18.2.0", 23 | "react-markdown": "^9.0.0", 24 | "remark-gfm": "^3.0.1", 25 | "remarkable": "^2.0.1", 26 | "server-only": "^0.0.1", 27 | "zod": "^3.22.2" 28 | }, 29 | "devDependencies": { 30 | "@types/node": "20.3.3", 31 | "@types/react": "18.2.14", 32 | "@types/react-dom": "18.2.6", 33 | "@types/remarkable": "^2.0.3", 34 | "autoprefixer": "^10.4.16", 35 | "drizzle-kit": "^0.19.3", 36 | "eslint": "8.26.0", 37 | "eslint-config-next": "13.0.0", 38 | "postcss": "^8.4.30", 39 | "tailwindcss": "^3.3.3", 40 | "typescript": "5.1.6" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Next 13 contacts 2 | 3 | This is a [Next.js](https://nextjs.org/) project bootstrapped with `create-next-app`. 4 | 5 | ## Pre-requisities 6 | 7 | - A [turso](https://turso.tech/) database 8 | - [Bun](https://bun.sh/) installed on your project 9 | - Node >= v18 10 | 11 | ## Getting Started 12 | 13 | 1. Install the packages : 14 | 15 | ```bash 16 | bun install 17 | ``` 18 | 19 | 1. Run the nextjs development server: 20 | 21 | ```bash 22 | bun dev 23 | ``` 24 | 25 | Open with your browser to see the result. 26 | 27 | You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. 28 | 29 | ## Learn More 30 | 31 | To learn more about Next.js, take a look at the following resources: 32 | 33 | - [Next.js Documentation](https://beta.nextjs.org/docs) - learn about Next.js features and API. 34 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 35 | 36 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! 37 | 38 | ## Deploy on Vercel 39 | 40 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 41 | 42 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. -------------------------------------------------------------------------------- /src/app/(components)/sidebar.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | // components 4 | import { NavLink } from "~/app/(components)/nav-link"; 5 | import { SidebarForm } from "./sidebar-form"; 6 | 7 | // utils 8 | import { 9 | searchContactByName, 10 | getAllContactIds, 11 | getContactDetail, 12 | } from "~/app/(models)/contact"; 13 | import { isSSR } from "~/lib/server-utils"; 14 | 15 | // types 16 | export type SidebarProps = { 17 | query?: string; 18 | }; 19 | 20 | export async function Sidebar({ query = "" }: SidebarProps) { 21 | const filteredContacts = 22 | query.length > 0 23 | ? await searchContactByName(query) 24 | : await getAllContactIds(); 25 | 26 | return ( 27 | <> 28 | 29 | 30 | 56 | 57 | ); 58 | } 59 | 60 | async function SingleContact(props: { id: number }) { 61 | const contact = await getContactDetail(props.id); 62 | 63 | if (!contact) return null; 64 | 65 | return ( 66 | 67 | {contact.first || contact.last ? ( 68 | <> 69 | {contact.first} {contact.last} 70 | 71 | ) : ( 72 | No Name 73 | )} 74 |  {contact.favorite && } 75 | 76 | ); 77 | } 78 | -------------------------------------------------------------------------------- /src/app/(components)/sidebar-form.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import { usePathname, useRouter } from "next/navigation"; 5 | import { debounce } from "~/lib/functions"; 6 | import Link from "next/link"; 7 | 8 | export type SidebarFormProps = { 9 | searchQuery?: string; 10 | }; 11 | 12 | export function SidebarForm({ searchQuery }: SidebarFormProps) { 13 | const router = useRouter(); 14 | const path = usePathname(); 15 | const formRef = React.useRef(null); 16 | const [isPending, startTransition] = React.useTransition(); 17 | 18 | const submitForm = React.useCallback( 19 | debounce(() => { 20 | formRef.current?.requestSubmit(); 21 | }), 22 | [] 23 | ); 24 | 25 | return ( 26 | <> 27 |
28 | 64 | 65 | ); 66 | } 67 | -------------------------------------------------------------------------------- /src/app/(actions)/contacts.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { revalidatePath } from "next/cache"; 4 | import { redirect } from "next/navigation"; 5 | import { ssrRedirect } from "~/lib/server-utils"; 6 | import { 7 | createContact, 8 | deleteContact, 9 | getContactByGithubHandle, 10 | toggleFavoriteContact, 11 | updateContact, 12 | } from "~/app/(models)/contact"; 13 | import { contactSchema } from "~/lib/validators"; 14 | 15 | export type ActionResult = 16 | | { 17 | type: "success"; 18 | data: T; 19 | message: string; 20 | } 21 | | { 22 | type: "error"; 23 | errors: Record; 24 | } 25 | | { type?: undefined; message: null }; 26 | 27 | export async function removeContact(formData: FormData) { 28 | const id = formData.get("id")!.toString(); 29 | await deleteContact(Number(id)); 30 | 31 | revalidatePath("/"); 32 | ssrRedirect("/"); 33 | } 34 | 35 | export async function newContact(_: ActionResult, formData: FormData) { 36 | const result = contactSchema.safeParse(Object.fromEntries(formData)); 37 | if (!result.success) { 38 | return { 39 | type: "error", 40 | errors: result.error.flatten().fieldErrors, 41 | } satisfies ActionResult; 42 | } 43 | 44 | const existingContacts = await getContactByGithubHandle( 45 | result.data.github_handle 46 | ); 47 | if (existingContacts.length > 0) { 48 | return { 49 | type: "error", 50 | errors: { 51 | github_handle: ["A user with this handle already exists in DB"], 52 | }, 53 | } satisfies ActionResult; 54 | } 55 | 56 | const id = await createContact(result.data); 57 | revalidatePath("/"); 58 | redirect(`/contacts/${id}`); 59 | } 60 | 61 | export async function editContact(_: any, formData: FormData) { 62 | const id = formData.get("id")!.toString(); 63 | const result = contactSchema.safeParse(Object.fromEntries(formData)); 64 | 65 | if (!result.success) { 66 | redirect("/"); 67 | } 68 | 69 | await updateContact(result.data, Number(id)); 70 | 71 | revalidatePath("/"); 72 | 73 | ssrRedirect(`/contacts/${id}`); 74 | } 75 | 76 | export async function favoriteContact(formData: FormData) { 77 | const id = formData.get("id")!.toString(); 78 | 79 | await toggleFavoriteContact(Number(id)); 80 | 81 | revalidatePath("/"); 82 | 83 | ssrRedirect(`/contacts/${id}`); 84 | } 85 | -------------------------------------------------------------------------------- /src/app/(routes)/(app)/contacts/[id]/page.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | // components 4 | import Image from "next/image"; 5 | import Link from "next/link"; 6 | import { DeleteForm } from "~/app/(components)/delete-form"; 7 | import { FavoriteForm } from "~/app/(components)/favorite-form"; 8 | 9 | // utils 10 | import { renderMarkdown } from "~/lib/server-utils"; 11 | import { notFound } from "next/navigation"; 12 | import { getContactDetail } from "~/app/(models)/contact"; 13 | 14 | // types 15 | import type { Metadata } from "next"; 16 | 17 | export async function generateMetadata({ 18 | params, 19 | }: { 20 | params: { 21 | id: string; 22 | }; 23 | }): Promise { 24 | const contact = await getContactDetail(Number(params.id)); 25 | 26 | if (!contact) { 27 | notFound(); 28 | } 29 | 30 | return { 31 | title: `Contact : ${contact.github_handle ?? ""}`, 32 | }; 33 | } 34 | 35 | export default async function ContactPage({ 36 | params, 37 | }: { 38 | params: { 39 | id: string; 40 | }; 41 | }) { 42 | const contact = await getContactDetail(Number(params.id)); 43 | 44 | if (!contact) { 45 | notFound(); 46 | } 47 | 48 | return ( 49 |
50 |
51 | {contact.avatar_url ? ( 52 | {`Contact`} 58 | ) : ( 59 |
60 | )} 61 |
62 | 63 |
64 |

65 | {contact.first || contact.last ? ( 66 | <> 67 | {contact.first} {contact.last} 68 | 69 | ) : ( 70 | No Name 71 | )} 72 | 76 |

77 | 78 | {contact.github_handle && ( 79 |

80 | 86 | {contact.github_handle} 87 | 88 |

89 | )} 90 | 91 | {contact.notes && ( 92 |
97 | )} 98 | 99 |
100 | 104 | Edit 105 | 106 | 107 |
108 |
109 |
110 | ); 111 | } 112 | -------------------------------------------------------------------------------- /src/app/(models)/contact.ts: -------------------------------------------------------------------------------- 1 | import "server-only"; 2 | import { sql, eq, asc } from "drizzle-orm"; 3 | import { cache } from "react"; 4 | import { db } from "~/lib/db"; 5 | import { contacts } from "~/lib/schema/contact.sql"; 6 | import { nextCache } from "~/lib/server-utils"; 7 | import { contactKeys } from "~/lib/constants"; 8 | import { revalidateTag } from "next/cache"; 9 | import type { ContactPayload } from "~/lib/validators"; 10 | 11 | export const getContactDetail = async function getContactDetail(id: number) { 12 | const fn = nextCache( 13 | async (id: number) => { 14 | return ( 15 | (await db.query.contacts.findFirst({ 16 | where: (fields, { eq }) => eq(fields.id, id), 17 | })) ?? null 18 | ); 19 | }, 20 | { 21 | tags: contactKeys.detail(id), 22 | } 23 | ); 24 | 25 | return fn(id); 26 | }; 27 | 28 | export const getAllContactIds = async function getAllContactIds() { 29 | const fn = nextCache( 30 | async () => { 31 | return await db 32 | .select({ 33 | id: contacts.id, 34 | }) 35 | .from(contacts) 36 | .orderBy(asc(contacts.createdAt)) 37 | .all(); 38 | }, 39 | { 40 | tags: contactKeys.all(), 41 | } 42 | ); 43 | 44 | return fn(); 45 | }; 46 | 47 | export const searchContactByName = cache(async function searchContactByName( 48 | query: string 49 | ) { 50 | const searchStr = query.toLowerCase().trim() + "%"; 51 | return await db 52 | .select({ 53 | id: contacts.id, 54 | }) 55 | .from(contacts) 56 | .where( 57 | sql`lower(${contacts.first}) LIKE ${searchStr} or lower(${contacts.last}) LIKE ${searchStr}` 58 | ) 59 | .orderBy(asc(contacts.createdAt)) 60 | .all(); 61 | }); 62 | 63 | export const getContactByGithubHandle = cache( 64 | async function searchContactByName(handle: string) { 65 | return await db 66 | .select({ 67 | id: contacts.id, 68 | }) 69 | .from(contacts) 70 | .where(eq(contacts.github_handle, handle)) 71 | .all(); 72 | } 73 | ); 74 | 75 | export async function createContact(payload: ContactPayload) { 76 | const res = await db 77 | .insert(contacts) 78 | .values({ 79 | ...payload, 80 | }) 81 | .returning({ insertedId: contacts.id }) 82 | .get(); 83 | 84 | revalidateTag(contactKeys.allKey()); 85 | 86 | return res.insertedId; 87 | } 88 | 89 | export async function deleteContact(id: number) { 90 | db.delete(contacts).where(eq(contacts.id, id)).run(); 91 | revalidateTag(contactKeys.allKey()); 92 | revalidateTag(contactKeys.singleKey(id)); 93 | } 94 | 95 | export async function updateContact(payload: ContactPayload, id: number) { 96 | await db 97 | .update(contacts) 98 | .set({ ...payload }) 99 | .where(eq(contacts.id, id)) 100 | .run(); 101 | revalidateTag(contactKeys.singleKey(id)); 102 | } 103 | 104 | export async function toggleFavoriteContact(id: number) { 105 | const oldContact = await getContactDetail(id); 106 | 107 | if (!oldContact) return; 108 | 109 | await db 110 | .update(contacts) 111 | .set({ favorite: !oldContact.favorite }) 112 | .where(eq(contacts.id, Number(id))) 113 | .returning({ 114 | favorite: contacts.favorite, 115 | }) 116 | .run(); 117 | 118 | revalidateTag(contactKeys.singleKey(id)); 119 | } 120 | -------------------------------------------------------------------------------- /public/nextjs.svg: -------------------------------------------------------------------------------- 1 | 2 | Next.js Logo 3 | 4 | 7 | 10 | 11 | 12 | 14 | 17 | 20 | 23 | -------------------------------------------------------------------------------- /public/nextjs-dark.svg: -------------------------------------------------------------------------------- 1 | 3 | 5 | 8 | 11 | 13 | 16 | 19 | 22 | 25 | -------------------------------------------------------------------------------- /src/app/(components)/edit-form.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import * as React from "react"; 3 | // components 4 | import Link from "next/link"; 5 | 6 | // utils 7 | import { experimental_useFormState as useFormState } from "react-dom"; 8 | import { experimental_useFormStatus as useFormStatus } from "react-dom"; 9 | import { newContact } from "~/app/(actions)/contacts"; 10 | 11 | // types 12 | import type { Contact } from "~/lib/schema/contact.sql"; 13 | 14 | type EditFormProps = { 15 | contact?: Contact; 16 | }; 17 | 18 | export function EditForm({ contact }: EditFormProps) { 19 | const [state, formAction] = useFormState(newContact, { 20 | message: null, 21 | }); 22 | 23 | return ( 24 | <> 25 |
26 |

27 | Name 28 | 35 | 42 |

43 | 71 | 100 |