added mail service

This commit is contained in:
Yevhen Odynets 2024-04-10 21:24:25 +03:00
parent c76d4b9717
commit 78107d4ec7
80 changed files with 3478 additions and 329 deletions

79
.gitignore vendored
View File

@ -27,6 +27,7 @@ yarn-error.log*
# local env files
.env*.local
.env
# vercel
.vercel
@ -34,3 +35,81 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
# User-specific stuff
.idea/**/workspace.xml
.idea/**/tasks.xml
.idea/**/usage.statistics.xml
.idea/**/dictionaries
.idea/**/shelf
# AWS User-specific
.idea/**/aws.xml
# Generated files
.idea/**/contentModel.xml
# Sensitive or high-churn files
.idea/**/dataSources/
.idea/**/dataSources.ids
.idea/**/dataSources.local.xml
.idea/**/sqlDataSources.xml
.idea/**/dynamic.xml
.idea/**/uiDesigner.xml
.idea/**/dbnavigator.xml
# Gradle
.idea/**/gradle.xml
.idea/**/libraries
# Gradle and Maven with auto-import
# When using Gradle or Maven with auto-import, you should exclude module files,
# since they will be recreated, and may cause churn. Uncomment if using
# auto-import.
# .idea/artifacts
# .idea/compiler.xml
# .idea/jarRepositories.xml
# .idea/modules.xml
# .idea/*.iml
# .idea/modules
# *.iml
# *.ipr
# CMake
cmake-build-*/
# Mongo Explorer plugin
.idea/**/mongoSettings.xml
# File-based project format
*.iws
# IntelliJ
out/
# mpeltonen/sbt-idea plugin
.idea_modules/
# JIRA plugin
atlassian-ide-plugin.xml
# Cursive Clojure plugin
.idea/replstate.xml
# SonarLint plugin
.idea/sonarlint/
# Crashlytics plugin (for Android Studio and IntelliJ)
com_crashlytics_export_strings.xml
crashlytics.properties
crashlytics-build.properties
fabric.properties
# Editor-based Rest Client
.idea/httpRequests
# Android studio 3.1+ serialized cache file
.idea/caches/build_file_checksums.ser

5
.idea/.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/

12
.idea/a-naklejka.iml Normal file
View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/.tmp" />
<excludeFolder url="file://$MODULE_DIR$/temp" />
<excludeFolder url="file://$MODULE_DIR$/tmp" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

View File

@ -0,0 +1,5 @@
<component name="ProjectCodeStyleConfiguration">
<state>
<option name="PREFERRED_PROJECT_CODE_STYLE" value="Default" />
</state>
</component>

View File

@ -0,0 +1,7 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="TsLint" enabled="true" level="WARNING" enabled_by_default="true" />
</profile>
</component>

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="JavaScriptLibraryMappings">
<includedPredefinedLibrary name="Node.js Core" />
</component>
</project>

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="EslintConfiguration">
<option name="fix-on-save" value="true" />
</component>
</project>

9
.idea/markdown.xml Normal file
View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="MarkdownSettings">
<enabledExtensions>
<entry key="MermaidLanguageExtension" value="false" />
<entry key="PlantUMLLanguageExtension" value="false" />
</enabledExtensions>
</component>
</project>

8
.idea/modules.xml Normal file
View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/a-naklejka.iml" filepath="$PROJECT_DIR$/.idea/a-naklejka.iml" />
</modules>
</component>
</project>

7
.idea/prettier.xml Normal file
View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="PrettierConfiguration">
<option name="myConfigurationMode" value="AUTOMATIC" />
<option name="myRunOnSave" value="true" />
</component>
</project>

7
.idea/vcs.xml Normal file
View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

4
.idea/watcherTasks.xml Normal file
View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectTasksOptions" suppressed-tasks="SCSS" />
</project>

55
actions/login.ts Normal file
View File

@ -0,0 +1,55 @@
'use server'
import { infer as zInfer } from 'zod'
import { LoginSchema } from '@/schemas'
import { signIn } from '@/config/auth'
import { DEFAULT_LOGIN_REDIRECT } from '@/config/routes'
import { AuthError } from 'next-auth'
import { getUserByEmail } from '@/data/user'
import { sendVerificationEmail } from '@/actions/send-verification-email'
export const login = async (values: zInfer<typeof LoginSchema>) => {
const validatedFields = LoginSchema.safeParse(values)
if (!validatedFields.success) {
return { error: 'auth.form.error.invalid_fields' }
}
const { email, password } = validatedFields.data
const existingUser = await getUserByEmail(email)
if (!existingUser || !existingUser.email || !existingUser.password) {
return { error: 'auth.form.error.invalid_credentials' }
}
if (!existingUser.emailVerified) {
return await sendVerificationEmail(existingUser.email, existingUser.name)
}
try {
await signIn('credentials', {
email, password, redirectTo: DEFAULT_LOGIN_REDIRECT,
})
} catch (error) {
if (error instanceof AuthError) {
switch (error.type) {
case 'CredentialsSignin':
return { error: 'auth.form.error.invalid_credentials' }
case 'AccessDenied':
return { error: 'auth.form.error.access_denied' }
default:
console.error('ERROR.TYPE:', error.type)
return { error: 'common.something_went_wrong' }
}
}
throw error
}
}
export const SignInProvider = async (provider: 'google' | 'github' | 'facebook') => {
await signIn(provider, {
redirectTo: DEFAULT_LOGIN_REDIRECT,
})
}

35
actions/register.ts Normal file
View File

@ -0,0 +1,35 @@
'use server'
import { infer as zInfer } from 'zod'
import bcrypt from 'bcryptjs'
import { RegisterSchema } from '@/schemas'
import { PASSWORD_SALT_LENGTH } from '@/config/validation'
import { db } from '@/lib/db'
import { getUserByEmail } from '@/data/user'
import { sendVerificationEmail } from '@/actions/send-verification-email'
export const register = async (values: zInfer<typeof RegisterSchema>) => {
const validatedFields = RegisterSchema.safeParse(values)
if (!validatedFields.success) {
return { error: 'auth.form.error.invalid_fields' }
}
const { email, password, name } = validatedFields.data
const hashedPassword = await bcrypt.hash(password, PASSWORD_SALT_LENGTH)
const existingUser = await getUserByEmail(email)
if (existingUser) {
return { error: 'auth.form.error.email_taken' }
}
await db.user.create({
data: {
name, email, password: hashedPassword,
},
})
return await sendVerificationEmail(email, name)
}

View File

@ -0,0 +1,27 @@
'use server'
import mailer from '@/lib/mailer'
import { env } from 'process'
import { AUTH_EMAIL_VERIFICATION_URL } from '@/config/routes'
import { generateVerificationToken } from '@/lib/tokens'
const sendVerificationEmail = async (email: string, name?: string | null) => {
const verificationToken = await generateVerificationToken(email)
const confirmLink: string = [env.SITE_URL, AUTH_EMAIL_VERIFICATION_URL, '?token=', verificationToken].join('')
const { isOk, code, info, error } = await mailer({
to: name ? [
{ name: name?.toString(), address: verificationToken.email },
`test-xyhy2bvhj@srv1.mail-tester.com`] : verificationToken.email,
subject: 'Complete email verification for A-Naklejka',
html: `<p>Click <a href="${confirmLink}">here</a> to confirm email</p>`,
})
if (isOk) {
return { success: code === 250 ? 'auth.email.success.confirmation_email_sent' : info?.response }
} else {
return { error: env.DEBUG === 'true' ? error?.response : 'auth.email.error.verification_email_sending_error' }
}
}
export { sendVerificationEmail }

