feat: initial commit

This commit is contained in:
2026-04-09 20:52:10 -03:00
commit 1a92cc6c11
86 changed files with 19533 additions and 0 deletions

13
.copier-answers.yml Normal file
View File

@@ -0,0 +1,13 @@
# Changes here will be overwritten by Copier; NEVER EDIT MANUALLY
_commit: 324b193
_src_path: ssh://git@ssh.gitea.hideyoshi.com.br:2222/hideyoshi-solutions/frontend-template.git
author_email: vitor@hideyoshi.com.br
author_github_url: https://github.com/HideyoshiNakazone
author_name: Vitor Hideyoshi
author_twitter_url: https://twitter.com/NakazoneVitor
copyright_holder: Vitor Hideyoshi
copyright_year: 2026
project_description: Personal Dev Blog
project_name: hideyoshi-blog
project_slug: hideyoshi-blog
project_url: https://hideyoshi.com.br

50
.github/workflows/test.yml vendored Normal file
View File

@@ -0,0 +1,50 @@
name: Build and Test
on:
push:
jobs:
run-test:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [20.x]
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
lfs: 'true'
- name: Cache node modules
id: cache-npm
uses: actions/cache@v4
env:
cache-name: cache-node-modules
with:
# npm cache files are stored in `~/.npm` on Linux/macOS
path: ~/.npm
key: ${{ runner.os }}-node-${{ matrix.node-version }}-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-${{ matrix.node-version }}-
${{ runner.os }}-node-
${{ runner.os }}-
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- name: Install dependencies
run: npm install
- name: Run Lint
run: npm run lint
- name: Run Build
run: npm run build
env:
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: ${{ secrets.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY }}
- name: Run Tests
run: npm run test

48
.gitignore vendored Normal file
View File

@@ -0,0 +1,48 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
# ide
.idea/
.vscode/
# clerk configuration (can include secrets)
/.clerk/

1
.nvmrc Normal file
View File

@@ -0,0 +1 @@
v24.13.1

36
README.md Normal file
View File

@@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.

25
components.json Normal file
View File

@@ -0,0 +1,25 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "radix-nova",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/ui/components",
"ui": "@/ui/components/shadcn",
"lib": "@/ui/components/shadcn/lib",
"utils": "@/ui/components/shadcn/lib/utils",
"hooks": "@/ui/hooks"
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
}

17
docker-compose.yml Normal file
View File

@@ -0,0 +1,17 @@
name: hideyoshi-blog
services:
postgres:
image: postgres:16
restart: always
environment:
POSTGRES_DB: local_db
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
volumes:
- db_data:/var/lib/postgresql/data
ports:
- '5332:5432'
volumes:
db_data:

37
eslint.config.mjs Normal file
View File

@@ -0,0 +1,37 @@
import nextVitals from 'eslint-config-next/core-web-vitals';
import nextTs from 'eslint-config-next/typescript';
import prettierConfig from 'eslint-plugin-prettier/recommended';
import unusedImports from 'eslint-plugin-unused-imports';
import { defineConfig, globalIgnores } from 'eslint/config';
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
prettierConfig,
{
plugins: {
'unused-imports': unusedImports,
},
rules: {
'unused-imports/no-unused-imports': 'error',
'@typescript-eslint/no-unused-vars': [
'error',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
},
],
},
},
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
'.next/**',
'out/**',
'build/**',
'next-env.d.ts',
]),
]);
export default eslintConfig;

View File

