├── bun.lockb ├── postcss.config.js ├── app ├── api │ ├── elysia │ │ └── [[...slug]] │ │ │ └── route.ts │ └── auth │ │ └── [...auth] │ │ └── route.ts ├── layout.tsx ├── _components │ ├── auth-showcase.tsx │ └── post.tsx ├── page.tsx └── globals.css ├── lib ├── api │ ├── index.ts │ └── query-client.ts ├── utils.ts └── metadata.ts ├── next.config.ts ├── server ├── db.ts ├── api │ ├── root.ts │ ├── routers │ │ └── post.ts │ └── elysia.ts └── auth │ ├── index.ts │ └── oauth.ts ├── components.json ├── .env.example ├── components ├── theme-btn.tsx ├── ui │ ├── input.tsx │ ├── toaster.tsx │ ├── typography.tsx │ ├── card.tsx │ ├── button.tsx │ └── toast.tsx └── providers.tsx ├── .gitignore ├── tsconfig.json ├── prettier.config.js ├── LICENSE ├── README.md ├── hooks ├── use-post.ts ├── use-posts.ts └── use-toast.ts ├── prisma └── schema.prisma ├── env.ts ├── package.json ├── tailwind.config.ts └── eslint.config.js /bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tiesen243/create-yukie-app/HEAD/bun.lockb -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('postcss-load-config').Config} */ 2 | const config = { plugins: { tailwindcss: {} } } 3 | 4 | export default config 5 | -------------------------------------------------------------------------------- /app/api/elysia/[[...slug]]/route.ts: -------------------------------------------------------------------------------- 1 | import { appRouter } from '@/server/api/root' 2 | 3 | const handler = appRouter.handle 4 | 5 | export { handler as GET, handler as POST } 6 | -------------------------------------------------------------------------------- /lib/api/index.ts: -------------------------------------------------------------------------------- 1 | import { treaty } from '@elysiajs/eden' 2 | 3 | import type { AppRouter } from '@/server/api/root' 4 | import { getBaseUrl } from '@/lib/utils' 5 | 6 | export const api = treaty(getBaseUrl()).api.elysia 7 | -------------------------------------------------------------------------------- /next.config.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially useful 3 | * for Docker builds. 4 | */ 5 | import '@/env' 6 | 7 | import type { NextConfig } from 'next' 8 | 9 | const nextConfig: NextConfig = { 10 | reactStrictMode: true, 11 | eslint: { ignoreDuringBuilds: true }, 12 | typescript: { ignoreBuildErrors: true }, 13 | } 14 | 15 | export default nextConfig 16 | -------------------------------------------------------------------------------- /server/db.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from '@prisma/client' 2 | 3 | import { env } from '@/env' 4 | 5 | const createPrismaClient = () => 6 | new PrismaClient({ 7 | log: env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'], 8 | }) 9 | 10 | const globalForPrisma = globalThis as unknown as { 11 | prisma: ReturnType | undefined 12 | } 13 | 14 | export const db = globalForPrisma.prisma ?? createPrismaClient() 15 | 16 | if (env.NODE_ENV !== 'production') globalForPrisma.prisma = db 17 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "app/globals.css", 9 | "baseColor": "neutral", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | }, 20 | "iconLibrary": "lucide" 21 | } 22 | -------------------------------------------------------------------------------- /lib/api/query-client.ts: -------------------------------------------------------------------------------- 1 | import { defaultShouldDehydrateQuery, QueryClient } from '@tanstack/react-query' 2 | 3 | export const createQueryClient = () => 4 | new QueryClient({ 5 | defaultOptions: { 6 | queries: { 7 | // With SSR, we usually want to set some default staleTime 8 | // above 0 to avoid refetching immediately on the client 9 | staleTime: 60 * 1000, 10 | }, 11 | dehydrate: { 12 | shouldDehydrateQuery: (query) => 13 | defaultShouldDehydrateQuery(query) || query.state.status === 'pending', 14 | }, 15 | hydrate: {}, 16 | }, 17 | }) 18 | -------------------------------------------------------------------------------- /lib/utils.ts: -------------------------------------------------------------------------------- 1 | import type { ClassValue } from 'clsx' 2 | import { clsx } from 'clsx' 3 | import { twMerge } from 'tailwind-merge' 4 | 5 | import { env } from '@/env' 6 | 7 | export function cn(...inputs: ClassValue[]) { 8 | return twMerge(clsx(inputs)) 9 | } 10 | 11 | export function getBaseUrl() { 12 | if (typeof window !== 'undefined') return window.location.origin 13 | if (env.VERCEL_PROJECT_PRODUCTION_URL) 14 | return `https://${env.VERCEL_PROJECT_PRODUCTION_URL}` 15 | if (env.VERCEL_URL) return `https://${env.VERCEL_URL}` 16 | // eslint-disable-next-line no-restricted-properties 17 | return `http://localhost:${process.env.PORT ?? 3000}` 18 | } 19 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # Since .env is gitignored, you can use .env.example to build a new `.env` file when you clone the repo. 2 | # Keep this file up-to-date when you add new variables to \`.env\`. 3 | 4 | # This file will be committed to version control, so make sure not to have any secrets in it. 5 | # If you are cloning this repo, create a copy of this file named `.env` and populate it with your secrets. 6 | 7 | # The database URL is used to connect to your Neon database (pooler). 8 | DATABASE_URL="postgres://[USERNAME]:[PASSWORD]@ep-lively-haze-a10zei6o-pooler.ap-southeast-1.aws.neon.tech/neondb?sslmode=require" 9 | 10 | # Preconfigured Discord OAuth provider, works out-of-the-box 11 | # @see https://arcticjs.dev/guides/oauth2 12 | DISCORD_ID='' 13 | DISCORD_SECRET='' 14 | 15 | -------------------------------------------------------------------------------- /server/api/root.ts: -------------------------------------------------------------------------------- 1 | import { treaty } from '@elysiajs/eden' 2 | 3 | import { elysia } from '@/server/api/elysia' 4 | import { postRouter } from '@/server/api/routers/post' 5 | 6 | /** 7 | * This is the primary router for your server. 8 | * 9 | * All routers added in /api/routers should be manually added here. 10 | */ 11 | 12 | const appRouter = elysia({ prefix: '/api/elysia' }).use(postRouter) 13 | 14 | // export type definition of API 15 | type AppRouter = typeof appRouter 16 | 17 | /** 18 | * Create a server-side caller for the tRPC API. 19 | * @example 20 | * const elysia = createCaller(createContext); 21 | * const res = await elysia.post.all(); 22 | * ^? Post[] 23 | */ 24 | const createCaller = treaty(appRouter).api.elysia 25 | 26 | export { appRouter, createCaller } 27 | export type { AppRouter } 28 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import '@/app/globals.css' 2 | 3 | import { Geist } from 'next/font/google' 4 | 5 | import { Providers } from '@/components/providers' 6 | import { Toaster } from '@/components/ui/toaster' 7 | import { createMetadata } from '@/lib/metadata' 8 | import { cn } from '@/lib/utils' 9 | 10 | const geistSans = Geist({ subsets: ['latin'], variable: '--font-geist-sans' }) 11 | 12 | const RootLayout = ({ children }: Readonly<{ children: React.ReactNode }>) => ( 13 | 14 | 15 | 16 | {children} 17 | 18 | 19 | 20 | 21 | ) 22 | 23 | export default RootLayout 24 | 25 | export const metadata = createMetadata({}) 26 | -------------------------------------------------------------------------------- /components/theme-btn.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useEffect, useState } from 'react' 4 | import { MoonIcon, SunIcon } from 'lucide-react' 5 | import { useTheme } from 'next-themes' 6 | 7 | import { Button } from '@/components/ui/button' 8 | 9 | export const ThemeBtn: React.FC = () => { 10 | const { theme, setTheme } = useTheme() 11 | 12 | const [isMounted, setMount] = useState(false) 13 | useEffect(() => { 14 | setMount(true) 15 | }, []) 16 | if (!isMounted) return null 17 | 18 | const toggle = () => { 19 | setTheme(theme === 'dark' ? 'light' : 'dark') 20 | } 21 | 22 | return ( 23 | 31 | ) 32 | } 33 | -------------------------------------------------------------------------------- /.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 | .cache 8 | 9 | # testing 10 | /coverage 11 | 12 | # database 13 | /prisma/db.sqlite 14 | /prisma/db.sqlite-journal 15 | db.sqlite 16 | 17 | # next.js 18 | /.next/ 19 | /out/ 20 | next-env.d.ts 21 | 22 | # production 23 | /build 24 | /.source 25 | 26 | # misc 27 | .DS_Store 28 | *.pem 29 | 30 | # debug 31 | npm-debug.log* 32 | yarn-debug.log* 33 | yarn-error.log* 34 | .pnpm-debug.log* 35 | 36 | # local env files 37 | # do not commit any .env files to git, except for the .env.example file. https://create.t3.gg/en/usage/env-variables#using-environment-variables 38 | .env* 39 | !.env.example 40 | 41 | # vercel 42 | .vercel 43 | 44 | # typescript 45 | *.tsbuildinfo 46 | 47 | # idea files 48 | .idea 49 | 50 | -------------------------------------------------------------------------------- /app/_components/auth-showcase.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from '@/components/ui/button' 2 | import { Typography } from '@/components/ui/typography' 3 | import { auth, invalidateSession } from '@/server/auth' 4 | 5 | export async function AuthShowcase() { 6 | const session = await auth() 7 | 8 | if (!session.user) { 9 | return ( 10 |
11 | 14 |
15 | ) 16 | } 17 | 18 | return ( 19 |
20 | Logged in as {session.user.name} 21 | 22 |
23 | 24 |
25 |
26 | ) 27 | } 28 | -------------------------------------------------------------------------------- /components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | 3 | import { cn } from '@/lib/utils' 4 | 5 | const Input = React.forwardRef>( 6 | ({ className, type, ...props }, ref) => { 7 | return ( 8 | 17 | ) 18 | }, 19 | ) 20 | Input.displayName = 'Input' 21 | 22 | export { Input } 23 | -------------------------------------------------------------------------------- /components/providers.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import type { QueryClient } from '@tanstack/react-query' 4 | import { QueryClientProvider } from '@tanstack/react-query' 5 | import { ThemeProvider } from 'next-themes' 6 | 7 | import { createQueryClient } from '@/lib/api/query-client' 8 | 9 | let clientQueryClientSingleton: QueryClient | undefined = undefined 10 | const getQueryClient = () => { 11 | if (typeof window === 'undefined') return createQueryClient() 12 | else return (clientQueryClientSingleton ??= createQueryClient()) 13 | } 14 | 15 | export const Providers = ({ children }: Readonly<{ children: React.ReactNode }>) => { 16 | const queryClient = getQueryClient() 17 | 18 | return ( 19 | 20 | {children} 21 | 22 | ) 23 | } 24 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Base Options: */ 4 | "esModuleInterop": true, 5 | "skipLibCheck": true, 6 | "target": "es2022", 7 | "allowJs": true, 8 | "resolveJsonModule": true, 9 | "moduleDetection": "force", 10 | "isolatedModules": true, 11 | "verbatimModuleSyntax": true, 12 | 13 | /* Strictness */ 14 | "strict": true, 15 | "noUncheckedIndexedAccess": true, 16 | "checkJs": true, 17 | 18 | /* Bundled projects */ 19 | "lib": ["dom", "dom.iterable", "ES2022"], 20 | "noEmit": true, 21 | "module": "ESNext", 22 | "moduleResolution": "Bundler", 23 | "jsx": "preserve", 24 | "plugins": [{ "name": "next" }], 25 | "incremental": true, 26 | 27 | /* Path Aliases */ 28 | "baseUrl": ".", 29 | "paths": { "@/*": ["./*"] } 30 | }, 31 | "include": [ 32 | "next-env.d.ts", 33 | "*.config.js", 34 | "**/*.ts", 35 | "**/*.tsx", 36 | "**/*.cjs", 37 | "**/*.js", 38 | ".next/types/**/*.ts" 39 | ], 40 | "exclude": ["node_modules"] 41 | } 42 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | /** @typedef {import("prettier").Config} PrettierConfig */ 2 | /** @typedef {import("prettier-plugin-tailwindcss").PluginOptions} TailwindConfig */ 3 | /** @typedef {import("@ianvs/prettier-plugin-sort-imports").PluginConfig} SortImportsConfig */ 4 | 5 | /** @type { PrettierConfig | SortImportsConfig | TailwindConfig } */ 6 | const config = { 7 | /* General Prettier Config */ 8 | semi: false, 9 | tabWidth: 2, 10 | printWidth: 90, 11 | singleQuote: true, 12 | trailingComma: 'all', 13 | 14 | plugins: ['@ianvs/prettier-plugin-sort-imports', 'prettier-plugin-tailwindcss'], 15 | 16 | tailwindFunctions: ['cn', 'cva'], 17 | 18 | importOrder: [ 19 | '', 20 | '^(react/(.*)$)|^(react$)', 21 | '^(next/(.*)$)|^(next$)', 22 | '', 23 | '', 24 | '^(@/(.*)$)', 25 | '^[.|..]', 26 | '^@/', 27 | '^[../]', 28 | '^[./]', 29 | ], 30 | importOrderParserPlugins: ['typescript', 'jsx', 'decorators-legacy'], 31 | importOrderTypeScriptVersion: '4.4.0', 32 | } 33 | 34 | export default config 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Tran Tien (Tiesen) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /server/api/routers/post.ts: -------------------------------------------------------------------------------- 1 | import { t } from 'elysia' 2 | 3 | import { createElysiaRouter } from '@/server/api/elysia' 4 | 5 | export const postRouter = createElysiaRouter({ prefix: '/post' }) 6 | .get('/all', async ({ ctx }) => { 7 | return ctx.db.post.findMany({ orderBy: { createdAt: 'desc' } }) 8 | }) 9 | .get('/byId/:id', async ({ ctx, params }) => { 10 | return ctx.db.post.findUnique({ where: { id: params.id } }) 11 | }) 12 | .post( 13 | '/create', 14 | async ({ ctx, body, error }) => { 15 | if (!ctx.session.user) 16 | return error('Unauthorized', 'You must be logged in to create a post') 17 | 18 | return ctx.db.post.create({ 19 | data: { ...body, user: { connect: { id: ctx.session.user.id } } }, 20 | }) 21 | }, 22 | { 23 | body: t.Object({ 24 | title: t.String({ minLength: 1, error: 'Title is required' }), 25 | content: t.String({ minLength: 1, error: 'Content is required' }), 26 | }), 27 | }, 28 | ) 29 | .post('/remove/:id', async ({ ctx, params, error }) => { 30 | if (!ctx.session.user) 31 | return error('Unauthorized', 'You must be logged in to create a post') 32 | 33 | return ctx.db.post.delete({ where: { id: params.id } }) 34 | }) 35 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Create Yukie App 2 | 3 | > [!WARNING] 4 | > This repository has been moved to [tiesen243/create-yuki-stack](https://github.com/tiesen243/create-yuki-stack) 5 | 6 | ## The Stack 7 | 8 | This stack is a web development stack focused on **simplicity**, **modularity**, and **full-stack typesafety**. It consists of: 9 | 10 | - [Next.js](https://nextjs.org/) 11 | - [ElysiaJS](https://elysiajs.com/) 12 | - [Tailwind CSS](https://tailwindcss.com/) 13 | - [TypeScript](https://www.typescriptlang.org/) 14 | - [Prisma](https://www.prisma.io/) 15 | 16 | ## Getting Started 17 | 18 | To create a new Yukie app, run: 19 | 20 | **NPM** 21 | 22 | ```bash 23 | npm create next-app@latest --example https://github.com/tiesen243/create-yukie-app 24 | ``` 25 | 26 | **Yarn** 27 | 28 | ```bash 29 | yarn create next-app@latest --example https://github.com/tiesen243/create-yukie-app 30 | ``` 31 | 32 | **PNPM** 33 | 34 | ```bash 35 | pnpm create next-app@latest --example https://github.com/tiesen243/create-yukie-app 36 | ``` 37 | 38 | **Bun** 39 | 40 | ```bash 41 | bun create next-app@latest --example https://github.com/tiesen243/create-yukie-app 42 | ``` 43 | 44 | ## License 45 | 46 | This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. 47 | -------------------------------------------------------------------------------- /app/page.tsx: -------------------------------------------------------------------------------- 1 | import { ThemeBtn } from '@/components/theme-btn' 2 | import { Typography } from '@/components/ui/typography' 3 | import { AuthShowcase } from './_components/auth-showcase' 4 | import { CreatePostForm, PostList } from './_components/post' 5 | 6 | const Page = () => { 7 | return ( 8 |
9 |
10 | 11 | Create Yukie App 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 |
21 | 22 |
23 |
24 | ) 25 | } 26 | 27 | export default Page 28 | -------------------------------------------------------------------------------- /hooks/use-post.ts: -------------------------------------------------------------------------------- 1 | import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' 2 | 3 | import { api } from '@/lib/api' 4 | import { toast } from './use-toast' 5 | 6 | export const usePost = (id: string) => { 7 | const queryClient = useQueryClient() 8 | 9 | const { data: post, isLoading } = useQuery({ 10 | queryKey: ['post', id], 11 | queryFn: () => 12 | api.post 13 | .byId({ id }) 14 | .get() 15 | .then((res) => (res.error ? Promise.reject(res.error.value) : res.data)), 16 | }) 17 | 18 | const deletePost = useMutation({ 19 | mutationKey: ['post', 'delete'], 20 | mutationFn: () => 21 | api.post 22 | .remove({ id }) 23 | .post() 24 | .then((res) => { 25 | if (res.error && typeof res.error.value === 'string') 26 | toast({ 27 | description: res.error.value, 28 | variant: 'error', 29 | }) 30 | return res.data 31 | }), 32 | onSuccess: () => queryClient.invalidateQueries({ queryKey: ['post', 'all'] }), 33 | }) 34 | 35 | return { 36 | post, 37 | deletePost: deletePost.mutate, 38 | deleteErrors: deletePost.error, 39 | isLoading, 40 | isDeleting: deletePost.isPending, 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /components/ui/toaster.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { 4 | CircleAlertIcon, 5 | CircleCheckIcon, 6 | InfoIcon, 7 | TriangleAlertIcon, 8 | } from 'lucide-react' 9 | 10 | import { 11 | Toast, 12 | ToastClose, 13 | ToastDescription, 14 | ToastProvider, 15 | ToastTitle, 16 | ToastViewport, 17 | } from '@/components/ui/toast' 18 | import { useToast } from '@/hooks/use-toast' 19 | 20 | export function Toaster() { 21 | const { toasts } = useToast() 22 | 23 | return ( 24 | 25 | {toasts.map(({ id, title, description, action, ...props }) => ( 26 | 27 | {props.variant === 'success' && } 28 | {props.variant === 'warning' && } 29 | {props.variant === 'info' && } 30 | {props.variant === 'error' && } 31 | 32 |
33 | {title && {title}} 34 | {description && {description}} 35 |
36 | {action} 37 | 38 |
39 | ))} 40 | 41 |
42 | ) 43 | } 44 | -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | generator client { 2 | provider = "prisma-client-js" 3 | } 4 | 5 | datasource db { 6 | provider = "postgresql" 7 | url = env("DATABASE_URL") 8 | } 9 | 10 | model User { 11 | id String @id @default(cuid()) 12 | name String 13 | email String @unique 14 | image String 15 | password String? 16 | accounts Account[] 17 | sessions Session[] 18 | 19 | posts Post[] 20 | 21 | createdAt DateTime @default(now()) 22 | updatedAt DateTime @updatedAt 23 | } 24 | 25 | model Account { 26 | provider String 27 | providerId String 28 | providerName String 29 | 30 | user User @relation(fields: [userId], references: [id], onDelete: Cascade) 31 | userId String 32 | 33 | @@id([provider, providerId]) 34 | } 35 | 36 | model Session { 37 | sessionToken String @unique 38 | expiresAt DateTime 39 | user User @relation(fields: [userId], references: [id], onDelete: Cascade) 40 | userId String 41 | 42 | createdAt DateTime @default(now()) 43 | } 44 | 45 | model Post { 46 | id String @id @default(cuid()) 47 | title String 48 | content String 49 | 50 | user User @relation(fields: [userId], references: [id], onDelete: Cascade) 51 | userId String 52 | 53 | createdAt DateTime @default(now()) 54 | } 55 | -------------------------------------------------------------------------------- /lib/metadata.ts: -------------------------------------------------------------------------------- 1 | import type { Metadata } from 'next' 2 | 3 | import { getBaseUrl } from '@/lib/utils' 4 | 5 | export const createMetadata = ( 6 | override: Omit & { title?: string }, 7 | ): Metadata => { 8 | const siteName = 'Create Yukie App' 9 | 10 | const url = override.openGraph?.url 11 | ? `${getBaseUrl()}${override.openGraph.url}` 12 | : getBaseUrl() 13 | const images = [ 14 | ...((override.openGraph?.images as [] | null) ?? []), 15 | 'https://tiesen.id.vn/api/og', // Or create your own API route to generate OG images in `/app/api/og` 16 | ] 17 | 18 | return { 19 | ...override, 20 | metadataBase: new URL(getBaseUrl()), 21 | title: override.title ? `${siteName} | ${override.title}` : siteName, 22 | description: 23 | override.description ?? 24 | 'Create Yukie App is a starter template for Next.js and ElysiaJS with TypeScript, Tailwind CSS, and React Query.', 25 | applicationName: siteName, 26 | alternates: { canonical: url }, 27 | twitter: { card: 'summary_large_image' }, 28 | openGraph: { url, images, siteName, type: 'website', ...override.openGraph }, 29 | icons: { 30 | // Replace with your own icons 31 | icon: 'https://tiesen.id.vn/favicon.ico', 32 | shortcut: 'https://tiesen.id.vn/favicon-16x16.png', 33 | apple: 'https://tiesen.id.vn/apple-touch-icon.png', 34 | }, 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /hooks/use-posts.ts: -------------------------------------------------------------------------------- 1 | import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' 2 | 3 | import { toast } from '@/hooks/use-toast' 4 | import { api } from '@/lib/api' 5 | 6 | export const usePosts = () => { 7 | const queryClient = useQueryClient() 8 | 9 | const { data: posts = [], isLoading } = useQuery({ 10 | queryKey: ['post', 'all'], 11 | queryFn: () => 12 | api.post.all 13 | .get() 14 | .then((res) => (res.error ? Promise.reject(res.error.value) : res.data)), 15 | }) 16 | 17 | interface Input { 18 | title: string 19 | content: string 20 | } 21 | const createPost = useMutation, Input>({ 22 | mutationKey: ['post', 'create'], 23 | mutationFn: async (data) => 24 | api.post.create.post(data).then((res) => { 25 | if (res.error) { 26 | if (typeof res.error.value === 'string') 27 | toast({ 28 | description: res.error.value, 29 | variant: 'error', 30 | }) 31 | 32 | return Promise.reject(res.error.value) 33 | } 34 | return res.data 35 | }), 36 | onSuccess: () => void queryClient.invalidateQueries({ queryKey: ['post', 'all'] }), 37 | }) 38 | 39 | return { 40 | posts, 41 | createPost: createPost.mutate, 42 | fieldErrors: createPost.error, 43 | isLoading, 44 | isCreating: createPost.isPending, 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /env.ts: -------------------------------------------------------------------------------- 1 | import { createEnv } from '@t3-oss/env-nextjs' 2 | import { vercel } from '@t3-oss/env-nextjs/presets' 3 | import { z } from 'zod' 4 | 5 | export const env = createEnv({ 6 | extends: [vercel()], 7 | /** 8 | * Specify your server-side environment variables schema here. This way you can ensure the app 9 | * isn't built with invalid env vars. 10 | */ 11 | server: { 12 | NODE_ENV: z.enum(['development', 'test', 'production']), 13 | DATABASE_URL: z.string().url(), 14 | DISCORD_ID: z.string(), 15 | DISCORD_SECRET: z.string(), 16 | }, 17 | 18 | /** 19 | * Specify your client-side environment variables schema here. This way you can ensure the app 20 | * isn't built with invalid env vars. To expose them to the client, prefix them with 21 | * `NEXT_PUBLIC_`. 22 | */ 23 | client: { 24 | // NEXT_PUBLIC_CLIENTVAR: z.string(), 25 | }, 26 | 27 | /** 28 | * You can't destruct `process.env` as a regular object in the Next.js edge runtimes (e.g. 29 | * middlewares) or client-side so we need to destruct manually. 30 | */ 31 | runtimeEnv: { 32 | NODE_ENV: process.env.NODE_ENV, 33 | DATABASE_URL: process.env.DATABASE_URL, 34 | DISCORD_ID: process.env.DISCORD_ID, 35 | DISCORD_SECRET: process.env.DISCORD_SECRET, 36 | // NEXT_PUBLIC_CLIENTVAR: process.env.NEXT_PUBLIC_CLIENTVAR, 37 | }, 38 | /** 39 | * Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially 40 | * useful for Docker builds. 41 | */ 42 | skipValidation: !!process.env.SKIP_ENV_VALIDATION, 43 | /** 44 | * Makes it so that empty strings are treated as undefined. `SOME_VAR: z.string()` and 45 | * `SOME_VAR=''` will throw an error. 46 | */ 47 | emptyStringAsUndefined: true, 48 | }) 49 | -------------------------------------------------------------------------------- /components/ui/typography.tsx: -------------------------------------------------------------------------------- 1 | import type { VariantProps } from 'class-variance-authority' 2 | import * as React from 'react' 3 | import { cva } from 'class-variance-authority' 4 | 5 | import { cn } from '@/lib/utils' 6 | 7 | const typographyVariants = cva('font-sans text-base font-normal text-foreground', { 8 | variants: { 9 | level: { 10 | h1: 'scroll-m-20 text-4xl font-extrabold tracking-tight lg:text-5xl', 11 | h2: 'scroll-m-20 border-b pb-2 text-3xl font-semibold tracking-tight first:mt-0', 12 | h3: 'scroll-m-20 text-2xl font-semibold tracking-tight', 13 | h4: 'scroll-m-20 text-xl font-semibold tracking-tight', 14 | p: 'leading-7 [&:not(:first-child)]:mt-4', 15 | blockquote: 'my-4 border-l-2 pl-6 italic', 16 | code: 'relative rounded bg-muted px-[0.3rem] py-[0.2rem] font-mono text-sm font-semibold', 17 | }, 18 | color: { 19 | primary: 'text-primary', 20 | secondary: 'text-secondary', 21 | destructive: 'text-destructive', 22 | muted: 'text-muted-foreground', 23 | }, 24 | }, 25 | defaultVariants: { 26 | level: 'p', 27 | color: 'primary', 28 | }, 29 | }) 30 | 31 | export interface TypographyProps 32 | extends Omit, 'color'>, 33 | VariantProps {} 34 | 35 | const Typography = React.forwardRef( 36 | ({ className, level = 'p', color, ...props }, ref) => { 37 | const Comp = level as React.ElementType 38 | 39 | return ( 40 | 45 | ) 46 | }, 47 | ) 48 | Typography.displayName = 'Typography' 49 | 50 | export { Typography, typographyVariants } 51 | -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | :root { 7 | --background: 0 0% 100%; 8 | --foreground: 0 0% 3.9%; 9 | --card: 0 0% 100%; 10 | --card-foreground: 0 0% 3.9%; 11 | --popover: 0 0% 100%; 12 | --popover-foreground: 0 0% 3.9%; 13 | --primary: 0 0% 9%; 14 | --primary-foreground: 0 0% 98%; 15 | --secondary: 0 0% 96.1%; 16 | --secondary-foreground: 0 0% 9%; 17 | --muted: 0 0% 96.1%; 18 | --muted-foreground: 0 0% 45.1%; 19 | --accent: 0 0% 96.1%; 20 | --accent-foreground: 0 0% 9%; 21 | --destructive: 0 84.2% 60.2%; 22 | --destructive-foreground: 0 0% 98%; 23 | --border: 0 0% 89.8%; 24 | --input: 0 0% 89.8%; 25 | --ring: 0 0% 3.9%; 26 | --chart-1: 12 76% 61%; 27 | --chart-2: 173 58% 39%; 28 | --chart-3: 197 37% 24%; 29 | --chart-4: 43 74% 66%; 30 | --chart-5: 27 87% 67%; 31 | --radius: 0.5rem; 32 | } 33 | .dark { 34 | --background: 0 0% 3.9%; 35 | --foreground: 0 0% 98%; 36 | --card: 0 0% 3.9%; 37 | --card-foreground: 0 0% 98%; 38 | --popover: 0 0% 3.9%; 39 | --popover-foreground: 0 0% 98%; 40 | --primary: 0 0% 98%; 41 | --primary-foreground: 0 0% 9%; 42 | --secondary: 0 0% 14.9%; 43 | --secondary-foreground: 0 0% 98%; 44 | --muted: 0 0% 14.9%; 45 | --muted-foreground: 0 0% 63.9%; 46 | --accent: 0 0% 14.9%; 47 | --accent-foreground: 0 0% 98%; 48 | --destructive: 0 62.8% 30.6%; 49 | --destructive-foreground: 0 0% 98%; 50 | --border: 0 0% 14.9%; 51 | --input: 0 0% 14.9%; 52 | --ring: 0 0% 83.1%; 53 | --chart-1: 220 70% 50%; 54 | --chart-2: 160 60% 45%; 55 | --chart-3: 30 80% 55%; 56 | --chart-4: 280 65% 60%; 57 | --chart-5: 340 75% 55%; 58 | } 59 | } 60 | 61 | @layer base { 62 | * { 63 | @apply border-border; 64 | } 65 | body { 66 | @apply bg-background text-foreground; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /components/ui/card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | 3 | import { cn } from '@/lib/utils' 4 | 5 | const Card = React.forwardRef>( 6 | ({ className, ...props }, ref) => ( 7 |
12 | ), 13 | ) 14 | Card.displayName = 'Card' 15 | 16 | const CardHeader = React.forwardRef>( 17 | ({ className, ...props }, ref) => ( 18 |
23 | ), 24 | ) 25 | CardHeader.displayName = 'CardHeader' 26 | 27 | const CardTitle = React.forwardRef>( 28 | ({ className, ...props }, ref) => ( 29 |
34 | ), 35 | ) 36 | CardTitle.displayName = 'CardTitle' 37 | 38 | const CardDescription = React.forwardRef< 39 | HTMLDivElement, 40 | React.HTMLAttributes 41 | >(({ className, ...props }, ref) => ( 42 |
43 | )) 44 | CardDescription.displayName = 'CardDescription' 45 | 46 | const CardContent = React.forwardRef< 47 | HTMLDivElement, 48 | React.HTMLAttributes 49 | >(({ className, ...props }, ref) => ( 50 |
51 | )) 52 | CardContent.displayName = 'CardContent' 53 | 54 | const CardFooter = React.forwardRef>( 55 | ({ className, ...props }, ref) => ( 56 |
57 | ), 58 | ) 59 | CardFooter.displayName = 'CardFooter' 60 | 61 | export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } 62 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "create-yukie-app", 3 | "version": "2.0.0", 4 | "private": true, 5 | "type": "module", 6 | "scripts": { 7 | "build": "bun db:generate && next build", 8 | "check": "bun lint && bun format && tsc --noEmit", 9 | "db:generate": "prisma generate", 10 | "db:push": "prisma db push", 11 | "db:studio": "prisma studio", 12 | "dev": "next dev --turbopack", 13 | "format": "prettier --check . --cache", 14 | "format:fix": "prettier --check . --write --cache", 15 | "lint": "next lint --cache-location .cache/.eslintcache", 16 | "lint:fix": "next lint --fix --cache-location .cache/.eslintcache", 17 | "preview": "next build && next start", 18 | "start": "next start", 19 | "typecheck": "tsc --noEmit" 20 | }, 21 | "dependencies": { 22 | "@elysiajs/eden": "^1.2.0", 23 | "@prisma/client": "6.2.1", 24 | "@radix-ui/react-slot": "^1.1.1", 25 | "@radix-ui/react-toast": "^1.2.5", 26 | "@t3-oss/env-nextjs": "^0.11.1", 27 | "@tanstack/react-query": "^5.64.2", 28 | "arctic": "^3.2.1", 29 | "class-variance-authority": "^0.7.1", 30 | "clsx": "^2.1.1", 31 | "elysia": "^1.2.10", 32 | "lucide-react": "^0.473.0", 33 | "next": "15.1.6", 34 | "next-themes": "^0.4.4", 35 | "react": "^19.0.0", 36 | "react-dom": "^19.0.0", 37 | "tailwind-merge": "^2.6.0", 38 | "tailwindcss-animate": "^1.0.7", 39 | "zod": "^3.24.1" 40 | }, 41 | "devDependencies": { 42 | "@eslint/js": "^9.18.0", 43 | "@ianvs/prettier-plugin-sort-imports": "^4.4.1", 44 | "@next/eslint-plugin-next": "^15.1.6", 45 | "@types/node": "^20.17.14", 46 | "@types/react": "^19.0.7", 47 | "@types/react-dom": "^19.0.3", 48 | "eslint": "^9.18.0", 49 | "eslint-plugin-import": "^2.31.0", 50 | "eslint-plugin-react": "^7.37.4", 51 | "eslint-plugin-react-hooks": "^5.1.0", 52 | "postcss": "^8.5.1", 53 | "prettier": "^3.4.2", 54 | "prettier-plugin-tailwindcss": "^0.6.10", 55 | "prisma": "^6.2.1", 56 | "tailwindcss": "^3.4.17", 57 | "typescript": "^5.7.3", 58 | "typescript-eslint": "^8.21.0" 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import type { VariantProps } from 'class-variance-authority' 2 | import * as React from 'react' 3 | import { Slot } from '@radix-ui/react-slot' 4 | import { cva } from 'class-variance-authority' 5 | 6 | import { cn } from '@/lib/utils' 7 | 8 | const buttonVariants = cva( 9 | 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0', 10 | { 11 | variants: { 12 | variant: { 13 | default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90', 14 | destructive: 15 | 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90', 16 | outline: 17 | 'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground', 18 | secondary: 19 | 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80', 20 | ghost: 'hover:bg-accent hover:text-accent-foreground', 21 | link: 'text-primary underline-offset-4 hover:underline', 22 | }, 23 | size: { 24 | default: 'h-9 px-4 py-2', 25 | sm: 'h-8 rounded-md px-3 text-xs', 26 | lg: 'h-10 rounded-md px-8', 27 | icon: 'h-9 w-9', 28 | }, 29 | }, 30 | defaultVariants: { 31 | variant: 'default', 32 | size: 'default', 33 | }, 34 | }, 35 | ) 36 | 37 | export interface ButtonProps 38 | extends React.ButtonHTMLAttributes, 39 | VariantProps { 40 | asChild?: boolean 41 | } 42 | 43 | const Button = React.forwardRef( 44 | ({ className, variant, size, asChild = false, ...props }, ref) => { 45 | const Comp = asChild ? Slot : 'button' 46 | return ( 47 | 52 | ) 53 | }, 54 | ) 55 | Button.displayName = 'Button' 56 | 57 | export { Button, buttonVariants } 58 | -------------------------------------------------------------------------------- /app/api/auth/[...auth]/route.ts: -------------------------------------------------------------------------------- 1 | import type { NextRequest } from 'next/server' 2 | import { cookies } from 'next/headers' 3 | import { NextResponse } from 'next/server' 4 | import { OAuth2RequestError } from 'arctic' 5 | import { env } from 'elysia' 6 | 7 | import { createSession, generateSessionToken } from '@/server/auth' 8 | import { OAuth } from '@/server/auth/oauth' 9 | 10 | // export const runtime = 'edge' 11 | 12 | export const GET = async ( 13 | req: NextRequest, 14 | { params }: { params: Promise<{ auth: [string, string] }> }, 15 | ) => { 16 | const nextUrl = new URL(req.url) 17 | 18 | const [provider, isCallback] = (await params).auth 19 | const callbackUrl = `${nextUrl.origin}/api/auth/${provider}/callback` 20 | 21 | const authProvider = new OAuth(provider, callbackUrl) 22 | 23 | if (!isCallback) { 24 | const { url, state } = authProvider.getOAuthUrl() 25 | ;(await cookies()).set('oauth_state', state) 26 | 27 | return NextResponse.redirect(new URL(`${url}`, nextUrl)) 28 | } 29 | 30 | try { 31 | const code = nextUrl.searchParams.get('code') ?? '' 32 | const state = nextUrl.searchParams.get('state') ?? '' 33 | const storedState = req.cookies.get('oauth_state')?.value ?? '' 34 | ;(await cookies()).delete('oauth_state') 35 | 36 | if (!code || !state || state !== storedState) throw new Error('Invalid state') 37 | 38 | const user = await authProvider.callback(code) 39 | const token = await generateSessionToken() 40 | const session = await createSession(token, user.id) 41 | ;(await cookies()).set('auth_token', token, { 42 | httpOnly: true, 43 | path: '/', 44 | secure: env.NODE_ENV === 'production', 45 | sameSite: 'lax', 46 | expires: session.expiresAt, 47 | }) 48 | 49 | return NextResponse.redirect(new URL('/', nextUrl)) 50 | } catch (e) { 51 | if (e instanceof OAuth2RequestError) 52 | return NextResponse.json({ error: e.message }, { status: Number(e.code) }) 53 | else if (e instanceof Error) 54 | return NextResponse.json({ error: e.message }, { status: 500 }) 55 | else return NextResponse.json({ error: 'An unknown error occurred' }, { status: 500 }) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from 'tailwindcss' 2 | import animatePlugin from 'tailwindcss-animate' 3 | import { fontFamily } from 'tailwindcss/defaultTheme' 4 | 5 | export default { 6 | darkMode: ['class'], 7 | content: ['./components/**/*.{ts,tsx}', './app/**/*.{ts,tsx}'], 8 | theme: { 9 | extend: { 10 | container: { center: true, padding: '2rem', screens: { '2xl': '1400px' } }, 11 | fontFamily: { 12 | sans: ['var(--font-geist-sans)', ...fontFamily.sans], 13 | mono: ['var(--font-geist-mono)', ...fontFamily.mono], 14 | }, 15 | colors: { 16 | background: 'hsl(var(--background))', 17 | foreground: 'hsl(var(--foreground))', 18 | card: { 19 | DEFAULT: 'hsl(var(--card))', 20 | foreground: 'hsl(var(--card-foreground))', 21 | }, 22 | popover: { 23 | DEFAULT: 'hsl(var(--popover))', 24 | foreground: 'hsl(var(--popover-foreground))', 25 | }, 26 | primary: { 27 | DEFAULT: 'hsl(var(--primary))', 28 | foreground: 'hsl(var(--primary-foreground))', 29 | }, 30 | secondary: { 31 | DEFAULT: 'hsl(var(--secondary))', 32 | foreground: 'hsl(var(--secondary-foreground))', 33 | }, 34 | muted: { 35 | DEFAULT: 'hsl(var(--muted))', 36 | foreground: 'hsl(var(--muted-foreground))', 37 | }, 38 | accent: { 39 | DEFAULT: 'hsl(var(--accent))', 40 | foreground: 'hsl(var(--accent-foreground))', 41 | }, 42 | destructive: { 43 | DEFAULT: 'hsl(var(--destructive))', 44 | foreground: 'hsl(var(--destructive-foreground))', 45 | }, 46 | border: 'hsl(var(--border))', 47 | input: 'hsl(var(--input))', 48 | ring: 'hsl(var(--ring))', 49 | chart: { 50 | '1': 'hsl(var(--chart-1))', 51 | '2': 'hsl(var(--chart-2))', 52 | '3': 'hsl(var(--chart-3))', 53 | '4': 'hsl(var(--chart-4))', 54 | '5': 'hsl(var(--chart-5))', 55 | }, 56 | }, 57 | borderRadius: { 58 | lg: 'var(--radius)', 59 | md: 'calc(var(--radius) - 2px)', 60 | sm: 'calc(var(--radius) - 4px)', 61 | }, 62 | }, 63 | }, 64 | plugins: [animatePlugin], 65 | } satisfies Config 66 | -------------------------------------------------------------------------------- /server/auth/index.ts: -------------------------------------------------------------------------------- 1 | 'use server' 2 | 3 | import type { Session, User } from '@prisma/client' 4 | import { cookies } from 'next/headers' 5 | import { sha256 } from '@oslojs/crypto/sha2' 6 | import { encodeBase32LowerCaseNoPadding, encodeHexLowerCase } from '@oslojs/encoding' 7 | 8 | import { db } from '@/server/db' 9 | 10 | const EXPIRES_IN = 1000 * 60 * 60 * 24 * 30 11 | 12 | // eslint-disable-next-line @typescript-eslint/require-await 13 | export const generateSessionToken = async (): Promise => { 14 | const bytes = new Uint8Array(20) 15 | crypto.getRandomValues(bytes) 16 | const token = encodeBase32LowerCaseNoPadding(bytes) 17 | return token 18 | } 19 | 20 | export const createSession = async (token: string, userId: string): Promise => { 21 | const session = { 22 | sessionToken: encodeHexLowerCase(sha256(new TextEncoder().encode(token))), 23 | expiresAt: new Date(Date.now() + EXPIRES_IN), 24 | user: { connect: { id: userId } }, 25 | } 26 | 27 | return await db.session.create({ data: session }) 28 | } 29 | 30 | export const auth = async (): Promise => { 31 | const token = (await cookies()).get('auth_token')?.value ?? '' 32 | if (!token) return { expires: new Date(Date.now()) } 33 | 34 | const sessionToken = encodeHexLowerCase(sha256(new TextEncoder().encode(token))) 35 | const result = await db.session.findUnique({ 36 | where: { sessionToken }, 37 | include: { user: true }, 38 | }) 39 | if (!result) return { expires: new Date(Date.now()) } 40 | 41 | const { user, ...session } = result 42 | if (Date.now() >= session.expiresAt.getTime()) { 43 | await db.session.delete({ where: { sessionToken } }) 44 | return { expires: new Date(Date.now()) } 45 | } 46 | 47 | if (Date.now() >= session.expiresAt.getTime() - EXPIRES_IN / 2) { 48 | session.expiresAt = new Date(Date.now() + EXPIRES_IN) 49 | await db.session.update({ 50 | where: { sessionToken }, 51 | data: { expiresAt: session.expiresAt }, 52 | }) 53 | } 54 | 55 | return { user, expires: session.createdAt } 56 | } 57 | 58 | export const invalidateSession = async (): Promise => { 59 | const token = (await cookies()).get('auth_token')?.value ?? '' 60 | if (!token) return 61 | 62 | const sessionToken = encodeHexLowerCase(sha256(new TextEncoder().encode(token))) 63 | await db.session.delete({ where: { sessionToken } }) 64 | } 65 | 66 | export interface SessionValidation { 67 | user?: User 68 | expires: Date 69 | } 70 | -------------------------------------------------------------------------------- /app/_components/post.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import type { Post } from '@prisma/client' 4 | 5 | import { Button } from '@/components/ui/button' 6 | import { Card, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' 7 | import { Input } from '@/components/ui/input' 8 | import { usePost } from '@/hooks/use-post' 9 | import { usePosts } from '@/hooks/use-posts' 10 | import { cn } from '@/lib/utils' 11 | 12 | export const CreatePostForm: React.FC = () => { 13 | const { createPost, isCreating, fieldErrors } = usePosts() 14 | 15 | return ( 16 |
{ 19 | e.preventDefault() 20 | const fd = new FormData(e.currentTarget) 21 | createPost({ 22 | title: fd.get('title') as string, 23 | content: fd.get('content') as string, 24 | }) 25 | e.currentTarget.reset() 26 | }} 27 | > 28 |
29 | 30 | {fieldErrors?.title && ( 31 |

{fieldErrors.title}

32 | )} 33 |
34 | 35 |
36 | 37 | {fieldErrors?.content && ( 38 |

{fieldErrors.content}

39 | )} 40 |
41 | 42 |
43 | ) 44 | } 45 | 46 | export const PostList: React.FC = () => { 47 | const { posts, isLoading } = usePosts() 48 | 49 | if (isLoading) 50 | return ( 51 |
52 | 53 | 54 | 55 | 56 |
57 | ) 58 | 59 | return ( 60 |
61 | {posts.map((p) => ( 62 | 63 | ))} 64 |
65 | ) 66 | } 67 | 68 | export const PostCard: React.FC<{ post: Post }> = ({ post }) => { 69 | const { deletePost } = usePost(post.id) 70 | return ( 71 | 72 | 73 | {post.title} 74 | {post.content} 75 | 76 | 77 | 86 | 87 | ) 88 | } 89 | 90 | export const PostCardSkeleton: React.FC<{ pulse?: boolean }> = ({ pulse = true }) => ( 91 | 92 | 93 | 94 |   95 | 96 | 99 |   100 | 101 | 102 | 103 | ) 104 | -------------------------------------------------------------------------------- /server/api/elysia.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * YOU PROBABLY DON'T NEED TO EDIT THIS FILE, UNLESS: 3 | * 1. You want to modify request context (see Part 1). 4 | * 2. You want to create a new middleware or type of procedure (see Part 3). 5 | * 6 | * TL;DR - This is where all the tRPC server stuff is created and plugged in. The pieces you will 7 | * need to use are documented accordingly near the end. 8 | */ 9 | 10 | import type { ElysiaConfig } from 'elysia' 11 | import Elysia from 'elysia' 12 | 13 | import { auth } from '@/server/auth' 14 | import { db } from '@/server/db' 15 | 16 | /** 17 | * 1. CONTEXT 18 | * 19 | * This section defines the "contexts" that are available in the backend API. 20 | * 21 | * These allow you to access things when processing a request, like the database, the session, etc. 22 | * 23 | * This helper generates the "internals" for a tRPC context. The API handler and RSC clients each 24 | * wrap this and provides the required context. 25 | * 26 | * @see https://trpc.io/docs/server/context 27 | */ 28 | export const createElysiaContext = new Elysia() 29 | .derive(async () => { 30 | const session = await auth() 31 | 32 | return { ctx: { db, session } } 33 | }) 34 | .decorate('ctx', { db }) 35 | .as('plugin') 36 | 37 | /** 38 | * 2. INITIALIZATION 39 | * 40 | * This is where the elysia API is initialized, connecting the context and transformer. We also parse 41 | * ZodErrors so that you get typesafety on the frontend if your procedure fails due to validation 42 | * errors on the backend. 43 | */ 44 | export const elysia =