View File

@ -0,0 +1,18 @@
import { auth, signOut } from '@/config/auth'
const CabinetPage = async () => {
const session = await auth()
return (
<div>
{JSON.stringify(session)}
<form action={async () => {
'use server'
await signOut()
}}>
<button type="submit">SignOut {session?.user.role}</button>
</form>
</div>
)
}
export default CabinetPage

View File

@ -0,0 +1,23 @@
'use client'
import mailer from '@/lib/mailer'
export default function AboutPage () {
const onClick = () => {
mailer({
to: [
{ name: 'Yevhen', address: 'it@amok.space' },
{ name: 'Євген', address: 'yevhen.odynets@gmail.com' },
],
subject: 'ПОСИЛЕННЯ МОБІЛІЗАЦІЇ В УКРАЇНІ',
html: `<div>Коли Рада <strong>розгляне</strong> законопроєкт про мобілізацію у <del>другому</del> читанні</div>`,
}).catch(console.error)
}
return (
<button onClick={onClick}
className="bg-transparent hover:bg-blue-500 text-blue-700 font-semibold hover:text-white py-2 px-4 border border-blue-500 hover:border-transparent">
sendmail
</button>
)
}

View File

@ -0,0 +1,3 @@
export default function AboutUsPage () {
return (<div>AboutUsPage</div>)
}

View File

@ -0,0 +1,29 @@
import { Poppins } from 'next/font/google'
import { getScopedI18n } from '@/locales/server'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import LoginButton from '@/components/auth/LoginButton'
import { bg as bgg } from '@/config/layout'
const font = Poppins({
subsets: ['latin'], weight: ['600'],
})
export default async function Home () {
const t = await getScopedI18n('auth')
return (<main
className="flex flex-col items-center justify-center h-full bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-lime-400 to-emerald-800">
<div className="space-y-6 text-center">
<h1 className={cn('text-6xl font-semibold text-white drop-shadow-md',
font.className)}>
🔐 {t('title')}
</h1>
<p className="text-lg text-white">{t('subtitle')}</p>
<div>
<LoginButton>
<Button variant="secondary" size="lg">{t('sign_in')}</Button>
</LoginButton>
</div>
</div>
</main>)
}

View File

@ -0,0 +1,9 @@
import ErrorCard from '@/components/auth/ErrorCard'
const AuthErrorPage = () => {
return (
<ErrorCard/>
)
}
export default AuthErrorPage

View File

@ -0,0 +1,22 @@
'use client'
import { ReactElement } from 'react'
import Navbar from '@/components/auth/Navbar'
type Props = {
//params: { locale: string };
children: ReactElement;
}
const AuthLayout = ({ children }: Props) => {
return (
<>
<Navbar/>
<div
className="h-full flex items-center justify-center bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-sky-400 to-blue-800">
{children}
</div>
</>
)
}
export default AuthLayout

View File

@ -0,0 +1,9 @@
import { LoginForm } from '@/components/auth/LoginForm'
const LoginPage = () => {
return (
<LoginForm/>
)
}
export default LoginPage

View File

@ -0,0 +1,11 @@
import { RegisterForm } from '@/components/auth/RegisterForm'
const RegisterPage = () => {
return (
<div>
<RegisterForm/>
</div>
)
}
export default RegisterPage

66
app/[locale]/globals.css Normal file
View File

@ -0,0 +1,66 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
html,
body,
:root {
height: 100%;
}
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 221.2 83.2% 53.3%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 221.2 83.2% 53.3%;
--radius: 0rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 217.2 91.2% 59.8%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 224.3 76.3% 48%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}

29
app/[locale]/layout.tsx Normal file
View File

@ -0,0 +1,29 @@
import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
import './globals.css'
import { ReactElement } from 'react'
import { I18nProviderClient } from '@/locales/client'
import { lc } from '@/lib/utils'
const inter = Inter({ subsets: ['cyrillic'] })
export const metadata: Metadata = {
title: 'Create Next App', description: 'Generated by create next app',
}
type Props = {
params: { locale: string }; children: ReactElement;
}
export default function RootLayout ({
params: { locale }, children,
}: Readonly<Props>) {
return (<html lang={lc(locale).java}>
<body className={inter.className}>
<I18nProviderClient locale={locale} fallback={<p>Loading...</p>}>
{children}
</I18nProviderClient>
</body>
</html>)
}

View File

@ -0,0 +1 @@
export { GET, POST } from '@/config/auth'

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -1,33 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--foreground-rgb: 0, 0, 0;
--background-start-rgb: 214, 219, 220;
--background-end-rgb: 255, 255, 255;
}
@media (prefers-color-scheme: dark) {
:root {
--foreground-rgb: 255, 255, 255;
--background-start-rgb: 0, 0, 0;
--background-end-rgb: 0, 0, 0;
}
}
body {
color: rgb(var(--foreground-rgb));
background: linear-gradient(
to bottom,
transparent,
rgb(var(--background-end-rgb))
)
rgb(var(--background-start-rgb));
}
@layer utilities {
.text-balance {
text-wrap: balance;
}
}

View File

@ -1,22 +0,0 @@
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body className={inter.className}>{children}</body>
</html>
);
}

View File

