feat: initial commit
13
.copier-answers.yml
Normal 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
@@ -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
@@ -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/
|
||||
36
README.md
Normal 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
@@ -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
@@ -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
@@ -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;
|
||||
21
migrations/1775776542850-adds-base-triggers.ts
Normal 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`);
|
||||
}
|
||||
}
|
||||
31
migrations/1775776680658-adds-user-table.ts
Normal 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
@@ -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
97
package.json
Normal 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
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
BIN
public/img/logo/black/icon-1024.png
Normal file
|
After Width: | Height: | Size: 129 KiB |
BIN
public/img/logo/black/icon-16.png
Normal file
|
After Width: | Height: | Size: 227 B |
BIN
public/img/logo/black/icon-180.png
Normal file
|
After Width: | Height: | Size: 7.4 KiB |
BIN
public/img/logo/black/icon-32.png
Normal file
|
After Width: | Height: | Size: 446 B |
BIN
public/img/logo/black/icon-512.png
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
public/img/logo/black/icon-96.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
public/img/logo/red/icon-1024.png
Normal file
|
After Width: | Height: | Size: 74 KiB |
BIN
public/img/logo/red/icon-16.png
Normal file
|
After Width: | Height: | Size: 213 B |
BIN
public/img/logo/red/icon-180.png
Normal file
|
After Width: | Height: | Size: 4.8 KiB |
BIN
public/img/logo/red/icon-32.png
Normal file
|
After Width: | Height: | Size: 376 B |
BIN
public/img/logo/red/icon-512.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
public/img/logo/red/icon-96.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
public/img/logo/white/icon-1024.png
Normal file
|
After Width: | Height: | Size: 129 KiB |
BIN
public/img/logo/white/icon-16.png
Normal file
|
After Width: | Height: | Size: 236 B |
BIN
public/img/logo/white/icon-180.png
Normal file
|
After Width: | Height: | Size: 7.4 KiB |
BIN
public/img/logo/white/icon-32.png
Normal file
|
After Width: | Height: | Size: 462 B |
BIN
public/img/logo/white/icon-512.png
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
public/img/logo/white/icon-96.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
12
src/app/(pages)/about/page.tsx
Normal 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;
|
||||
12
src/app/(pages)/admin/page.tsx
Normal 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;
|
||||
12
src/app/(pages)/home/page.tsx
Normal 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;
|
||||
22
src/app/api/user/sync/route.ts
Normal 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('/');
|
||||
}
|
||||
7
src/app/api/user/unload/route.ts
Normal 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
|
After Width: | Height: | Size: 15 KiB |
15
src/app/fonts.ts
Normal 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
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
15
src/lib/db/data-source.cli.ts
Normal 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
@@ -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
@@ -0,0 +1 @@
|
||||
export { UserEntity } from '@/lib/feature/user/user.entity';
|
||||
14
src/lib/feature/user/clerk.model.ts
Normal 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>;
|
||||
49
src/lib/feature/user/user.entity.ts
Normal 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;
|
||||
}
|
||||
30
src/lib/feature/user/user.external.ts
Normal 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);
|
||||
};
|
||||
41
src/lib/feature/user/user.model.ts
Normal 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>;
|
||||
110
src/lib/feature/user/user.service.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
9
src/lib/session/session-data.type.ts
Normal 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];
|
||||
16
src/lib/session/session-storage.internal.ts
Normal 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();
|
||||
};
|
||||
49
src/lib/session/session-storage.ts
Normal 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
@@ -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
@@ -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;
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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} />;
|
||||
};
|
||||
@@ -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 />} />;
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
import { BaseMobileHeader } from '@/ui/components/internal/header/mobile-header/base-mobile-header';
|
||||
|
||||
export const StaticMobileHeader = () => {
|
||||
return <BaseMobileHeader active={false} />;
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
48
src/ui/components/internal/header/site-header.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
30
src/ui/components/internal/site-footer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
39
src/ui/components/internal/theme-toggle.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
15
src/ui/components/internal/user-profile/icons/login-icon.tsx
Normal 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;
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
111
src/ui/components/shadcn/avatar.tsx
Normal 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,
|
||||
};
|
||||
66
src/ui/components/shadcn/button.tsx
Normal 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 };
|
||||
280
src/ui/components/shadcn/dropdown-menu.tsx
Normal 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,
|
||||
};
|
||||
6
src/ui/components/shadcn/lib/utils.ts
Normal 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));
|
||||
}
|
||||
88
src/ui/components/shadcn/popover.tsx
Normal 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,
|
||||
};
|
||||
27
src/ui/components/shadcn/separator.tsx
Normal 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 };
|
||||
45
src/ui/components/shadcn/sonner.tsx
Normal 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 };
|
||||
15
src/ui/components/shadcn/spinner.tsx
Normal 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 };
|
||||
56
src/ui/components/shadcn/tooltip.tsx
Normal 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 };
|
||||
30
src/ui/providers/providers.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
23
src/ui/providers/utils/get-query-client.ts
Normal 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;
|
||||
};
|
||||
1
src/utils/types/relation.ts
Normal file
@@ -0,0 +1 @@
|
||||
export type Relation<T> = T;
|
||||
22
src/utils/types/uuid.ts
Normal 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>}`;
|
||||
244
tests/lib/feature/user/user.service.test.ts
Normal 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
@@ -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
@@ -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"]
|
||||
}
|
||||