(options?: ElysiaConfig

) => 45 | new Elysia(options).use(createElysiaContext).onError(({ code, error }) => { 46 | switch (code) { 47 | case 'VALIDATION': { 48 | const formattedErrors = error.all.reduce( 49 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 50 | (acc: Record, err: any) => { 51 | // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call 52 | acc[err.path.slice(1)] = err.schema.error ?? err.message 53 | return acc 54 | }, 55 | {}, 56 | ) 57 | 58 | return formattedErrors 59 | } 60 | default: 61 | return 'Unknown error' 62 | } 63 | }) 64 | 65 | /** 66 | * Middleware for timing procedure execution and adding an artificial delay in development. 67 | * 68 | * You can remove this if you don't like it, but it can help catch unwanted waterfalls by simulating 69 | * network latency that would occur in production but not in local development. 70 | */ 71 | const timmingMiddleware = new Elysia() 72 | .state({ start: 0 }) 73 | .onBeforeHandle(({ store }) => (store.start = Date.now())) 74 | .onAfterHandle(({ path, store: { start } }) => { 75 | console.log(`[Elysia] ${path} took ${Date.now() - start}ms to execute`) 76 | }) 77 | .as('plugin') 78 | 79 | /** 80 | * 3. ROUTER & PROCEDURE (THE IMPORTANT BIT) 81 | * 82 | * These are the pieces you use to build your elysia API. You should import these a lot in the 83 | * "/src/server/api/routers" directory. 84 | */ 85 | export const createElysiaRouter =

(options?: ElysiaConfig

) => 86 | elysia(options).use(timmingMiddleware) 87 | -------------------------------------------------------------------------------- /server/auth/oauth.ts: -------------------------------------------------------------------------------- 1 | import { Discord, generateState } from 'arctic' 2 | 3 | import { env } from '@/env' 4 | import { db } from '../db' 5 | 6 | export class OAuth { 7 | private name: string 8 | private provider: Discord 9 | private scopes: string[] 10 | private oauthUser: { id: string; email: string; name: string; image: string } 11 | 12 | constructor(provider: string, callback_url: string) { 13 | this.oauthUser = { id: '', email: '', name: '', image: '' } 14 | 15 | switch (provider) { 16 | case 'discord': 17 | this.name = 'discord' 18 | this.provider = new Discord(env.DISCORD_ID, env.DISCORD_SECRET, callback_url) 19 | this.scopes = ['identify', 'email'] 20 | break 21 | default: 22 | throw new Error(`Provider ${provider} not supported`) 23 | } 24 | } 25 | 26 | public getOAuthUrl(): { url: URL; state: string } { 27 | const state = generateState() 28 | 29 | const url = 30 | this.provider.createAuthorizationURL.length === 3 31 | ? this.provider.createAuthorizationURL(state, null, this.scopes) 32 | : // @ts-expect-error - This is a hack to make the types work 33 | this.provider.createAuthorizationURL(state, this.scopes) 34 | 35 | return { url, state } 36 | } 37 | 38 | public async callback(code: string) { 39 | const tokens = 40 | this.provider.validateAuthorizationCode.length == 2 41 | ? await this.provider.validateAuthorizationCode(code, '') 42 | : // @ts-expect-error - This is a hack to make the types work 43 | await this.provider.validateAuthorizationCode(code) 44 | 45 | switch (this.name) { 46 | case 'discord': 47 | await this.discord(tokens.accessToken()) 48 | break 49 | default: 50 | throw new Error(`Provider ${this.name} not supported`) 51 | } 52 | 53 | return await this.createUser() 54 | } 55 | 56 | private async createUser() { 57 | const { id, email, name, image } = this.oauthUser 58 | const create = { provider: this.name, providerId: id, providerName: name } 59 | 60 | const account = await db.account.findUnique({ 61 | where: { provider_providerId: { provider: this.name, providerId: id } }, 62 | }) 63 | let user = await db.user.findFirst({ where: { email } }) 64 | 65 | if (!account && !user) 66 | user = await db.user.create({ 67 | data: { email, name, image, accounts: { create } }, 68 | }) 69 | else if (!account && user) 70 | user = await db.user.update({ 71 | where: { email }, 72 | data: { accounts: { create } }, 73 | }) 74 | 75 | if (!user) throw new Error(`Failed to sign in with ${this.name}`) 76 | return user 77 | } 78 | 79 | private async discord(token: string) { 80 | // prettier-ignore 81 | interface DiscordUser { id: string; email: string; username: string; avatar: string } 82 | this.oauthUser = await fetch('https://discord.com/api/users/@me', { 83 | headers: { Authorization: `Bearer ${token}` }, 84 | }) 85 | .then((res) => res.json() as Promise) 86 | .then((account) => ({ 87 | id: account.id, 88 | name: account.username, 89 | email: account.email, 90 | image: `https://cdn.discordapp.com/avatars/${account.id}/${account.avatar}.png`, 91 | })) 92 | .catch(() => { 93 | throw new Error('Failed to fetch user data from Discord') 94 | }) 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | import jseslint from '@eslint/js' 3 | import nextPlugin from '@next/eslint-plugin-next' 4 | import importPlugin from 'eslint-plugin-import' 5 | import reactPlugin from 'eslint-plugin-react' 6 | import hooksPlugin from 'eslint-plugin-react-hooks' 7 | import tseslint from 'typescript-eslint' 8 | 9 | /** @type {import('typescript-eslint').Config} */ 10 | export default [ 11 | { ignores: ['.next/**'] }, 12 | 13 | // Base configs 14 | ...tseslint.config( 15 | { ignores: ['*.config.js'] }, 16 | { 17 | files: ['**/*.js', '**/*.ts', '**/*.tsx'], 18 | plugins: { import: importPlugin }, 19 | extends: [ 20 | jseslint.configs.recommended, 21 | ...tseslint.configs.strictTypeChecked, 22 | ...tseslint.configs.stylisticTypeChecked, 23 | ], 24 | rules: { 25 | '@typescript-eslint/consistent-type-imports': [ 26 | 'warn', 27 | { prefer: 'type-imports', fixStyle: 'separate-type-imports' }, 28 | ], 29 | '@typescript-eslint/no-misused-promises': [ 30 | 'error', 31 | { checksVoidReturn: { attributes: false } }, 32 | ], 33 | '@typescript-eslint/no-unnecessary-condition': [ 34 | 'error', 35 | { allowConstantLoopConditions: true }, 36 | ], 37 | '@typescript-eslint/no-unused-vars': [ 38 | 'error', 39 | { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }, 40 | ], 41 | '@typescript-eslint/prefer-promise-reject-errors': 'off', 42 | '@typescript-eslint/restrict-template-expressions': 'off', 43 | 'import/no-anonymous-default-export': 'warn', 44 | }, 45 | }, 46 | { 47 | linterOptions: { reportUnusedDisableDirectives: true }, 48 | languageOptions: { 49 | parser: tseslint.parser, 50 | parserOptions: { projectService: true }, 51 | }, 52 | }, 53 | ), 54 | 55 | // React configs 56 | { 57 | files: ['**/*.ts', '**/*.tsx'], 58 | plugins: { 59 | react: reactPlugin, 60 | 'react-hooks': hooksPlugin, 61 | '@next/next': nextPlugin, 62 | }, 63 | settings: { react: { version: 'detect' } }, 64 | rules: { 65 | ...reactPlugin.configs.recommended.rules, 66 | ...hooksPlugin.configs.recommended.rules, 67 | ...nextPlugin.configs.recommended.rules, 68 | ...nextPlugin.configs['core-web-vitals'].rules, 69 | 70 | 'react/no-unknown-property': 'off', 71 | 'react/react-in-jsx-scope': 'off', 72 | 'react/prop-types': 'off', 73 | }, 74 | }, 75 | 76 | // Restric environment variables 77 | ...tseslint.config( 78 | { ignores: ['**/env.ts'] }, 79 | { 80 | files: ['**/*.ts', '**/*.tsx'], 81 | rules: { 82 | 'no-restricted-properties': [ 83 | 'error', 84 | { 85 | object: 'process', 86 | property: 'env', 87 | message: 88 | "Use `import { env } from '@/env'` instead to ensure validated types.", 89 | }, 90 | ], 91 | 'no-restricted-imports': [ 92 | 'error', 93 | { 94 | name: 'process', 95 | importNames: ['env'], 96 | message: 97 | "Use `import { env } from '@/env'` instead to ensure validated types.", 98 | }, 99 | ], 100 | }, 101 | }, 102 | ), 103 | ] 104 | -------------------------------------------------------------------------------- /hooks/use-toast.ts: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | // Inspired by react-hot-toast library 4 | import * as React from 'react' 5 | 6 | import type { ToastActionElement, ToastProps } from '@/components/ui/toast' 7 | 8 | const TOAST_LIMIT = 3 9 | const TOAST_REMOVE_DELAY = 2000 10 | 11 | type ToasterToast = ToastProps & { 12 | id: string 13 | title?: React.ReactNode 14 | description?: React.ReactNode 15 | action?: ToastActionElement 16 | } 17 | 18 | const _actionTypes = { 19 | ADD_TOAST: 'ADD_TOAST', 20 | UPDATE_TOAST: 'UPDATE_TOAST', 21 | DISMISS_TOAST: 'DISMISS_TOAST', 22 | REMOVE_TOAST: 'REMOVE_TOAST', 23 | } as const 24 | 25 | let count = 0 26 | 27 | function genId() { 28 | count = (count + 1) % Number.MAX_SAFE_INTEGER 29 | return count.toString() 30 | } 31 | 32 | type ActionType = typeof _actionTypes 33 | 34 | type Action = 35 | | { 36 | type: ActionType['ADD_TOAST'] 37 | toast: ToasterToast 38 | } 39 | | { 40 | type: ActionType['UPDATE_TOAST'] 41 | toast: Partial 42 | } 43 | | { 44 | type: ActionType['DISMISS_TOAST'] 45 | toastId?: ToasterToast['id'] 46 | } 47 | | { 48 | type: ActionType['REMOVE_TOAST'] 49 | toastId?: ToasterToast['id'] 50 | } 51 | 52 | interface State { 53 | toasts: ToasterToast[] 54 | } 55 | 56 | const toastTimeouts = new Map>() 57 | 58 | const addToRemoveQueue = (toastId: string) => { 59 | if (toastTimeouts.has(toastId)) { 60 | return 61 | } 62 | 63 | const timeout = setTimeout(() => { 64 | toastTimeouts.delete(toastId) 65 | dispatch({ 66 | type: 'REMOVE_TOAST', 67 | toastId: toastId, 68 | }) 69 | }, TOAST_REMOVE_DELAY) 70 | 71 | toastTimeouts.set(toastId, timeout) 72 | } 73 | 74 | export const reducer = (state: State, action: Action): State => { 75 | switch (action.type) { 76 | case 'ADD_TOAST': 77 | return { 78 | ...state, 79 | toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT), 80 | } 81 | 82 | case 'UPDATE_TOAST': 83 | return { 84 | ...state, 85 | toasts: state.toasts.map((t) => 86 | t.id === action.toast.id ? { ...t, ...action.toast } : t, 87 | ), 88 | } 89 | 90 | case 'DISMISS_TOAST': { 91 | const { toastId } = action 92 | 93 | // ! Side effects ! - This could be extracted into a dismissToast() action, 94 | // but I'll keep it here for simplicity 95 | if (toastId) { 96 | addToRemoveQueue(toastId) 97 | } else { 98 | state.toasts.forEach((toast) => { 99 | addToRemoveQueue(toast.id) 100 | }) 101 | } 102 | 103 | return { 104 | ...state, 105 | toasts: state.toasts.map((t) => 106 | t.id === toastId || toastId === undefined 107 | ? { 108 | ...t, 109 | open: false, 110 | } 111 | : t, 112 | ), 113 | } 114 | } 115 | case 'REMOVE_TOAST': 116 | if (action.toastId === undefined) { 117 | return { 118 | ...state, 119 | toasts: [], 120 | } 121 | } 122 | return { 123 | ...state, 124 | toasts: state.toasts.filter((t) => t.id !== action.toastId), 125 | } 126 | } 127 | } 128 | 129 | const listeners: ((state: State) => void)[] = [] 130 | 131 | let memoryState: State = { toasts: [] } 132 | 133 | function dispatch(action: Action) { 134 | memoryState = reducer(memoryState, action) 135 | listeners.forEach((listener) => { 136 | listener(memoryState) 137 | }) 138 | } 139 | 140 | type Toast = Omit 141 | 142 | function toast({ ...props }: Toast) { 143 | const id = genId() 144 | 145 | const update = (props: ToasterToast) => { 146 | dispatch({ 147 | type: 'UPDATE_TOAST', 148 | toast: { ...props, id }, 149 | }) 150 | } 151 | const dismiss = () => { 152 | dispatch({ type: 'DISMISS_TOAST', toastId: id }) 153 | } 154 | 155 | dispatch({ 156 | type: 'ADD_TOAST', 157 | toast: { 158 | ...props, 159 | id, 160 | open: true, 161 | onOpenChange: (open) => { 162 | if (!open) dismiss() 163 | }, 164 | }, 165 | }) 166 | 167 | return { 168 | id: id, 169 | dismiss, 170 | update, 171 | } 172 | } 173 | 174 | function useToast() { 175 | const [state, setState] = React.useState(memoryState) 176 | 177 | React.useEffect(() => { 178 | listeners.push(setState) 179 | return () => { 180 | const index = listeners.indexOf(setState) 181 | if (index > -1) { 182 | listeners.splice(index, 1) 183 | } 184 | } 185 | }, [state]) 186 | 187 | return { 188 | ...state, 189 | toast, 190 | dismiss: (toastId?: string) => { 191 | dispatch({ type: 'DISMISS_TOAST', toastId }) 192 | }, 193 | } 194 | } 195 | 196 | export { useToast, toast } 197 | -------------------------------------------------------------------------------- /components/ui/toast.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import type { VariantProps } from 'class-variance-authority' 4 | import * as React from 'react' 5 | import * as ToastPrimitives from '@radix-ui/react-toast' 6 | import { cva } from 'class-variance-authority' 7 | import { X } from 'lucide-react' 8 | 9 | import { cn } from '@/lib/utils' 10 | 11 | const ToastProvider = ToastPrimitives.Provider 12 | 13 | const ToastViewport = React.forwardRef< 14 | React.ComponentRef, 15 | React.ComponentPropsWithoutRef 16 | >(({ className, ...props }, ref) => ( 17 | 25 | )) 26 | ToastViewport.displayName = ToastPrimitives.Viewport.displayName 27 | 28 | const toastVariants = cva( 29 | 'group pointer-events-auto relative mt-2 flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full', 30 | { 31 | variants: { 32 | variant: { 33 | default: 'border bg-card text-card-foreground', 34 | success: 35 | 'border-[hsl(145,92%,91%)] bg-[hsl(143,85%,96%)] text-[hsl(140,100%,27%)] dark:border-[hsl(147,100%,12%)] dark:bg-[hsl(150,100%,6%)] dark:text-[hsl(150,86%,65%)]', 36 | info: 'border-[hsl(221,91%,91%)] bg-[hsl(208,100%,97%)] text-[hsl(210,92%,45%)] dark:border-[hsl(223,100%,12%)] dark:bg-[hsl(215,100%,6%)] dark:text-[hsl(216,87%,65%)]', 37 | warning: 38 | 'border-[hsl(49,91%,91%)] bg-[hsl(49,100%,97%)] text-[hsl(31,92%,45%)] dark:border-[hsl(60,100%,12%)] dark:bg-[hsl(64,100%,6%)] dark:text-[hsl(46,87%,65%)]', 39 | error: 40 | 'border-[hsl(359,100%,94%)] bg-[hsl(359,100%,97%)] text-[hsl(360,100%,45%)] dark:border-[hsl(357,89%,16%)] dark:bg-[hsl(358,76%,10%)] dark:text-[hsl(358,100%,81%)]', 41 | }, 42 | }, 43 | defaultVariants: { 44 | variant: 'default', 45 | }, 46 | }, 47 | ) 48 | 49 | const Toast = React.forwardRef< 50 | React.ComponentRef, 51 | React.ComponentPropsWithoutRef & 52 | VariantProps 53 | >(({ className, variant, ...props }, ref) => { 54 | return ( 55 | 60 | ) 61 | }) 62 | Toast.displayName = ToastPrimitives.Root.displayName 63 | 64 | const ToastAction = React.forwardRef< 65 | React.ComponentRef, 66 | React.ComponentPropsWithoutRef 67 | >(({ className, ...props }, ref) => ( 68 | 76 | )) 77 | ToastAction.displayName = ToastPrimitives.Action.displayName 78 | 79 | const ToastClose = React.forwardRef< 80 | React.ComponentRef, 81 | React.ComponentPropsWithoutRef 82 | >(({ className, ...props }, ref) => ( 83 | 92 | 93 | 94 | )) 95 | ToastClose.displayName = ToastPrimitives.Close.displayName 96 | 97 | const ToastTitle = React.forwardRef< 98 | React.ComponentRef, 99 | React.ComponentPropsWithoutRef 100 | >(({ className, ...props }, ref) => ( 101 | 106 | )) 107 | ToastTitle.displayName = ToastPrimitives.Title.displayName 108 | 109 | const ToastDescription = React.forwardRef< 110 | React.ComponentRef, 111 | React.ComponentPropsWithoutRef 112 | >(({ className, ...props }, ref) => ( 113 | 118 | )) 119 | ToastDescription.displayName = ToastPrimitives.Description.displayName 120 | 121 | type ToastProps = React.ComponentPropsWithoutRef 122 | 123 | type ToastActionElement = React.ReactElement 124 | 125 | export { 126 | type ToastProps, 127 | type ToastActionElement, 128 | ToastProvider, 129 | ToastViewport, 130 | Toast, 131 | ToastTitle, 132 | ToastDescription, 133 | ToastClose, 134 | ToastAction, 135 | } 136 | --------------------------------------------------------------------------------