@ -1,113 +0,0 @@
import Image from "next/image";
export default function Home() {
return (
<main className="flex min-h-screen flex-col items-center justify-between p-24">
<div className="z-10 max-w-5xl w-full items-center justify-between font-mono text-sm lg:flex">
<p className="fixed left-0 top-0 flex w-full justify-center border-b border-gray-300 bg-gradient-to-b from-zinc-200 pb-6 pt-8 backdrop-blur-2xl dark:border-neutral-800 dark:bg-zinc-800/30 dark:from-inherit lg:static lg:w-auto lg:rounded-xl lg:border lg:bg-gray-200 lg:p-4 lg:dark:bg-zinc-800/30">
Get started by editing&nbsp;
<code className="font-mono font-bold">app/page.tsx</code>
</p>
<div className="fixed bottom-0 left-0 flex h-48 w-full items-end justify-center bg-gradient-to-t from-white via-white dark:from-black dark:via-black lg:static lg:h-auto lg:w-auto lg:bg-none">
<a
className="pointer-events-none flex place-items-center gap-2 p-8 lg:pointer-events-auto lg:p-0"
href="https://vercel.com?utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
By{" "}
<Image
src="/vercel.svg"
alt="Vercel Logo"
className="dark:invert"
width={100}
height={24}
priority
/>
</a>
</div>
</div>
<div className="relative flex place-items-center before:absolute before:h-[300px] before:w-full sm:before:w-[480px] before:-translate-x-1/2 before:rounded-full before:bg-gradient-radial before:from-white before:to-transparent before:blur-2xl before:content-[''] after:absolute after:-z-20 after:h-[180px] after:w-full sm:after:w-[240px] after:translate-x-1/3 after:bg-gradient-conic after:from-sky-200 after:via-blue-200 after:blur-2xl after:content-[''] before:dark:bg-gradient-to-br before:dark:from-transparent before:dark:to-blue-700 before:dark:opacity-10 after:dark:from-sky-900 after:dark:via-[#0141ff] after:dark:opacity-40 before:lg:h-[360px] z-[-1]">
<Image
className="relative dark:drop-shadow-[0_0_0.3rem_#ffffff70] dark:invert"
src="/next.svg"
alt="Next.js Logo"
width={180}
height={37}
priority
/>
</div>
<div className="mb-32 grid text-center lg:max-w-5xl lg:w-full lg:mb-0 lg:grid-cols-4 lg:text-left">
<a
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
className="group rounded-lg border border-transparent px-5 py-4 transition-colors hover:border-gray-300 hover:bg-gray-100 hover:dark:border-neutral-700 hover:dark:bg-neutral-800/30"
target="_blank"
rel="noopener noreferrer"
>
<h2 className={`mb-3 text-2xl font-semibold`}>
Docs{" "}
<span className="inline-block transition-transform group-hover:translate-x-1 motion-reduce:transform-none">
-&gt;
</span>
</h2>
<p className={`m-0 max-w-[30ch] text-sm opacity-50`}>
Find in-depth information about Next.js features and API.
</p>
</a>
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="group rounded-lg border border-transparent px-5 py-4 transition-colors hover:border-gray-300 hover:bg-gray-100 hover:dark:border-neutral-700 hover:dark:bg-neutral-800/30"
target="_blank"
rel="noopener noreferrer"
>
<h2 className={`mb-3 text-2xl font-semibold`}>
Learn{" "}
<span className="inline-block transition-transform group-hover:translate-x-1 motion-reduce:transform-none">
-&gt;
</span>
</h2>
<p className={`m-0 max-w-[30ch] text-sm opacity-50`}>
Learn about Next.js in an interactive course with&nbsp;quizzes!
</p>
</a>
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
className="group rounded-lg border border-transparent px-5 py-4 transition-colors hover:border-gray-300 hover:bg-gray-100 hover:dark:border-neutral-700 hover:dark:bg-neutral-800/30"
target="_blank"
rel="noopener noreferrer"
>
<h2 className={`mb-3 text-2xl font-semibold`}>
Templates{" "}
<span className="inline-block transition-transform group-hover:translate-x-1 motion-reduce:transform-none">
-&gt;
</span>
</h2>
<p className={`m-0 max-w-[30ch] text-sm opacity-50`}>
Explore starter templates for Next.js.
</p>
</a>
<a
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
className="group rounded-lg border border-transparent px-5 py-4 transition-colors hover:border-gray-300 hover:bg-gray-100 hover:dark:border-neutral-700 hover:dark:bg-neutral-800/30"
target="_blank"
rel="noopener noreferrer"
>
<h2 className={`mb-3 text-2xl font-semibold`}>
Deploy{" "}
<span className="inline-block transition-transform group-hover:translate-x-1 motion-reduce:transform-none">
-&gt;
</span>
</h2>
<p className={`m-0 max-w-[30ch] text-sm opacity-50 text-balance`}>
Instantly deploy your Next.js site to a shareable URL with Vercel.
</p>
</a>
</div>
</main>
);
}

46
auth.config.ts Normal file
View File

@ -0,0 +1,46 @@
import type { NextAuthConfig } from 'next-auth'
import Credentials from 'next-auth/providers/credentials'
import Google from 'next-auth/providers/google'
import Github from 'next-auth/providers/github'
//import Facebook from 'next-auth/providers/facebook'
//import Twitter from 'next-auth/providers/twitter'
import { LoginSchema } from '@/schemas'
import bcrypt from 'bcryptjs'
import { getUserByEmail } from '@/data/user'
import { env } from 'process'
export default {
secret: env.AUTH_SECRET,
providers: [
Google({
clientId: env.GOOGLE_CLIENT_ID,
clientSecret: env.GOOGLE_CLIENT_SECRET,
}),
Github({
clientId: env.GITHUB_CLIENT_ID,
clientSecret: env.GITHUB_CLIENT_SECRET,
}),
//Twitter({}),
/*Facebook({
clientId: env.FACEBOOK_CLIENT_ID,
clientSecret: env.FACEBOOK_CLIENT_SECRET,
}),*/
Credentials({
// @ts-ignore
async authorize (credentials) {
const validatedFields = LoginSchema.safeParse(credentials)
if (validatedFields.success) {
const { email, password } = validatedFields.data
const user = await getUserByEmail(email)
if (!user || !user.password) return null
const passwordMatch: boolean = await bcrypt.compare(password, user.password)
if (passwordMatch) return user
}
return null
},
})],
} satisfies NextAuthConfig

17
components.json Normal file
View File

@ -0,0 +1,17 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "app/[locale]/globals.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils"
}
}

19
components/FormError.tsx Normal file
View File

@ -0,0 +1,19 @@
import { TriangleAlert } from 'lucide-react'
type Props = {
message?: string
}
const FormError = ({ message }: Props) => {
if (!message) return null
return (
<div
className="bg-destructive/15 p-3 rounded-md flex items-center gap-x-2 text-sm text-destructive">
<TriangleAlert className="w-4 h-4"/>
<p>{message}</p>
</div>
)
}
export default FormError

View File

@ -0,0 +1,19 @@
import { CircleCheck } from 'lucide-react'
type Props = {
message?: string
}
const FormSuccess = ({ message }: Props) => {
if (!message) return null
return (
<div
className="bg-lime-500/15 p-3 rounded-md flex items-center gap-x-2 text-sm text-lime-800">
<CircleCheck className="w-4 h-4"/>
<p>{message}</p>
</div>
)
}
export default FormSuccess

View File

@ -0,0 +1,24 @@
'use client'
import { useChangeLocale, useCurrentLocale } from '@/locales/client'
import { LC, type loc } from '@/config/locales'
import { ChangeEvent } from 'react'
import styles from '@/styles/LocaleSwitcher.module.scss'
export default function LocaleSwitcher () {
const changeLocale = useChangeLocale()
const locale = useCurrentLocale()
const selectHandler = (e: ChangeEvent<HTMLSelectElement>) => changeLocale(
e.target.value as loc)
return (
//@ts-ignore
<select onChange={selectHandler} defaultValue={locale}
className={styles['yo-locale-switcher']}>
{LC.map(item => (
<option key={item.iso} value={item.code}>
{item.iso.toUpperCase()}
</option>
))}
</select>
)
}

View File

@ -0,0 +1,26 @@
import { useI18n } from '@/locales/client'
type Props = {
message: string
}
const _ = (message: string): string => {
const t = useI18n()
if (message.startsWith('["')) {
const data = JSON.parse(message)
if (data.length > 1) {
message = data.shift()
// @ts-ignore
return t(message, ...data)
}
}
// @ts-ignore
return t(message)
}
const TranslateClientFragment = ({ message }: Props) => {
return <>{_(message)}</>
}
export default TranslateClientFragment

View File

@ -0,0 +1,65 @@
'use client'
//https://gist.github.com/mjbalcueva/b21f39a8787e558d4c536bf68e267398
import { forwardRef, useState } from 'react'
import { EyeIcon, EyeOffIcon } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input, InputProps } from '@/components/ui/input'
import { cn } from '@/lib/utils'
import { FormControl } from '@/components/ui/form'
const PasswordInput = forwardRef<HTMLInputElement, InputProps>(
({ className, ...props }, ref) => {
const [showPassword, setShowPassword] = useState(false)
const disabled = props.value === '' || props.value === undefined ||
props.disabled
return (<div className="relative">
<Input
type={showPassword ? 'text' : 'password'}
className={cn('hide-password-toggle pr-10', className)}
ref={ref}
{...props}
/>
<Button
type="button"
variant="ghost"
size="sm"
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
onClick={() => setShowPassword((prev) => !prev)}
disabled={disabled}
>
{showPassword && !disabled ? (
<EyeIcon
className="h-4 w-4"
aria-hidden="true"
/>
) : (
<EyeOffIcon
className="h-4 w-4"
aria-hidden="true"
/>
)}
<span className="sr-only">
{showPassword ? 'Hide password' : 'Show password'}
</span>
</Button>
{/* hides browsers password toggles */}
<style>{`
.hide-password-toggle::-ms-reveal,
.hide-password-toggle::-ms-clear {
visibility: hidden;
pointer-events: none;
display: none;
}
`}</style>
</div>
)
},
)
PasswordInput.displayName = 'PasswordInput'
export { PasswordInput }

