mirror of
https://github.com/johndoe6345789/postgres.git
synced 2026-04-24 13:55:00 +00:00
Initial commit
This commit is contained in:
15
src/app/[locale]/(auth)/(center)/layout.tsx
Normal file
15
src/app/[locale]/(auth)/(center)/layout.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { setRequestLocale } from 'next-intl/server';
|
||||
|
||||
export default async function CenteredLayout(props: {
|
||||
children: React.ReactNode;
|
||||
params: Promise<{ locale: string }>;
|
||||
}) {
|
||||
const { locale } = await props.params;
|
||||
setRequestLocale(locale);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { SignIn } from '@clerk/nextjs';
|
||||
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||
import { getI18nPath } from '@/utils/Helpers';
|
||||
|
||||
type ISignInPageProps = {
|
||||
params: Promise<{ locale: string }>;
|
||||
};
|
||||
|
||||
export async function generateMetadata(props: ISignInPageProps): Promise<Metadata> {
|
||||
const { locale } = await props.params;
|
||||
const t = await getTranslations({
|
||||
locale,
|
||||
namespace: 'SignIn',
|
||||
});
|
||||
|
||||
return {
|
||||
title: t('meta_title'),
|
||||
description: t('meta_description'),
|
||||
};
|
||||
}
|
||||
|
||||
export default async function SignInPage(props: ISignInPageProps) {
|
||||
const { locale } = await props.params;
|
||||
setRequestLocale(locale);
|
||||
|
||||
return (
|
||||
<SignIn path={getI18nPath('/sign-in', locale)} />
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { SignUp } from '@clerk/nextjs';
|
||||
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||
import { getI18nPath } from '@/utils/Helpers';
|
||||
|
||||
type ISignUpPageProps = {
|
||||
params: Promise<{ locale: string }>;
|
||||
};
|
||||
|
||||
export async function generateMetadata(props: ISignUpPageProps): Promise<Metadata> {
|
||||
const { locale } = await props.params;
|
||||
const t = await getTranslations({
|
||||
locale,
|
||||
namespace: 'SignUp',
|
||||
});
|
||||
|
||||
return {
|
||||
title: t('meta_title'),
|
||||
description: t('meta_description'),
|
||||
};
|
||||
}
|
||||
|
||||
export default async function SignUpPage(props: ISignUpPageProps) {
|
||||
const { locale } = await props.params;
|
||||
setRequestLocale(locale);
|
||||
|
||||
return (
|
||||
<SignUp path={getI18nPath('/sign-up', locale)} />
|
||||
);
|
||||
};
|
||||
59
src/app/[locale]/(auth)/dashboard/layout.tsx
Normal file
59
src/app/[locale]/(auth)/dashboard/layout.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { SignOutButton } from '@clerk/nextjs';
|
||||
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||
import Link from 'next/link';
|
||||
import { LocaleSwitcher } from '@/components/LocaleSwitcher';
|
||||
import { BaseTemplate } from '@/templates/BaseTemplate';
|
||||
|
||||
export default async function DashboardLayout(props: {
|
||||
children: React.ReactNode;
|
||||
params: Promise<{ locale: string }>;
|
||||
}) {
|
||||
const { locale } = await props.params;
|
||||
setRequestLocale(locale);
|
||||
const t = await getTranslations({
|
||||
locale,
|
||||
namespace: 'DashboardLayout',
|
||||
});
|
||||
|
||||
return (
|
||||
<BaseTemplate
|
||||
leftNav={(
|
||||
<>
|
||||
<li>
|
||||
<Link
|
||||
href="/dashboard/"
|
||||
className="border-none text-gray-700 hover:text-gray-900"
|
||||
>
|
||||
{t('dashboard_link')}
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
href="/dashboard/user-profile/"
|
||||
className="border-none text-gray-700 hover:text-gray-900"
|
||||
>
|
||||
{t('user_profile_link')}
|
||||
</Link>
|
||||
</li>
|
||||
</>
|
||||
)}
|
||||
rightNav={(
|
||||
<>
|
||||
<li>
|
||||
<SignOutButton>
|
||||
<button className="border-none text-gray-700 hover:text-gray-900" type="button">
|
||||
{t('sign_out')}
|
||||
</button>
|
||||
</SignOutButton>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<LocaleSwitcher />
|
||||
</li>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
</BaseTemplate>
|
||||
);
|
||||
}
|
||||
25
src/app/[locale]/(auth)/dashboard/page.tsx
Normal file
25
src/app/[locale]/(auth)/dashboard/page.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import { Hello } from '@/components/Hello';
|
||||
|
||||
export async function generateMetadata(props: {
|
||||
params: Promise<{ locale: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await props.params;
|
||||
const t = await getTranslations({
|
||||
locale,
|
||||
namespace: 'Dashboard',
|
||||
});
|
||||
|
||||
return {
|
||||
title: t('meta_title'),
|
||||
};
|
||||
}
|
||||
|
||||
export default function Dashboard() {
|
||||
return (
|
||||
<div className="py-5 [&_p]:my-6">
|
||||
<Hello />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { UserProfile } from '@clerk/nextjs';
|
||||
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||
import { getI18nPath } from '@/utils/Helpers';
|
||||
|
||||
type IUserProfilePageProps = {
|
||||
params: Promise<{ locale: string }>;
|
||||
};
|
||||
|
||||
export async function generateMetadata(props: IUserProfilePageProps): Promise<Metadata> {
|
||||
const { locale } = await props.params;
|
||||
const t = await getTranslations({
|
||||
locale,
|
||||
namespace: 'UserProfile',
|
||||
});
|
||||
|
||||
return {
|
||||
title: t('meta_title'),
|
||||
};
|
||||
}
|
||||
|
||||
export default async function UserProfilePage(props: IUserProfilePageProps) {
|
||||
const { locale } = await props.params;
|
||||
setRequestLocale(locale);
|
||||
|
||||
return (
|
||||
<div className="my-6 -ml-16">
|
||||
<UserProfile
|
||||
path={getI18nPath('/dashboard/user-profile', locale)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
41
src/app/[locale]/(auth)/layout.tsx
Normal file
41
src/app/[locale]/(auth)/layout.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { ClerkProvider } from '@clerk/nextjs';
|
||||
import { setRequestLocale } from 'next-intl/server';
|
||||
import { routing } from '@/libs/I18nRouting';
|
||||
import { ClerkLocalizations } from '@/utils/AppConfig';
|
||||
|
||||
export default async function AuthLayout(props: {
|
||||
children: React.ReactNode;
|
||||
params: Promise<{ locale: string }>;
|
||||
}) {
|
||||
const { locale } = await props.params;
|
||||
setRequestLocale(locale);
|
||||
|
||||
const clerkLocale = ClerkLocalizations.supportedLocales[locale] ?? ClerkLocalizations.defaultLocale;
|
||||
let signInUrl = '/sign-in';
|
||||
let signUpUrl = '/sign-up';
|
||||
let dashboardUrl = '/dashboard';
|
||||
let afterSignOutUrl = '/';
|
||||
|
||||
if (locale !== routing.defaultLocale) {
|
||||
signInUrl = `/${locale}${signInUrl}`;
|
||||
signUpUrl = `/${locale}${signUpUrl}`;
|
||||
dashboardUrl = `/${locale}${dashboardUrl}`;
|
||||
afterSignOutUrl = `/${locale}${afterSignOutUrl}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<ClerkProvider
|
||||
appearance={{
|
||||
cssLayerName: 'clerk', // Ensure Clerk is compatible with Tailwind CSS v4
|
||||
}}
|
||||
localization={clerkLocale}
|
||||
signInUrl={signInUrl}
|
||||
signUpUrl={signUpUrl}
|
||||
signInFallbackRedirectUrl={dashboardUrl}
|
||||
signUpFallbackRedirectUrl={dashboardUrl}
|
||||
afterSignOutUrl={afterSignOutUrl}
|
||||
>
|
||||
{props.children}
|
||||
</ClerkProvider>
|
||||
);
|
||||
}
|
||||
55
src/app/[locale]/(marketing)/about/page.tsx
Normal file
55
src/app/[locale]/(marketing)/about/page.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||
import Image from 'next/image';
|
||||
|
||||
type IAboutProps = {
|
||||
params: Promise<{ locale: string }>;
|
||||
};
|
||||
|
||||
export async function generateMetadata(props: IAboutProps): Promise<Metadata> {
|
||||
const { locale } = await props.params;
|
||||
const t = await getTranslations({
|
||||
locale,
|
||||
namespace: 'About',
|
||||
});
|
||||
|
||||
return {
|
||||
title: t('meta_title'),
|
||||
description: t('meta_description'),
|
||||
};
|
||||
}
|
||||
|
||||
export default async function About(props: IAboutProps) {
|
||||
const { locale } = await props.params;
|
||||
setRequestLocale(locale);
|
||||
const t = await getTranslations({
|
||||
locale,
|
||||
namespace: 'About',
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<p>{t('about_paragraph')}</p>
|
||||
|
||||
<div className="mt-2 text-center text-sm">
|
||||
{`${t('translation_powered_by')} `}
|
||||
<a
|
||||
className="text-blue-700 hover:border-b-2 hover:border-blue-700"
|
||||
href="https://l.crowdin.com/next-js"
|
||||
>
|
||||
Crowdin
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<a href="https://l.crowdin.com/next-js">
|
||||
<Image
|
||||
className="mx-auto mt-2"
|
||||
src="/assets/images/crowdin-dark.png"
|
||||
alt="Crowdin Translation Management System"
|
||||
width={128}
|
||||
height={26}
|
||||
/>
|
||||
</a>
|
||||
</>
|
||||
);
|
||||
};
|
||||
57
src/app/[locale]/(marketing)/counter/page.tsx
Normal file
57
src/app/[locale]/(marketing)/counter/page.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import Image from 'next/image';
|
||||
import { CounterForm } from '@/components/CounterForm';
|
||||
import { CurrentCount } from '@/components/CurrentCount';
|
||||
|
||||
export async function generateMetadata(props: {
|
||||
params: Promise<{ locale: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await props.params;
|
||||
const t = await getTranslations({
|
||||
locale,
|
||||
namespace: 'Counter',
|
||||
});
|
||||
|
||||
return {
|
||||
title: t('meta_title'),
|
||||
description: t('meta_description'),
|
||||
};
|
||||
}
|
||||
|
||||
export default function Counter() {
|
||||
const t = useTranslations('Counter');
|
||||
|
||||
return (
|
||||
<>
|
||||
<CounterForm />
|
||||
|
||||
<div className="mt-3">
|
||||
<CurrentCount />
|
||||
</div>
|
||||
|
||||
<div className="mt-5 text-center text-sm">
|
||||
{`${t('security_powered_by')} `}
|
||||
<a
|
||||
className="text-blue-700 hover:border-b-2 hover:border-blue-700"
|
||||
href="https://launch.arcjet.com/Q6eLbRE"
|
||||
>
|
||||
Arcjet
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<a
|
||||
href="https://launch.arcjet.com/Q6eLbRE"
|
||||
>
|
||||
<Image
|
||||
className="mx-auto mt-2"
|
||||
src="/assets/images/arcjet-light.svg"
|
||||
alt="Arcjet"
|
||||
width={128}
|
||||
height={38}
|
||||
/>
|
||||
</a>
|
||||
</>
|
||||
);
|
||||
};
|
||||
96
src/app/[locale]/(marketing)/layout.tsx
Normal file
96
src/app/[locale]/(marketing)/layout.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||
import Link from 'next/link';
|
||||
import { DemoBanner } from '@/components/DemoBanner';
|
||||
import { LocaleSwitcher } from '@/components/LocaleSwitcher';
|
||||
import { BaseTemplate } from '@/templates/BaseTemplate';
|
||||
|
||||
export default async function Layout(props: {
|
||||
children: React.ReactNode;
|
||||
params: Promise<{ locale: string }>;
|
||||
}) {
|
||||
const { locale } = await props.params;
|
||||
setRequestLocale(locale);
|
||||
const t = await getTranslations({
|
||||
locale,
|
||||
namespace: 'RootLayout',
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<DemoBanner />
|
||||
<BaseTemplate
|
||||
leftNav={(
|
||||
<>
|
||||
<li>
|
||||
<Link
|
||||
href="/"
|
||||
className="border-none text-gray-700 hover:text-gray-900"
|
||||
>
|
||||
{t('home_link')}
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
href="/about/"
|
||||
className="border-none text-gray-700 hover:text-gray-900"
|
||||
>
|
||||
{t('about_link')}
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
href="/counter/"
|
||||
className="border-none text-gray-700 hover:text-gray-900"
|
||||
>
|
||||
{t('counter_link')}
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
href="/portfolio/"
|
||||
className="border-none text-gray-700 hover:text-gray-900"
|
||||
>
|
||||
{t('portfolio_link')}
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
className="border-none text-gray-700 hover:text-gray-900"
|
||||
href="https://github.com/ixartz/Next-js-Boilerplate"
|
||||
>
|
||||
GitHub
|
||||
</a>
|
||||
</li>
|
||||
</>
|
||||
)}
|
||||
rightNav={(
|
||||
<>
|
||||
<li>
|
||||
<Link
|
||||
href="/sign-in/"
|
||||
className="border-none text-gray-700 hover:text-gray-900"
|
||||
>
|
||||
{t('sign_in_link')}
|
||||
</Link>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<Link
|
||||
href="/sign-up/"
|
||||
className="border-none text-gray-700 hover:text-gray-900"
|
||||
>
|
||||
{t('sign_up_link')}
|
||||
</Link>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<LocaleSwitcher />
|
||||
</li>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<div className="py-5 text-xl [&_p]:my-6">{props.children}</div>
|
||||
</BaseTemplate>
|
||||
</>
|
||||
);
|
||||
}
|
||||
134
src/app/[locale]/(marketing)/page.tsx
Normal file
134
src/app/[locale]/(marketing)/page.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||
import { Sponsors } from '@/components/Sponsors';
|
||||
|
||||
type IIndexProps = {
|
||||
params: Promise<{ locale: string }>;
|
||||
};
|
||||
|
||||
export async function generateMetadata(props: IIndexProps): Promise<Metadata> {
|
||||
const { locale } = await props.params;
|
||||
const t = await getTranslations({
|
||||
locale,
|
||||
namespace: 'Index',
|
||||
});
|
||||
|
||||
return {
|
||||
title: t('meta_title'),
|
||||
description: t('meta_description'),
|
||||
};
|
||||
}
|
||||
|
||||
export default async function Index(props: IIndexProps) {
|
||||
const { locale } = await props.params;
|
||||
setRequestLocale(locale);
|
||||
const t = await getTranslations({
|
||||
locale,
|
||||
namespace: 'Index',
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<p>
|
||||
{`Follow `}
|
||||
<a
|
||||
className="text-blue-700 hover:border-b-2 hover:border-blue-700"
|
||||
href="https://twitter.com/ixartz"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
@Ixartz on Twitter
|
||||
</a>
|
||||
{` for updates and more information about the boilerplate.`}
|
||||
</p>
|
||||
<h2 className="mt-5 text-2xl font-bold">
|
||||
Boilerplate Code for Your Next.js Project with Tailwind CSS
|
||||
</h2>
|
||||
<p className="text-base">
|
||||
Next.js Boilerplate is a developer-friendly starter code for Next.js projects, built with Tailwind CSS and TypeScript.
|
||||
{' '}
|
||||
<span role="img" aria-label="zap">
|
||||
⚡️
|
||||
</span>
|
||||
{' '}
|
||||
Designed with developer experience in mind, it includes:
|
||||
</p>
|
||||
<ul className="mt-3 text-base">
|
||||
<li>🚀 Next.js with App Router support</li>
|
||||
<li>🔥 TypeScript for type checking</li>
|
||||
<li>💎 Tailwind CSS integration</li>
|
||||
<li>
|
||||
🔒 Authentication with
|
||||
{' '}
|
||||
<a
|
||||
className="font-bold text-blue-700 hover:border-b-2 hover:border-blue-700"
|
||||
href="https://clerk.com?utm_source=github&utm_medium=sponsorship&utm_campaign=nextjs-boilerplate"
|
||||
>
|
||||
Clerk
|
||||
</a>
|
||||
{' '}
|
||||
(includes passwordless, social, and multi-factor auth)
|
||||
</li>
|
||||
<li>📦 ORM with DrizzleORM (PostgreSQL, SQLite, MySQL support)</li>
|
||||
<li>
|
||||
💽 Dev database with PGlite and production with Neon (PostgreSQL)
|
||||
</li>
|
||||
<li>
|
||||
🌐 Multi-language support (i18n) with next-intl and
|
||||
{' '}
|
||||
<a
|
||||
className="font-bold text-blue-700 hover:border-b-2 hover:border-blue-700"
|
||||
href="https://l.crowdin.com/next-js"
|
||||
>
|
||||
Crowdin
|
||||
</a>
|
||||
</li>
|
||||
<li>🔴 Form handling (React Hook Form) and validation (Zod)</li>
|
||||
<li>📏 Linting and formatting (ESLint, Prettier)</li>
|
||||
<li>🦊 Git hooks and commit linting (Husky, Commitlint)</li>
|
||||
<li>🦺 Testing suite (Vitest, React Testing Library, Playwright)</li>
|
||||
<li>🎉 Storybook for UI development</li>
|
||||
<li>
|
||||
🐰 AI-powered code reviews with
|
||||
{' '}
|
||||
<a
|
||||
className="font-bold text-blue-700 hover:border-b-2 hover:border-blue-700"
|
||||
href="https://www.coderabbit.ai?utm_source=next_js_starter&utm_medium=github&utm_campaign=next_js_starter_oss_2025"
|
||||
>
|
||||
CodeRabbit
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
🚨 Error monitoring (
|
||||
<a
|
||||
className="font-bold text-blue-700 hover:border-b-2 hover:border-blue-700"
|
||||
href="https://sentry.io/for/nextjs/?utm_source=github&utm_medium=paid-community&utm_campaign=general-fy25q1-nextjs&utm_content=github-banner-nextjsboilerplate-logo"
|
||||
>
|
||||
Sentry
|
||||
</a>
|
||||
) and logging (LogTape, an alternative to Pino.js)
|
||||
</li>
|
||||
<li>🖥️ Monitoring as Code (Checkly)</li>
|
||||
<li>
|
||||
🔐 Security and bot protection (
|
||||
<a
|
||||
className="font-bold text-blue-700 hover:border-b-2 hover:border-blue-700"
|
||||
href="https://launch.arcjet.com/Q6eLbRE"
|
||||
>
|
||||
Arcjet
|
||||
</a>
|
||||
)
|
||||
</li>
|
||||
<li>🤖 SEO optimization (metadata, JSON-LD, Open Graph tags)</li>
|
||||
<li>⚙️ Development tools (VSCode config, bundler analyzer, changelog generation)</li>
|
||||
</ul>
|
||||
<p className="text-base">
|
||||
Our sponsors' exceptional support has made this project possible.
|
||||
Their services integrate seamlessly with the boilerplate, and we
|
||||
recommend trying them out.
|
||||
</p>
|
||||
<h2 className="mt-5 text-2xl font-bold">{t('sponsors_title')}</h2>
|
||||
<Sponsors />
|
||||
</>
|
||||
);
|
||||
};
|
||||
72
src/app/[locale]/(marketing)/portfolio/[slug]/page.tsx
Normal file
72
src/app/[locale]/(marketing)/portfolio/[slug]/page.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||
import Image from 'next/image';
|
||||
import { routing } from '@/libs/I18nRouting';
|
||||
|
||||
type IPortfolioDetailProps = {
|
||||
params: Promise<{ slug: string; locale: string }>;
|
||||
};
|
||||
|
||||
export function generateStaticParams() {
|
||||
return routing.locales
|
||||
.map(locale =>
|
||||
Array.from(Array.from({ length: 6 }).keys()).map(elt => ({
|
||||
slug: `${elt}`,
|
||||
locale,
|
||||
})),
|
||||
)
|
||||
.flat(1);
|
||||
}
|
||||
|
||||
export async function generateMetadata(props: IPortfolioDetailProps): Promise<Metadata> {
|
||||
const { locale, slug } = await props.params;
|
||||
const t = await getTranslations({
|
||||
locale,
|
||||
namespace: 'PortfolioSlug',
|
||||
});
|
||||
|
||||
return {
|
||||
title: t('meta_title', { slug }),
|
||||
description: t('meta_description', { slug }),
|
||||
};
|
||||
}
|
||||
|
||||
export default async function PortfolioDetail(props: IPortfolioDetailProps) {
|
||||
const { locale, slug } = await props.params;
|
||||
setRequestLocale(locale);
|
||||
const t = await getTranslations({
|
||||
locale,
|
||||
namespace: 'PortfolioSlug',
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className="capitalize">{t('header', { slug })}</h1>
|
||||
<p>{t('content')}</p>
|
||||
|
||||
<div className="mt-5 text-center text-sm">
|
||||
{`${t('code_review_powered_by')} `}
|
||||
<a
|
||||
className="text-blue-700 hover:border-b-2 hover:border-blue-700"
|
||||
href="https://www.coderabbit.ai?utm_source=next_js_starter&utm_medium=github&utm_campaign=next_js_starter_oss_2025"
|
||||
>
|
||||
CodeRabbit
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<a
|
||||
href="https://www.coderabbit.ai?utm_source=next_js_starter&utm_medium=github&utm_campaign=next_js_starter_oss_2025"
|
||||
>
|
||||
<Image
|
||||
className="mx-auto mt-2"
|
||||
src="/assets/images/coderabbit-logo-light.svg"
|
||||
alt="CodeRabbit"
|
||||
width={128}
|
||||
height={22}
|
||||
/>
|
||||
</a>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const dynamicParams = false;
|
||||
77
src/app/[locale]/(marketing)/portfolio/page.tsx
Normal file
77
src/app/[locale]/(marketing)/portfolio/page.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
|
||||
type IPortfolioProps = {
|
||||
params: Promise<{ locale: string }>;
|
||||
};
|
||||
|
||||
export async function generateMetadata(props: IPortfolioProps): Promise<Metadata> {
|
||||
const { locale } = await props.params;
|
||||
const t = await getTranslations({
|
||||
locale,
|
||||
namespace: 'Portfolio',
|
||||
});
|
||||
|
||||
return {
|
||||
title: t('meta_title'),
|
||||
description: t('meta_description'),
|
||||
};
|
||||
}
|
||||
|
||||
export default async function Portfolio(props: IPortfolioProps) {
|
||||
const { locale } = await props.params;
|
||||
setRequestLocale(locale);
|
||||
const t = await getTranslations({
|
||||
locale,
|
||||
namespace: 'Portfolio',
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<p>{t('presentation')}</p>
|
||||
|
||||
<div className="grid grid-cols-1 justify-items-start gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
{Array.from(Array.from({ length: 6 }).keys()).map(elt => (
|
||||
<Link
|
||||
className="hover:text-blue-700"
|
||||
key={elt}
|
||||
href={`/portfolio/${elt}`}
|
||||
>
|
||||
{t('portfolio_name', { name: elt })}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-5 text-center text-sm">
|
||||
{`${t('error_reporting_powered_by')} `}
|
||||
<a
|
||||
className="text-blue-700 hover:border-b-2 hover:border-blue-700"
|
||||
href="https://sentry.io/for/nextjs/?utm_source=github&utm_medium=paid-community&utm_campaign=general-fy25q1-nextjs&utm_content=github-banner-nextjsboilerplate-logo"
|
||||
>
|
||||
Sentry
|
||||
</a>
|
||||
{` - ${t('coverage_powered_by')} `}
|
||||
<a
|
||||
className="text-blue-700 hover:border-b-2 hover:border-blue-700"
|
||||
href="https://about.codecov.io/codecov-free-trial/?utm_source=github&utm_medium=paid-community&utm_campaign=general-fy25q1-nextjs&utm_content=github-banner-nextjsboilerplate-logo"
|
||||
>
|
||||
Codecov
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<a
|
||||
href="https://sentry.io/for/nextjs/?utm_source=github&utm_medium=paid-community&utm_campaign=general-fy25q1-nextjs&utm_content=github-banner-nextjsboilerplate-logo"
|
||||
>
|
||||
<Image
|
||||
className="mx-auto mt-2"
|
||||
src="/assets/images/sentry-dark.png"
|
||||
alt="Sentry"
|
||||
width={128}
|
||||
height={38}
|
||||
/>
|
||||
</a>
|
||||
</>
|
||||
);
|
||||
};
|
||||
36
src/app/[locale]/api/counter/route.ts
Normal file
36
src/app/[locale]/api/counter/route.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { headers } from 'next/headers';
|
||||
import { NextResponse } from 'next/server';
|
||||
import * as z from 'zod';
|
||||
import { db } from '@/libs/DB';
|
||||
import { logger } from '@/libs/Logger';
|
||||
import { counterSchema } from '@/models/Schema';
|
||||
import { CounterValidation } from '@/validations/CounterValidation';
|
||||
|
||||
export const PUT = async (request: Request) => {
|
||||
const json = await request.json();
|
||||
const parse = CounterValidation.safeParse(json);
|
||||
|
||||
if (!parse.success) {
|
||||
return NextResponse.json(z.treeifyError(parse.error), { status: 422 });
|
||||
}
|
||||
|
||||
// `x-e2e-random-id` is used for end-to-end testing to make isolated requests
|
||||
// The default value is 0 when there is no `x-e2e-random-id` header
|
||||
const id = Number((await headers()).get('x-e2e-random-id')) || 0;
|
||||
|
||||
const count = await db
|
||||
.insert(counterSchema)
|
||||
.values({ id, count: parse.data.increment })
|
||||
.onConflictDoUpdate({
|
||||
target: counterSchema.id,
|
||||
set: { count: sql`${counterSchema.count} + ${parse.data.increment}` },
|
||||
})
|
||||
.returning();
|
||||
|
||||
logger.info('Counter has been incremented');
|
||||
|
||||
return NextResponse.json({
|
||||
count: count[0]?.count,
|
||||
});
|
||||
};
|
||||
64
src/app/[locale]/layout.tsx
Normal file
64
src/app/[locale]/layout.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { hasLocale, NextIntlClientProvider } from 'next-intl';
|
||||
import { setRequestLocale } from 'next-intl/server';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { PostHogProvider } from '@/components/analytics/PostHogProvider';
|
||||
import { DemoBadge } from '@/components/DemoBadge';
|
||||
import { routing } from '@/libs/I18nRouting';
|
||||
import '@/styles/global.css';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
icons: [
|
||||
{
|
||||
rel: 'apple-touch-icon',
|
||||
url: '/apple-touch-icon.png',
|
||||
},
|
||||
{
|
||||
rel: 'icon',
|
||||
type: 'image/png',
|
||||
sizes: '32x32',
|
||||
url: '/favicon-32x32.png',
|
||||
},
|
||||
{
|
||||
rel: 'icon',
|
||||
type: 'image/png',
|
||||
sizes: '16x16',
|
||||
url: '/favicon-16x16.png',
|
||||
},
|
||||
{
|
||||
rel: 'icon',
|
||||
url: '/favicon.ico',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export function generateStaticParams() {
|
||||
return routing.locales.map(locale => ({ locale }));
|
||||
}
|
||||
|
||||
export default async function RootLayout(props: {
|
||||
children: React.ReactNode;
|
||||
params: Promise<{ locale: string }>;
|
||||
}) {
|
||||
const { locale } = await props.params;
|
||||
|
||||
if (!hasLocale(routing.locales, locale)) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
setRequestLocale(locale);
|
||||
|
||||
return (
|
||||
<html lang={locale}>
|
||||
<body>
|
||||
<NextIntlClientProvider>
|
||||
<PostHogProvider>
|
||||
{props.children}
|
||||
</PostHogProvider>
|
||||
|
||||
<DemoBadge />
|
||||
</NextIntlClientProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
26
src/app/global-error.tsx
Normal file
26
src/app/global-error.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
'use client';
|
||||
|
||||
import * as Sentry from '@sentry/nextjs';
|
||||
import NextError from 'next/error';
|
||||
import { useEffect } from 'react';
|
||||
import { routing } from '@/libs/I18nRouting';
|
||||
|
||||
export default function GlobalError(props: {
|
||||
error: Error & { digest?: string };
|
||||
}) {
|
||||
useEffect(() => {
|
||||
Sentry.captureException(props.error);
|
||||
}, [props.error]);
|
||||
|
||||
return (
|
||||
<html lang={routing.defaultLocale}>
|
||||
<body>
|
||||
{/* `NextError` is the default Next.js error page component. Its type
|
||||
definition requires a `statusCode` prop. However, since the App Router
|
||||
does not expose status codes for errors, we simply pass 0 to render a
|
||||
generic error message. */}
|
||||
<NextError statusCode={0} />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
13
src/app/robots.ts
Normal file
13
src/app/robots.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { MetadataRoute } from 'next';
|
||||
import { getBaseUrl } from '@/utils/Helpers';
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
return {
|
||||
rules: {
|
||||
userAgent: '*',
|
||||
allow: '/',
|
||||
disallow: '/dashboard',
|
||||
},
|
||||
sitemap: `${getBaseUrl()}/sitemap.xml`,
|
||||
};
|
||||
}
|
||||
14
src/app/sitemap.ts
Normal file
14
src/app/sitemap.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { MetadataRoute } from 'next';
|
||||
import { getBaseUrl } from '@/utils/Helpers';
|
||||
|
||||
export default function sitemap(): MetadataRoute.Sitemap {
|
||||
return [
|
||||
{
|
||||
url: `${getBaseUrl()}/`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: 'daily',
|
||||
priority: 0.7,
|
||||
},
|
||||
// Add more URLs here
|
||||
];
|
||||
}
|
||||
64
src/components/CounterForm.tsx
Normal file
64
src/components/CounterForm.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
'use client';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { CounterValidation } from '@/validations/CounterValidation';
|
||||
|
||||
export const CounterForm = () => {
|
||||
const t = useTranslations('CounterForm');
|
||||
const form = useForm({
|
||||
resolver: zodResolver(CounterValidation),
|
||||
defaultValues: {
|
||||
increment: 1,
|
||||
},
|
||||
});
|
||||
const router = useRouter();
|
||||
|
||||
const handleIncrement = form.handleSubmit(async (data) => {
|
||||
const response = await fetch(`/api/counter`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
await response.json();
|
||||
|
||||
router.refresh();
|
||||
});
|
||||
|
||||
return (
|
||||
<form onSubmit={handleIncrement}>
|
||||
<p>{t('presentation')}</p>
|
||||
<div>
|
||||
<label className="text-sm font-bold text-gray-700" htmlFor="increment">
|
||||
{t('label_increment')}
|
||||
<input
|
||||
id="increment"
|
||||
type="number"
|
||||
className="ml-2 w-32 appearance-none rounded-sm border border-gray-200 px-2 py-1 text-sm leading-tight text-gray-700 focus:ring-3 focus:ring-blue-300/50 focus:outline-hidden"
|
||||
{...form.register('increment', { valueAsNumber: true })}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{form.formState.errors.increment && (
|
||||
<div className="my-2 text-xs text-red-500 italic">
|
||||
{t('error_increment_range')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-2">
|
||||
<button
|
||||
className="rounded-sm bg-blue-500 px-5 py-1 font-bold text-white hover:bg-blue-600 focus:ring-3 focus:ring-blue-300/50 focus:outline-hidden disabled:pointer-events-none disabled:opacity-50"
|
||||
type="submit"
|
||||
disabled={form.formState.isSubmitting}
|
||||
>
|
||||
{t('button_increment')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
26
src/components/CurrentCount.tsx
Normal file
26
src/components/CurrentCount.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import { headers } from 'next/headers';
|
||||
import { db } from '@/libs/DB';
|
||||
import { logger } from '@/libs/Logger';
|
||||
import { counterSchema } from '@/models/Schema';
|
||||
|
||||
export const CurrentCount = async () => {
|
||||
const t = await getTranslations('CurrentCount');
|
||||
|
||||
// `x-e2e-random-id` is used for end-to-end testing to make isolated requests
|
||||
// The default value is 0 when there is no `x-e2e-random-id` header
|
||||
const id = Number((await headers()).get('x-e2e-random-id')) || 0;
|
||||
const result = await db.query.counterSchema.findFirst({
|
||||
where: eq(counterSchema.id, id),
|
||||
});
|
||||
const count = result?.count ?? 0;
|
||||
|
||||
logger.info('Counter fetched successfully');
|
||||
|
||||
return (
|
||||
<div>
|
||||
{t('count', { count })}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
12
src/components/DemoBadge.tsx
Normal file
12
src/components/DemoBadge.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
export const DemoBadge = () => (
|
||||
<div className="fixed right-20 bottom-0 z-10">
|
||||
<a
|
||||
href="https://github.com/ixartz/Next-js-Boilerplate"
|
||||
>
|
||||
<div className="rounded-md bg-gray-900 px-3 py-2 font-semibold text-gray-100">
|
||||
<span className="text-gray-500">Demo of</span>
|
||||
{` Next.js Boilerplate`}
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
9
src/components/DemoBanner.tsx
Normal file
9
src/components/DemoBanner.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
export const DemoBanner = () => (
|
||||
<div className="sticky top-0 z-50 bg-gray-900 p-4 text-center text-lg font-semibold text-gray-100 [&_a]:text-fuchsia-500 [&_a:hover]:text-indigo-500">
|
||||
Live Demo of Next.js Boilerplate -
|
||||
{' '}
|
||||
<Link href="/sign-up">Explore the Authentication</Link>
|
||||
</div>
|
||||
);
|
||||
30
src/components/Hello.tsx
Normal file
30
src/components/Hello.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { currentUser } from '@clerk/nextjs/server';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import { Sponsors } from './Sponsors';
|
||||
|
||||
export const Hello = async () => {
|
||||
const t = await getTranslations('Dashboard');
|
||||
const user = await currentUser();
|
||||
|
||||
return (
|
||||
<>
|
||||
<p>
|
||||
{`👋 `}
|
||||
{t('hello_message', { email: user?.primaryEmailAddress?.emailAddress ?? '' })}
|
||||
</p>
|
||||
<p>
|
||||
{t.rich('alternative_message', {
|
||||
url: () => (
|
||||
<a
|
||||
className="text-blue-700 hover:border-b-2 hover:border-blue-700"
|
||||
href="https://nextjs-boilerplate.com/pro-saas-starter-kit"
|
||||
>
|
||||
Next.js Boilerplate Pro
|
||||
</a>
|
||||
),
|
||||
})}
|
||||
</p>
|
||||
<Sponsors />
|
||||
</>
|
||||
);
|
||||
};
|
||||
33
src/components/LocaleSwitcher.tsx
Normal file
33
src/components/LocaleSwitcher.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
'use client';
|
||||
|
||||
import type { ChangeEventHandler } from 'react';
|
||||
import { useLocale } from 'next-intl';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { usePathname } from '@/libs/I18nNavigation';
|
||||
import { routing } from '@/libs/I18nRouting';
|
||||
|
||||
export const LocaleSwitcher = () => {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const locale = useLocale();
|
||||
|
||||
const handleChange: ChangeEventHandler<HTMLSelectElement> = (event) => {
|
||||
router.push(`/${event.target.value}${pathname}`);
|
||||
router.refresh(); // Ensure the page takes the new locale into account related to the issue #395
|
||||
};
|
||||
|
||||
return (
|
||||
<select
|
||||
defaultValue={locale}
|
||||
onChange={handleChange}
|
||||
className="border border-gray-300 font-medium focus:outline-hidden focus-visible:ring-3"
|
||||
aria-label="lang-switcher"
|
||||
>
|
||||
{routing.locales.map(elt => (
|
||||
<option key={elt} value={elt}>
|
||||
{elt.toUpperCase()}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
};
|
||||
136
src/components/Sponsors.tsx
Normal file
136
src/components/Sponsors.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import Image from 'next/image';
|
||||
|
||||
export const Sponsors = () => (
|
||||
<table className="border-collapse">
|
||||
<tbody>
|
||||
<tr className="h-56">
|
||||
<td className="border-2 border-gray-300 p-3">
|
||||
<a
|
||||
href="https://clerk.com?utm_source=github&utm_medium=sponsorship&utm_campaign=nextjs-boilerplate"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
<Image
|
||||
src="/assets/images/clerk-logo-dark.png"
|
||||
alt="Clerk – Authentication & User Management for Next.js"
|
||||
width={260}
|
||||
height={224}
|
||||
/>
|
||||
</a>
|
||||
</td>
|
||||
<td className="border-2 border-gray-300 p-3">
|
||||
<a href="https://www.coderabbit.ai?utm_source=next_js_starter&utm_medium=github&utm_campaign=next_js_starter_oss_2025" target="_blank" rel="noopener">
|
||||
<Image
|
||||
src="/assets/images/coderabbit-logo-light.svg"
|
||||
alt="CodeRabbit"
|
||||
width={260}
|
||||
height={224}
|
||||
/>
|
||||
</a>
|
||||
</td>
|
||||
<td className="border-2 border-gray-300 p-3">
|
||||
<a
|
||||
href="https://sentry.io/for/nextjs/?utm_source=github&utm_medium=paid-community&utm_campaign=general-fy25q1-nextjs&utm_content=github-banner-nextjsboilerplate-logo"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
<Image
|
||||
src="/assets/images/sentry-dark.png"
|
||||
alt="Sentry"
|
||||
width={260}
|
||||
height={224}
|
||||
/>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr className="h-56">
|
||||
<td className="border-2 border-gray-300 p-3">
|
||||
<a href="https://launch.arcjet.com/Q6eLbRE">
|
||||
<Image
|
||||
src="/assets/images/arcjet-light.svg"
|
||||
alt="Arcjet"
|
||||
width={260}
|
||||
height={224}
|
||||
/>
|
||||
</a>
|
||||
</td>
|
||||
<td className="border-2 border-gray-300 p-3">
|
||||
<a href="https://sevalla.com/">
|
||||
<Image
|
||||
src="/assets/images/sevalla-light.png"
|
||||
alt="Sevalla"
|
||||
width={260}
|
||||
height={224}
|
||||
/>
|
||||
</a>
|
||||
</td>
|
||||
<td className="border-2 border-gray-300 p-3">
|
||||
<a href="https://l.crowdin.com/next-js" target="_blank" rel="noopener">
|
||||
<Image
|
||||
src="/assets/images/crowdin-dark.png"
|
||||
alt="Crowdin"
|
||||
width={260}
|
||||
height={224}
|
||||
/>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr className="h-56">
|
||||
<td className="border-2 border-gray-300 p-3">
|
||||
<a
|
||||
href="https://betterstack.com/?utm_source=github&utm_medium=sponsorship&utm_campaign=next-js-boilerplate"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
<Image
|
||||
src="/assets/images/better-stack-dark.png"
|
||||
alt="Better Stack"
|
||||
width={260}
|
||||
height={224}
|
||||
/>
|
||||
</a>
|
||||
</td>
|
||||
<td className="border-2 border-gray-300 p-3">
|
||||
<a
|
||||
href="https://posthog.com/?utm_source=github&utm_medium=sponsorship&utm_campaign=next-js-boilerplate"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
<Image
|
||||
src="https://posthog.com/brand/posthog-logo.svg"
|
||||
alt="PostHog"
|
||||
width={260}
|
||||
height={224}
|
||||
/>
|
||||
</a>
|
||||
</td>
|
||||
<td className="border-2 border-gray-300 p-3">
|
||||
<a
|
||||
href="https://www.checklyhq.com/?utm_source=github&utm_medium=sponsorship&utm_campaign=next-js-boilerplate"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
<Image
|
||||
src="/assets/images/checkly-logo-light.png"
|
||||
alt="Checkly"
|
||||
width={260}
|
||||
height={224}
|
||||
/>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr className="h-56">
|
||||
<td className="border-2 border-gray-300 p-3">
|
||||
<a href="https://nextjs-boilerplate.com/pro-saas-starter-kit">
|
||||
<Image
|
||||
src="/assets/images/nextjs-boilerplate-saas.png"
|
||||
alt="Next.js SaaS Boilerplate"
|
||||
width={260}
|
||||
height={224}
|
||||
/>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
36
src/components/analytics/PostHogPageView.tsx
Normal file
36
src/components/analytics/PostHogPageView.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
'use client';
|
||||
|
||||
import { usePathname, useSearchParams } from 'next/navigation';
|
||||
import { usePostHog } from 'posthog-js/react';
|
||||
import { Suspense, useEffect } from 'react';
|
||||
|
||||
const PostHogPageView = () => {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const posthog = usePostHog();
|
||||
|
||||
// Track pageviews
|
||||
useEffect(() => {
|
||||
if (pathname && posthog) {
|
||||
let url = window.origin + pathname;
|
||||
if (searchParams.toString()) {
|
||||
url = `${url}?${searchParams.toString()}`;
|
||||
}
|
||||
|
||||
posthog.capture('$pageview', { $current_url: url });
|
||||
}
|
||||
}, [pathname, searchParams, posthog]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
// Wrap this in Suspense to avoid the `useSearchParams` usage above
|
||||
// from de-opting the whole app into client-side rendering
|
||||
// See: https://nextjs.org/docs/messages/deopted-into-client-rendering
|
||||
export const SuspendedPostHogPageView = () => {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<PostHogPageView />
|
||||
</Suspense>
|
||||
);
|
||||
};
|
||||
30
src/components/analytics/PostHogProvider.tsx
Normal file
30
src/components/analytics/PostHogProvider.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
'use client';
|
||||
|
||||
import posthog from 'posthog-js';
|
||||
import { PostHogProvider as PHProvider } from 'posthog-js/react';
|
||||
import { useEffect } from 'react';
|
||||
import { Env } from '@/libs/Env';
|
||||
import { SuspendedPostHogPageView } from './PostHogPageView';
|
||||
|
||||
export const PostHogProvider = (props: { children: React.ReactNode }) => {
|
||||
useEffect(() => {
|
||||
if (Env.NEXT_PUBLIC_POSTHOG_KEY) {
|
||||
posthog.init(Env.NEXT_PUBLIC_POSTHOG_KEY, {
|
||||
api_host: Env.NEXT_PUBLIC_POSTHOG_HOST,
|
||||
capture_pageview: false, // Disable automatic pageview capture, as we capture manually
|
||||
capture_pageleave: true, // Enable pageleave capture
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (!Env.NEXT_PUBLIC_POSTHOG_KEY) {
|
||||
return props.children;
|
||||
}
|
||||
|
||||
return (
|
||||
<PHProvider client={posthog}>
|
||||
<SuspendedPostHogPageView />
|
||||
{props.children}
|
||||
</PHProvider>
|
||||
);
|
||||
};
|
||||
43
src/instrumentation-client.ts
Normal file
43
src/instrumentation-client.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
// This file configures the initialization of Sentry on the client.
|
||||
// The added config here will be used whenever a users loads a page in their browser.
|
||||
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
|
||||
import * as Sentry from '@sentry/nextjs';
|
||||
|
||||
if (!process.env.NEXT_PUBLIC_SENTRY_DISABLED) {
|
||||
Sentry.init({
|
||||
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
|
||||
|
||||
// Add optional integrations for additional features
|
||||
integrations: [
|
||||
Sentry.replayIntegration(),
|
||||
Sentry.consoleLoggingIntegration(),
|
||||
Sentry.browserTracingIntegration(),
|
||||
|
||||
...(process.env.NODE_ENV === 'development'
|
||||
? [Sentry.spotlightBrowserIntegration()]
|
||||
: []),
|
||||
],
|
||||
|
||||
// Adds request headers and IP for users, for more info visit
|
||||
sendDefaultPii: true,
|
||||
|
||||
// Define how likely traces are sampled. Adjust this value in production, or use tracesSampler for greater control.
|
||||
tracesSampleRate: 1,
|
||||
|
||||
// Define how likely Replay events are sampled.
|
||||
// This sets the sample rate to be 10%. You may want this to be 100% while
|
||||
// in development and sample at a lower rate in production
|
||||
replaysSessionSampleRate: 0.1,
|
||||
|
||||
// Define how likely Replay events are sampled when an error occurs.
|
||||
replaysOnErrorSampleRate: 1.0,
|
||||
|
||||
// Enable logs to be sent to Sentry
|
||||
enableLogs: true,
|
||||
|
||||
// Setting this option to true will print useful information to the console while you're setting up Sentry.
|
||||
debug: false,
|
||||
});
|
||||
}
|
||||
|
||||
export const onRouterTransitionStart = Sentry.captureRouterTransitionStart;
|
||||
41
src/instrumentation.ts
Normal file
41
src/instrumentation.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import * as Sentry from '@sentry/nextjs';
|
||||
|
||||
const sentryOptions: Sentry.NodeOptions | Sentry.EdgeOptions = {
|
||||
// Sentry DSN
|
||||
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
|
||||
|
||||
// Enable Spotlight in development
|
||||
spotlight: process.env.NODE_ENV === 'development',
|
||||
|
||||
integrations: [
|
||||
Sentry.consoleLoggingIntegration(),
|
||||
],
|
||||
|
||||
// Adds request headers and IP for users, for more info visit
|
||||
sendDefaultPii: true,
|
||||
|
||||
// Adjust this value in production, or use tracesSampler for greater control
|
||||
tracesSampleRate: 1,
|
||||
|
||||
// Enable logs to be sent to Sentry
|
||||
enableLogs: true,
|
||||
|
||||
// Setting this option to true will print useful information to the console while you're setting up Sentry.
|
||||
debug: false,
|
||||
};
|
||||
|
||||
export async function register() {
|
||||
if (!process.env.NEXT_PUBLIC_SENTRY_DISABLED) {
|
||||
if (process.env.NEXT_RUNTIME === 'nodejs') {
|
||||
// Node.js Sentry configuration
|
||||
Sentry.init(sentryOptions);
|
||||
}
|
||||
|
||||
if (process.env.NEXT_RUNTIME === 'edge') {
|
||||
// Edge Sentry configuration
|
||||
Sentry.init(sentryOptions);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const onRequestError = Sentry.captureRequestError;
|
||||
17
src/libs/Arcjet.ts
Normal file
17
src/libs/Arcjet.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import arcjet, { shield } from '@arcjet/next';
|
||||
|
||||
// Create a base Arcjet instance which can be imported and extended in each route.
|
||||
export default arcjet({
|
||||
// Get your site key from https://launch.arcjet.com/Q6eLbRE
|
||||
// Use `process.env` instead of Env to reduce bundle size in middleware
|
||||
key: process.env.ARCJET_KEY ?? '',
|
||||
// Identify the user by their IP address
|
||||
characteristics: ['ip.src'],
|
||||
rules: [
|
||||
// Protect against common attacks with Arcjet Shield
|
||||
shield({
|
||||
mode: 'LIVE', // will block requests. Use "DRY_RUN" to log only
|
||||
}),
|
||||
// Other rules are added in different routes
|
||||
],
|
||||
});
|
||||
18
src/libs/DB.ts
Normal file
18
src/libs/DB.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||
import type * as schema from '@/models/Schema';
|
||||
import { createDbConnection } from '@/utils/DBConnection';
|
||||
import { Env } from './Env';
|
||||
|
||||
// Stores the db connection in the global scope to prevent multiple instances due to hot reloading with Next.js
|
||||
const globalForDb = globalThis as unknown as {
|
||||
drizzle: NodePgDatabase<typeof schema>;
|
||||
};
|
||||
|
||||
const db = globalForDb.drizzle || createDbConnection();
|
||||
|
||||
// Only store in global during development to prevent hot reload issues
|
||||
if (Env.NODE_ENV !== 'production') {
|
||||
globalForDb.drizzle = db;
|
||||
}
|
||||
|
||||
export { db };
|
||||
35
src/libs/Env.ts
Normal file
35
src/libs/Env.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { createEnv } from '@t3-oss/env-nextjs';
|
||||
import * as z from 'zod';
|
||||
|
||||
export const Env = createEnv({
|
||||
server: {
|
||||
ARCJET_KEY: z.string().startsWith('ajkey_').optional(),
|
||||
CLERK_SECRET_KEY: z.string().min(1),
|
||||
DATABASE_URL: z.string().min(1),
|
||||
},
|
||||
client: {
|
||||
NEXT_PUBLIC_APP_URL: z.string().optional(),
|
||||
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: z.string().min(1),
|
||||
NEXT_PUBLIC_BETTER_STACK_SOURCE_TOKEN: z.string().optional(),
|
||||
NEXT_PUBLIC_BETTER_STACK_INGESTING_HOST: z.string().optional(),
|
||||
NEXT_PUBLIC_POSTHOG_KEY: z.string().optional(),
|
||||
NEXT_PUBLIC_POSTHOG_HOST: z.string().optional(),
|
||||
},
|
||||
shared: {
|
||||
NODE_ENV: z.enum(['test', 'development', 'production']).optional(),
|
||||
},
|
||||
// You need to destructure all the keys manually
|
||||
runtimeEnv: {
|
||||
ARCJET_KEY: process.env.ARCJET_KEY,
|
||||
CLERK_SECRET_KEY: process.env.CLERK_SECRET_KEY,
|
||||
DATABASE_URL: process.env.DATABASE_URL,
|
||||
NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
|
||||
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY:
|
||||
process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY,
|
||||
NEXT_PUBLIC_BETTER_STACK_SOURCE_TOKEN: process.env.NEXT_PUBLIC_BETTER_STACK_SOURCE_TOKEN,
|
||||
NEXT_PUBLIC_BETTER_STACK_INGESTING_HOST: process.env.NEXT_PUBLIC_BETTER_STACK_INGESTING_HOST,
|
||||
NEXT_PUBLIC_POSTHOG_KEY: process.env.NEXT_PUBLIC_POSTHOG_KEY,
|
||||
NEXT_PUBLIC_POSTHOG_HOST: process.env.NEXT_PUBLIC_POSTHOG_HOST,
|
||||
NODE_ENV: process.env.NODE_ENV,
|
||||
},
|
||||
});
|
||||
26
src/libs/I18n.ts
Normal file
26
src/libs/I18n.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { hasLocale } from 'next-intl';
|
||||
import { getRequestConfig } from 'next-intl/server';
|
||||
import { routing } from './I18nRouting';
|
||||
|
||||
// NextJS Boilerplate uses Crowdin as the localization software.
|
||||
// As a developer, you only need to take care of the English (or another default language) version.
|
||||
// Other languages are automatically generated and handled by Crowdin.
|
||||
|
||||
// The localisation files are synced with Crowdin using GitHub Actions.
|
||||
// By default, there are 3 ways to sync the message files:
|
||||
// 1. Automatically sync on push to the `main` branch
|
||||
// 2. Run manually the workflow on GitHub Actions
|
||||
// 3. Every 24 hours at 5am, the workflow will run automatically
|
||||
|
||||
export default getRequestConfig(async ({ requestLocale }) => {
|
||||
// Typically corresponds to the `[locale]` segment
|
||||
const requested = await requestLocale;
|
||||
const locale = hasLocale(routing.locales, requested)
|
||||
? requested
|
||||
: routing.defaultLocale;
|
||||
|
||||
return {
|
||||
locale,
|
||||
messages: (await import(`../locales/${locale}.json`)).default,
|
||||
};
|
||||
});
|
||||
4
src/libs/I18nNavigation.ts
Normal file
4
src/libs/I18nNavigation.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { createNavigation } from 'next-intl/navigation';
|
||||
import { routing } from './I18nRouting';
|
||||
|
||||
export const { usePathname } = createNavigation(routing);
|
||||
8
src/libs/I18nRouting.ts
Normal file
8
src/libs/I18nRouting.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { defineRouting } from 'next-intl/routing';
|
||||
import { AppConfig } from '@/utils/AppConfig';
|
||||
|
||||
export const routing = defineRouting({
|
||||
locales: AppConfig.locales,
|
||||
localePrefix: AppConfig.localePrefix,
|
||||
defaultLocale: AppConfig.defaultLocale,
|
||||
});
|
||||
33
src/libs/Logger.ts
Normal file
33
src/libs/Logger.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { AsyncSink } from '@logtape/logtape';
|
||||
import { configure, fromAsyncSink, getConsoleSink, getJsonLinesFormatter, getLogger } from '@logtape/logtape';
|
||||
import { Env } from './Env';
|
||||
|
||||
const betterStackSink: AsyncSink = async (record) => {
|
||||
await fetch(`https://${Env.NEXT_PUBLIC_BETTER_STACK_INGESTING_HOST}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${Env.NEXT_PUBLIC_BETTER_STACK_SOURCE_TOKEN}`,
|
||||
},
|
||||
body: JSON.stringify(record),
|
||||
});
|
||||
};
|
||||
|
||||
await configure({
|
||||
sinks: {
|
||||
console: getConsoleSink({ formatter: getJsonLinesFormatter() }),
|
||||
betterStack: fromAsyncSink(betterStackSink),
|
||||
},
|
||||
loggers: [
|
||||
{ category: ['logtape', 'meta'], sinks: ['console'], lowestLevel: 'warning' },
|
||||
{
|
||||
category: ['app'],
|
||||
sinks: Env.NEXT_PUBLIC_BETTER_STACK_SOURCE_TOKEN && Env.NEXT_PUBLIC_BETTER_STACK_INGESTING_HOST
|
||||
? ['console', 'betterStack']
|
||||
: ['console'],
|
||||
lowestLevel: 'debug',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
export const logger = getLogger(['app']);
|
||||
75
src/locales/en.json
Normal file
75
src/locales/en.json
Normal file
@@ -0,0 +1,75 @@
|
||||
{
|
||||
"RootLayout": {
|
||||
"home_link": "Home",
|
||||
"about_link": "About",
|
||||
"counter_link": "Counter",
|
||||
"portfolio_link": "Portfolio",
|
||||
"sign_in_link": "Sign in",
|
||||
"sign_up_link": "Sign up"
|
||||
},
|
||||
"BaseTemplate": {
|
||||
"description": "Starter code for your Nextjs Boilerplate with Tailwind CSS",
|
||||
"made_with": "Made with <author></author>."
|
||||
},
|
||||
"Index": {
|
||||
"meta_title": "Next.js Boilerplate Presentation",
|
||||
"meta_description": "Next js Boilerplate is the perfect starter code for your project. Build your React application with the Next.js framework.",
|
||||
"sponsors_title": "Sponsors"
|
||||
},
|
||||
"Counter": {
|
||||
"meta_title": "Counter",
|
||||
"meta_description": "An example of DB operation",
|
||||
"security_powered_by": "Security, bot detection and rate limiting powered by"
|
||||
},
|
||||
"CounterForm": {
|
||||
"presentation": "The counter is stored in the database and incremented by the value you provide.",
|
||||
"label_increment": "Increment by",
|
||||
"button_increment": "Increment",
|
||||
"error_increment_range": "Value must be between 1 and 3"
|
||||
},
|
||||
"CurrentCount": {
|
||||
"count": "Count: {count}"
|
||||
},
|
||||
"About": {
|
||||
"meta_title": "About",
|
||||
"meta_description": "About page description",
|
||||
"about_paragraph": "Welcome to our About page! We are a team of passionate individuals dedicated to creating amazing software.",
|
||||
"translation_powered_by": "Translation powered by"
|
||||
},
|
||||
"Portfolio": {
|
||||
"meta_title": "Portfolio",
|
||||
"meta_description": "Welcome to my portfolio page!",
|
||||
"presentation": "Welcome to my portfolio page! Here you will find a carefully curated collection of my work and accomplishments. Through this portfolio, I'm to showcase my expertise, creativity, and the value I can bring to your projects.",
|
||||
"portfolio_name": "Portfolio {name}",
|
||||
"error_reporting_powered_by": "Error reporting powered by",
|
||||
"coverage_powered_by": "Code coverage powered by"
|
||||
},
|
||||
"PortfolioSlug": {
|
||||
"meta_title": "Portfolio {slug}",
|
||||
"meta_description": "Portfolio {slug} description",
|
||||
"header": "Portfolio {slug}",
|
||||
"content": "Created a set of promotional materials and branding elements for a corporate event. Crafted a visually unified theme, encompassing a logo, posters, banners, and digital assets. Integrated the client's brand identity while infusing it with a contemporary and innovative approach.",
|
||||
"code_review_powered_by": "Code review powered by"
|
||||
},
|
||||
"SignIn": {
|
||||
"meta_title": "Sign in",
|
||||
"meta_description": "Seamlessly sign in to your account with our user-friendly login process."
|
||||
},
|
||||
"SignUp": {
|
||||
"meta_title": "Sign up",
|
||||
"meta_description": "Effortlessly create an account through our intuitive sign-up process."
|
||||
},
|
||||
"Dashboard": {
|
||||
"meta_title": "Dashboard",
|
||||
"hello_message": "Hello {email}!",
|
||||
"alternative_message": "Need advanced features? Multi-tenancy & Teams, Roles & Permissions, Shadcn UI, End-to-End Typesafety with oRPC, Stripe Payment, Light / Dark mode. Try <url></url>."
|
||||
},
|
||||
"UserProfile": {
|
||||
"meta_title": "User Profile"
|
||||
},
|
||||
"DashboardLayout": {
|
||||
"dashboard_link": "Dashboard",
|
||||
"user_profile_link": "Manage your account",
|
||||
"sign_out": "Sign out"
|
||||
}
|
||||
}
|
||||
75
src/locales/fr.json
Normal file
75
src/locales/fr.json
Normal file
@@ -0,0 +1,75 @@
|
||||
{
|
||||
"RootLayout": {
|
||||
"home_link": "Accueil",
|
||||
"about_link": "A propos",
|
||||
"counter_link": "Compteur",
|
||||
"portfolio_link": "Portfolio",
|
||||
"sign_in_link": "Se connecter",
|
||||
"sign_up_link": "S'inscrire"
|
||||
},
|
||||
"BaseTemplate": {
|
||||
"description": "Code de démarrage pour Next.js avec Tailwind CSS",
|
||||
"made_with": "Fait avec <author></author>."
|
||||
},
|
||||
"Index": {
|
||||
"meta_title": "Présentation de Next.js Boilerplate",
|
||||
"meta_description": "Next js Boilerplate est le code de démarrage parfait pour votre projet. Construisez votre application React avec le framework Next.js.",
|
||||
"sponsors_title": "Partenaires"
|
||||
},
|
||||
"Counter": {
|
||||
"meta_title": "Compteur",
|
||||
"meta_description": "Un exemple d'opération DB",
|
||||
"security_powered_by": "Sécurité, détection de bot et rate limiting propulsés par"
|
||||
},
|
||||
"CounterForm": {
|
||||
"presentation": "Le compteur est stocké dans la base de données et incrémenté par la valeur que vous fournissez.",
|
||||
"label_increment": "Incrémenter de",
|
||||
"button_increment": "Incrémenter",
|
||||
"error_increment_range": "La valeur doit être entre 1 et 3"
|
||||
},
|
||||
"CurrentCount": {
|
||||
"count": "Nombre : {count}"
|
||||
},
|
||||
"About": {
|
||||
"meta_title": "A propos",
|
||||
"meta_description": "A propos description",
|
||||
"about_paragraph": "Bienvenue sur notre page À propos ! Nous sommes une équipe de passionnés et dévoués à la création de logiciels.",
|
||||
"translation_powered_by": "Traduction propulsée par"
|
||||
},
|
||||
"Portfolio": {
|
||||
"meta_title": "Portfolio",
|
||||
"meta_description": "Bienvenue sur la page de mon portfolio !",
|
||||
"presentation": "Bienvenue sur ma page portfolio ! Vous trouverez ici une collection soigneusement organisée de mon travail et de mes réalisations. À travers ce portfolio, je mets en valeur mon expertise, ma créativité et la valeur que je peux apporter à vos projets.",
|
||||
"portfolio_name": "Portfolio {name}",
|
||||
"error_reporting_powered_by": "Rapport d'erreur propulsé par",
|
||||
"coverage_powered_by": "Couverture de code propulsée par"
|
||||
},
|
||||
"PortfolioSlug": {
|
||||
"meta_title": "Portfolio {slug}",
|
||||
"meta_description": "Description du Portfolio {slug}",
|
||||
"header": "Portfolio {slug}",
|
||||
"content": "Créé un ensemble de matériel promotionnel et d'éléments de marquage pour un événement d'entreprise. Conçu un thème visuellement unifié, englobant un logo, des affiches, des bannières et des actifs numériques. Intégrer l'identité de marque du client tout en l'insufflant à une approche contemporaine et innovante.",
|
||||
"code_review_powered_by": "Code review propulsé par"
|
||||
},
|
||||
"SignIn": {
|
||||
"meta_title": "Se connecter",
|
||||
"meta_description": "Connectez-vous à votre compte avec facilité."
|
||||
},
|
||||
"SignUp": {
|
||||
"meta_title": "S'inscrire",
|
||||
"meta_description": "Créez un compte facilement grâce à notre processus d'inscription intuitif."
|
||||
},
|
||||
"Dashboard": {
|
||||
"meta_title": "Tableau de bord",
|
||||
"hello_message": "Bonjour {email}!",
|
||||
"alternative_message": "Besoin de fonctionnalités avancées ? Multi-tenant et équipes, rôles et permissions, Shadcn UI, typage de bout en bout avec oRPC, paiement Stripe, mode clair / sombre. Essayez <url></url>."
|
||||
},
|
||||
"UserProfile": {
|
||||
"meta_title": "Profil de l'utilisateur"
|
||||
},
|
||||
"DashboardLayout": {
|
||||
"dashboard_link": "Tableau de bord",
|
||||
"user_profile_link": "Gérer votre compte",
|
||||
"sign_out": "Se déconnecter"
|
||||
}
|
||||
}
|
||||
24
src/models/Schema.ts
Normal file
24
src/models/Schema.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { integer, pgTable, serial, timestamp } from 'drizzle-orm/pg-core';
|
||||
|
||||
// This file defines the structure of your database tables using the Drizzle ORM.
|
||||
|
||||
// To modify the database schema:
|
||||
// 1. Update this file with your desired changes.
|
||||
// 2. Generate a new migration by running: `npm run db:generate`
|
||||
|
||||
// The generated migration file will reflect your schema changes.
|
||||
// It automatically run the command `db-server:file`, which apply the migration before Next.js starts in development mode,
|
||||
// Alternatively, if your database is running, you can run `npm run db:migrate` and there is no need to restart the server.
|
||||
|
||||
// Need a database for production? Just claim it by running `npm run neon:claim`.
|
||||
// Tested and compatible with Next.js Boilerplate
|
||||
|
||||
export const counterSchema = pgTable('counter', {
|
||||
id: serial('id').primaryKey(),
|
||||
count: integer('count').default(0),
|
||||
updatedAt: timestamp('updated_at', { mode: 'date' })
|
||||
.defaultNow()
|
||||
.$onUpdate(() => new Date())
|
||||
.notNull(),
|
||||
createdAt: timestamp('created_at', { mode: 'date' }).defaultNow().notNull(),
|
||||
});
|
||||
78
src/proxy.ts
Normal file
78
src/proxy.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import type { NextFetchEvent, NextRequest } from 'next/server';
|
||||
import { detectBot } from '@arcjet/next';
|
||||
import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server';
|
||||
import createMiddleware from 'next-intl/middleware';
|
||||
import { NextResponse } from 'next/server';
|
||||
import arcjet from '@/libs/Arcjet';
|
||||
import { routing } from './libs/I18nRouting';
|
||||
|
||||
const handleI18nRouting = createMiddleware(routing);
|
||||
|
||||
const isProtectedRoute = createRouteMatcher([
|
||||
'/dashboard(.*)',
|
||||
'/:locale/dashboard(.*)',
|
||||
]);
|
||||
|
||||
const isAuthPage = createRouteMatcher([
|
||||
'/sign-in(.*)',
|
||||
'/:locale/sign-in(.*)',
|
||||
'/sign-up(.*)',
|
||||
'/:locale/sign-up(.*)',
|
||||
]);
|
||||
|
||||
// Improve security with Arcjet
|
||||
const aj = arcjet.withRule(
|
||||
detectBot({
|
||||
mode: 'LIVE',
|
||||
// Block all bots except the following
|
||||
allow: [
|
||||
// See https://docs.arcjet.com/bot-protection/identifying-bots
|
||||
'CATEGORY:SEARCH_ENGINE', // Allow search engines
|
||||
'CATEGORY:PREVIEW', // Allow preview links to show OG images
|
||||
'CATEGORY:MONITOR', // Allow uptime monitoring services
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
export default async function proxy(
|
||||
request: NextRequest,
|
||||
event: NextFetchEvent,
|
||||
) {
|
||||
// Verify the request with Arcjet
|
||||
// Use `process.env` instead of Env to reduce bundle size in middleware
|
||||
if (process.env.ARCJET_KEY) {
|
||||
const decision = await aj.protect(request);
|
||||
|
||||
if (decision.isDenied()) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
}
|
||||
|
||||
// Clerk keyless mode doesn't work with i18n, this is why we need to run the middleware conditionally
|
||||
if (
|
||||
isAuthPage(request) || isProtectedRoute(request)
|
||||
) {
|
||||
return clerkMiddleware(async (auth, req) => {
|
||||
if (isProtectedRoute(req)) {
|
||||
const locale = req.nextUrl.pathname.match(/(\/.*)\/dashboard/)?.at(1) ?? '';
|
||||
|
||||
const signInUrl = new URL(`${locale}/sign-in`, req.url);
|
||||
|
||||
await auth.protect({
|
||||
unauthenticatedUrl: signInUrl.toString(),
|
||||
});
|
||||
}
|
||||
|
||||
return handleI18nRouting(req);
|
||||
})(request, event);
|
||||
}
|
||||
|
||||
return handleI18nRouting(request);
|
||||
}
|
||||
|
||||
export const config = {
|
||||
// Match all pathnames except for
|
||||
// - … if they start with `/_next`, `/_vercel` or `monitoring`
|
||||
// - … the ones containing a dot (e.g. `favicon.ico`)
|
||||
matcher: '/((?!_next|_vercel|monitoring|.*\\..*).*)',
|
||||
};
|
||||
3
src/styles/global.css
Normal file
3
src/styles/global.css
Normal file
@@ -0,0 +1,3 @@
|
||||
@layer theme, base, clerk, components, utilities; /* Ensure Clerk is compatible with Tailwind CSS v4 */
|
||||
|
||||
@import 'tailwindcss';
|
||||
41
src/templates/BaseTemplate.stories.tsx
Normal file
41
src/templates/BaseTemplate.stories.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs-vite';
|
||||
import { NextIntlClientProvider } from 'next-intl';
|
||||
import messages from '@/locales/en.json';
|
||||
import { BaseTemplate } from './BaseTemplate';
|
||||
|
||||
const meta = {
|
||||
title: 'Example/BaseTemplate',
|
||||
component: BaseTemplate,
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
},
|
||||
decorators: [
|
||||
Story => (
|
||||
<NextIntlClientProvider locale="en" messages={messages}>
|
||||
<Story />
|
||||
</NextIntlClientProvider>
|
||||
),
|
||||
],
|
||||
} satisfies Meta<typeof BaseTemplate>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const BaseWithReactComponent: Story = {
|
||||
args: {
|
||||
children: <div>Children node</div>,
|
||||
leftNav: (
|
||||
<>
|
||||
<li>Link 1</li>
|
||||
<li>Link 2</li>
|
||||
</>
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
export const BaseWithString: Story = {
|
||||
args: {
|
||||
...BaseWithReactComponent.args,
|
||||
children: 'String',
|
||||
},
|
||||
};
|
||||
54
src/templates/BaseTemplate.test.tsx
Normal file
54
src/templates/BaseTemplate.test.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { NextIntlClientProvider } from 'next-intl';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { render } from 'vitest-browser-react';
|
||||
import { page } from 'vitest/browser';
|
||||
import messages from '@/locales/en.json';
|
||||
import { BaseTemplate } from './BaseTemplate';
|
||||
|
||||
describe('Base template', () => {
|
||||
describe('Render method', () => {
|
||||
it('should have 3 menu items', () => {
|
||||
render(
|
||||
<NextIntlClientProvider locale="en" messages={messages}>
|
||||
<BaseTemplate
|
||||
leftNav={(
|
||||
<>
|
||||
<li>link 1</li>
|
||||
<li>link 2</li>
|
||||
<li>link 3</li>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
{null}
|
||||
</BaseTemplate>
|
||||
</NextIntlClientProvider>,
|
||||
);
|
||||
|
||||
const menuItemList = page.getByRole('listitem');
|
||||
|
||||
expect(menuItemList.elements()).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('should have a link to support nextjs-boilerplate.com', () => {
|
||||
render(
|
||||
<NextIntlClientProvider locale="en" messages={messages}>
|
||||
<BaseTemplate leftNav={<li>1</li>}>{null}</BaseTemplate>
|
||||
</NextIntlClientProvider>,
|
||||
);
|
||||
|
||||
const copyrightSection = page.getByText(/© Copyright/);
|
||||
const copyrightLink = copyrightSection.getByRole('link');
|
||||
|
||||
/*
|
||||
* PLEASE READ THIS SECTION
|
||||
* We'll really appreciate if you could have a link to our website
|
||||
* The link doesn't need to appear on every pages, one link on one page is enough.
|
||||
* Thank you for your support it'll mean a lot for us.
|
||||
*/
|
||||
expect(copyrightLink).toHaveAttribute(
|
||||
'href',
|
||||
'https://nextjs-boilerplate.com',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
61
src/templates/BaseTemplate.tsx
Normal file
61
src/templates/BaseTemplate.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { AppConfig } from '@/utils/AppConfig';
|
||||
|
||||
export const BaseTemplate = (props: {
|
||||
leftNav: React.ReactNode;
|
||||
rightNav?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
const t = useTranslations('BaseTemplate');
|
||||
|
||||
return (
|
||||
<div className="w-full px-1 text-gray-700 antialiased">
|
||||
<div className="mx-auto max-w-screen-md">
|
||||
<header className="border-b border-gray-300">
|
||||
<div className="pt-16 pb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900">
|
||||
{AppConfig.name}
|
||||
</h1>
|
||||
<h2 className="text-xl">{t('description')}</h2>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between">
|
||||
<nav aria-label="Main navigation">
|
||||
<ul className="flex flex-wrap gap-x-5 text-xl">
|
||||
{props.leftNav}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<nav>
|
||||
<ul className="flex flex-wrap gap-x-5 text-xl">
|
||||
{props.rightNav}
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>{props.children}</main>
|
||||
|
||||
<footer className="border-t border-gray-300 py-8 text-center text-sm">
|
||||
{`© Copyright ${new Date().getFullYear()} ${AppConfig.name}. `}
|
||||
{t.rich('made_with', {
|
||||
author: () => (
|
||||
<a
|
||||
href="https://nextjs-boilerplate.com"
|
||||
className="text-blue-700 hover:border-b-2 hover:border-blue-700"
|
||||
>
|
||||
Next.js Boilerplate
|
||||
</a>
|
||||
),
|
||||
})}
|
||||
{/*
|
||||
* PLEASE READ THIS SECTION
|
||||
* I'm an indie maker with limited resources and funds, I'll really appreciate if you could have a link to my website.
|
||||
* The link doesn't need to appear on every pages, one link on one page is enough.
|
||||
* For example, in the `About` page. Thank you for your support, it'll mean a lot to me.
|
||||
*/}
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
10
src/types/I18n.ts
Normal file
10
src/types/I18n.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { routing } from '@/libs/I18nRouting';
|
||||
import type messages from '@/locales/en.json';
|
||||
|
||||
declare module 'next-intl' {
|
||||
// eslint-disable-next-line ts/consistent-type-definitions
|
||||
interface AppConfig {
|
||||
Locale: (typeof routing.locales)[number];
|
||||
Messages: typeof messages;
|
||||
}
|
||||
}
|
||||
50
src/utils/AIAutomation.test.ts
Normal file
50
src/utils/AIAutomation.test.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('AI Automation Validation', () => {
|
||||
const rootDir = path.resolve(__dirname, '..', '..');
|
||||
const configPath = path.join(rootDir, '.coderabbit.yaml');
|
||||
const readmePath = path.join(rootDir, 'README.md');
|
||||
|
||||
describe('CodeRabbit Configuration', () => {
|
||||
it('should have .coderabbit.yaml configuration file', () => {
|
||||
expect(fs.existsSync(configPath)).toBe(true);
|
||||
});
|
||||
|
||||
it('should have valid CodeRabbit YAML configuration', () => {
|
||||
const configContent = fs.readFileSync(configPath, 'utf-8');
|
||||
|
||||
// Validate required fields exist in the YAML content
|
||||
expect(configContent).toBeDefined();
|
||||
expect(configContent).toContain('language:');
|
||||
expect(configContent).toContain('reviews:');
|
||||
expect(configContent).toContain('CodeRabbit');
|
||||
});
|
||||
|
||||
it('should have reviews auto_review enabled', () => {
|
||||
const configContent = fs.readFileSync(configPath, 'utf-8');
|
||||
|
||||
// Check that auto_review section exists with enabled: true
|
||||
// Using a pattern that ensures we're checking the auto_review section specifically
|
||||
expect(configContent).toMatch(/auto_review:\s+enabled:\s+true/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('README Documentation', () => {
|
||||
it('should mention AI automation in README', () => {
|
||||
const readmeContent = fs.readFileSync(readmePath, 'utf-8');
|
||||
|
||||
// Check for AI-related mentions
|
||||
expect(readmeContent.toLowerCase()).toContain('ai');
|
||||
expect(readmeContent.toLowerCase()).toContain('coderabbit');
|
||||
});
|
||||
|
||||
it('should have AI-powered code reviews feature listed', () => {
|
||||
const readmeContent = fs.readFileSync(readmePath, 'utf-8');
|
||||
|
||||
// Check for specific feature mention
|
||||
expect(readmeContent).toContain('AI-powered code reviews');
|
||||
});
|
||||
});
|
||||
});
|
||||
23
src/utils/AppConfig.ts
Normal file
23
src/utils/AppConfig.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { LocalizationResource } from '@clerk/types';
|
||||
import type { LocalePrefixMode } from 'next-intl/routing';
|
||||
import { enUS, frFR } from '@clerk/localizations';
|
||||
|
||||
const localePrefix: LocalePrefixMode = 'as-needed';
|
||||
|
||||
// FIXME: Update this configuration file based on your project information
|
||||
export const AppConfig = {
|
||||
name: 'Nextjs Starter',
|
||||
locales: ['en', 'fr'],
|
||||
defaultLocale: 'en',
|
||||
localePrefix,
|
||||
};
|
||||
|
||||
const supportedLocales: Record<string, LocalizationResource> = {
|
||||
en: enUS,
|
||||
fr: frFR,
|
||||
};
|
||||
|
||||
export const ClerkLocalizations = {
|
||||
defaultLocale: enUS,
|
||||
supportedLocales,
|
||||
};
|
||||
18
src/utils/DBConnection.ts
Normal file
18
src/utils/DBConnection.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { drizzle } from 'drizzle-orm/node-postgres';
|
||||
import { Pool } from 'pg';
|
||||
import { Env } from '@/libs/Env';
|
||||
import * as schema from '@/models/Schema';
|
||||
|
||||
// Need a database for production? Just claim it by running `npm run neon:claim`.
|
||||
// Tested and compatible with Next.js Boilerplate
|
||||
export const createDbConnection = () => {
|
||||
const pool = new Pool({
|
||||
connectionString: Env.DATABASE_URL,
|
||||
max: 1,
|
||||
});
|
||||
|
||||
return drizzle({
|
||||
client: pool,
|
||||
schema,
|
||||
});
|
||||
};
|
||||
21
src/utils/Helpers.test.ts
Normal file
21
src/utils/Helpers.test.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { routing } from '@/libs/I18nRouting';
|
||||
import { getI18nPath } from './Helpers';
|
||||
|
||||
describe('Helpers', () => {
|
||||
describe('getI18nPath function', () => {
|
||||
it('should not change the path for default language', () => {
|
||||
const url = '/random-url';
|
||||
const locale = routing.defaultLocale;
|
||||
|
||||
expect(getI18nPath(url, locale)).toBe(url);
|
||||
});
|
||||
|
||||
it('should prepend the locale to the path for non-default language', () => {
|
||||
const url = '/random-url';
|
||||
const locale = 'fr';
|
||||
|
||||
expect(getI18nPath(url, locale)).toMatch(/^\/fr/);
|
||||
});
|
||||
});
|
||||
});
|
||||
32
src/utils/Helpers.ts
Normal file
32
src/utils/Helpers.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { routing } from '@/libs/I18nRouting';
|
||||
|
||||
export const getBaseUrl = () => {
|
||||
if (process.env.NEXT_PUBLIC_APP_URL) {
|
||||
return process.env.NEXT_PUBLIC_APP_URL;
|
||||
}
|
||||
|
||||
if (
|
||||
process.env.VERCEL_ENV === 'production'
|
||||
&& process.env.VERCEL_PROJECT_PRODUCTION_URL
|
||||
) {
|
||||
return `https://${process.env.VERCEL_PROJECT_PRODUCTION_URL}`;
|
||||
}
|
||||
|
||||
if (process.env.VERCEL_URL) {
|
||||
return `https://${process.env.VERCEL_URL}`;
|
||||
}
|
||||
|
||||
return 'http://localhost:3000';
|
||||
};
|
||||
|
||||
export const getI18nPath = (url: string, locale: string) => {
|
||||
if (locale === routing.defaultLocale) {
|
||||
return url;
|
||||
}
|
||||
|
||||
return `/${locale}${url}`;
|
||||
};
|
||||
|
||||
export const isServer = () => {
|
||||
return typeof window === 'undefined';
|
||||
};
|
||||
5
src/validations/CounterValidation.ts
Normal file
5
src/validations/CounterValidation.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import * as z from 'zod';
|
||||
|
||||
export const CounterValidation = z.object({
|
||||
increment: z.number().min(1).max(3),
|
||||
});
|
||||
Reference in New Issue
Block a user