@@ -0,0 +1,21 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddsBaseTriggers1775776542850 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE OR REPLACE FUNCTION set_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = now();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
--end-sql`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`--sql
DROP FUNCTION set_updated_at();
--end-sql`);
}
}

View File

@@ -0,0 +1,31 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddsUserTable1775776680658 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`--sql
CREATE TYPE user_role AS ENUM ('admin', 'internal', 'user');
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL,
email TEXT NOT NULL UNIQUE,
role user_role NOT NULL DEFAULT 'user',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
external_id UUID NOT NULL DEFAULT gen_random_uuid()
);
CREATE TRIGGER set_users_updated_at
BEFORE UPDATE on users
FOR EACH ROW
EXECUTE FUNCTION set_updated_at();
--end-sql`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`--sql
DROP TABLE users CASCADE;
DROP TYPE user_role;
--end-sql`);
}
}

15
next.config.ts Normal file
View File

@@ -0,0 +1,15 @@
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
async redirects() {
return [
{
source: '/',
destination: '/home',
permanent: true,
},
];
},
};
export default nextConfig;

16811
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

97
package.json Normal file
View File

@@ -0,0 +1,97 @@
{
"name": "hideyoshi-blog",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"format": "prettier --check --ignore-path .gitignore .",
"format:fix": "prettier --write --ignore-path .gitignore .",
"typeorm": "ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js",
"typeorm:create": "npm run typeorm -- migration:create ./migrations/$npm_config_name",
"typeorm:migration": "npm run typeorm -- migration:run -d src/lib/db/data-source.cli.ts",
"typeorm:migration:down": "npm run typeorm -- migration:revert -d src/lib/db/data-source.cli.ts",
"test": "jest"
},
"dependencies": {
"@clerk/nextjs": "^7.0.7",
"@tanstack/react-query": "^5.95.2",
"@vercel/analytics": "^2.0.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"iron-session": "^8.0.4",
"jotai": "^2.19.0",
"lucide-react": "^1.7.0",
"next": "16.2.1",
"next-themes": "^0.4.6",
"pg": "^8.20.0",
"radix-ui": "^1.4.3",
"react": "19.2.4",
"react-dom": "19.2.4",
"shadcn": "^4.1.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0",
"typeorm": "^0.3.28",
"zod": "^4.3.6"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@testcontainers/postgresql": "^11.13.0",
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
"@types/jest": "^30.0.0",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.2.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.5",
"eslint-plugin-unused-imports": "^4.4.1",
"jest": "^30.3.0",
"prettier": "^3.8.1",
"prettier-plugin-tailwindcss": "^0.7.2",
"tailwindcss": "^4",
"ts-jest": "^29.4.6",
"ts-node": "^10.9.2",
"typescript": "^5.9.3"
},
"prettier": {
"trailingComma": "es5",
"semi": true,
"tabWidth": 4,
"useTabs": false,
"singleQuote": true,
"jsxSingleQuote": true,
"plugins": [
"prettier-plugin-tailwindcss",
"@trivago/prettier-plugin-sort-imports"
],
"importOrderParserPlugins": [
"typescript",
"jsx",
"decorators-legacy"
]
},
"jest": {
"testEnvironment": "node",
"transform": {
"^.+\\.(ts|tsx)$": "ts-jest"
},
"moduleNameMapper": {
"^@/(.*)$": "<rootDir>/src/$1",
"^~/(.*)$": "<rootDir>/$1"
},
"moduleFileExtensions": [
"ts",
"tsx",
"js"
],
"testMatch": [
"**/?(*.)+(spec|test).[jt]s?(x)"
]
}
}

7
postcss.config.mjs Normal file
View File

@@ -0,0 +1,7 @@
const config = {
plugins: {
'@tailwindcss/postcss': {},
},
};
export default config;

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 227 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 446 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 213 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 376 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 236 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 462 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

@@ -0,0 +1,12 @@
import { siteConfig } from '@/site.config';
const Home = async () => {
return (
<div className='flex flex-col items-center justify-center'>
<h1 className='mb-4 text-4xl font-bold'>About</h1>
<p className='text-lg'>Welcome {siteConfig.name}!</p>
</div>
);
};
export default Home;

View File

@@ -0,0 +1,12 @@
import { siteConfig } from '@/site.config';
const Home = async () => {
return (
<div className='flex flex-col items-center justify-center'>
<h1 className='mb-4 text-4xl font-bold'>Admin</h1>
<p className='text-lg'>Welcome {siteConfig.name}!</p>
</div>
);
};
export default Home;

View File

@@ -0,0 +1,12 @@
import { siteConfig } from '@/site.config';
const Home = async () => {
return (
<div className='flex flex-col items-center justify-center'>
<h1 className='mb-4 text-4xl font-bold'>Home</h1>
<p className='text-lg'>Welcome {siteConfig.name}!</p>
</div>
);
};
export default Home;

View File

@@ -0,0 +1,22 @@
import { SessionClaims } from '@/lib/feature/user/clerk.model';
import { syncUser } from '@/lib/feature/user/user.service';
import { setSessionData } from '@/lib/session/session-storage.internal';
import { auth } from '@clerk/nextjs/server';
import { redirect } from 'next/navigation';
export async function GET() {
const { sessionClaims } = await auth();
let parsedClaims: SessionClaims;
try {
parsedClaims = SessionClaims.parse(sessionClaims);
} catch (error) {
console.error(error);
redirect('/');
}
const syncedUser = await syncUser(parsedClaims);
await setSessionData('user', syncedUser);
redirect('/');
}

View File

@@ -0,0 +1,7 @@
import { clearSessionData } from '@/lib/session/session-storage';
import { redirect } from 'next/navigation';
export async function GET() {
await clearSessionData();
redirect('/');
}

BIN
src/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

15
src/app/fonts.ts Normal file
View File

@@ -0,0 +1,15 @@
import { Montserrat, Source_Code_Pro } from 'next/font/google';
export const montserrat = Montserrat({
display: 'swap',
variable: '--font-montserrat',
weight: ['400', '500', '600', '700'],
subsets: ['latin'],
});
export const sourceCodePro = Source_Code_Pro({
display: 'swap',
variable: '--font-source-code-pro',
weight: ['200', '300', '400', '500', '600'],
subsets: ['latin'],
});

159
src/app/globals.css Normal file
View File

@@ -0,0 +1,159 @@
@import 'tailwindcss';
@import 'tw-animate-css';
@import 'shadcn/tailwind.css';
html {
font-family: var(--font-source-code-pro), sans-serif;
}
h1,
h2,
h3,
h4,
h5,
h6 {
@apply font-bold;
font-family: var(--font-montserrat), sans-serif;
}
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-sans);
--font-mono: var(--font-geist-mono);
--font-heading: var(--font-sans);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
}
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
html {
@apply font-sans;
}
}
@layer components {
.header-height {
@apply h-[8dvh] md:min-h-[60px];
}
.footer-height {
@apply md:h-24;
}
.page-height {
@apply min-h-[calc(100dvh-8dvh)];
}
.content-height {
@apply min-h-[calc(100dvh-8dvh-6rem)];
}
}

66
src/app/layout.tsx Normal file
View File

@@ -0,0 +1,66 @@
import './globals.css';
import { montserrat, sourceCodePro } from '@/app/fonts';
import { siteConfig } from '@/site.config';
import { SiteHeader } from '@/ui/components/internal/header/site-header';
import { SiteFooter } from '@/ui/components/internal/site-footer';
import { Toaster } from '@/ui/components/shadcn/sonner';
import { Provider } from '@/ui/providers/providers';
import { Analytics } from '@vercel/analytics/next';
import type { Metadata, Viewport } from 'next';
export const metadata: Metadata = {
title: siteConfig.name,
description: siteConfig.description,
openGraph: {
title: siteConfig.name,
description: siteConfig.description,
url: siteConfig.url,
siteName: siteConfig.name,
images: [
{
url: `${siteConfig.url}/${siteConfig.icon.lightPaths.x512}`,
width: 512,
height: 512,
alt: siteConfig.name,
},
],
},
};
export const viewport: Viewport = {
themeColor: [
{ media: '(prefers-color-scheme: light)', color: 'white' },
{ media: '(prefers-color-scheme: dark)', color: 'black' },
],
};
export default async function RootLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
return (
<html
lang='en'
className={`${montserrat.className} ${sourceCodePro.className}`}
suppressHydrationWarning
>
<body className={'relative h-screen bg-background antialiased'}>
<Provider
attribute='class'
defaultTheme='system'
enableSystem
disableTransitionOnChange
>
<div className='h-full flex flex-col bg-background'>
<SiteHeader />
<div className='content-height flex flex-col items-center justify-center'>
{children}
</div>
<SiteFooter />
<Toaster />
<Analytics />
</div>
</Provider>
</body>
</html>
);
}

17
src/lib/db/client.ts Normal file
View File

@@ -0,0 +1,17 @@
import { configAppDataSource } from '@/lib/db/data-source';
import { EntityTarget, ObjectLiteral } from 'typeorm';
export async function getDataSource() {
const dataSource = configAppDataSource();
if (!dataSource.isInitialized) {
await dataSource.initialize();
}
return dataSource;
}
export async function getRepository<Entity extends ObjectLiteral>(
entity: EntityTarget<Entity>
) {
const dataSource = await getDataSource();
return dataSource.getRepository(entity);
}

View File

@@ -0,0 +1,15 @@
import * as dotenv from 'dotenv';
import { DataSource } from 'typeorm';
dotenv.config();
const AppDataSource = new DataSource({
type: 'postgres',
url: process.env.DATABASE_URL,
synchronize: false,
logging: false,
entities: ['src/feature/**/*.entity.ts'],
migrations: ['migrations/*.ts'],
});
export default AppDataSource;

26
src/lib/db/data-source.ts Normal file
View File

@@ -0,0 +1,26 @@
import * as Entities from './entities';
import { resolve } from 'path';
import { DataSource } from 'typeorm';
let AppDataSource: DataSource | null = null;
const migrationsPath = resolve(__dirname, '../../../migrations/*.ts');
export const configAppDataSource = (url: string | undefined = undefined) => {
if (AppDataSource) {
return AppDataSource;
}
AppDataSource = new DataSource({
type: 'postgres',
url: url || process.env.DATABASE_URL,
synchronize: false,
logging: false,
entities: Object.values(Entities),
migrations: [migrationsPath],
});
return AppDataSource;
};
export default AppDataSource;

1
src/lib/db/entities.ts Normal file
View File

@@ -0,0 +1 @@
export { UserEntity } from '@/lib/feature/user/user.entity';

View File

@@ -0,0 +1,14 @@
import { z } from 'zod';
export const SessionClaims = z.object({
user: z.object({
email: z.email(),
username: z.string(),
full_name: z.string(),
image_url: z.string().optional(),
public_metadata: z.object({
role: z.enum(['admin', 'internal', 'user']).default('user'),
}),
}),
});
export type SessionClaims = z.infer<typeof SessionClaims>;

View File

@@ -0,0 +1,49 @@
import { UserRole, UserRoleValues } from '@/lib/feature/user/user.model';
import type { UUIDv4 } from '@/utils/types/uuid';
import { Entity, Column, PrimaryColumn } from 'typeorm';
@Entity('users')
export class UserEntity {
@PrimaryColumn({
type: 'bigint',
primary: true,
generated: true,
})
id: string;
@Column({ type: 'text' })
name: string;
@Column({ type: 'text', unique: true })
email: string;
@Column({
type: 'enum',
enum: UserRoleValues,
enumName: 'user_role',
default: UserRole.USER,
})
role: UserRole;
@Column({
name: 'created_at',
type: 'timestamp with time zone',
default: () => 'now()',
})
createdAt: Date;
@Column({
name: 'updated_at',
type: 'timestamp with time zone',
default: () => 'now()',
})
updatedAt: Date;
@Column({
name: 'external_id',
type: 'uuid',
unique: true,
default: () => 'gen_random_uuid()',
})
externalId: UUIDv4;
}

View File

@@ -0,0 +1,30 @@
'use server';
import { getRepository } from '@/lib/db/client';
import { UserEntity } from '@/lib/db/entities';
import { userEntityToModel } from '@/lib/feature/user/user.service';
import { getSessionData } from '@/lib/session/session-storage';
import { UUIDv4 } from '@/utils/types/uuid';
export const getUserByExternalId = async (externalId: UUIDv4) => {
const sessionData = await getSessionData();
if (
!sessionData ||
!sessionData.user ||
sessionData.user.externalId !== externalId
) {
throw new Error('User not permitted to access this data');
}
const userRepository = await getRepository(UserEntity);
const userEntity = await userRepository.findOne({
where: { externalId: externalId },
});
if (!userEntity) {
return null;
}
return userEntityToModel(userEntity);
};

View File

@@ -0,0 +1,41 @@
import { z } from 'zod';
export const UserRole = {
ADMIN: 'admin',
INTERNAL: 'internal',
USER: 'user',
} as const;
export type UserRole = (typeof UserRole)[keyof typeof UserRole];
export const UserRoleValues: UserRole[] = Object.values(UserRole);
export const CreateUserModel = z.object({
name: z.string(),
email: z.email(),
role: z.enum(UserRoleValues),
});
export type CreateUserModel = z.infer<typeof CreateUserModel>;
export const UpdateUserModel = z.object({
name: z.string().optional(),
email: z.email().optional(),
role: z.enum(UserRoleValues).optional(),
});
export type UpdateUserModel = z.infer<typeof UpdateUserModel>;
export const UserModel = z.object({
id: z.string(),
name: z.string(),
email: z.email(),
active: z.boolean().optional(),
role: z.enum(UserRoleValues),
imageUrl: z.string().optional(),
externalId: z.uuid(),
});
export type UserModel = z.infer<typeof UserModel>;
export const UserInfoModel = z.object({
externalId: z.uuid(),
name: z.string(),
email: z.email(),
});
export type UserInfoModel = z.infer<typeof UserInfoModel>;

View File

@@ -0,0 +1,110 @@
import { getRepository } from '@/lib/db/client';
import { SessionClaims } from '@/lib/feature/user/clerk.model';
import { UserEntity } from '@/lib/feature/user/user.entity';
import {
CreateUserModel,
UpdateUserModel,
UserModel,
} from '@/lib/feature/user/user.model';
export const userEntityToModel = (userEntity: UserEntity): UserModel => {
return {
id: userEntity.id,
name: userEntity.name,
email: userEntity.email,
role: userEntity.role,
externalId: userEntity.externalId,
};
};
/**
* Retrieves a user by their email address.
* @param email - The email address of the user to retrieve.
* @returns {Promise<UserModel | null>} The user model if found, otherwise null.
*/
export const getUserByEmail = async (
email: string
): Promise<UserModel | null> => {
const userRepository = await getRepository(UserEntity);
const userEntity = await userRepository.findOneBy({ email });
if (!userEntity) {
return null;
}
return userEntityToModel(userEntity);
};
/**
* Saves a new user to the database.
* @param user - The user data to save.
* @returns {Promise<UserModel>} The saved user model.
* @throws {Error} If a user with the same email already exists.
*/
export const saveUser = async (user: CreateUserModel): Promise<UserModel> => {
const userRepository = await getRepository(UserEntity);
if (!!(await userRepository.findOneBy({ email: user.email }))) {
throw new Error(`User with email ${user.email} already exists`);
}
const newUser = userRepository.create(user);
return userEntityToModel(await userRepository.save(newUser));
};
/**
* Updates an existing user in the database.
* @param userId - The ID of the user to update.
* @param user - The new user data.
* @returns {Promise<UserModel>} The updated user model.
* @throws {Error} If the user with the given ID does not exist.
*/
export const updateUser = async (
userId: string,
user: UpdateUserModel
): Promise<UserModel> => {
const userRepository = await getRepository(UserEntity);
const existingUser = await userRepository.findOneBy({ id: userId });
if (!existingUser) {
throw new Error(`User with ID ${userId} not found`);
}
if (!!user.email) existingUser.email = user.email;
if (!!user.name) existingUser.name = user.name;
if (!!user.role) existingUser.role = user.role;
return userEntityToModel(await userRepository.save(existingUser));
};
/**
* Synchronizes a user with the database.
* If the user already exists, it skips saving and returns the existing user.
* If the user does not exist, it creates a new user record.
* @returns {Promise<UserModel>} The synchronized user model.
* @throws {Error} If the user email is not provided or if there is an issue
* saving the user.
* @param sessionClaims Session Claims from the Auth Provider
*/
export const syncUser = async (
sessionClaims: SessionClaims
): Promise<UserModel> => {
const { full_name, email } = sessionClaims.user;
const role = sessionClaims.user.public_metadata.role;
const existingUser = await getUserByEmail(email);
if (!existingUser) {
return await saveUser({
name: full_name,
email: email,
role: role,
});
}
return await updateUser(existingUser.id, {
name: full_name,
email: existingUser.email,
role: role,
});
};

View File

@@ -0,0 +1,9 @@
import { UserModel } from '@/lib/feature/user/user.model';
import { z } from 'zod';
export const SessionData = z.object({
user: UserModel.optional(),
});
export type SessionData = z.infer<typeof SessionData>;
export type SessionDataKey = keyof SessionData;
export type SessionDataValue = SessionData[SessionDataKey];

View File

@@ -0,0 +1,16 @@
import {
SessionDataKey,
SessionDataValue,
} from '@/lib/session/session-data.type';
import { getSession } from '@/lib/session/session-storage';
export const setSessionData = async (
key: SessionDataKey,
data: SessionDataValue
): Promise<void> => {
const session = await getSession();
Object.assign(session, {
[key]: data,
});
await session.save();
};

View File

@@ -0,0 +1,49 @@
'use server';
import { SessionData } from '@/lib/session/session-data.type';
import { siteConfig } from '@/site.config';
import { getIronSession, IronSession, SessionOptions } from 'iron-session';
import { cookies } from 'next/headers';
let sessionOptions: SessionOptions | undefined;
const getSessionOptions = (): SessionOptions => {
if (!!sessionOptions) return sessionOptions;
const password = process.env.SESSION_SECRET;
if (!password) {
throw new Error('SESSION_SECRET is not set in environment variables.');
}
sessionOptions = {
cookieName: siteConfig.slug,
password: password,
cookieOptions: {
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 30, // 30 days
},
};
return sessionOptions;
};
export const getSession = async (): Promise<IronSession<SessionData>> => {
return await getIronSession(await cookies(), getSessionOptions());
};
export const clearSessionData = async (): Promise<void> => {
const session = await getSession();
session.destroy();
await session.save();
};
export const getSessionData = async (): Promise<SessionData | null> => {
try {
const session = await getSession();
return SessionData.parse(session);
} catch {
await clearSessionData();
}
return null;
};

36
src/proxy.ts Normal file
View File

@@ -0,0 +1,36 @@
import { getSessionData } from '@/lib/session/session-storage';
import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server';
import { NextResponse } from 'next/server';
const isPublic = createRouteMatcher([
'/home(.*)?',
'/about(.*)?',
'/api/user(.*)?',
]);
const isAdmin = createRouteMatcher(['/admin(.*)?']);
export default clerkMiddleware(async (auth, req) => {
if (isPublic(req)) {
return;
}
const sessionData = await getSessionData();
if (!sessionData || !sessionData.user) {
await auth.protect();
return NextResponse.redirect(new URL('/api/user/sync', req.url));
}
if (isAdmin(req) && sessionData?.user?.role !== 'admin') {
return NextResponse.redirect(new URL('/', req.url));
}
});
export const config = {
matcher: [
// Skip Next.js internals and all static files, unless found in search params
'/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)',
// Always run for API routes
'/(api|trpc)(.*)',
],
};

42
src/site.config.ts Normal file
View File

@@ -0,0 +1,42 @@
import lightSiteIcon from '../public/img/logo/red/icon-512.png';
import darkSiteIcon from '../public/img/logo/white/icon-512.png';
export const siteConfig = {
shortName: 'hideyoshi-blog',
name: 'hideyoshi-blog',
slug: 'hideyoshi-blog',
description: 'Personal Dev Blog',
url: process.env.FRONTEND_PATH || 'https://hideyoshi.com.br',
author: {
name: 'Vitor Hideyoshi',
email: 'vitor@hideyoshi.com.br',
links: {
github: 'https://github.com/HideyoshiNakazone',
twitter: 'https://twitter.com/NakazoneVitor',
},
},
icon: {
light: lightSiteIcon,
lightPaths: {
x16: 'img/logo/red/icon-16.png',
x32: 'img/logo/red/icon-32.png',
x96: 'img/logo/red/icon-96.png',
x180: 'img/logo/red/icon-180.png',
x512: 'img/logo/red/icon-512.png',
},
dark: darkSiteIcon,
darkPaths: {
x16: 'img/logo/white/icon-16.png',
x32: 'img/logo/white/icon-32.png',
x96: 'img/logo/white/icon-96.png',
x180: 'img/logo/white/icon-180.png',
x512: 'img/logo/white/icon-512.png',
},
},
copyright: {
company: '',
initialYear: 2026,
},
};
export type SiteConfig = typeof siteConfig;

View File

@@ -0,0 +1,42 @@
import ThemeToggle from '@/ui/components/internal/theme-toggle';
import Link from 'next/link';
export type HeaderLinksProps = {
links: Array<{
href: string;
label: string;
condition: boolean;
}>;
};
export type ProfileButtonProps = {
userButton: React.ReactNode;
};
export const BaseDesktopHeader = ({
links,
userButton,
}: HeaderLinksProps & ProfileButtonProps) => {
return (
<>
<div className='flex w-3/5 flex-row items-center justify-around align-middle'>
{links
.filter((link) => link.condition)
.map((link) => (
<Link
key={link.href}
href={link.href}
className='text-xl font-normal transition-colors hover:font-bold hover:text-primary'
>
{link.label}
</Link>
))}
</div>
<div className='flex items-center space-x-4'>
<ThemeToggle></ThemeToggle>
{userButton}
</div>
</>
);
};

View File

@@ -0,0 +1,26 @@
'use client';
import { getSessionData } from '@/lib/session/session-storage';
import { BaseDesktopHeader } from '@/ui/components/internal/header/desktop-header/base-desktop-header';
import { UserButton } from '@/ui/components/internal/user-profile/user-profile-button';
import { useQuery } from '@tanstack/react-query';
export const DynamicDesktopHeader = () => {
const { data: sessionData } = useQuery({
queryKey: ['sessionData'],
queryFn: async () => {
return await getSessionData();
},
});
const user = sessionData?.user;
const links = [
{ href: '/home', label: 'Home', condition: true },
{ href: '/about', label: 'About', condition: true },
{ href: '/admin', label: 'Admin', condition: !!user },
];
const userButton = !!user ? <UserButton user={user} /> : <div />;
return <BaseDesktopHeader links={links} userButton={userButton} />;
};

View File

@@ -0,0 +1,11 @@
import { BaseDesktopHeader } from '@/ui/components/internal/header/desktop-header/base-desktop-header';
import { BlankUserButton } from '@/ui/components/internal/user-profile/user-profile-button';
export const StaticDesktopHeader = () => {
const links = [
{ href: '/home', label: 'Home', condition: true },
{ href: '/about', label: 'About', condition: true },
];
return <BaseDesktopHeader links={links} userButton={<BlankUserButton />} />;
};

View File

@@ -0,0 +1,22 @@
import { MenuBurgerButton } from '@/ui/components/internal/header/mobile-header/utils/menu-burguer-button';
import ThemeToggle from '@/ui/components/internal/theme-toggle';
export type BaseMobileHeaderProps = {
active?: boolean;
onStateChange?: (value: boolean) => void;
};
export const BaseMobileHeader = ({
active,
onStateChange,
}: BaseMobileHeaderProps) => {
return (
<div className='flex items-center space-x-4'>
<ThemeToggle></ThemeToggle>
<MenuBurgerButton
state={active ?? false}
onStateChange={onStateChange}
/>
</div>
);
};

View File

@@ -0,0 +1,62 @@
'use client';
import { getSessionData } from '@/lib/session/session-storage';
import { BaseMobileHeader } from '@/ui/components/internal/header/mobile-header/base-mobile-header';
import { HeaderSlider } from '@/ui/components/internal/header/mobile-header/utils/header-slider';
import { UserButton } from '@/ui/components/internal/user-profile/user-profile-button';
import { useQuery } from '@tanstack/react-query';
import Link from 'next/link';
import { useState } from 'react';
export const DynamicMobileHeader = () => {
const { data: sessionData } = useQuery({
queryKey: ['sessionData'],
queryFn: async () => {
return await getSessionData();
},
});
const user = sessionData?.user;
const [isNavigationState, setNavigationState] = useState(false);
const links = [
{ href: '/home', label: 'Home', condition: true },
{ href: '/about', label: 'About', condition: true },
{ href: '/admin', label: 'Admin', condition: !!user },
];
const userButton = !!user ? <UserButton user={user} /> : <div />;
return (
<>
<BaseMobileHeader
active={isNavigationState}
onStateChange={(value: boolean) => setNavigationState(value)}
/>
<HeaderSlider
isOpen={isNavigationState}
onClose={() => setNavigationState(false)}
>
<div className='h-full flex flex-col items-center justify-center'>
<div className='h-2/3 flex flex-col items-start justify-around'>
{links
.filter((link) => link.condition)
.map((link) => (
<Link
key={link.href}
href={link.href}
className='text-xl font-normal transition-colors hover:font-bold hover:text-primary'
>
{link.label}
</Link>
))}
</div>
<div className='h-1/3 flex items-center space-x-4 mt-4'>
{userButton}
</div>
</div>
</HeaderSlider>
</>
);
};

View File

@@ -0,0 +1,5 @@
import { BaseMobileHeader } from '@/ui/components/internal/header/mobile-header/base-mobile-header';
export const StaticMobileHeader = () => {
return <BaseMobileHeader active={false} />;
};

View File

@@ -0,0 +1,29 @@
export const HeaderSlider = ({
children,
isOpen,
onClose,
}: Readonly<{
children: React.ReactNode;
isOpen: boolean;
onClose?: () => void;
}>) => {
const clickedOutside = () => {
if (onClose) onClose();
};
return (
<div
className={`absolute top-[8dvh] right-0 flex col w-full duration-300
${isOpen ? '' : 'delay-300 translate-x-[102vw]'}`}
>
<div
className={`page-height w-1/2 transition-all ease-in-out duration-300 ${
isOpen ? 'delay-300 bg-black/60' : 'bg-black/0'
}`}
onClick={clickedOutside}
/>
{/* Only slide the content panel */}
<div className={`bg-background page-height w-1/2`}>{children}</div>
</div>
);
};

View File

@@ -0,0 +1,41 @@
'use client';
type MenuBurgerButtonProps = {
state: boolean;
onStateChange?: (value: boolean) => void;
};
export const MenuBurgerButton = ({
state,
onStateChange,
}: MenuBurgerButtonProps) => {
state = state ?? false;
// This function is called when the button is clicked
const handleClick = () => {
if (onStateChange) onStateChange(!state);
};
return (
<div
className='flex flex-col md:hidden w-10 h-10 justify-center items-center cursor-pointer relative'
onClick={handleClick}
>
<span
className={`w-8 h-[4px] bg-primary rounded transition-all duration-200 ${
state ? 'opacity-0' : 'opacity-100'
}`}
/>
<span
className={`absolute w-8 h-[4px] bg-primary rounded transition-all duration-200 ${
state ? 'rotate-45' : '-translate-y-[10px]'
}`}
/>
<span
className={`absolute w-8 h-[4px] bg-primary rounded transition-all duration-200 ${
state ? '-rotate-45' : 'translate-y-[10px]'
}`}
/>
</div>
);
};

View File

@@ -0,0 +1,48 @@
import { siteConfig } from '@/site.config';
import { DynamicDesktopHeader } from '@/ui/components/internal/header/desktop-header/dynamic-desktop-header';
import { StaticDesktopHeader } from '@/ui/components/internal/header/desktop-header/static-desktop-header';
import { DynamicMobileHeader } from '@/ui/components/internal/header/mobile-header/dynamic-mobile-header';
import { StaticMobileHeader } from '@/ui/components/internal/header/mobile-header/static-mobile-header';
import Image from 'next/image';
import Link from 'next/link';
import { Suspense } from 'react';
export const SiteHeader = () => {
return (
<header className='header-height sticky top-0 z-50 w-full bg-background/95 backdrop-blur supports-backdrop-filter:bg-background/60'>
<div className='container mx-auto flex h-full items-center justify-between px-4 py-4 align-middle'>
<Link href='/'>
<Image
src={siteConfig['icon']['dark']}
className='hidden dark:block'
alt='Site Icon - Dark'
width={50}
height={50}
priority
/>
<Image
src={siteConfig['icon']['light']}
className='block dark:hidden'
alt='Site Icon - Light'
width={50}
height={50}
priority
/>
</Link>
<div className='hidden w-full justify-between align-middle md:flex'>
<Suspense fallback={<StaticDesktopHeader />}>
<DynamicDesktopHeader />
</Suspense>
</div>
<div className='flex w-full justify-end align-middle md:hidden'>
<Suspense fallback={<StaticMobileHeader />}>
<DynamicMobileHeader />
</Suspense>
</div>
</div>
</header>
);
};

View File

@@ -0,0 +1,30 @@
import { siteConfig } from '@/site.config';
export function SiteFooter() {
const buildCopyrightYear = (): string => {
if (siteConfig.copyright.initialYear == new Date().getFullYear()) {
return `(${new Date().getFullYear()})`;
}
return `(${siteConfig.copyright.initialYear}-${new Date().getFullYear()})`;
};
return (
<footer className='w-full'>
<div className='footer-height container mx-auto flex flex-col items-center justify-between gap-4 md:flex-row'>
<p className='text-balance text-center text-sm leading-loose text-muted-foreground md:text-left'>
Built by{' '}
<a
href={siteConfig.author.links.twitter}
target='_blank'
rel='noreferrer'
className='font-medium underline underline-offset-4'
>
{siteConfig.author.name}
</a>
. ©{buildCopyrightYear()} {siteConfig.copyright.company}.
All rights reserved.
</p>
</div>
</footer>
);
}

View File

@@ -0,0 +1,39 @@
'use client';
import { Button } from '@/ui/components/shadcn/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
} from '@/ui/components/shadcn/dropdown-menu';
import { DropdownMenuTrigger } from '@radix-ui/react-dropdown-menu';
import { Moon, Sun } from 'lucide-react';
import { useTheme } from 'next-themes';
import * as React from 'react';
export default function ThemeToggle() {
const { setTheme } = useTheme();
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant='outline' size='icon'>
<Sun className='h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0' />
<Moon className='absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100' />
<span className='sr-only'>Toggle theme</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end'>
<DropdownMenuItem onClick={() => setTheme('light')}>
Light
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme('dark')}>
Dark
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme('system')}>
System
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -0,0 +1,15 @@
import { twMerge } from 'tailwind-merge';
const EmptyProfileIcon = ({ className }: { className?: string }) => {
return (
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 448 512'
className={twMerge('h-[50px] w-[50px]', className)}
>
<path d='M304 128a80 80 0 1 0 -160 0 80 80 0 1 0 160 0zM96 128a128 128 0 1 1 256 0A128 128 0 1 1 96 128zM49.3 464l349.5 0c-8.9-63.3-63.3-112-129-112l-91.4 0c-65.7 0-120.1 48.7-129 112zM0 482.3C0 383.8 79.8 304 178.3 304l91.4 0C368.2 304 448 383.8 448 482.3c0 16.4-13.3 29.7-29.7 29.7L29.7 512C13.3 512 0 498.7 0 482.3z' />
</svg>
);
};
export default EmptyProfileIcon;

View File

@@ -0,0 +1,15 @@
import { twMerge } from 'tailwind-merge';
const LoginIcon = ({ className }: { className?: string }) => {
return (
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 640 640'
className={twMerge('h-[50px] w-[50px]', className)}
>
<path d='M409 337C418.4 327.6 418.4 312.4 409 303.1L265 159C258.1 152.1 247.8 150.1 238.8 153.8C229.8 157.5 224 166.3 224 176L224 256L112 256C85.5 256 64 277.5 64 304L64 336C64 362.5 85.5 384 112 384L224 384L224 464C224 473.7 229.8 482.5 238.8 486.2C247.8 489.9 258.1 487.9 265 481L409 337zM416 480C398.3 480 384 494.3 384 512C384 529.7 398.3 544 416 544L480 544C533 544 576 501 576 448L576 192C576 139 533 96 480 96L416 96C398.3 96 384 110.3 384 128C384 145.7 398.3 160 416 160L480 160C497.7 160 512 174.3 512 192L512 448C512 465.7 497.7 480 480 480L416 480z' />
</svg>
);
};
export default LoginIcon;

View File

@@ -0,0 +1,42 @@
'use client';
import { montserrat } from '@/app/fonts';
import { Separator } from '@/ui/components/shadcn/separator';
import { useClerk } from '@clerk/nextjs';
import { useQueryClient } from '@tanstack/react-query';
export type UserProfileButtonProps = {
username: string;
};
export function UserManagmentPopover({ username }: UserProfileButtonProps) {
const queryClient = useQueryClient();
const { signOut } = useClerk();
const onSignOutUser = () => {
signOut({ redirectUrl: '/api/user/unload' }).then(() => {
queryClient.invalidateQueries({ queryKey: ['sessionData'] });
console.log(`Signing out user: ${username}`);
});
};
return (
<>
<div className='space-y-4'>
<p className='text-sm font-medium leading-none'>{username}</p>
<Separator />
<button
className='flex h-[24px] w-full cursor-pointer items-center justify-start'
onClick={onSignOutUser}
>
<p
className={`text-sm font-medium leading-none ${montserrat.className}`}
>
Sign out
</p>
</button>
</div>
</>
);
}

View File

@@ -0,0 +1,63 @@
import LoginIcon from './icons/login-icon';
import { UserModel } from '@/lib/feature/user/user.model';
import EmptyProfileIcon from '@/ui/components/internal/user-profile/icons/empty-profile-icon';
import { UserManagmentPopover } from '@/ui/components/internal/user-profile/popover/user-manage';
import {
Avatar,
AvatarFallback,
AvatarImage,
} from '@/ui/components/shadcn/avatar';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/ui/components/shadcn/popover';
import { SignInButton } from '@clerk/nextjs';
const genInitials = (name: string | null) => {
if (!name) {
return 'US';
}
return name.match(/(\b\S)?/g)?.join('') || '';
};
export const UserButton = ({ user }: { user: UserModel }) => {
const userName = user.name || 'PlaceHolder';
return (
<Popover>
<PopoverTrigger>
<Avatar className='flex h-[47px] w-[47px] items-center justify-center align-middle'>
<>
<AvatarImage
src={user.imageUrl || 'https://no-image'}
className='fill-white'
/>
<AvatarFallback>{genInitials(userName)}</AvatarFallback>
</>
</Avatar>
</PopoverTrigger>
<PopoverContent align='end' className='w-[200px]'>
<UserManagmentPopover username={userName} />
</PopoverContent>
</Popover>
);
};
export const BlankUserButton = () => {
return (
<>
<SignInButton>
<Avatar className='group flex h-[47px] w-[47px] items-center justify-center align-middle cursor-pointer'>
<>
<AvatarImage />
<AvatarFallback>
<EmptyProfileIcon className='block group-hover:hidden h-[22px] w-[22px] fill-black dark:fill-white font-light' />
<LoginIcon className='hidden group-hover:block h-[22px] w-[22px] fill-black dark:fill-white font-light' />
</AvatarFallback>
</>
</Avatar>
</SignInButton>
</>
);
};

View File

@@ -0,0 +1,111 @@
'use client';
import { cn } from '@/ui/components/shadcn/lib/utils';
import { Avatar as AvatarPrimitive } from 'radix-ui';
import * as React from 'react';
function Avatar({
className,
size = 'default',
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root> & {
size?: 'default' | 'sm' | 'lg';
}) {
return (
<AvatarPrimitive.Root
data-slot='avatar'
data-size={size}
className={cn(
'group/avatar relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten',
className
)}
{...props}
/>
);
}
function AvatarImage({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image
data-slot='avatar-image'
className={cn(
'aspect-square size-full rounded-full object-cover',
className
)}
{...props}
/>
);
}
function AvatarFallback({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
data-slot='avatar-fallback'
className={cn(
'flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs',
className
)}
{...props}
/>
);
}
function AvatarBadge({ className, ...props }: React.ComponentProps<'span'>) {
return (
<span
data-slot='avatar-badge'
className={cn(
'absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground bg-blend-color ring-2 ring-background select-none',
'group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden',
'group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2',
'group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2',
className
)}
{...props}
/>
);
}
function AvatarGroup({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='avatar-group'
className={cn(
'group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background',
className
)}
{...props}
/>
);
}
function AvatarGroupCount({
className,
...props
}: React.ComponentProps<'div'>) {
return (
<div
data-slot='avatar-group-count'
className={cn(
'relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3',
className
)}
{...props}
/>
);
}
export {
Avatar,
AvatarImage,
AvatarFallback,
AvatarGroup,
AvatarGroupCount,
AvatarBadge,
};

View File

@@ -0,0 +1,66 @@
import { cn } from '@/ui/components/shadcn/lib/utils';
import { cva, type VariantProps } from 'class-variance-authority';
import { Slot } from 'radix-ui';
import * as React from 'react';
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default:
'bg-primary text-primary-foreground [a]:hover:bg-primary/80',
outline:
'border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50',
secondary:
'bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground',
ghost: 'hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50',
destructive:
'bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default:
'h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: 'h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3',
icon: 'size-8',
'icon-xs':
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
'icon-sm':
'size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg',
'icon-lg': 'size-9',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
}
);
function Button({
className,
variant = 'default',
size = 'default',
asChild = false,
...props
}: React.ComponentProps<'button'> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot.Root : 'button';
return (
<Comp
data-slot='button'
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Button, buttonVariants };

View File

@@ -0,0 +1,280 @@
'use client';
import { cn } from '@/ui/components/shadcn/lib/utils';
import { CheckIcon, ChevronRightIcon } from 'lucide-react';
import { DropdownMenu as DropdownMenuPrimitive } from 'radix-ui';
import * as React from 'react';
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot='dropdown-menu' {...props} />;
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal
data-slot='dropdown-menu-portal'
{...props}
/>
);
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot='dropdown-menu-trigger'
{...props}
/>
);
}
function DropdownMenuContent({
className,
align = 'start',
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot='dropdown-menu-content'
sideOffset={sideOffset}
align={align}
className={cn(
'z-50 max-h-(--radix-dropdown-menu-content-available-height) w-(--radix-dropdown-menu-trigger-width) min-w-32 origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:overflow-hidden data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95',
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
);
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group
data-slot='dropdown-menu-group'
{...props}
/>
);
}
function DropdownMenuItem({
className,
inset,
variant = 'default',
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
variant?: 'default' | 'destructive';
}) {
return (
<DropdownMenuPrimitive.Item
data-slot='dropdown-menu-item'
data-inset={inset}
data-variant={variant}
className={cn(
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
className
)}
{...props}
/>
);
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem> & {
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot='dropdown-menu-checkbox-item'
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span
className='pointer-events-none absolute right-2 flex items-center justify-center'
data-slot='dropdown-menu-checkbox-item-indicator'
>
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
);
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot='dropdown-menu-radio-group'
{...props}
/>
);
}
function DropdownMenuRadioItem({
className,
children,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem> & {
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot='dropdown-menu-radio-item'
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span
className='pointer-events-none absolute right-2 flex items-center justify-center'
data-slot='dropdown-menu-radio-item-indicator'
>
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
);
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.Label
data-slot='dropdown-menu-label'
data-inset={inset}
className={cn(
'px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7',
className
)}
{...props}
/>
);
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot='dropdown-menu-separator'
className={cn('-mx-1 my-1 h-px bg-border', className)}
{...props}
/>
);
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<'span'>) {
return (
<span
data-slot='dropdown-menu-shortcut'
className={cn(
'ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground',
className
)}
{...props}
/>
);
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return (
<DropdownMenuPrimitive.Sub data-slot='dropdown-menu-sub' {...props} />
);
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot='dropdown-menu-sub-trigger'
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className='ml-auto' />
</DropdownMenuPrimitive.SubTrigger>
);
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot='dropdown-menu-sub-content'
className={cn(
'z-50 min-w-[96px] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95',
className
)}
{...props}
/>
);
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
};

View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

View File

@@ -0,0 +1,88 @@
'use client';
import { cn } from '@/ui/components/shadcn/lib/utils';
import { Popover as PopoverPrimitive } from 'radix-ui';
import * as React from 'react';
function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot='popover' {...props} />;
}
function PopoverTrigger({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot='popover-trigger' {...props} />;
}
function PopoverContent({
className,
align = 'center',
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot='popover-content'
align={align}
sideOffset={sideOffset}
className={cn(
'z-50 flex w-72 origin-(--radix-popover-content-transform-origin) flex-col gap-2.5 rounded-lg bg-popover p-2.5 text-sm text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-hidden duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95',
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
);
}
function PopoverAnchor({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot='popover-anchor' {...props} />;
}
function PopoverHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='popover-header'
className={cn('flex flex-col gap-0.5 text-sm', className)}
{...props}
/>
);
}
function PopoverTitle({ className, ...props }: React.ComponentProps<'h2'>) {
return (
<div
data-slot='popover-title'
className={cn('font-heading font-medium', className)}
{...props}
/>
);
}
function PopoverDescription({
className,
...props
}: React.ComponentProps<'p'>) {
return (
<p
data-slot='popover-description'
className={cn('text-muted-foreground', className)}
{...props}
/>
);
}
export {
Popover,
PopoverAnchor,
PopoverContent,
PopoverDescription,
PopoverHeader,
PopoverTitle,
PopoverTrigger,
};

View File

@@ -0,0 +1,27 @@
'use client';
import { cn } from '@/ui/components/shadcn/lib/utils';
import { Separator as SeparatorPrimitive } from 'radix-ui';
import * as React from 'react';
function Separator({
className,
orientation = 'horizontal',
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot='separator'
decorative={decorative}
orientation={orientation}
className={cn(
'shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch',
className
)}
{...props}
/>
);
}
export { Separator };

View File

@@ -0,0 +1,45 @@
'use client';
import {
CircleCheckIcon,
InfoIcon,
TriangleAlertIcon,
OctagonXIcon,
Loader2Icon,
} from 'lucide-react';
import { useTheme } from 'next-themes';
import { Toaster as Sonner, type ToasterProps } from 'sonner';
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = 'system' } = useTheme();
return (
<Sonner
theme={theme as ToasterProps['theme']}
className='toaster group'
icons={{
success: <CircleCheckIcon className='size-4' />,
info: <InfoIcon className='size-4' />,
warning: <TriangleAlertIcon className='size-4' />,
error: <OctagonXIcon className='size-4' />,
loading: <Loader2Icon className='size-4 animate-spin' />,
}}
style={
{
'--normal-bg': 'var(--popover)',
'--normal-text': 'var(--popover-foreground)',
'--normal-border': 'var(--border)',
'--border-radius': 'var(--radius)',
} as React.CSSProperties
}
toastOptions={{
classNames: {
toast: 'cn-toast',
},
}}
{...props}
/>
);
};
export { Toaster };

View File

@@ -0,0 +1,15 @@
import { cn } from '@/ui/components/shadcn/lib/utils';
import { Loader2Icon } from 'lucide-react';
function Spinner({ className, ...props }: React.ComponentProps<'svg'>) {
return (
<Loader2Icon
role='status'
aria-label='Loading'
className={cn('size-4 animate-spin', className)}
{...props}
/>
);
}
export { Spinner };

View File

@@ -0,0 +1,56 @@
'use client';
import { cn } from '@/ui/components/shadcn/lib/utils';
import { Tooltip as TooltipPrimitive } from 'radix-ui';
import * as React from 'react';
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot='tooltip-provider'
delayDuration={delayDuration}
{...props}
/>
);
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return <TooltipPrimitive.Root data-slot='tooltip' {...props} />;
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot='tooltip-trigger' {...props} />;
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot='tooltip-content'
sideOffset={sideOffset}
className={cn(
'z-50 inline-flex w-fit max-w-xs origin-(--radix-tooltip-content-transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95',
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className='z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground' />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
);
}
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger };

View File

@@ -0,0 +1,30 @@
'use client';
import { TooltipProvider } from '@/ui/components/shadcn/tooltip';
import { getQueryClient } from '@/ui/providers/utils/get-query-client';
import { ClerkProvider } from '@clerk/nextjs';
import { QueryClientProvider } from '@tanstack/react-query';
import { Provider as JotaiProvider } from 'jotai';
import {
ThemeProvider as NextThemesProvider,
ThemeProviderProps,
} from 'next-themes';
import * as React from 'react';
export function Provider({ children, ...props }: ThemeProviderProps) {
const queryClient = getQueryClient();
return (
<ClerkProvider>
<JotaiProvider>
<QueryClientProvider client={queryClient}>
<NextThemesProvider {...props}>
<TooltipProvider delayDuration={0}>
{children}
</TooltipProvider>
</NextThemesProvider>
</QueryClientProvider>
</JotaiProvider>
</ClerkProvider>
);
}

View File

@@ -0,0 +1,23 @@
import { environmentManager } from '@tanstack/query-core';
import { QueryClient } from '@tanstack/react-query';
const makeQueryClient = () => {
return new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5 minutes
},
},
});
};
let browserQueryClient: QueryClient | null = null;
export const getQueryClient = () => {
if (environmentManager.isServer()) {
return makeQueryClient();
}
if (!browserQueryClient) browserQueryClient = makeQueryClient();
return browserQueryClient;
};

View File

@@ -0,0 +1 @@
export type Relation<T> = T;

22
src/utils/types/uuid.ts Normal file
View File

@@ -0,0 +1,22 @@
type HexChar =
| '0'
| '1'
| '2'
| '3'
| '4'
| '5'
| '6'
| '7'
| '8'
| '9'
| 'a'
| 'b'
| 'c'
| 'd'
| 'e'
| 'f';
type UUIDv4Segment<Length extends number> =
`${HexChar extends string ? HexChar : never}${string extends `${Length}` ? never : never}`; // Simplified for brevity
export type UUIDv4 =
`${UUIDv4Segment<8>}-${UUIDv4Segment<4>}-4${UUIDv4Segment<3>}-${'8' | '9' | 'a' | 'b'}${UUIDv4Segment<3>}-${UUIDv4Segment<12>}`;

View File

@@ -0,0 +1,244 @@
import { SessionClaims } from '@/lib/feature/user/clerk.model';
import {
CreateUserModel,
UpdateUserModel,
} from '@/lib/feature/user/user.model';
import {
getUserByEmail,
saveUser,
syncUser,
updateUser,
} from '@/lib/feature/user/user.service';
import { clerkClient } from '@clerk/nextjs/server';
import { AbstractStartedContainer } from 'testcontainers';
import { startTestDB } from '~/tests/setup/setup-db';
jest.mock('@clerk/nextjs/server', () => ({
clerkClient: jest.fn(),
}));
describe('UserService', () => {
let container: AbstractStartedContainer;
beforeAll(async () => {
container = await startTestDB();
}, 1_000_000);
afterAll(async () => {
await container.stop();
}, 1_000_000);
test('can save user', async () => {
const userToSave: CreateUserModel = {
name: 'Test User',
email: 'test@email.com',
role: 'user',
};
const savedUser = await saveUser(userToSave);
expect(savedUser.id).toBeDefined();
expect(savedUser.name).toBe(userToSave.name);
expect(savedUser.email).toBe(userToSave.email);
expect(savedUser.role).toBe(userToSave.role);
expect(savedUser.role).toBe(userToSave.role);
expect(savedUser.externalId).toBeDefined(); // Default to true if not set
});
test('cannot save user with existing email', async () => {
const userToSave: CreateUserModel = {
name: 'Test User',
email: 'test@email.com',
role: 'user',
};
await expect(saveUser(userToSave)).rejects.toThrow(
`User with email ${userToSave.email} already exists`
);
});
test('can getUserByEmail', async () => {
const user = await getUserByEmail('test@email.com');
expect(user).toBeDefined();
expect(user?.email).toBe('test@email.com');
expect(user?.name).toBe('Test User');
expect(user?.role).toBe('user');
expect(user?.externalId).toBeDefined(); // Default to true if not set
});
test('cannot getUserByEmail with non-existing email', async () => {
await expect(getUserByEmail('missing@email.com')).resolves.toBeNull();
});
test('can update user', async () => {
const dataToUpdate: UpdateUserModel = {
name: 'Updated User',
role: 'admin',
};
const user = await getUserByEmail('test@email.com');
expect(user).toBeDefined();
const updatedUser = await updateUser(user!.id, dataToUpdate);
expect(updatedUser).toBeDefined();
expect(updatedUser.id).toBe(user!.id);
expect(updatedUser.name).toBe(dataToUpdate.name);
expect(updatedUser.role).toBe(dataToUpdate.role);
expect(updatedUser.email).toBe(user!.email);
expect(updatedUser.externalId).toBeDefined(); // Default to true if not set
});
test('cannot update non-existing user', async () => {
const dataToUpdate: UpdateUserModel = {
name: 'Updated User',
role: 'admin',
};
await expect(updateUser('9999', dataToUpdate)).rejects.toThrow(
`User with ID 9999 not found`
);
});
test('can sync admin user', async () => {
process.env.CLERK_ORG_ID = 'test-org-id';
const mockGetOrganizationMembershipList = jest.fn().mockResolvedValue({
data: [
{
organization: {
id: process.env.CLERK_ORG_ID,
},
role: 'org:admin',
},
],
});
(clerkClient as jest.Mock).mockResolvedValue({
users: {
getOrganizationMembershipList:
mockGetOrganizationMembershipList,
},
});
const sessionClaims = {
user: {
full_name: 'Updated Name',
email: 'test@email.com',
username: 'test',
public_metadata: {
role: 'admin',
},
},
} as SessionClaims;
const syncedUser = await syncUser(sessionClaims);
expect(syncedUser).toBeDefined();
expect(syncedUser.name).toBe('Updated Name');
expect(syncedUser.email).toBe('test@email.com');
expect(syncedUser.role).toBe('admin');
});
test('can sync internal user', async () => {
process.env.CLERK_ORG_ID = 'test-org-id';
const mockGetOrganizationMembershipList = jest.fn().mockResolvedValue({
data: [
{
organization: {
id: process.env.CLERK_ORG_ID,
},
role: 'org:member',
},
],
});
(clerkClient as jest.Mock).mockResolvedValue({
users: {
getOrganizationMembershipList:
mockGetOrganizationMembershipList,
},
});
const sessionClaims = {
user: {
full_name: 'Updated Name',
email: 'test@email.com',
username: 'test',
public_metadata: {
role: 'internal',
},
},
} as SessionClaims;
const syncedUser = await syncUser(sessionClaims);
expect(syncedUser).toBeDefined();
expect(syncedUser.name).toBe('Updated Name');
expect(syncedUser.email).toBe('test@email.com');
expect(syncedUser.role).toBe('internal');
});
test('can sync user', async () => {
process.env.CLERK_ORG_ID = 'test-org-id';
const mockGetOrganizationMembershipList = jest.fn().mockResolvedValue({
data: [],
});
(clerkClient as jest.Mock).mockResolvedValue({
users: {
getOrganizationMembershipList:
mockGetOrganizationMembershipList,
},
});
const sessionClaims = {
user: {
full_name: 'Updated Name',
email: 'test@email.com',
username: 'test',
public_metadata: {
role: 'user',
},
},
} as SessionClaims;
const syncedUser = await syncUser(sessionClaims);
expect(syncedUser).toBeDefined();
expect(syncedUser.name).toBe('Updated Name');
expect(syncedUser.email).toBe('test@email.com');
expect(syncedUser.role).toBe('user');
});
test('can sync saving new user', async () => {
process.env.CLERK_ORG_ID = 'test-org-id';
const mockGetOrganizationMembershipList = jest.fn().mockResolvedValue({
data: [],
});
(clerkClient as jest.Mock).mockResolvedValue({
users: {
getOrganizationMembershipList:
mockGetOrganizationMembershipList,
},
});
const sessionClaims = {
user: {
full_name: 'Updated Name',
email: 'new@email.com',
username: 'test',
public_metadata: {
role: 'user',
},
},
} as SessionClaims;
const syncedUser = await syncUser(sessionClaims);
expect(syncedUser).toBeDefined();
expect(syncedUser.name).toBe('Updated Name');
expect(syncedUser.email).toBe('new@email.com');
expect(syncedUser.role).toBe('user');
});
});

21
tests/setup/setup-db.ts Normal file
View File

@@ -0,0 +1,21 @@
import { configAppDataSource } from '@/lib/db/data-source';
import { PostgreSqlContainer } from '@testcontainers/postgresql';
import 'reflect-metadata';
const runMigrations = async (url: string) => {
const dataSource = configAppDataSource(url);
if (!dataSource.isInitialized) {
await dataSource.initialize();
}
await dataSource.runMigrations();
};
export const startTestDB = async () => {
const container = await new PostgreSqlContainer('postgres:16').start();
await runMigrations(container.getConnectionUri());
return container;
};

39
tsconfig.json Normal file
View File

@@ -0,0 +1,39 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"experimentalDecorators": true,
"strictPropertyInitialization": false,
"emitDecoratorMetadata": true,
"jsx": "react-jsx",
"incremental": true,
"baseUrl": ".",
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"],
"~/*": ["./*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}