View File

@ -0,0 +1,18 @@
'use client'
import { Button } from '@/components/ui/button'
import Link from 'next/link'
type Props = {
href: string
label: string
}
export const BackButton = ({ href, label }: Props) => {
return (
<Button variant="link" size="sm"
className="font-normal w-full" asChild>
<Link href={href}>{label}</Link>
</Button>
)
}

View File

@ -0,0 +1,55 @@
'use client'
import { Card, CardContent, CardFooter, CardHeader } from '@/components/ui/card'
import { Header } from '@/components/auth/Header'
import { Social } from '@/components/auth/Social'
import { BackButton } from '@/components/auth/BackButton'
type Props = {
children: React.ReactNode
headerLabel: string
headerTitle: string
backButtonLabel: string
backButtonHref: string
showSocial?: boolean
continueWithLabel?: string
}
export const CardWrapper = ({
children,
headerLabel,
headerTitle,
backButtonLabel,
backButtonHref,
showSocial,
continueWithLabel,
}: Props) => {
return (
<Card
className="max-w-[414px] w-[100%] shadow-md md:min-w-[414px] sm:w-full">
<CardHeader>
<Header label={headerLabel} title={headerTitle}/>
</CardHeader>
<CardContent>
{children}
</CardContent>
{showSocial && <CardFooter className="flex-wrap">
<div className="relative flex-none w-[100%] mb-4"
style={{ display: 'block' }}>
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t"></span>
</div>
<div className="relative flex justify-center text-xs uppercase">
<span
className="bg-background px-2 text-muted-foreground">{continueWithLabel}</span>
</div>
</div>
{/*<Separator className="my-4"/>*/}
<Social/>
</CardFooter>}
<CardFooter>
<BackButton label={backButtonLabel} href={backButtonHref}/>
</CardFooter>
</Card>
)
}

View File

@ -0,0 +1,25 @@
'use client'
import { CardWrapper } from '@/components/auth/CardWrapper'
import { AUTH_LOGIN_URL } from '@/config/routes'
import { useI18n } from '@/locales/client'
import { TriangleAlert } from 'lucide-react'
const ErrorCard = () => {
const t = useI18n()
return (
<CardWrapper
headerLabel={t('auth.form.error.header_label')}
headerTitle={t('auth.title')}
backButtonLabel={t('auth.form.error.back_button_label')}
backButtonHref={AUTH_LOGIN_URL}
>
<div className="w-full flex items-center justify-center">
<TriangleAlert className="w-4 h-4 text-destructive"/>
<p>ssss</p>
</div>
</CardWrapper>
)
}
export default ErrorCard

View File

@ -0,0 +1,20 @@
import { Poppins } from 'next/font/google'
import { cn } from '@/lib/utils'
const font = Poppins({
subsets: ['latin'], weight: ['600'],
})
type Props = {
label: string, title: string
}
export const Header = ({ label, title }: Props) => {
return (
<div className="w-full flex flex-col gap-y-4 items-center justify-center">
<h1 className={cn('text-3xl font-semibold', font.className)}>
🔐 {title || 'Auth'}
</h1>
<p className="text-muted-foreground text-sm">{label}</p>
</div>)
}

View File

@ -0,0 +1,25 @@
'use client'
import { useRouter } from 'next/navigation'
import { AUTH_LOGIN_URL } from '@/config/routes'
type Props = {
children: React.ReactNode
mode?: 'modal' | 'redirect'
asChild?: boolean
}
const LoginButton = ({
children, mode = 'redirect', asChild,
}: Props) => {
const router = useRouter()
const onClick = () => router.push(AUTH_LOGIN_URL)
if (mode === 'modal') {
return <span>TODO: Implement modal</span>
}
return <span onClick={onClick} className="cursor-pointer">{children}</span>
}
export default LoginButton

View File

@ -0,0 +1,112 @@
'use client'
import { infer as zInfer } from 'zod'
import { useState, useTransition } from 'react'
import { useForm } from 'react-hook-form'
import { useSearchParams } from 'next/navigation'
import { zodResolver } from '@hookform/resolvers/zod'
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { CardWrapper } from '@/components/auth/CardWrapper'
import { useI18n } from '@/locales/client'
import { Button } from '@/components/ui/button'
import FormError from '@/components/FormError'
import FormSuccess from '@/components/FormSuccess'
import { login } from '@/actions/login'
import { LoginSchema } from '@/schemas'
import { AUTH_REGISTER_URL } from '@/config/routes'
export const LoginForm = () => {
const t = useI18n()
const searchParams = useSearchParams()
const urlError = searchParams.get('error') === 'OAuthAccountNotLinked'
? t('auth.form.error.email_in_use')
: ''
const [error, setError] = useState<string | undefined>('')
const [success, setSuccess] = useState<string | undefined>('')
const [isPending, startTransition] = useTransition()
const form = useForm<zInfer<typeof LoginSchema>>({
resolver: zodResolver(LoginSchema), defaultValues: {
email: '', password: '',
},
})
const onSubmit = (values: zInfer<typeof LoginSchema>) => {
setError('')
setSuccess('')
startTransition(() => {
login(values).then((data) => {
// @ts-ignore
setError(t(data?.error))
// @ts-ignore
setSuccess(t(data?.success))
})
})
}
return (<CardWrapper
headerLabel={t('auth.form.login.header_label')}
headerTitle={t('auth.title')}
backButtonLabel={t('auth.form.login.back_button_label')}
backButtonHref={AUTH_REGISTER_URL}
showSocial
continueWithLabel={t('form.label.continue_with')}
>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-6"
>
<div className="space-y-4">
<FormField control={form.control} name="email"
render={({ field }) => (<FormItem>
<FormLabel>{t('form.label.email')}</FormLabel>
<FormControl>
<Input
{...field}
disabled={isPending}
placeholder={t('form.placeholder.email')}
type="email"
autoComplete="username"
/>
</FormControl>
<FormMessage className="text-xs"/>
</FormItem>)}/>
{/*Password*/}
<FormField control={form.control} name="password"
render={({ field }) => (<FormItem>
<FormLabel>{t('form.label.password')}</FormLabel>
<FormControl>
<Input
{...field}
disabled={isPending}
placeholder="******"
type="password"
autoComplete="current-password"
/>
</FormControl>
<FormMessage className="text-xs"/>
</FormItem>)}/>
</div>
<FormSuccess message={success}/>
<FormError message={error || urlError}/>
<Button type="submit" className="w-full" disabled={isPending}>
{t('form.label.login')}
</Button>
</form>
</Form>
</CardWrapper>)
}
//1:30:00

View File

@ -0,0 +1,14 @@
'use client'
//import { useScopedI18n } from '@/locales/client'
import LocaleSwitcher from '@/components/LocaleSwitcher'
export default function Navbar () {
//const t = useScopedI18n('navbar')
return (
<nav className="flex justify-between top-0 absolute w-full px-3.5 py-1.5">
<div>Logo</div>
<LocaleSwitcher/>
</nav>
)
}

View File

@ -0,0 +1,123 @@
'use client'
import { infer as zInfer } from 'zod'
import { useState, useTransition } from 'react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { CardWrapper } from '@/components/auth/CardWrapper'
import { useI18n } from '@/locales/client'
import { Button } from '@/components/ui/button'
import FormError from '@/components/FormError'
import FormSuccess from '@/components/FormSuccess'
import { register } from '@/actions/register'
import { RegisterSchema } from '@/schemas'
import { AUTH_LOGIN_URL } from '@/config/routes'
export const RegisterForm = () => {
// const [currentPassword, setCurrentPassword] = useState('')
// const [password, setPassword] = useState('')
// const [passwordConfirmation, setPasswordConfirmation] = useState('')
const [error, setError] = useState<string | undefined>('')
const [success, setSuccess] = useState<string | undefined>('')
const [isPending, startTransition] = useTransition()
const t = useI18n()
const form = useForm<zInfer<typeof RegisterSchema>>({
resolver: zodResolver(RegisterSchema), defaultValues: {
email: '', password: '', name: '',
},
})
const onSubmit = (values: zInfer<typeof RegisterSchema>) => {
setError('')
setSuccess('')
startTransition(() => {
register(values).then((data) => {
// @ts-ignore
setError(t(data?.error))
// @ts-ignore
setSuccess(t(data?.success))
})
})
}
return (<CardWrapper
headerLabel={t('auth.form.register.header_label')}
headerTitle={t('auth.title')}
backButtonLabel={t('auth.form.register.back_button_label')}
backButtonHref={AUTH_LOGIN_URL}
showSocial
continueWithLabel={t('form.label.continue_with')}
>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-6"
>
<div className="space-y-4">
{/*Name*/}
<FormField control={form.control} name="name"
render={({ field }) => (<FormItem>
<FormLabel>{t('form.label.name')}</FormLabel>
<FormControl>
<Input
{...field}
disabled={isPending}
placeholder={t('form.placeholder.name')}
type="text"
/>
</FormControl>
<FormMessage className="text-xs"/>
</FormItem>)}/>
{/*Email*/}
<FormField control={form.control} name="email"
render={({ field }) => (<FormItem>
<FormLabel>{t('form.label.email')}</FormLabel>
<FormControl>
<Input
{...field}
disabled={isPending}
placeholder={t('form.placeholder.email')}
type="email"
autoComplete="username"
/>
</FormControl>
<FormMessage className="text-xs"/>
</FormItem>)}/>
{/*Password*/}
<FormField control={form.control} name="password"
render={({ field }) => (<FormItem className="zhopa">
<FormLabel>{t('form.label.password')}</FormLabel>
<FormControl>
<Input
{...field}
disabled={isPending}
type="password"
placeholder="******"
autoComplete="new-password"
/>
</FormControl>
<FormMessage className="text-xs"/>
</FormItem>)}/>
</div>
<FormSuccess message={success}/>
<FormError message={error}/>
<Button type="submit" className="w-full" disabled={isPending}>
{t('form.label.register')}
</Button>
</form>
</Form>
</CardWrapper>)
}

View File

@ -0,0 +1,31 @@
'use client'
import { FcGoogle } from 'react-icons/fc'
import { FaFacebook, FaGithub } from 'react-icons/fa'
//import { RiTwitterXLine } from 'react-icons/ri'
import { Button } from '@/components/ui/button'
import { SignInProvider } from '@/actions/login'
export const Social = () => {
return (
<div className="flex items-center w-full gap-x-2">
<Button size="lg" className="w-full" variant="outline"
onClick={() => SignInProvider('google')}>
<FcGoogle className="w-5 h-5"/>
</Button>
<Button size="lg" className="w-full" variant="outline"
onClick={() => SignInProvider('github')}>
<FaGithub className="w-5 h-5"/>
</Button>
{/*<Button size="lg" className="w-full" variant="outline" onClick={() => {}}>
<RiTwitterXLine className="w-5 h-5"/>
</Button>*/}
{/*<Button size="lg" className="w-full" variant="outline"
onClick={() => SignInProvider('facebook')}>
<FaFacebook className="w-5 h-5" style={{ color: '#1877F2' }}/>
</Button>*/}
</div>
)
}

56
components/ui/button.tsx Normal file
View File

@ -0,0 +1,56 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }

79
components/ui/card.tsx Normal file
View File

@ -0,0 +1,79 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm",
className
)}
{...props}
/>
))
Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn(
"text-2xl font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

152
components/ui/form.tsx Normal file
View File

@ -0,0 +1,152 @@
import * as React from 'react'
import * as LabelPrimitive from '@radix-ui/react-label'
import { Slot } from '@radix-ui/react-slot'
import {
Controller,
ControllerProps,
FieldPath,
FieldValues,
FormProvider,
useFormContext,
} from 'react-hook-form'
import { cn } from '@/lib/utils'
import { Label } from '@/components/ui/label'
import TranslateClientFragment from '@/components/TranslateClientFragment'
const Form = FormProvider
type FormFieldContextValue<TFieldValues extends FieldValues = FieldValues, TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>> = {
name: TName
}
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue)
const FormField = <TFieldValues extends FieldValues = FieldValues, TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>> ({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>)
}
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext)
const itemContext = React.useContext(FormItemContext)
const { getFieldState, formState } = useFormContext()
const fieldState = getFieldState(fieldContext.name, formState)
if (!fieldContext) {
throw new Error('useFormField should be used within <FormField>')
}
const { id } = itemContext
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`, ...fieldState,
}
}
type FormItemContextValue = {
id: string
}
const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue)
const FormItem = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => {
const id = React.useId()
return (<FormItemContext.Provider value={{ id }}>
<div ref={ref} className={cn('space-y-2', className)} {...props} />
</FormItemContext.Provider>)
})
FormItem.displayName = 'FormItem'
const FormLabel = React.forwardRef<React.ElementRef<typeof LabelPrimitive.Root>, React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>>(
({ className, ...props }, ref) => {
const { error, formItemId } = useFormField()
return (<Label
ref={ref}
className={cn(error && 'text-destructive', className)}
htmlFor={formItemId}
{...props}
/>)
})
FormLabel.displayName = 'FormLabel'
const FormControl = React.forwardRef<React.ElementRef<typeof Slot>, React.ComponentPropsWithoutRef<typeof Slot>>(
({ ...props }, ref) => {
const {
error,
formItemId,
formDescriptionId,
formMessageId,
} = useFormField()
return (<Slot
ref={ref}
id={formItemId}
aria-describedby={!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`}
aria-invalid={!!error}
{...props}
/>)
})
FormControl.displayName = 'FormControl'
const FormDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
({ className, ...props }, ref) => {
const { formDescriptionId } = useFormField()
return (<p
ref={ref}
id={formDescriptionId}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>)
})
FormDescription.displayName = 'FormDescription'
const FormMessage = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
({ className, children, ...props }, ref) => {
const { error, formMessageId } = useFormField()
let body = error ? String(error?.message) : children
if (!body) {
return null
}
return (<p
ref={ref}
id={formMessageId}
className={cn('text-sm font-medium text-destructive', className)}
{...props}
>
{!process.env.IS_SERVER_FLAG && typeof body === 'string' &&
body.includes('schema.message')
? <TranslateClientFragment message={body}/>
: body}
</p>)
})
FormMessage.displayName = 'FormMessage'
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
}

25
components/ui/input.tsx Normal file
View File

@ -0,0 +1,25 @@
import * as React from "react"
import { cn } from "@/lib/utils"
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }

26
components/ui/label.tsx Normal file
View File

@ -0,0 +1,26 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }

View File

@ -0,0 +1,31 @@
"use client"
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils"
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(
(
{ className, orientation = "horizontal", decorative = true, ...props },
ref
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className
)}
{...props}
/>
)
)
Separator.displayName = SeparatorPrimitive.Root.displayName
export { Separator }

75
config/auth.ts Normal file
View File

@ -0,0 +1,75 @@
import NextAuth from 'next-auth'
import { UserRole } from '@prisma/client'
import { PrismaAdapter } from '@auth/prisma-adapter'
import { db } from '@/lib/db'
import authConfig from '@/auth.config'
import { getUserById } from '@/data/user'
import { AUTH_ERROR_URL, AUTH_LOGIN_URL } from '@/config/routes'
declare module 'next-auth' {
interface Session {
user: { role: UserRole }
}
}
export const VERIFICATION_TOKEN_EXPIRATION_DURATION = 3_600_000
export const {
handlers: { GET, POST },
auth,
signIn,
signOut,
} = NextAuth({
pages: {
signIn: AUTH_LOGIN_URL,
error: AUTH_ERROR_URL,
},
events: {
async linkAccount ({ user }) {
await db.user.update({
where: { id: user.id as string },
data: { emailVerified: new Date() },
})
},
},
callbacks: {
async signIn ({ user, account }) {
// Bypass email verification when Oauth are being used
if (account?.provider !== 'credentials') return true
const existingUser = await getUserById(user.id as string)
// Prevent sign in without email verification
if (!existingUser?.emailVerified) return false
// TODO: Add 2FA check
return true
},
async session ({ token, session }) {
if (token.sub && session.user) {
session.user.id = token.sub
}
if (token.role && session.user) {
session.user.role = token.role as UserRole
}
return session
},
async jwt ({ token }) {
if (!token.sub) return token
const existingUser = await getUserById(token.sub)
if (!existingUser) return token
token.role = existingUser.role
return token
},
},
adapter: PrismaAdapter(db),
session: { strategy: 'jwt' },
...authConfig,
})

1
config/layout.ts Normal file
View File

@ -0,0 +1 @@
export const bg: string = 'bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-sky-400 to-blue-800'

30
config/locales.ts Normal file
View File

@ -0,0 +1,30 @@
// @https://www.localeplanet.com/icu/index.html
const defaultLocale = 'uk'
export type loc = ('uk' | 'en')
const importLocales = {
uk: () => import('@/locales/uk'), en: () => import('@/locales/en'),
}
const LC = [
{
id: 'uk_UA',
java: 'uk-UA',
iso: 'ukr',
code: 'uk',
name: 'Ukrainian',
originalName: 'Українська',
},
{
id: 'en_US',
java: 'en-US',
iso: 'eng',
code: 'en',
name: 'English',
originalName: 'English',
}]
const locales = LC.map(locale => locale.code)
export { locales, defaultLocale, LC, importLocales }

16
config/mailer.ts Normal file
View File

@ -0,0 +1,16 @@
import { env } from 'process'
import SMTPTransport from 'nodemailer/lib/smtp-transport'
export const from: string = `"${env.MAIL_SERVER_SENDER_NAME}" <${env.MAIL_SERVER_USERNAME}>`
export const transportOptions: SMTPTransport | SMTPTransport.Options | string = {
host: env.MAIL_SERVER_HOST,
debug: env.MAIL_SERVER_DEBUG === 'true',
logger: env.MAIL_SERVER_LOG === 'true',
port: parseInt(env.MAIL_SERVER_PORT as string),
secure: env.MAIL_SERVER_PORT === '465', // Use `true` for port 465, `false` for all other ports
auth: {
user: env.MAIL_SERVER_USERNAME, pass: env.MAIL_SERVER_PASSWORD,
},
}

44
config/routes.ts Normal file
View File

@ -0,0 +1,44 @@
import { locales } from '@/config/locales'
export const USER_PROFILE_URL: string = '/cabinet'
export const AUTH_LOGIN_URL: string = '/auth/login'
export const AUTH_REGISTER_URL: string = '/auth/register'
export const AUTH_ERROR_URL: string = '/auth/error'
export const AUTH_EMAIL_VERIFICATION_URL: string = '/auth/email-verification'
/**
* An array of routes that accessible to the public.
* These routes do not requite authentication.
* @type {string[]}
*/
export const publicRoutes: string[] = [
'/', '/about']
/**
* An array of routes that are used for authentication.
* These routes will redirect logged-in users to /cabinet
* @type {string[]}
*/
export const authRoutes: string[] = [
AUTH_LOGIN_URL, AUTH_REGISTER_URL, AUTH_ERROR_URL, AUTH_EMAIL_VERIFICATION_URL]
/**
* The prefix for API authentication routes.
* Routes that start with this prefix are used for API authentication purpose.
* @type {string}
*/
export const apiAuthPrefix: string = '/api/auth'
/**
* The default redirect path after logging in.
* @type {string}
*/
export const DEFAULT_LOGIN_REDIRECT: string = USER_PROFILE_URL
export const testPathnameRegex = (
pages: string[], pathName: string): boolean => {
const pattern: string = `^(/(${locales.join('|')}))?(${pages.flatMap(
(p) => (p === '/' ? ['', '/'] : p)).join('|')})/?$`
return RegExp(pattern, 'is').test(pathName)
}

2
config/validation.ts Normal file
View File

@ -0,0 +1,2 @@
export const MIN_PASSWORD_LENGTH: number = 6
export const PASSWORD_SALT_LENGTH: number = 10

18
data/user.ts Normal file
View File

@ -0,0 +1,18 @@
import { User } from '@prisma/client'
import { db } from '@/lib/db'
export const getUserByEmail = async (email: string): Promise<User | null> => {
try {
return await db.user.findUnique({ where: { email } })
} catch {
return null
}
}
export const getUserById = async (id: string): Promise<User | null> => {
try {
return await db.user.findUnique({ where: { id } })
} catch {
return null
}
}

View File

@ -0,0 +1,17 @@
import { db } from '@/lib/db'
export const getVerificationTokenByToken = async (token: string) => {
try {
return await db.verificationToken.findUnique({ where: { token } })
} catch {
return null
}
}
export const getVerificationTokenByEmail = async (email: string) => {
try {
return await db.verificationToken.findFirst({ where: { email } })
} catch {
return null
}
}

10
lib/db.ts Normal file
View File

@ -0,0 +1,10 @@
import { PrismaClient } from '@prisma/client'
import * as process from 'process'
declare global {
var prisma: PrismaClient | undefined
}
export const db = globalThis.prisma || new PrismaClient()
if (process.env.NODE_ENV !== 'production') globalThis.prisma = db

22
lib/mailer.ts Normal file
View File

@ -0,0 +1,22 @@
'use server'
import { from, transportOptions } from '@/config/mailer'
import nodemailer, { Transporter } from 'nodemailer'
import type { Options } from 'nodemailer/lib/mailer'
import { SentMessageInfo } from 'nodemailer/lib/smtp-transport'
const transporter: Transporter<SentMessageInfo> = nodemailer.createTransport(transportOptions)
type Return = {
isOk: boolean, code?: number, info?: SentMessageInfo, error?: any
}
export default async function mailer ({ to, subject, text, html }: Options): Promise<Return> {
try {
const info: SentMessageInfo = await transporter.sendMail({ from, to, subject, text, html })
return { isOk: true, code: parseInt((info?.response ?? '0').substring(0, 3), 10), info }
} catch (error: any) {
return { isOk: false, error }
}
//TODO: MAILER LOGGING >> error.response || info?.messageId
}

32
lib/tokens.ts Normal file
View File

@ -0,0 +1,32 @@
import { v4 as uuid } from 'uuid'
import {
VERIFICATION_TOKEN_EXPIRATION_DURATION,
} from '@/config/auth'
import { db } from '@/lib/db'
import { getVerificationTokenByEmail } from '@/data/verification-token'
export const generateVerificationToken = async (email: string) => {
const token = uuid()
const expires = new Date(
new Date().getTime() + VERIFICATION_TOKEN_EXPIRATION_DURATION)
const existingToken = await getVerificationTokenByEmail(email)
if (existingToken) {
await db.verificationToken.delete({
where: {
id: existingToken.id,
},
})
}
const verificationToken = await db.verificationToken.create({
data: {
email,
token,
expires,
},
})
return verificationToken
}

12
lib/utils.ts Normal file
View File

@ -0,0 +1,12 @@
import { type ClassValue, clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
import { LC } from '@/config/locales'
import bcrypt from 'bcryptjs'
export function cn (...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
export function lc (locale: string) {
return LC.filter(lc => locale === lc.code)[0]
}

11
locales/client.ts Normal file
View File

@ -0,0 +1,11 @@
'use client'
import { createI18nClient } from 'next-international/client'
import { importLocales } from '@/config/locales'
export const {
useI18n,
useScopedI18n,
useChangeLocale,
useCurrentLocale,
I18nProviderClient,
} = createI18nClient(importLocales)

60
locales/en.ts Normal file
View File

@ -0,0 +1,60 @@
export default {
auth: {
title: 'Auth',
subtitle: 'Simple authentication service',
sign_in: 'Sign In',
common: {
something_went_wrong: 'Something went wrong!',
},
form: {
login: {
header_label: 'Welcome back',
back_button_label: 'Don\'t have an account?',
},
register: {
header_label: 'Create an account',
back_button_label: 'Already have an account?',
},
error: {
email_in_use: 'Email already in use with different provider!',
header_label: 'Oops! Something went wrong!',
back_button_label: 'Back to login',
email_taken: 'Can\'t create an user! Wait for verification by provided email.',
invalid_fields: 'Invalid fields!',
invalid_credentials: 'Invalid Credentials!',
access_denied: 'Access denied!',
},
},
email: {
success: {
confirmation_email_sent: 'Confirmation email sent!',
},
error: {
verification_email_sending_error: 'Could not send verification email!',
},
},
},
schema: {
message: {
email_required: 'Invalid email address',
password_required: `Password is required`,
name_required: `Name is required`,
password_min: `Password must be at least {min} characters`,
},
},
form: {
label: {
email: 'Email',
password: 'Password',
confirm_password: 'Confirm password',
login: 'Login',
name: 'Name',
register: 'Register',
continue_with: 'Or continue with',
},
placeholder: {
email: 'john.doe@example.com',
name: 'John Doe',
},
},
} as const

9
locales/server.ts Normal file
View File

@ -0,0 +1,9 @@
import { createI18nServer } from 'next-international/server'
import { importLocales } from '@/config/locales'
export const {
getI18n,
getScopedI18n,
getCurrentLocale,
getStaticParams,
} = createI18nServer(importLocales)

60
locales/uk.ts Normal file
View File

@ -0,0 +1,60 @@
export default {
auth: {
title: 'Auth',
subtitle: 'Простий сервіс аутентифікації',
sign_in: 'Увійти',
common: {
something_went_wrong: 'Щось пішло не так!',
},
form: {
login: {
header_label: 'Вхід до облікового запису',
back_button_label: 'Не маєте облікового запису?',
},
register: {
header_label: 'Реєстрація облікового запису',
back_button_label: 'Вже маєте обліковий запис?',
},
error: {
email_in_use: 'Електронна пошта вже використовується з іншим логін-провайдером!',
header_label: 'Отакої! Щось пішло не так!',
back_button_label: 'Назад до форми входу до облікового запису',
email_taken: 'Не можу створити користувача! Не пройдена верифікація за допомогою вказаної електронної пошти.',
invalid_fields: 'Недійсні поля!',
invalid_credentials: 'Недійсні облікові дані!',
access_denied: 'У доступі відмовлено!',
},
},
email: {
success: {
confirmation_email_sent: 'Лист із підтвердженням надіслано!',
},
error: {
verification_email_sending_error: 'Не вдалося надіслати електронний лист для підтвердження!',
},
},
},
schema: {
message: {
email_required: 'Невірна адреса електронної пошти',
password_required: `Необхідно ввести пароль`,
name_required: `Необхідно вказати ім'я`,
password_min: `Пароль має містити принаймні {min} символів`,
},
},
form: {
label: {
email: 'Електронна пошта',
password: 'Пароль',
confirm_password: 'Підтвердьте пароль',
login: 'Лоґін',
name: 'Ім\'я та прізвище',
register: 'Створити обліковий запис',
continue_with: 'Або продовжити за допомогою',
},
placeholder: {
email: 'polina.melnyk@mocking.net',
name: 'Поліна Мельник',
},
},
} as const

60
middleware.ts Normal file
View File

@ -0,0 +1,60 @@
import NextAuth from 'next-auth'
import { createI18nMiddleware } from 'next-international/middleware'
import { NextRequest, NextResponse } from 'next/server'
import { defaultLocale, locales } from '@/config/locales'
import authConfig from '@/auth.config'
import {
apiAuthPrefix,
AUTH_LOGIN_URL,
authRoutes,
DEFAULT_LOGIN_REDIRECT,
publicRoutes,
testPathnameRegex,
} from '@/config/routes'
import { NextURL } from 'next/dist/server/web/next-url'
interface AppRouteHandlerFnContext {
params?: Record<string, string | string[]>;
}
export const middleware = (
request: NextRequest,
event: AppRouteHandlerFnContext): NextResponse | null => {
return NextAuth(authConfig).auth((request): any => {
const { nextUrl }: { nextUrl: NextURL } = request
const isLoggedIn: boolean = !!request.auth
const isApiAuthRoute: boolean = nextUrl.pathname.startsWith(apiAuthPrefix)
const isPublicRoute: boolean = testPathnameRegex(publicRoutes,
nextUrl.pathname)
const isAuthRoute: boolean = testPathnameRegex(authRoutes, nextUrl.pathname)
if (isApiAuthRoute) {
return null
}
const I18nMiddleware = createI18nMiddleware({
locales, defaultLocale, urlMappingStrategy: 'rewriteDefault',
})
if (isAuthRoute) {
if (isLoggedIn) {
return NextResponse.redirect(new URL(DEFAULT_LOGIN_REDIRECT, nextUrl))
}
return I18nMiddleware(request)
}
if (!isLoggedIn && !isPublicRoute) {
return NextResponse.redirect(new URL(AUTH_LOGIN_URL, nextUrl))
}
return I18nMiddleware(request)
})(request, event) as NextResponse
}
export const config = {
matcher: [
'/((?!.+\\.[\\w]+$|_next).*)',
'/(api|static|trpc)(.*)'],
}

View File

@ -1,4 +1,10 @@
/** @type {import('next').NextConfig} */
const nextConfig = {};
import path from 'node:path'
export default nextConfig;
/** @type {import('next').NextConfig} */
const nextConfig = {
sassOptions: {
includePaths: [path.join(path.resolve('.'), 'styles')],
},
}
export default nextConfig

1320
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -9,19 +9,44 @@
"lint": "next lint"
},
"dependencies": {
"@auth/prisma-adapter": "^1.5.2",
"@hookform/resolvers": "^3.3.4",
"@prisma/client": "^5.12.1",
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-separator": "^1.0.3",
"@radix-ui/react-slot": "^1.0.2",
"bcryptjs": "^2.4.3",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.0",
"lucide-react": "^0.365.0",
"next": "14.1.4",
"next-auth": "^5.0.0-beta.16",
"next-international": "^1.2.4",
"nodemailer": "^6.9.13",
"react": "^18",
"react-dom": "^18",
"next": "14.1.4"
"react-hook-form": "^7.51.2",
"react-icons": "^5.0.1",
"tailwind-merge": "^2.2.2",
"tailwindcss-animate": "^1.0.7",
"uuid": "^9.0.1",
"zod": "^3.22.4"
},
"devDependencies": {
"typescript": "^5",
"@types/bcryptjs": "^2.4.6",
"@types/node": "^20",
"@types/nodemailer": "^6.4.14",
"@types/react": "^18",
"@types/react-dom": "^18",
"@types/uuid": "^9.0.8",
"autoprefixer": "^10.0.1",
"postcss": "^8",
"tailwindcss": "^3.3.0",
"eslint": "^8",
"eslint-config-next": "14.1.4"
"eslint-config-next": "14.1.4",
"postcss": "^8",
"prisma": "^5.12.1",
"sass": "^1.74.1",
"tailwindcss": "^3.3.0",
"typescript": "^5"
}
}

View File

@ -0,0 +1,58 @@
-- CreateEnum
CREATE TYPE "UserRole" AS ENUM ('SUPERVISOR', 'ADMIN', 'EDITOR', 'SUPPLIER', 'CUSTOMER', 'USER', 'OBSERVER', 'SYSTEM', 'CRON');
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL,
"name" TEXT,
"email" TEXT,
"emailVerified" TIMESTAMP(3),
"image" TEXT,
"password" TEXT,
"role" "UserRole" NOT NULL DEFAULT 'CUSTOMER',
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Account" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"type" TEXT NOT NULL,
"provider" TEXT NOT NULL,
"providerAccountId" TEXT NOT NULL,
"refresh_token" TEXT,
"access_token" TEXT,
"expires_at" INTEGER,
"token_type" TEXT,
"scope" TEXT,
"id_token" TEXT,
"session_state" TEXT,
CONSTRAINT "Account_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "VerificationToken" (
"id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"token" TEXT NOT NULL,
"expires" TIMESTAMP(3) NOT NULL,
CONSTRAINT "VerificationToken_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- CreateIndex
CREATE UNIQUE INDEX "Account_provider_providerAccountId_key" ON "Account"("provider", "providerAccountId");
-- CreateIndex
CREATE UNIQUE INDEX "VerificationToken_token_key" ON "VerificationToken"("token");
-- CreateIndex
CREATE UNIQUE INDEX "VerificationToken_email_token_key" ON "VerificationToken"("email", "token");
-- AddForeignKey
ALTER TABLE "Account" ADD CONSTRAINT "Account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "extendedData" JSONB;

View File

@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "postgresql"

66
prisma/schema.prisma Normal file
View File

@ -0,0 +1,66 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
// Looking for ways to speed up your queries, or scale easily with your serverless or edge functions?
// Try Prisma Accelerate: https://pris.ly/cli/accelerate-init
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
enum UserRole {
SUPERVISOR
ADMIN
EDITOR
SUPPLIER
CUSTOMER
USER
OBSERVER
SYSTEM
CRON
}
model User {
id String @id @default(cuid())
name String?
email String? @unique
emailVerified DateTime?
image String?
password String?
role UserRole @default(CUSTOMER)
extendedData Json?
accounts Account[]
}
model Account {
id String @id @default(cuid())
userId String
type String
provider String
providerAccountId String
refresh_token String? @db.Text
access_token String? @db.Text
expires_at Int?
token_type String?
scope String?
id_token String? @db.Text
session_state String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerAccountId])
}
model VerificationToken {
id String @id @default(cuid())
email String
token String @unique
expires DateTime
@@unique([email, token])
}

26
schemas/index.ts Normal file
View File

@ -0,0 +1,26 @@
import { MIN_PASSWORD_LENGTH } from '@/config/validation'
import { object, string } from 'zod'
const passwordMessage = JSON.stringify(
['schema.message.password_min', { min: MIN_PASSWORD_LENGTH }])
export const LoginSchema = object({
email: string().
trim().
email({ message: 'schema.message.email_required' }).
toLowerCase(),
password: string().
trim().
min(1, { message: 'schema.message.password_required' }),
})
export const RegisterSchema = object({
email: string().
email({ message: 'schema.message.email_required' }).
toLowerCase(),
password: string().
trim().
min(MIN_PASSWORD_LENGTH, { message: passwordMessage }),
name: string().trim().min(1, { message: 'schema.message.name_required' }),
})

View File

@ -0,0 +1,12 @@
.yo-locale-switcher {
appearance: none;
display: block;
text-align: center;
padding: 0;
color: darkblue;
//width: 2.5rem;
//height: 2.5rem;
border-radius: 2px;
margin: .5rem 1rem;
font-size: .75rem;
}

View File

@ -1,20 +1,80 @@
import type { Config } from "tailwindcss";
import type { Config } from "tailwindcss"
const config: Config = {
const config = {
darkMode: ["class"],
content: [
"./pages/**/*.{js,ts,jsx,tsx,mdx}",
"./components/**/*.{js,ts,jsx,tsx,mdx}",
"./app/**/*.{js,ts,jsx,tsx,mdx}",
],
'./pages/**/*.{ts,tsx}',
'./components/**/*.{ts,tsx}',
'./app/**/*.{ts,tsx}',
'./src/**/*.{ts,tsx}',
],
prefix: "",
theme: {
container: {
center: true,
padding: "2rem",
screens: {
"2xl": "1400px",
},
},
extend: {
backgroundImage: {
"gradient-radial": "radial-gradient(var(--tw-gradient-stops))",
"gradient-conic":
"conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))",
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
keyframes: {
"accordion-down": {
from: { height: "0" },
to: { height: "var(--radix-accordion-content-height)" },
},
"accordion-up": {
from: { height: "var(--radix-accordion-content-height)" },
to: { height: "0" },
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
},
},
},
plugins: [],
};
export default config;
plugins: [require("tailwindcss-animate")],
} satisfies Config
export default config

View File

@ -1,6 +1,11 @@
{
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"],
"baseUrl": ".",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
@ -18,9 +23,36 @@
}
],
"paths": {
"@/*": ["./*"]
"@/actions/*": [
"./actions/*"
],
"@/config/*": [
"./config/*"
],
"@/data/*": [
"./data/*"
],
"@/styles/*": [
"./styles/*"
],
"@/locales/*": [
"./locales/*"
],
"@/components/*": [
"./components/*"
],
"@/*": [
"./*"
]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}