Configure VM services and fix auth flow

- point MySQL and Whisper settings to the VM
- add VM MySQL bootstrap scripts and docs
- allow LAN Vite origins for CORS
- fix Express 5 validation assignment crash
- allow login with username or email
- prevent recursive auth refresh retries
This commit is contained in:
KevinB-T 2026-05-15 15:19:49 +05:30
parent ca2d1cd716
commit 30894e7f27
151 changed files with 21128 additions and 0 deletions

68
.env.example Normal file
View File

@ -0,0 +1,68 @@
# Orphion API
NODE_ENV=development
HOST=127.0.0.1
PORT=4000
CLIENT_ORIGIN=http://localhost:5173,http://127.0.0.1:5173,http://172.16.11.139:5173,http://172.19.0.1:5173
ORPHION_SERVICE_HOST=127.0.0.1
TRUST_PROXY=false
# MySQL VM
DB_HOST=172.16.10.64
DB_PORT=3306
DB_NAME=orphion
DB_USER=orphion
DB_PASSWORD=NexaVault2026!Blue
DB_CONNECTION_LIMIT=10
DB_AUTO_MIGRATE=true
# JWT / sessions
JWT_ACCESS_SECRET=replace-with-a-long-random-access-secret
JWT_REFRESH_SECRET=replace-with-a-different-long-random-refresh-secret
JWT_ACCESS_EXPIRES_IN=15m
JWT_REFRESH_EXPIRES_IN=30d
JWT_COOKIE_NAME=orphion_access_token
JWT_REFRESH_COOKIE_NAME=orphion_refresh_token
JWT_COOKIE_SECURE=false
JWT_COOKIE_SAME_SITE=lax
# Faster-Whisper VM
WHISPER_VM_IP=172.16.10.64
WHISPER_VM_PORT=8000
WHISPER_API_URL=http://172.16.10.64:8000
WHISPER_TRANSCRIBE_PATH=/transcribe
WHISPER_HEALTH_PATH=/health
WHISPER_FILE_FIELD=file
WHISPER_TIMEOUT_MS=900000
WHISPER_RETRIES=2
WHISPER_RETRY_DELAY_MS=1500
WHISPER_MODEL_NAME=faster-whisper-large-v3
WHISPER_ALLOW_MOCK=false
TRANSCRIPTION_QUEUE_CONCURRENCY=1
# Upload validation
UPLOAD_TEMP_DIR=.tmp/uploads
MAX_AUDIO_SIZE_MB=200
AUDIO_ALLOWED_MIME_TYPES=audio/webm,audio/wav,audio/mpeg,audio/mp4,audio/ogg,audio/x-m4a,video/webm
# Storage: local | smb | nfs | http | s3
STORAGE_DRIVER=local
STORAGE_BASE_PATH=../storage/audio
STORAGE_PUBLIC_BASE_URL=
# HTTP storage adapter
STORAGE_HTTP_BASE_URL=http://127.0.0.1:9000/audio
STORAGE_HTTP_TOKEN=
# S3-compatible storage adapter
S3_ENDPOINT=http://127.0.0.1:9000
S3_REGION=us-east-1
S3_BUCKET=orphion-audio
S3_ACCESS_KEY_ID=
S3_SECRET_ACCESS_KEY=
S3_FORCE_PATH_STYLE=true
# Frontend
VITE_API_BASE_URL=
VITE_API_PREFIX=/api/v1
VITE_API_PORT=4000
VITE_ORPHION_SERVICE_HOST=127.0.0.1

32
.gitignore vendored Normal file
View File

@ -0,0 +1,32 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
frontend/dist
backend/.tmp
/storage
dist-ssr
.output
.vinxi
.tanstack/**
.nitro
*.local
.env
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

1
.husky/pre-commit Executable file
View File

@ -0,0 +1 @@
npm exec lint-staged

9
.prettierignore Normal file
View File

@ -0,0 +1,9 @@
node_modules
dist
.output
.vinxi
pnpm-lock.yaml
package-lock.json
bun.lock
routeTree.gen.ts
/storage

6
.prettierrc Normal file
View File

@ -0,0 +1,6 @@
{
"printWidth": 100,
"semi": true,
"singleQuote": false,
"trailingComma": "all"
}

71
README.md Normal file
View File

@ -0,0 +1,71 @@
# Orphion MoM
Orphion is a premium voice-to-text collaboration platform for Minutes of Meeting workflows.
## Architecture
```text
frontend/ React + Vite dashboard
backend/ Node.js + Express REST API
scripts/ Optional local/ops helpers
docs/ API, database, storage, and Whisper notes
```
The backend exposes versioned REST APIs at `/api/v1`, stores users/transcripts/jobs in remote
MySQL, writes audio through a storage adapter, and processes transcription with a queued
Faster-Whisper VM client.
## Local Development
1. Install dependencies:
```sh
npm install
```
2. Copy env values:
```sh
cp .env.example .env
```
3. Create the MySQL database/user on the VM:
```sh
sh scripts/bootstrap-vm-mysql.sh <vm-user>@172.16.10.64
```
The sample env uses MySQL and Whisper on `172.16.10.64`. For alternate MySQL, storage, or Whisper
settings, see the files in `docs/`.
4. Run the API and frontend:
```sh
npm run dev:api
npm run dev
```
Or start both from one terminal:
```sh
npm run dev:all
```
Frontend: `http://localhost:5173` or the Vite network URL printed in your terminal
Backend health: `http://localhost:4000/health`
Swagger UI: `http://localhost:4000/api/docs`
## Key Features
- JWT access tokens and refresh-token sessions in secure cookies
- Role-ready auth model with protected routes
- Remote MySQL connection pooling and migration runner
- Storage adapter pattern for SMB/NFS mounts, HTTP storage, S3-compatible storage, and local dev
- Queued transcription jobs with status tracking
- Faster-Whisper VM retries, timeout handling, and health checks
- Standard API envelope: `{ success, message, data }`
- Premium dark React dashboard with recording, upload progress, transcript history, playback, sharing, and downloads
See `docs/` for setup details.
`DB_PASSWORD=NexaVault2026!Blue`

24
backend/.env.example Normal file
View File

@ -0,0 +1,24 @@
NODE_ENV=development
HOST=127.0.0.1
PORT=4000
CLIENT_ORIGIN=http://localhost:5173
DB_HOST=192.168.X.X
DB_PORT=3306
DB_NAME=orphion
DB_USER=orphion
DB_PASSWORD=change_me
DB_AUTO_MIGRATE=true
JWT_ACCESS_SECRET=replace-with-a-long-random-access-secret
JWT_REFRESH_SECRET=replace-with-a-different-long-random-refresh-secret
JWT_COOKIE_SECURE=false
WHISPER_VM_IP=192.168.X.X
WHISPER_VM_PORT=8000
WHISPER_API_URL=http://192.168.X.X:8000
WHISPER_ALLOW_MOCK=false
STORAGE_DRIVER=smb
STORAGE_BASE_PATH=/mnt/orphion-audio
MAX_AUDIO_SIZE_MB=200

32
backend/package.json Normal file
View File

@ -0,0 +1,32 @@
{
"name": "@orphion/backend",
"private": true,
"type": "module",
"scripts": {
"dev": "node --watch src/index.js",
"start": "node src/index.js",
"migrate": "node src/config/migrate.js",
"lint": "eslint ."
},
"dependencies": {
"@aws-sdk/client-s3": "^3.940.0",
"axios": "^1.13.2",
"bcrypt": "^6.0.0",
"compression": "^1.8.1",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"dotenv": "^17.2.3",
"express": "^5.2.1",
"express-rate-limit": "^8.2.1",
"form-data": "^4.0.5",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.2",
"morgan": "^1.10.1",
"multer": "^2.0.2",
"mysql2": "^3.15.3",
"nanoid": "^5.1.6",
"swagger-jsdoc": "^6.2.8",
"swagger-ui-express": "^5.0.1",
"zod": "^4.4.3"
}
}

27
backend/src/app.js Normal file
View File

@ -0,0 +1,27 @@
import express from "express";
import cookieParser from "cookie-parser";
import swaggerUi from "swagger-ui-express";
import { env } from "./config/env.js";
import { errorHandler, notFound } from "./middlewares/errorHandler.js";
import { requestLogger } from "./middlewares/requestLogger.js";
import { securityMiddleware } from "./middlewares/security.js";
import { openApiSpec } from "./docs/openapi.js";
import { routes } from "./routes/index.js";
export function createApp() {
const app = express();
securityMiddleware(app);
app.use(requestLogger);
app.use(express.json({ limit: env.requestBodyLimit }));
app.use(express.urlencoded({ extended: true }));
app.use(cookieParser());
app.use("/api/docs", swaggerUi.serve, swaggerUi.setup(openApiSpec));
app.use(routes);
app.use(notFound);
app.use(errorHandler);
return app;
}

View File

@ -0,0 +1,46 @@
import mysql from "mysql2/promise";
import { env } from "./env.js";
export const pool = mysql.createPool({
host: env.database.host,
port: env.database.port,
user: env.database.user,
password: env.database.password,
database: env.database.name,
waitForConnections: true,
connectionLimit: env.database.connectionLimit,
namedPlaceholders: true,
decimalNumbers: true,
enableKeepAlive: true,
keepAliveInitialDelay: 0,
});
export async function query(sql, params = {}) {
const [rows] = await pool.execute(sql, params);
return rows;
}
export async function transaction(work) {
const connection = await pool.getConnection();
try {
await connection.beginTransaction();
const result = await work(connection);
await connection.commit();
return result;
} catch (error) {
await connection.rollback();
throw error;
} finally {
connection.release();
}
}
export async function pingDatabase() {
const connection = await pool.getConnection();
try {
await connection.ping();
return true;
} finally {
connection.release();
}
}

129
backend/src/config/env.js Normal file
View File

@ -0,0 +1,129 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
import dotenv from "dotenv";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const backendRoot = path.resolve(__dirname, "../..");
const repoRoot = path.resolve(backendRoot, "..");
dotenv.config({ path: path.join(repoRoot, ".env"), quiet: true });
dotenv.config({ path: path.join(backendRoot, ".env"), override: true, quiet: true });
function stringEnv(name, fallback = "") {
const value = process.env[name];
return value && value.trim().length > 0 ? value.trim() : fallback;
}
function numberEnv(name, fallback) {
const value = Number(process.env[name]);
return Number.isFinite(value) ? value : fallback;
}
function boolEnv(name, fallback = false) {
const value = process.env[name];
if (value === undefined) return fallback;
return ["1", "true", "yes", "on"].includes(value.toLowerCase());
}
function firstString(names, fallback = "") {
for (const name of names) {
const value = stringEnv(name);
if (value) return value;
}
return fallback;
}
function normalizeBaseUrl(value) {
return value.replace(/\/+$/, "");
}
const serviceHost = stringEnv("ORPHION_SERVICE_HOST", "127.0.0.1");
const whisperVmIp = firstString(["WHISPER_VM_IP", "WHISPER_HOST"], serviceHost);
const whisperVmPort = numberEnv("WHISPER_VM_PORT", numberEnv("WHISPER_PORT", 8000));
const whisperApiUrl = normalizeBaseUrl(
firstString(["WHISPER_API_URL"], `http://${whisperVmIp}:${whisperVmPort}`),
);
export const env = {
appName: "Orphion",
nodeEnv: stringEnv("NODE_ENV", "development"),
isProduction: stringEnv("NODE_ENV") === "production",
host: stringEnv("HOST", "127.0.0.1"),
port: numberEnv("PORT", 4000),
backendRoot,
repoRoot,
apiPrefix: "/api/v1",
clientOrigin: stringEnv("CLIENT_ORIGIN", "http://localhost:5173"),
trustProxy: boolEnv("TRUST_PROXY", false),
requestBodyLimit: stringEnv("REQUEST_BODY_LIMIT", "2mb"),
database: {
host: firstString(["DB_HOST", "MYSQL_HOST"], serviceHost),
port: numberEnv("DB_PORT", numberEnv("MYSQL_PORT", 3306)),
name: firstString(["DB_NAME", "MYSQL_DATABASE"], "orphion"),
user: firstString(["DB_USER", "MYSQL_USER"], "root"),
password: firstString(["DB_PASSWORD", "MYSQL_PASSWORD"], ""),
connectionLimit: numberEnv("DB_CONNECTION_LIMIT", numberEnv("MYSQL_CONNECTION_LIMIT", 10)),
autoMigrate: boolEnv("DB_AUTO_MIGRATE", boolEnv("MYSQL_AUTO_MIGRATE", true)),
},
auth: {
accessTokenSecret: firstString(["JWT_ACCESS_SECRET", "JWT_SECRET"], "replace-access-secret"),
refreshTokenSecret: firstString(
["JWT_REFRESH_SECRET"],
"replace-refresh-secret-with-a-different-long-value",
),
accessTokenTtl: stringEnv("JWT_ACCESS_EXPIRES_IN", stringEnv("JWT_EXPIRES_IN", "15m")),
refreshTokenTtl: stringEnv("JWT_REFRESH_EXPIRES_IN", "30d"),
accessCookieName: stringEnv("JWT_COOKIE_NAME", "orphion_access_token"),
refreshCookieName: stringEnv("JWT_REFRESH_COOKIE_NAME", "orphion_refresh_token"),
cookieSecure: boolEnv("JWT_COOKIE_SECURE", false),
cookieSameSite: stringEnv("JWT_COOKIE_SAME_SITE", "lax"),
},
upload: {
tempDir: path.resolve(backendRoot, stringEnv("UPLOAD_TEMP_DIR", ".tmp/uploads")),
maxAudioSizeMb: numberEnv("MAX_AUDIO_SIZE_MB", 200),
allowedMimeTypes: stringEnv(
"AUDIO_ALLOWED_MIME_TYPES",
"audio/webm,audio/wav,audio/mpeg,audio/mp4,audio/ogg,audio/x-m4a,video/webm",
)
.split(",")
.map((item) => item.trim())
.filter(Boolean),
},
storage: {
driver: stringEnv("STORAGE_DRIVER", "smb"),
basePath: path.resolve(
backendRoot,
stringEnv("STORAGE_BASE_PATH", stringEnv("REMOTE_STORAGE_PATH", "../storage/audio")),
),
publicBaseUrl: normalizeBaseUrl(stringEnv("STORAGE_PUBLIC_BASE_URL", "")),
httpBaseUrl: normalizeBaseUrl(stringEnv("STORAGE_HTTP_BASE_URL", "")),
httpToken: stringEnv("STORAGE_HTTP_TOKEN", ""),
s3: {
endpoint: stringEnv("S3_ENDPOINT", ""),
region: stringEnv("S3_REGION", "us-east-1"),
bucket: stringEnv("S3_BUCKET", ""),
accessKeyId: stringEnv("S3_ACCESS_KEY_ID", ""),
secretAccessKey: stringEnv("S3_SECRET_ACCESS_KEY", ""),
forcePathStyle: boolEnv("S3_FORCE_PATH_STYLE", true),
},
},
whisper: {
vmIp: whisperVmIp,
vmPort: whisperVmPort,
apiUrl: whisperApiUrl,
transcribePath: stringEnv("WHISPER_TRANSCRIBE_PATH", "/transcribe"),
healthPath: stringEnv("WHISPER_HEALTH_PATH", "/health"),
fileField: stringEnv("WHISPER_FILE_FIELD", "file"),
timeoutMs: numberEnv("WHISPER_TIMEOUT_MS", 900000),
retries: numberEnv("WHISPER_RETRIES", 2),
retryDelayMs: numberEnv("WHISPER_RETRY_DELAY_MS", 1500),
modelName: stringEnv("WHISPER_MODEL_NAME", "faster-whisper-large-v3"),
allowMock: boolEnv("WHISPER_ALLOW_MOCK", false),
queueConcurrency: numberEnv("TRANSCRIPTION_QUEUE_CONCURRENCY", 1),
},
rateLimit: {
windowMs: numberEnv("RATE_LIMIT_WINDOW_MS", 15 * 60 * 1000),
max: numberEnv("RATE_LIMIT_MAX", 300),
authMax: numberEnv("AUTH_RATE_LIMIT_MAX", 30),
},
};

View File

@ -0,0 +1,99 @@
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import mysql from "mysql2/promise";
import { env } from "./env.js";
import { pool, query } from "./database.js";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
function identifier(value) {
return `\`${String(value).replaceAll("`", "``")}\``;
}
function splitSql(source) {
return source
.split(";")
.map((statement) => statement.trim())
.filter(Boolean);
}
export async function ensureDatabase() {
const connection = await mysql.createConnection({
host: env.database.host,
port: env.database.port,
user: env.database.user,
password: env.database.password,
});
try {
await connection.query(
`CREATE DATABASE IF NOT EXISTS ${identifier(
env.database.name,
)} CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci`,
);
} finally {
await connection.end();
}
}
export async function ensureSchema() {
await ensureDatabase();
const schema = await fs.readFile(path.join(__dirname, "schema.sql"), "utf8");
for (const statement of splitSql(schema)) {
await pool.query(statement);
}
await runMigrations();
}
export async function runMigrations() {
await query(`
CREATE TABLE IF NOT EXISTS schema_migrations (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(190) NOT NULL UNIQUE,
applied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
)
`);
const migrationDir = path.join(__dirname, "migrations");
const entries = await fs.readdir(migrationDir).catch(() => []);
const files = entries.filter((file) => file.endsWith(".sql")).sort();
for (const file of files) {
const existing = await query("SELECT id FROM schema_migrations WHERE name = :name LIMIT 1", {
name: file,
});
if (existing.length > 0) continue;
const source = await fs.readFile(path.join(migrationDir, file), "utf8");
const connection = await pool.getConnection();
try {
await connection.beginTransaction();
for (const statement of splitSql(source)) {
await connection.query(statement);
}
await connection.execute("INSERT INTO schema_migrations (name) VALUES (:name)", {
name: file,
});
await connection.commit();
} catch (error) {
await connection.rollback();
throw error;
} finally {
connection.release();
}
}
}
if (import.meta.url === `file://${process.argv[1]}`) {
ensureSchema()
.then(() => {
console.info("Database schema is ready.");
return pool.end();
})
.catch(async (error) => {
console.error(error);
await pool.end().catch(() => {});
process.exit(1);
});
}

View File

@ -0,0 +1,106 @@
CREATE TABLE IF NOT EXISTS users (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
full_name VARCHAR(140) NOT NULL,
username VARCHAR(80) NOT NULL,
email VARCHAR(180) NOT NULL,
password_hash VARCHAR(255) NOT NULL,
role ENUM('admin', 'member') NOT NULL DEFAULT 'member',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY users_username_unique (username),
UNIQUE KEY users_email_unique (email),
INDEX users_role_idx (role)
);
CREATE TABLE IF NOT EXISTS refresh_tokens (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT UNSIGNED NOT NULL,
token_hash CHAR(64) NOT NULL,
user_agent VARCHAR(255) NULL,
ip_address VARCHAR(64) NULL,
expires_at TIMESTAMP NOT NULL,
revoked_at TIMESTAMP NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT refresh_tokens_user_fk FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
UNIQUE KEY refresh_tokens_hash_unique (token_hash),
INDEX refresh_tokens_user_idx (user_id),
INDEX refresh_tokens_expiry_idx (expires_at)
);
CREATE TABLE IF NOT EXISTS audio_assets (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
storage_driver VARCHAR(32) NOT NULL,
storage_key VARCHAR(700) NOT NULL,
public_url VARCHAR(900) NULL,
original_name VARCHAR(255) NULL,
mime_type VARCHAR(120) NULL,
file_size BIGINT UNSIGNED NULL,
checksum_sha256 CHAR(64) NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY audio_assets_storage_unique (storage_driver, storage_key(255)),
INDEX audio_assets_created_idx (created_at)
);
CREATE TABLE IF NOT EXISTS transcripts (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
sender_id BIGINT UNSIGNED NOT NULL,
receiver_id BIGINT UNSIGNED NULL,
audio_asset_id BIGINT UNSIGNED NULL,
title VARCHAR(220) NULL,
transcript_text LONGTEXT NULL,
language VARCHAR(40) NULL,
timestamps JSON NULL,
status ENUM('queued', 'processing', 'completed', 'failed') NOT NULL DEFAULT 'queued',
failure_reason TEXT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT transcripts_sender_fk FOREIGN KEY (sender_id) REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT transcripts_receiver_fk FOREIGN KEY (receiver_id) REFERENCES users(id) ON DELETE SET NULL,
CONSTRAINT transcripts_audio_asset_fk FOREIGN KEY (audio_asset_id) REFERENCES audio_assets(id) ON DELETE SET NULL,
INDEX transcripts_sender_idx (sender_id),
INDEX transcripts_receiver_idx (receiver_id),
INDEX transcripts_audio_asset_idx (audio_asset_id),
INDEX transcripts_status_idx (status),
INDEX transcripts_created_idx (created_at),
FULLTEXT KEY transcripts_search_idx (title, transcript_text)
);
CREATE TABLE IF NOT EXISTS transcript_shares (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
transcript_id BIGINT UNSIGNED NOT NULL,
sender_id BIGINT UNSIGNED NOT NULL,
receiver_id BIGINT UNSIGNED NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT transcript_shares_transcript_fk FOREIGN KEY (transcript_id) REFERENCES transcripts(id) ON DELETE CASCADE,
CONSTRAINT transcript_shares_sender_fk FOREIGN KEY (sender_id) REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT transcript_shares_receiver_fk FOREIGN KEY (receiver_id) REFERENCES users(id) ON DELETE CASCADE,
UNIQUE KEY transcript_shares_unique (transcript_id, receiver_id),
INDEX transcript_shares_sender_idx (sender_id),
INDEX transcript_shares_receiver_idx (receiver_id)
);
CREATE TABLE IF NOT EXISTS audio_metadata (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
transcript_id BIGINT UNSIGNED NOT NULL,
file_size BIGINT UNSIGNED NULL,
duration DECIMAL(12, 3) NULL,
processing_time DECIMAL(12, 3) NULL,
model_name VARCHAR(120) NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT audio_metadata_transcript_fk FOREIGN KEY (transcript_id) REFERENCES transcripts(id) ON DELETE CASCADE,
UNIQUE KEY audio_metadata_transcript_unique (transcript_id)
);
CREATE TABLE IF NOT EXISTS transcription_jobs (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
transcript_id BIGINT UNSIGNED NOT NULL,
status ENUM('queued', 'processing', 'completed', 'failed') NOT NULL DEFAULT 'queued',
attempts INT UNSIGNED NOT NULL DEFAULT 0,
last_error TEXT NULL,
queued_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
started_at TIMESTAMP NULL,
completed_at TIMESTAMP NULL,
CONSTRAINT transcription_jobs_transcript_fk FOREIGN KEY (transcript_id) REFERENCES transcripts(id) ON DELETE CASCADE,
INDEX transcription_jobs_status_idx (status),
INDEX transcription_jobs_transcript_idx (transcript_id)
);

View File

@ -0,0 +1,13 @@
import { createTranscriptionRequest } from "../services/transcriptionService.js";
import { sendSuccess } from "../utils/apiResponse.js";
export async function transcribeAudio(req, res) {
const duration = Number(req.body.duration);
const result = await createTranscriptionRequest({
userId: req.user.id,
file: req.file,
title: String(req.body.title ?? "").trim() || null,
duration: Number.isFinite(duration) ? duration : null,
});
sendSuccess(res, "Audio accepted for transcription", result, 202);
}

View File

@ -0,0 +1,60 @@
import {
changePassword,
loginUser,
logoutSession,
refreshSession,
registerUser,
requestPasswordReset,
updateProfile,
} from "../services/authService.js";
import { clearAuthCookies, setAuthCookies } from "../services/tokenService.js";
import { env } from "../config/env.js";
import { sendSuccess } from "../utils/apiResponse.js";
export async function register(req, res) {
const { user, tokens } = await registerUser(req.body, req);
setAuthCookies(res, tokens);
sendSuccess(res, "Account created", { user }, 201);
}
export async function login(req, res) {
const { user, tokens } = await loginUser(req.body, req);
setAuthCookies(res, tokens);
sendSuccess(res, "Signed in", { user });
}
export async function refresh(req, res) {
const refreshToken = req.cookies?.[env.auth.refreshCookieName];
const {
user,
accessToken,
refreshToken: nextRefreshToken,
} = await refreshSession(refreshToken, req);
setAuthCookies(res, { accessToken, refreshToken: nextRefreshToken });
sendSuccess(res, "Session refreshed", { user });
}
export async function me(req, res) {
sendSuccess(res, "Authenticated user", { user: req.user });
}
export async function logout(req, res) {
await logoutSession(req.cookies?.[env.auth.refreshCookieName]);
clearAuthCookies(res);
sendSuccess(res, "Signed out");
}
export async function forgotPassword(req, res) {
await requestPasswordReset(req.body.email);
sendSuccess(res, "If that email exists, reset instructions will be sent");
}
export async function updateCurrentUser(req, res) {
const user = await updateProfile(req.user.id, req.body);
sendSuccess(res, "Profile updated", { user });
}
export async function changeCurrentPassword(req, res) {
await changePassword(req.user.id, req.body);
sendSuccess(res, "Password changed");
}

View File

@ -0,0 +1,116 @@
import {
deleteTranscript,
findTranscriptByIdForSender,
findTranscriptByIdForUser,
listInboxTranscripts,
listSentTranscripts,
listVisibleTranscripts,
sendTranscriptToUser,
updateTranscript,
} from "../repositories/transcriptRepository.js";
import { findUserById } from "../repositories/userRepository.js";
import { getTranscriptJob } from "../services/transcriptionService.js";
import { openAudioStream, removeAudio } from "../services/storage/storageService.js";
import { AppError } from "../utils/AppError.js";
import { sendSuccess } from "../utils/apiResponse.js";
import { safeFilename } from "../utils/safeFilename.js";
export async function listTranscripts(req, res) {
const transcripts = await listVisibleTranscripts(req.user.id, req.query);
sendSuccess(res, "Transcripts retrieved", {
transcripts,
stats: buildStats(transcripts, req.user.id),
});
}
export async function inbox(req, res) {
const transcripts = await listInboxTranscripts(req.user.id, req.query);
sendSuccess(res, "Inbox retrieved", { transcripts });
}
export async function sent(req, res) {
const transcripts = await listSentTranscripts(req.user.id, req.query);
sendSuccess(res, "Sent transcripts retrieved", { transcripts });
}
export async function getTranscript(req, res) {
const transcript = await findTranscriptByIdForUser(req.params.id, req.user.id);
if (!transcript) throw new AppError("Transcript not found", 404, "TRANSCRIPT_NOT_FOUND");
const job = await getTranscriptJob(transcript.id);
sendSuccess(res, "Transcript retrieved", {
transcript,
job: job
? {
id: Number(job.id),
status: job.status,
attempts: Number(job.attempts),
lastError: job.last_error,
}
: null,
});
}
export async function updateTranscriptText(req, res) {
const existing = await findTranscriptByIdForSender(req.params.id, req.user.id);
if (!existing) throw new AppError("Transcript not found", 404, "TRANSCRIPT_NOT_FOUND");
if (existing.status !== "completed") {
throw new AppError("Only completed transcripts can be edited", 409, "TRANSCRIPT_NOT_READY");
}
const transcript = await updateTranscript(req.params.id, req.user.id, {
title: req.body.title ?? null,
transcriptText: req.body.transcriptText,
});
sendSuccess(res, "Transcript updated", { transcript });
}
export async function removeTranscript(req, res) {
const transcript = await deleteTranscript(req.params.id, req.user.id);
if (!transcript) throw new AppError("Transcript not found", 404, "TRANSCRIPT_NOT_FOUND");
await removeAudio(transcript.audioPath);
sendSuccess(res, "Transcript deleted");
}
export async function sendTranscript(req, res) {
const { transcriptId, receiverId } = req.body;
if (receiverId === Number(req.user.id)) {
throw new AppError("Choose another user as the recipient", 400, "INVALID_RECIPIENT");
}
if (!(await findUserById(receiverId))) {
throw new AppError("Recipient not found", 404, "RECIPIENT_NOT_FOUND");
}
const existing = await findTranscriptByIdForSender(transcriptId, req.user.id);
if (!existing) throw new AppError("Transcript not found", 404, "TRANSCRIPT_NOT_FOUND");
const transcript = await sendTranscriptToUser(transcriptId, req.user.id, receiverId);
sendSuccess(res, "Transcript shared", { transcript });
}
export async function streamAudio(req, res) {
const transcript = await findTranscriptByIdForUser(req.params.id, req.user.id);
if (!transcript?.audioPath) {
throw new AppError("Audio not found", 404, "AUDIO_NOT_FOUND");
}
const stream = await openAudioStream(transcript.audioPath);
res.setHeader("Content-Type", transcript.metadata?.mimeType ?? "audio/webm");
stream.pipe(res);
}
export async function downloadTranscript(req, res) {
const transcript = await findTranscriptByIdForUser(req.params.id, req.user.id);
if (!transcript) throw new AppError("Transcript not found", 404, "TRANSCRIPT_NOT_FOUND");
const filename = `${safeFilename(transcript.title || `transcript-${transcript.id}`)}.txt`;
res.setHeader("Content-Type", "text/plain; charset=utf-8");
res.setHeader("Content-Disposition", `attachment; filename="${filename}"`);
res.send(transcript.transcriptText);
}
function buildStats(transcripts, userId) {
return {
totalRecordings: transcripts.filter((item) => item.senderId === Number(userId)).length,
totalTranscripts: transcripts.length,
sentFiles: transcripts.filter((item) => item.senderId === Number(userId) && item.receiverId)
.length,
receivedFiles: transcripts.filter((item) => item.receiverId === Number(userId)).length,
};
}

View File

@ -0,0 +1,16 @@
import { checkWhisperHealth, getTranscriptionStatus } from "../services/transcriptionService.js";
import { transcriptionQueueState } from "../jobs/transcriptionQueue.js";
import { sendSuccess } from "../utils/apiResponse.js";
export async function status(req, res) {
const data = await getTranscriptionStatus(req.params.id, req.user.id);
sendSuccess(res, "Transcription status retrieved", data);
}
export async function whisperHealth(_req, res) {
const whisper = await checkWhisperHealth();
sendSuccess(res, "Whisper health retrieved", {
whisper,
queue: transcriptionQueueState(),
});
}

View File

@ -0,0 +1,11 @@
import { listUsers } from "../repositories/userRepository.js";
import { sendSuccess } from "../utils/apiResponse.js";
export async function searchUsers(req, res) {
const users = await listUsers({
excludeId: req.user.id,
q: String(req.query.q ?? "").trim(),
limit: req.query.limit,
});
sendSuccess(res, "Users retrieved", { users });
}

View File

@ -0,0 +1,84 @@
import swaggerJSDoc from "swagger-jsdoc";
import { env } from "../config/env.js";
export const openApiSpec = swaggerJSDoc({
definition: {
openapi: "3.0.0",
info: {
title: "Orphion API",
version: "1.0.0",
description: "Production API for recording, transcribing, sharing, and playing MoM audio.",
},
servers: [{ url: env.apiPrefix }],
components: {
securitySchemes: {
cookieAuth: {
type: "apiKey",
in: "cookie",
name: env.auth.accessCookieName,
},
},
},
security: [{ cookieAuth: [] }],
paths: {
"/auth/register": { post: { tags: ["Auth"], summary: "Create an account" } },
"/auth/login": { post: { tags: ["Auth"], summary: "Sign in" } },
"/auth/refresh": { post: { tags: ["Auth"], summary: "Refresh session cookies" } },
"/auth/me": { get: { tags: ["Auth"], summary: "Get current user" } },
"/auth/logout": { post: { tags: ["Auth"], summary: "Sign out" } },
"/audio/transcribe": {
post: {
tags: ["Audio"],
summary: "Upload audio and queue transcription",
requestBody: {
required: true,
content: {
"multipart/form-data": {
schema: {
type: "object",
properties: {
audio: { type: "string", format: "binary" },
title: { type: "string" },
duration: { type: "number" },
},
required: ["audio"],
},
},
},
},
},
},
"/transcriptions/{id}/status": {
get: {
tags: ["Transcription"],
summary: "Get transcription job status",
parameters: [{ name: "id", in: "path", required: true, schema: { type: "integer" } }],
},
},
"/transcriptions/whisper/health": {
get: { tags: ["Transcription"], summary: "Check Whisper VM health" },
},
"/transcripts": { get: { tags: ["Transcripts"], summary: "List visible transcripts" } },
"/transcripts/inbox": {
get: { tags: ["Transcripts"], summary: "List received transcripts" },
},
"/transcripts/sent": { get: { tags: ["Transcripts"], summary: "List sent transcripts" } },
"/transcripts/send": { post: { tags: ["Transcripts"], summary: "Share transcript" } },
"/transcripts/{id}": {
get: {
tags: ["Transcripts"],
summary: "Get transcript",
parameters: [{ name: "id", in: "path", required: true, schema: { type: "integer" } }],
},
patch: { tags: ["Transcripts"], summary: "Update transcript" },
delete: { tags: ["Transcripts"], summary: "Delete transcript" },
},
"/transcripts/{id}/audio": { get: { tags: ["Transcripts"], summary: "Stream audio" } },
"/transcripts/{id}/download": {
get: { tags: ["Transcripts"], summary: "Download transcript text" },
},
"/users": { get: { tags: ["Users"], summary: "Search users" } },
},
},
apis: [],
});

20
backend/src/index.js Normal file
View File

@ -0,0 +1,20 @@
import { createApp } from "./app.js";
import { env } from "./config/env.js";
import { ensureSchema } from "./config/migrate.js";
import { logger } from "./utils/logger.js";
async function start() {
if (env.database.autoMigrate) {
await ensureSchema();
}
const app = createApp();
app.listen(env.port, env.host, () => {
logger.info(`Orphion API listening on http://${env.host}:${env.port}`);
});
}
start().catch((error) => {
logger.error("Failed to start Orphion API", { error: error.stack ?? error.message });
process.exit(1);
});

View File

@ -0,0 +1,37 @@
import { env } from "../config/env.js";
import { logger } from "../utils/logger.js";
const queue = [];
const active = new Set();
let handler = null;
export function configureTranscriptionQueue(processor) {
handler = processor;
}
export function enqueueTranscriptionJob(jobId) {
queue.push(Number(jobId));
processQueue();
}
export function transcriptionQueueState() {
return {
queued: queue.length,
active: active.size,
concurrency: env.whisper.queueConcurrency,
};
}
function processQueue() {
if (!handler) return;
while (active.size < env.whisper.queueConcurrency && queue.length > 0) {
const jobId = queue.shift();
active.add(jobId);
Promise.resolve(handler(jobId))
.catch((error) => logger.error("Transcription job failed", { jobId, error: error.message }))
.finally(() => {
active.delete(jobId);
processQueue();
});
}
}

View File

@ -0,0 +1,40 @@
import { env } from "../config/env.js";
import { findUserById } from "../repositories/userRepository.js";
import { verifyAccessToken } from "../services/tokenService.js";
import { AppError } from "../utils/AppError.js";
export async function authenticate(req, _res, next) {
try {
const header = req.headers.authorization;
const bearer = header?.startsWith("Bearer ") ? header.slice(7) : null;
const token = req.cookies?.[env.auth.accessCookieName] ?? req.cookies?.orphion_token ?? bearer;
if (!token) {
throw new AppError("Authentication required", 401, "AUTH_REQUIRED");
}
const payload = verifyAccessToken(token);
const user = await findUserById(payload.sub);
if (!user) {
throw new AppError("Invalid session", 401, "INVALID_SESSION");
}
req.user = user;
next();
} catch (error) {
next(error.status ? error : new AppError("Invalid or expired session", 401, "INVALID_SESSION"));
}
}
export function authorize(...roles) {
return function roleGuard(req, _res, next) {
if (!req.user) {
next(new AppError("Authentication required", 401, "AUTH_REQUIRED"));
return;
}
if (roles.length > 0 && !roles.includes(req.user.role)) {
next(new AppError("Insufficient permissions", 403, "FORBIDDEN"));
return;
}
next();
};
}

View File

@ -0,0 +1,27 @@
import { sendError } from "../utils/apiResponse.js";
import { AppError } from "../utils/AppError.js";
import { logger } from "../utils/logger.js";
export function notFound(req, _res, next) {
next(new AppError(`Route not found: ${req.method} ${req.originalUrl}`, 404, "NOT_FOUND"));
}
export function errorHandler(err, req, res, _next) {
let error = err;
if (err?.code === "LIMIT_FILE_SIZE") {
error = new AppError("Audio file is too large", 413, "AUDIO_TOO_LARGE");
}
if (err?.name === "ZodError") {
error = new AppError("Validation failed", 400, "VALIDATION_ERROR", err.flatten());
}
if (!error.status || error.status >= 500) {
logger.error("Unhandled API error", {
method: req.method,
path: req.originalUrl,
error: err?.stack ?? err?.message ?? String(err),
});
}
sendError(res, error);
}

View File

@ -0,0 +1,10 @@
import morgan from "morgan";
import { logger } from "../utils/logger.js";
export const requestLogger = morgan("combined", {
stream: {
write(message) {
logger.info(message.trim());
},
},
});

View File

@ -0,0 +1,43 @@
import compression from "compression";
import cors from "cors";
import helmet from "helmet";
import rateLimit from "express-rate-limit";
import { env } from "../config/env.js";
export function securityMiddleware(app) {
if (env.trustProxy) {
app.set("trust proxy", 1);
}
const origins = env.clientOrigin.split(",").map((origin) => origin.trim());
app.use(
helmet({
crossOriginResourcePolicy: { policy: "cross-origin" },
}),
);
app.use(compression());
app.use(
cors({
origin(origin, cb) {
if (!origin || origins.includes(origin)) return cb(null, true);
return cb(new Error(`Origin ${origin} is not allowed`));
},
credentials: true,
}),
);
app.use(
rateLimit({
windowMs: env.rateLimit.windowMs,
limit: env.rateLimit.max,
standardHeaders: "draft-8",
legacyHeaders: false,
}),
);
}
export const authRateLimiter = rateLimit({
windowMs: env.rateLimit.windowMs,
limit: env.rateLimit.authMax,
standardHeaders: "draft-8",
legacyHeaders: false,
});

View File

@ -0,0 +1,28 @@
import fs from "node:fs";
import multer from "multer";
import { env } from "../config/env.js";
import { randomStorageName } from "../utils/crypto.js";
import { AppError } from "../utils/AppError.js";
fs.mkdirSync(env.upload.tempDir, { recursive: true });
const storage = multer.diskStorage({
destination: (_req, _file, cb) => cb(null, env.upload.tempDir),
filename: (_req, file, cb) => cb(null, randomStorageName(file.originalname)),
});
export const audioUpload = multer({
storage,
limits: {
fileSize: env.upload.maxAudioSizeMb * 1024 * 1024,
},
fileFilter: (_req, file, cb) => {
const allowed =
env.upload.allowedMimeTypes.includes(file.mimetype) || file.mimetype.startsWith("audio/");
if (!allowed) {
cb(new AppError("Unsupported audio type", 400, "UNSUPPORTED_AUDIO_TYPE"));
return;
}
cb(null, true);
},
});

View File

@ -0,0 +1,23 @@
function setRequestValue(req, key, value) {
if (value === undefined) return;
Object.defineProperty(req, key, {
value,
writable: true,
enumerable: true,
configurable: true,
});
}
export function validate(schema) {
return function validationMiddleware(req, _res, next) {
const parsed = schema.parse({
body: req.body,
query: req.query,
params: req.params,
});
setRequestValue(req, "body", parsed.body);
setRequestValue(req, "query", parsed.query);
setRequestValue(req, "params", parsed.params);
next();
};
}

View File

@ -0,0 +1,68 @@
export function toUser(row) {
if (!row) return null;
return {
id: Number(row.id),
fullName: row.full_name,
username: row.username,
email: row.email,
role: row.role ?? "member",
createdAt: row.created_at instanceof Date ? row.created_at.toISOString() : row.created_at,
updatedAt: row.updated_at instanceof Date ? row.updated_at.toISOString() : row.updated_at,
};
}
export function toUserSummary(row, prefix = "") {
if (!row?.[`${prefix}id`]) return null;
return {
id: Number(row[`${prefix}id`]),
fullName: row[`${prefix}full_name`],
username: row[`${prefix}username`],
email: row[`${prefix}email`],
role: row[`${prefix}role`] ?? "member",
};
}
export function parseJsonArray(value) {
if (!value) return [];
if (Array.isArray(value)) return value;
try {
const parsed = typeof value === "string" ? JSON.parse(value) : value;
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
export function toTranscript(row) {
if (!row) return null;
return {
id: Number(row.id),
senderId: Number(row.sender_id),
receiverId: row.receiver_id ? Number(row.receiver_id) : null,
audioAssetId: row.audio_asset_id ? Number(row.audio_asset_id) : null,
title: row.title,
audioPath: row.storage_key ?? row.audio_path ?? null,
audioUrl: row.public_url ?? null,
transcriptText: row.transcript_text ?? "",
language: row.language,
timestamps: parseJsonArray(row.timestamps),
status: row.status ?? "completed",
transcriptionStatus: row.status ?? "completed",
failureReason: row.failure_reason ?? null,
createdAt: row.created_at instanceof Date ? row.created_at.toISOString() : row.created_at,
updatedAt: row.updated_at instanceof Date ? row.updated_at.toISOString() : row.updated_at,
sender: toUserSummary(row, "sender_"),
receiver: toUserSummary(row, "receiver_"),
metadata: row.metadata_id
? {
fileSize: row.file_size === null ? null : Number(row.file_size),
duration: row.duration === null ? null : Number(row.duration),
processingTime: row.processing_time === null ? null : Number(row.processing_time),
modelName: row.model_name,
originalName: row.original_name ?? null,
mimeType: row.mime_type ?? null,
storageDriver: row.storage_driver ?? null,
}
: null,
};
}

View File

@ -0,0 +1,46 @@
import { query } from "../config/database.js";
export async function createRefreshToken({
userId,
tokenHash,
expiresAt,
userAgent = null,
ipAddress = null,
}) {
await query(
`INSERT INTO refresh_tokens (user_id, token_hash, expires_at, user_agent, ip_address)
VALUES (:userId, :tokenHash, :expiresAt, :userAgent, :ipAddress)`,
{ userId, tokenHash, expiresAt, userAgent, ipAddress },
);
}
export async function findActiveRefreshToken(tokenHash) {
const rows = await query(
`SELECT *
FROM refresh_tokens
WHERE token_hash = :tokenHash
AND revoked_at IS NULL
AND expires_at > CURRENT_TIMESTAMP
LIMIT 1`,
{ tokenHash },
);
return rows[0] ?? null;
}
export async function revokeRefreshToken(tokenHash) {
await query(
`UPDATE refresh_tokens
SET revoked_at = CURRENT_TIMESTAMP
WHERE token_hash = :tokenHash AND revoked_at IS NULL`,
{ tokenHash },
);
}
export async function revokeAllUserRefreshTokens(userId) {
await query(
`UPDATE refresh_tokens
SET revoked_at = CURRENT_TIMESTAMP
WHERE user_id = :userId AND revoked_at IS NULL`,
{ userId },
);
}

View File

@ -0,0 +1,310 @@
import { query, transaction } from "../config/database.js";
import { toTranscript } from "../models/mappers.js";
const transcriptSelect = `
SELECT
t.*,
aa.storage_driver,
aa.storage_key,
aa.public_url,
aa.original_name,
aa.mime_type,
am.id AS metadata_id,
COALESCE(am.file_size, aa.file_size) AS file_size,
am.duration,
am.processing_time,
am.model_name,
sender.full_name AS sender_full_name,
sender.username AS sender_username,
sender.email AS sender_email,
sender.role AS sender_role,
receiver.full_name AS receiver_full_name,
receiver.username AS receiver_username,
receiver.email AS receiver_email,
receiver.role AS receiver_role
FROM transcripts t
LEFT JOIN audio_assets aa ON aa.id = t.audio_asset_id
LEFT JOIN audio_metadata am ON am.transcript_id = t.id
LEFT JOIN users sender ON sender.id = t.sender_id
LEFT JOIN users receiver ON receiver.id = t.receiver_id
`;
function appendFilters(base, params, filters, mode) {
const clauses = [
mode === "inbox"
? "t.receiver_id = :userId"
: mode === "sent"
? "t.sender_id = :userId AND t.receiver_id IS NOT NULL"
: "(t.sender_id = :userId OR t.receiver_id = :userId)",
];
if (filters?.q) {
clauses.push("(t.title LIKE :qLike OR t.transcript_text LIKE :qLike)");
params.qLike = `%${filters.q}%`;
}
if (filters?.sender) {
clauses.push("t.sender_id = :sender");
params.sender = Number(filters.sender);
}
if (filters?.receiver) {
clauses.push("t.receiver_id = :receiver");
params.receiver = Number(filters.receiver);
}
if (filters?.date) {
clauses.push("DATE(t.created_at) = :date");
params.date = filters.date;
}
if (filters?.status) {
clauses.push("t.status = :status");
params.status = filters.status;
}
return `${base} WHERE ${clauses.join(" AND ")} ORDER BY t.created_at DESC`;
}
export async function createAudioAsset(asset) {
const result = await query(
`INSERT INTO audio_assets
(storage_driver, storage_key, public_url, original_name, mime_type, file_size, checksum_sha256)
VALUES
(:storageDriver, :storageKey, :publicUrl, :originalName, :mimeType, :fileSize, :checksumSha256)`,
{
storageDriver: asset.storageDriver,
storageKey: asset.storageKey,
publicUrl: asset.publicUrl ?? null,
originalName: asset.originalName ?? null,
mimeType: asset.mimeType ?? null,
fileSize: asset.fileSize ?? null,
checksumSha256: asset.checksumSha256 ?? null,
},
);
return result.insertId;
}
export async function createQueuedTranscript({
senderId,
title,
audioAssetId,
duration = null,
fileSize = null,
modelName = null,
}) {
return transaction(async (connection) => {
const [insert] = await connection.execute(
`INSERT INTO transcripts
(sender_id, receiver_id, audio_asset_id, title, transcript_text, language, timestamps, status)
VALUES (:senderId, NULL, :audioAssetId, :title, '', NULL, JSON_ARRAY(), 'queued')`,
{
senderId,
audioAssetId,
title: title || null,
},
);
await connection.execute(
`INSERT INTO audio_metadata (transcript_id, file_size, duration, model_name)
VALUES (:transcriptId, :fileSize, :duration, :modelName)`,
{
transcriptId: insert.insertId,
fileSize,
duration,
modelName,
},
);
const [job] = await connection.execute(
`INSERT INTO transcription_jobs (transcript_id, status)
VALUES (:transcriptId, 'queued')`,
{ transcriptId: insert.insertId },
);
return { transcriptId: insert.insertId, jobId: job.insertId };
});
}
export async function listVisibleTranscripts(userId, filters = {}) {
const params = { userId };
const rows = await query(appendFilters(transcriptSelect, params, filters, "all"), params);
return rows.map(toTranscript);
}
export async function listInboxTranscripts(userId, filters = {}) {
const params = { userId };
const rows = await query(appendFilters(transcriptSelect, params, filters, "inbox"), params);
return rows.map(toTranscript);
}
export async function listSentTranscripts(userId, filters = {}) {
const params = { userId };
const rows = await query(appendFilters(transcriptSelect, params, filters, "sent"), params);
return rows.map(toTranscript);
}
export async function findTranscriptByIdForUser(id, userId) {
const rows = await query(
`${transcriptSelect}
WHERE t.id = :id AND (t.sender_id = :userId OR t.receiver_id = :userId)
LIMIT 1`,
{ id, userId },
);
return toTranscript(rows[0]);
}
export async function findTranscriptById(id) {
const rows = await query(
`${transcriptSelect}
WHERE t.id = :id
LIMIT 1`,
{ id },
);
return toTranscript(rows[0]);
}
export async function findTranscriptByIdForSender(id, senderId) {
const rows = await query(
`${transcriptSelect}
WHERE t.id = :id AND t.sender_id = :senderId
LIMIT 1`,
{ id, senderId },
);
return toTranscript(rows[0]);
}
export async function findTranscriptByJobForUser(jobId, userId) {
const rows = await query(
`${transcriptSelect}
INNER JOIN transcription_jobs tj ON tj.transcript_id = t.id
WHERE tj.id = :jobId AND (t.sender_id = :userId OR t.receiver_id = :userId)
LIMIT 1`,
{ jobId, userId },
);
return toTranscript(rows[0]);
}
export async function updateTranscript(id, senderId, { title, transcriptText }) {
await query(
`UPDATE transcripts
SET title = :title, transcript_text = :transcriptText
WHERE id = :id AND sender_id = :senderId`,
{ id, senderId, title: title || null, transcriptText },
);
return findTranscriptBySenderOrId(id, senderId);
}
async function findTranscriptBySenderOrId(id, senderId) {
return findTranscriptByIdForSender(id, senderId);
}
export async function completeTranscript(id, result) {
await query(
`UPDATE transcripts
SET transcript_text = :transcriptText,
language = :language,
timestamps = :timestamps,
status = 'completed',
failure_reason = NULL
WHERE id = :id`,
{
id,
transcriptText: result.transcriptText,
language: result.language,
timestamps: JSON.stringify(result.timestamps ?? []),
},
);
await query(
`UPDATE audio_metadata
SET duration = COALESCE(:duration, duration),
processing_time = :processingTime,
model_name = :modelName
WHERE transcript_id = :id`,
{
id,
duration: result.duration ?? null,
processingTime: result.processingTime ?? null,
modelName: result.modelName ?? null,
},
);
}
export async function markTranscriptProcessing(id) {
await query(
"UPDATE transcripts SET status = 'processing', failure_reason = NULL WHERE id = :id",
{
id,
},
);
}
export async function failTranscript(id, reason) {
await query(
`UPDATE transcripts
SET status = 'failed', failure_reason = :reason
WHERE id = :id`,
{ id, reason: String(reason).slice(0, 2000) },
);
}
export async function sendTranscriptToUser(id, senderId, receiverId) {
return transaction(async (connection) => {
await connection.execute(
`UPDATE transcripts
SET receiver_id = :receiverId
WHERE id = :id AND sender_id = :senderId`,
{ id, senderId, receiverId },
);
await connection.execute(
`INSERT INTO transcript_shares (transcript_id, sender_id, receiver_id)
VALUES (:id, :senderId, :receiverId)
ON DUPLICATE KEY UPDATE sender_id = VALUES(sender_id)`,
{ id, senderId, receiverId },
);
return findTranscriptBySenderOrId(id, senderId);
});
}
export async function deleteTranscript(id, senderId) {
const transcript = await findTranscriptByIdForSender(id, senderId);
if (!transcript) return null;
await query("DELETE FROM transcripts WHERE id = :id AND sender_id = :senderId", { id, senderId });
return transcript;
}
export async function findJobById(id) {
const rows = await query("SELECT * FROM transcription_jobs WHERE id = :id LIMIT 1", { id });
return rows[0] ?? null;
}
export async function findJobByTranscriptId(transcriptId) {
const rows = await query(
"SELECT * FROM transcription_jobs WHERE transcript_id = :transcriptId ORDER BY id DESC LIMIT 1",
{ transcriptId },
);
return rows[0] ?? null;
}
export async function markJobProcessing(id) {
await query(
`UPDATE transcription_jobs
SET status = 'processing', attempts = attempts + 1, started_at = COALESCE(started_at, CURRENT_TIMESTAMP)
WHERE id = :id`,
{ id },
);
}
export async function completeJob(id) {
await query(
`UPDATE transcription_jobs
SET status = 'completed', completed_at = CURRENT_TIMESTAMP
WHERE id = :id`,
{ id },
);
}
export async function failJob(id, error) {
await query(
`UPDATE transcription_jobs
SET status = 'failed', last_error = :error, completed_at = CURRENT_TIMESTAMP
WHERE id = :id`,
{ id, error: String(error).slice(0, 2000) },
);
}

View File

@ -0,0 +1,69 @@
import { query } from "../config/database.js";
import { toUser } from "../models/mappers.js";
export async function createUser({ fullName, username, email, passwordHash, role = "member" }) {
const result = await query(
`INSERT INTO users (full_name, username, email, password_hash, role)
VALUES (:fullName, :username, :email, :passwordHash, :role)`,
{ fullName, username, email, passwordHash, role },
);
return findUserById(result.insertId);
}
export async function findUserByEmail(email) {
const rows = await query("SELECT * FROM users WHERE email = :email LIMIT 1", { email });
return rows[0] ?? null;
}
export async function findUserByUsername(username) {
const rows = await query("SELECT * FROM users WHERE username = :username LIMIT 1", { username });
return rows[0] ?? null;
}
export async function findUserById(id) {
const rows = await query(
"SELECT id, full_name, username, email, role, created_at, updated_at FROM users WHERE id = :id LIMIT 1",
{ id },
);
return toUser(rows[0]);
}
export async function findUserCredentialsById(id) {
const rows = await query("SELECT * FROM users WHERE id = :id LIMIT 1", { id });
return rows[0] ?? null;
}
export async function updateUserProfile(id, { fullName, username }) {
await query(
`UPDATE users
SET full_name = :fullName, username = :username
WHERE id = :id`,
{ id, fullName, username },
);
return findUserById(id);
}
export async function updateUserPassword(id, passwordHash) {
await query("UPDATE users SET password_hash = :passwordHash WHERE id = :id", {
id,
passwordHash,
});
}
export async function listUsers({ excludeId, q = "", limit = 25 }) {
const rows = await query(
`SELECT id, full_name, username, email, role, created_at, updated_at
FROM users
WHERE id <> :excludeId
AND (
:q = ''
OR full_name LIKE :likeQ
OR username LIKE :likeQ
OR email LIKE :likeQ
)
ORDER BY full_name ASC
LIMIT :limit`,
{ excludeId, q, likeQ: `%${q}%`, limit: Math.min(Number(limit) || 25, 50) },
);
return rows.map(toUser);
}

View File

@ -0,0 +1,32 @@
import { Router } from "express";
import { pingDatabase } from "../config/database.js";
import { env } from "../config/env.js";
import { checkWhisperHealth } from "../services/transcriptionService.js";
import { sendSuccess } from "../utils/apiResponse.js";
import { asyncHandler } from "../utils/asyncHandler.js";
import { v1Routes } from "./v1/index.js";
export const routes = Router();
routes.get(
"/health",
asyncHandler(async (_req, res) => {
const [database, whisper] = await Promise.all([
pingDatabase()
.then(() => ({ healthy: true }))
.catch((error) => ({
healthy: false,
error: error.message,
})),
checkWhisperHealth(),
]);
sendSuccess(res, "Orphion API is healthy", {
service: "orphion-api",
env: env.nodeEnv,
database,
whisper,
});
}),
);
routes.use(env.apiPrefix, v1Routes);

View File

@ -0,0 +1,14 @@
import { Router } from "express";
import { transcribeAudio } from "../../controllers/audioController.js";
import { authenticate } from "../../middlewares/authenticate.js";
import { audioUpload } from "../../middlewares/upload.js";
import { asyncHandler } from "../../utils/asyncHandler.js";
export const audioRoutes = Router();
audioRoutes.post(
"/transcribe",
authenticate,
audioUpload.single("audio"),
asyncHandler(transcribeAudio),
);

View File

@ -0,0 +1,48 @@
import { Router } from "express";
import {
changeCurrentPassword,
forgotPassword,
login,
logout,
me,
refresh,
register,
updateCurrentUser,
} from "../../controllers/authController.js";
import { asyncHandler } from "../../utils/asyncHandler.js";
import { authenticate } from "../../middlewares/authenticate.js";
import { authRateLimiter } from "../../middlewares/security.js";
import { validate } from "../../middlewares/validate.js";
import {
changePasswordSchema,
forgotPasswordSchema,
loginSchema,
registerSchema,
updateProfileSchema,
} from "../../validators/authValidators.js";
export const authRoutes = Router();
authRoutes.post("/register", authRateLimiter, validate(registerSchema), asyncHandler(register));
authRoutes.post("/login", authRateLimiter, validate(loginSchema), asyncHandler(login));
authRoutes.post("/refresh", asyncHandler(refresh));
authRoutes.post(
"/forgot-password",
authRateLimiter,
validate(forgotPasswordSchema),
asyncHandler(forgotPassword),
);
authRoutes.get("/me", authenticate, asyncHandler(me));
authRoutes.post("/logout", asyncHandler(logout));
authRoutes.patch(
"/profile",
authenticate,
validate(updateProfileSchema),
asyncHandler(updateCurrentUser),
);
authRoutes.patch(
"/password",
authenticate,
validate(changePasswordSchema),
asyncHandler(changeCurrentPassword),
);

View File

@ -0,0 +1,14 @@
import { Router } from "express";
import { authRoutes } from "./authRoutes.js";
import { audioRoutes } from "./audioRoutes.js";
import { transcriptRoutes } from "./transcriptRoutes.js";
import { transcriptionRoutes } from "./transcriptionRoutes.js";
import { userRoutes } from "./userRoutes.js";
export const v1Routes = Router();
v1Routes.use("/auth", authRoutes);
v1Routes.use("/audio", audioRoutes);
v1Routes.use("/transcripts", transcriptRoutes);
v1Routes.use("/transcriptions", transcriptionRoutes);
v1Routes.use("/users", userRoutes);

View File

@ -0,0 +1,42 @@
import { Router } from "express";
import {
downloadTranscript,
getTranscript,
inbox,
listTranscripts,
removeTranscript,
sendTranscript,
sent,
streamAudio,
updateTranscriptText,
} from "../../controllers/transcriptController.js";
import { authenticate } from "../../middlewares/authenticate.js";
import { validate } from "../../middlewares/validate.js";
import { asyncHandler } from "../../utils/asyncHandler.js";
import {
sendTranscriptSchema,
transcriptIdSchema,
transcriptListSchema,
updateTranscriptSchema,
} from "../../validators/transcriptValidators.js";
export const transcriptRoutes = Router();
transcriptRoutes.use(authenticate);
transcriptRoutes.get("/", validate(transcriptListSchema), asyncHandler(listTranscripts));
transcriptRoutes.get("/inbox", validate(transcriptListSchema), asyncHandler(inbox));
transcriptRoutes.get("/sent", validate(transcriptListSchema), asyncHandler(sent));
transcriptRoutes.post("/send", validate(sendTranscriptSchema), asyncHandler(sendTranscript));
transcriptRoutes.get("/:id/audio", validate(transcriptIdSchema), asyncHandler(streamAudio));
transcriptRoutes.get(
"/:id/download",
validate(transcriptIdSchema),
asyncHandler(downloadTranscript),
);
transcriptRoutes.get("/:id", validate(transcriptIdSchema), asyncHandler(getTranscript));
transcriptRoutes.patch(
"/:id",
validate(updateTranscriptSchema),
asyncHandler(updateTranscriptText),
);
transcriptRoutes.delete("/:id", validate(transcriptIdSchema), asyncHandler(removeTranscript));

View File

@ -0,0 +1,16 @@
import { Router } from "express";
import { status, whisperHealth } from "../../controllers/transcriptionController.js";
import { authenticate } from "../../middlewares/authenticate.js";
import { validate } from "../../middlewares/validate.js";
import { asyncHandler } from "../../utils/asyncHandler.js";
import { transcriptionJobSchema } from "../../validators/transcriptValidators.js";
export const transcriptionRoutes = Router();
transcriptionRoutes.get("/whisper/health", authenticate, asyncHandler(whisperHealth));
transcriptionRoutes.get(
"/:id/status",
authenticate,
validate(transcriptionJobSchema),
asyncHandler(status),
);

View File

@ -0,0 +1,10 @@
import { Router } from "express";
import { searchUsers } from "../../controllers/userController.js";
import { authenticate } from "../../middlewares/authenticate.js";
import { validate } from "../../middlewares/validate.js";
import { asyncHandler } from "../../utils/asyncHandler.js";
import { userSearchSchema } from "../../validators/userValidators.js";
export const userRoutes = Router();
userRoutes.get("/", authenticate, validate(userSearchSchema), asyncHandler(searchUsers));

View File

@ -0,0 +1,84 @@
import bcrypt from "bcrypt";
import {
createUser,
findUserByEmail,
findUserById,
findUserByUsername,
findUserCredentialsById,
updateUserPassword,
updateUserProfile,
} from "../repositories/userRepository.js";
import { AppError } from "../utils/AppError.js";
import { issueTokenPair, rotateRefreshToken, revokeToken } from "./tokenService.js";
import { toUser } from "../models/mappers.js";
export async function registerUser(input, req) {
const normalizedEmail = input.email.toLowerCase().trim();
const normalizedUsername = input.username.toLowerCase().trim();
if (await findUserByEmail(normalizedEmail)) {
throw new AppError("Email is already registered", 409, "EMAIL_TAKEN");
}
if (await findUserByUsername(normalizedUsername)) {
throw new AppError("Username is already taken", 409, "USERNAME_TAKEN");
}
const passwordHash = await bcrypt.hash(input.password, 12);
const user = await createUser({
fullName: input.fullName.trim(),
username: normalizedUsername,
email: normalizedEmail,
passwordHash,
});
const tokens = await issueTokenPair(user, req);
return { user, tokens };
}
export async function loginUser({ identifier, email, password }, req) {
const normalizedIdentifier = String(identifier ?? email ?? "")
.toLowerCase()
.trim();
const record = normalizedIdentifier.includes("@")
? await findUserByEmail(normalizedIdentifier)
: await findUserByUsername(normalizedIdentifier);
if (!record || !(await bcrypt.compare(password, record.password_hash))) {
throw new AppError("Invalid username/email or password", 401, "INVALID_CREDENTIALS");
}
const user = toUser(record);
const tokens = await issueTokenPair(user, req);
return { user, tokens };
}
export async function refreshSession(refreshToken, req) {
return rotateRefreshToken(refreshToken, req);
}
export async function logoutSession(refreshToken) {
await revokeToken(refreshToken);
}
export async function updateProfile(userId, input) {
const fullName = input.fullName.trim();
const username = input.username.toLowerCase().trim();
const existing = await findUserByUsername(username);
if (existing && Number(existing.id) !== Number(userId)) {
throw new AppError("Username is already taken", 409, "USERNAME_TAKEN");
}
return updateUserProfile(userId, { fullName, username });
}
export async function changePassword(userId, { currentPassword, newPassword }) {
const record = await findUserCredentialsById(userId);
if (!record || !(await bcrypt.compare(currentPassword, record.password_hash))) {
throw new AppError("Current password is incorrect", 401, "INVALID_CURRENT_PASSWORD");
}
const passwordHash = await bcrypt.hash(newPassword, 12);
await updateUserPassword(userId, passwordHash);
}
export async function requestPasswordReset(email) {
await findUserByEmail(String(email).toLowerCase().trim());
return { accepted: true };
}
export { findUserById };

View File

@ -0,0 +1,59 @@
import fs from "node:fs";
import { Readable } from "node:stream";
import { env } from "../../config/env.js";
import { AppError } from "../../utils/AppError.js";
function buildUrl(storageKey) {
if (!env.storage.httpBaseUrl) {
throw new AppError("STORAGE_HTTP_BASE_URL is required for HTTP storage", 500, "STORAGE_CONFIG");
}
return `${env.storage.httpBaseUrl}/${storageKey}`;
}
function headers() {
return env.storage.httpToken ? { Authorization: `Bearer ${env.storage.httpToken}` } : {};
}
export function createHttpAdapter() {
return {
name: "http",
async save({ sourcePath, storageKey, mimeType }) {
const response = await fetch(buildUrl(storageKey), {
method: "PUT",
headers: {
...headers(),
"Content-Type": mimeType || "application/octet-stream",
},
body: fs.createReadStream(sourcePath),
duplex: "half",
});
if (!response.ok) {
throw new AppError(
`Remote storage rejected upload (${response.status})`,
502,
"STORAGE_UPLOAD",
);
}
return {
storageDriver: this.name,
storageKey,
publicUrl: env.storage.publicBaseUrl
? `${env.storage.publicBaseUrl}/${storageKey}`
: buildUrl(storageKey),
};
},
async createReadStream(storageKey) {
const response = await fetch(buildUrl(storageKey), { headers: headers() });
if (!response.ok || !response.body) {
throw new AppError("Audio file not found in remote storage", 404, "AUDIO_NOT_FOUND");
}
return Readable.fromWeb(response.body);
},
async remove(storageKey) {
await fetch(buildUrl(storageKey), { method: "DELETE", headers: headers() }).catch(() => {});
},
};
}

View File

@ -0,0 +1,32 @@
import fs from "node:fs";
import fsp from "node:fs/promises";
import path from "node:path";
import { pipeline } from "node:stream/promises";
import { env } from "../../config/env.js";
export function createLocalAdapter(basePath = env.storage.basePath) {
return {
name: "local",
async save({ sourcePath, storageKey }) {
const destination = path.join(basePath, storageKey);
await fsp.mkdir(path.dirname(destination), { recursive: true });
await pipeline(fs.createReadStream(sourcePath), fs.createWriteStream(destination));
return {
storageDriver: this.name,
storageKey,
publicUrl: env.storage.publicBaseUrl
? `${env.storage.publicBaseUrl}/${encodeURIComponent(storageKey)}`
: null,
};
},
createReadStream(storageKey) {
return fs.createReadStream(path.join(basePath, storageKey));
},
async remove(storageKey) {
await fsp.unlink(path.join(basePath, storageKey)).catch(() => {});
},
};
}

View File

@ -0,0 +1,66 @@
import fs from "node:fs";
import { env } from "../../config/env.js";
import { AppError } from "../../utils/AppError.js";
async function client() {
const { S3Client } = await import("@aws-sdk/client-s3");
if (!env.storage.s3.bucket) {
throw new AppError("S3_BUCKET is required for S3 storage", 500, "STORAGE_CONFIG");
}
return new S3Client({
endpoint: env.storage.s3.endpoint || undefined,
region: env.storage.s3.region,
forcePathStyle: env.storage.s3.forcePathStyle,
credentials:
env.storage.s3.accessKeyId && env.storage.s3.secretAccessKey
? {
accessKeyId: env.storage.s3.accessKeyId,
secretAccessKey: env.storage.s3.secretAccessKey,
}
: undefined,
});
}
export function createS3Adapter() {
return {
name: "s3",
async save({ sourcePath, storageKey, mimeType }) {
const { PutObjectCommand } = await import("@aws-sdk/client-s3");
const s3 = await client();
await s3.send(
new PutObjectCommand({
Bucket: env.storage.s3.bucket,
Key: storageKey,
Body: fs.createReadStream(sourcePath),
ContentType: mimeType,
}),
);
return {
storageDriver: this.name,
storageKey,
publicUrl: env.storage.publicBaseUrl ? `${env.storage.publicBaseUrl}/${storageKey}` : null,
};
},
async createReadStream(storageKey) {
const { GetObjectCommand } = await import("@aws-sdk/client-s3");
const s3 = await client();
const response = await s3.send(
new GetObjectCommand({ Bucket: env.storage.s3.bucket, Key: storageKey }),
);
if (!response.Body) {
throw new AppError("Audio file not found in S3 storage", 404, "AUDIO_NOT_FOUND");
}
return response.Body;
},
async remove(storageKey) {
const { DeleteObjectCommand } = await import("@aws-sdk/client-s3");
const s3 = await client();
await s3
.send(new DeleteObjectCommand({ Bucket: env.storage.s3.bucket, Key: storageKey }))
.catch(() => {});
},
};
}

View File

@ -0,0 +1,8 @@
import { createLocalAdapter } from "./localAdapter.js";
export function createSmbAdapter(basePath) {
return {
...createLocalAdapter(basePath),
name: "smb",
};
}

View File

@ -0,0 +1,68 @@
import fs from "node:fs/promises";
import path from "node:path";
import { createHash } from "node:crypto";
import { env } from "../../config/env.js";
import { AppError } from "../../utils/AppError.js";
import { randomStorageName } from "../../utils/crypto.js";
import { createHttpAdapter } from "./httpAdapter.js";
import { createLocalAdapter } from "./localAdapter.js";
import { createS3Adapter } from "./s3Adapter.js";
import { createSmbAdapter } from "./smbAdapter.js";
function adapter() {
switch (env.storage.driver) {
case "local":
return createLocalAdapter(env.storage.basePath);
case "smb":
case "nfs":
return createSmbAdapter(env.storage.basePath);
case "http":
return createHttpAdapter();
case "s3":
return createS3Adapter();
default:
throw new AppError(
`Unsupported storage driver: ${env.storage.driver}`,
500,
"STORAGE_CONFIG",
);
}
}
async function checksum(filePath) {
const file = await fs.readFile(filePath);
return createHash("sha256").update(file).digest("hex");
}
export async function saveAudio(file, userId) {
const today = new Date().toISOString().slice(0, 10);
const storageKey = path.posix.join(String(userId), today, randomStorageName(file.originalname));
const activeAdapter = adapter();
const stored = await activeAdapter.save({
sourcePath: file.path,
storageKey,
mimeType: file.mimetype,
});
return {
...stored,
originalName: file.originalname,
mimeType: file.mimetype,
fileSize: file.size,
checksumSha256: await checksum(file.path),
};
}
export async function openAudioStream(storageKey) {
return adapter().createReadStream(storageKey);
}
export async function removeAudio(storageKey) {
if (!storageKey) return;
await adapter().remove(storageKey);
}
export async function cleanupTempFile(file) {
if (file?.path) {
await fs.unlink(file.path).catch(() => {});
}
}

View File

@ -0,0 +1,86 @@
import jwt from "jsonwebtoken";
import { env } from "../config/env.js";
import {
createRefreshToken,
findActiveRefreshToken,
revokeRefreshToken,
} from "../repositories/tokenRepository.js";
import { findUserById } from "../repositories/userRepository.js";
import { AppError } from "../utils/AppError.js";
import { randomToken, sha256 } from "../utils/crypto.js";
function cookieOptions(maxAge) {
return {
httpOnly: true,
sameSite: env.auth.cookieSameSite,
secure: env.auth.cookieSecure,
maxAge,
path: "/",
};
}
export function signAccessToken(user) {
return jwt.sign(
{ sub: user.id, email: user.email, role: user.role },
env.auth.accessTokenSecret,
{
expiresIn: env.auth.accessTokenTtl,
},
);
}
export function verifyAccessToken(token) {
return jwt.verify(token, env.auth.accessTokenSecret);
}
export async function issueTokenPair(user, req) {
const accessToken = signAccessToken(user);
const refreshToken = randomToken(64);
const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
await createRefreshToken({
userId: user.id,
tokenHash: sha256(refreshToken),
expiresAt,
userAgent: req.get("user-agent")?.slice(0, 255) ?? null,
ipAddress: req.ip ?? null,
});
return { accessToken, refreshToken };
}
export async function rotateRefreshToken(refreshToken, req) {
if (!refreshToken) {
throw new AppError("Refresh token is required", 401, "REFRESH_TOKEN_REQUIRED");
}
const tokenHash = sha256(refreshToken);
const record = await findActiveRefreshToken(tokenHash);
if (!record) {
throw new AppError("Invalid or expired refresh token", 401, "INVALID_REFRESH_TOKEN");
}
await revokeRefreshToken(tokenHash);
const user = await findUserById(record.user_id);
if (!user) {
throw new AppError("Invalid refresh token", 401, "INVALID_REFRESH_TOKEN");
}
const tokens = await issueTokenPair(user, req);
return { user, ...tokens };
}
export async function revokeToken(refreshToken) {
if (refreshToken) {
await revokeRefreshToken(sha256(refreshToken));
}
}
export function setAuthCookies(res, { accessToken, refreshToken }) {
res.cookie(env.auth.accessCookieName, accessToken, cookieOptions(15 * 60 * 1000));
res.cookie(env.auth.refreshCookieName, refreshToken, cookieOptions(30 * 24 * 60 * 60 * 1000));
}
export function clearAuthCookies(res) {
res.clearCookie(env.auth.accessCookieName, cookieOptions(0));
res.clearCookie(env.auth.refreshCookieName, cookieOptions(0));
}

View File

@ -0,0 +1,122 @@
import { env } from "../config/env.js";
import {
enqueueTranscriptionJob,
configureTranscriptionQueue,
} from "../jobs/transcriptionQueue.js";
import {
completeJob,
completeTranscript,
createAudioAsset,
createQueuedTranscript,
failJob,
failTranscript,
findJobById,
findJobByTranscriptId,
findTranscriptById,
findTranscriptByIdForUser,
findTranscriptByJobForUser,
markJobProcessing,
markTranscriptProcessing,
} from "../repositories/transcriptRepository.js";
import { cleanupTempFile, openAudioStream, saveAudio } from "./storage/storageService.js";
import { transcribeAudioStream, checkWhisperHealth } from "../transcription/whisperClient.js";
import { AppError } from "../utils/AppError.js";
export async function createTranscriptionRequest({ userId, file, title, duration }) {
if (!file) {
throw new AppError("Audio file is required", 400, "AUDIO_REQUIRED");
}
let savedAsset = null;
try {
savedAsset = await saveAudio(file, userId);
const audioAssetId = await createAudioAsset(savedAsset);
const { transcriptId, jobId } = await createQueuedTranscript({
senderId: userId,
title,
audioAssetId,
duration,
fileSize: file.size,
modelName: env.whisper.modelName,
});
enqueueTranscriptionJob(jobId);
return {
job: { id: Number(jobId), status: "queued" },
transcript: await findTranscriptByIdForUser(transcriptId, userId),
};
} catch (error) {
if (savedAsset?.storageKey) {
await cleanupFailedRemoteAudio(savedAsset.storageKey);
}
throw error;
} finally {
await cleanupTempFile(file);
}
}
async function cleanupFailedRemoteAudio(storageKey) {
const { removeAudio } = await import("./storage/storageService.js");
await removeAudio(storageKey).catch(() => {});
}
export async function processTranscriptionJob(jobId) {
const job = await findJobById(jobId);
if (!job || !["queued", "failed"].includes(job.status)) return;
await markJobProcessing(jobId);
await markTranscriptProcessing(job.transcript_id);
try {
const transcript = await findTranscriptById(job.transcript_id);
if (!transcript?.audioPath) {
throw new AppError("Transcript audio asset is missing", 500, "AUDIO_MISSING");
}
const result = await transcribeAudioStream({
streamFactory: () => openAudioStream(transcript.audioPath),
filename: transcript.metadata?.originalName ?? `transcript-${transcript.id}.webm`,
mimeType: transcript.metadata?.mimeType ?? "audio/webm",
});
await completeTranscript(transcript.id, result);
await completeJob(jobId);
} catch (error) {
await failTranscript(job.transcript_id, error.message);
await failJob(jobId, error.message);
throw error;
}
}
configureTranscriptionQueue(processTranscriptionJob);
export async function getTranscriptionStatus(jobId, userId) {
const job = await findJobById(jobId);
if (!job) {
throw new AppError("Transcription job not found", 404, "JOB_NOT_FOUND");
}
const transcript = await findTranscriptByJobForUser(jobId, userId);
if (!transcript) {
throw new AppError("Transcription job not found", 404, "JOB_NOT_FOUND");
}
return {
job: {
id: Number(job.id),
transcriptId: Number(job.transcript_id),
status: job.status,
attempts: Number(job.attempts),
lastError: job.last_error,
queuedAt: job.queued_at,
startedAt: job.started_at,
completedAt: job.completed_at,
},
transcript,
};
}
export async function getTranscriptJob(transcriptId) {
return findJobByTranscriptId(transcriptId);
}
export { checkWhisperHealth };

View File

@ -0,0 +1,121 @@
import axios from "axios";
import FormData from "form-data";
import { env } from "../config/env.js";
import { AppError } from "../utils/AppError.js";
function endpoint(path) {
return `${env.whisper.apiUrl}${path.startsWith("/") ? path : `/${path}`}`;
}
function delay(ms) {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
export async function checkWhisperHealth() {
if (env.whisper.allowMock) {
return { healthy: true, mode: "mock", url: env.whisper.apiUrl };
}
try {
const response = await axios.get(endpoint(env.whisper.healthPath), {
timeout: Math.min(env.whisper.timeoutMs, 5000),
validateStatus: (status) => status >= 200 && status < 500,
});
return {
healthy: response.status >= 200 && response.status < 300,
status: response.status,
url: env.whisper.apiUrl,
data: response.data ?? null,
};
} catch (error) {
return {
healthy: false,
url: env.whisper.apiUrl,
error: error.message,
};
}
}
export async function transcribeAudioStream({ stream, streamFactory, filename, mimeType }) {
if (env.whisper.allowMock) {
return mockTranscript(filename);
}
let lastError;
for (let attempt = 0; attempt <= env.whisper.retries; attempt += 1) {
try {
const currentStream = streamFactory ? await streamFactory() : stream;
return await postToWhisper({ stream: currentStream, filename, mimeType });
} catch (error) {
lastError = error;
if (attempt >= env.whisper.retries) break;
await delay(env.whisper.retryDelayMs * (attempt + 1));
}
}
throw new AppError(
`Whisper VM unavailable: ${lastError?.message ?? "request failed"}`,
503,
"WHISPER_UNAVAILABLE",
);
}
async function postToWhisper({ stream, filename, mimeType }) {
const start = Date.now();
const form = new FormData();
form.append(env.whisper.fileField, stream, {
filename,
contentType: mimeType,
});
const response = await axios.post(endpoint(env.whisper.transcribePath), form, {
headers: form.getHeaders(),
maxBodyLength: Infinity,
maxContentLength: Infinity,
timeout: env.whisper.timeoutMs,
validateStatus: (status) => status >= 200 && status < 300,
});
return normalizeTranscript(response.data, Date.now() - start);
}
function normalizeTranscript(payload, elapsedMs) {
const transcriptText =
payload.transcript_text ??
payload.transcript ??
payload.text ??
payload.result?.transcript_text ??
payload.result?.text ??
"";
if (!transcriptText) {
throw new AppError("Whisper VM returned an empty transcript", 502, "EMPTY_TRANSCRIPT");
}
return {
transcriptText,
language: payload.language ?? payload.result?.language ?? null,
timestamps: payload.timestamps ?? payload.segments ?? payload.result?.timestamps ?? [],
duration: numberOrNull(payload.duration ?? payload.result?.duration),
processingTime: Number((elapsedMs / 1000).toFixed(3)),
modelName: env.whisper.modelName,
};
}
function mockTranscript(filename) {
return {
transcriptText: `Mock transcript for ${filename}. Configure WHISPER_API_URL to use Faster-Whisper Large v3.`,
language: "en",
timestamps: [{ start: 0, end: 4, text: "Mock transcript generated for local development." }],
duration: null,
processingTime: 0.15,
modelName: env.whisper.modelName,
};
}
function numberOrNull(value) {
const number = Number(value);
return Number.isFinite(number) ? number : null;
}

View File

@ -0,0 +1,16 @@
export class AppError extends Error {
constructor(message, status = 500, code = "INTERNAL_ERROR", details = null) {
super(message);
this.name = "AppError";
this.status = status;
this.code = code;
this.details = details;
}
}
export function assertFound(value, message = "Resource not found") {
if (!value) {
throw new AppError(message, 404, "NOT_FOUND");
}
return value;
}

View File

@ -0,0 +1,19 @@
export function sendSuccess(res, message, data = {}, status = 200) {
return res.status(status).json({
success: true,
message,
data,
});
}
export function sendError(res, error) {
const status = error?.status ?? 500;
return res.status(status).json({
success: false,
message: error?.message ?? "Internal server error",
error: {
code: error?.code ?? "INTERNAL_ERROR",
details: error?.details ?? null,
},
});
}

View File

@ -0,0 +1,5 @@
export function asyncHandler(handler) {
return function wrappedHandler(req, res, next) {
Promise.resolve(handler(req, res, next)).catch(next);
};
}

View File

@ -0,0 +1,19 @@
import crypto from "node:crypto";
export function sha256(value) {
return crypto.createHash("sha256").update(value).digest("hex");
}
export function randomToken(bytes = 48) {
return crypto.randomBytes(bytes).toString("base64url");
}
export function randomStorageName(originalName = "audio.webm") {
const safeExt = originalName.includes(".")
? originalName
.slice(originalName.lastIndexOf("."))
.replace(/[^a-z0-9.]/gi, "")
.toLowerCase()
: ".webm";
return `${Date.now()}-${crypto.randomUUID()}${safeExt || ".webm"}`;
}

View File

@ -0,0 +1,18 @@
const levels = ["debug", "info", "warn", "error"];
function write(level, message, meta) {
const payload = {
level,
time: new Date().toISOString(),
message,
...(meta ? { meta } : {}),
};
const output = JSON.stringify(payload);
if (level === "error") console.error(output);
else if (level === "warn") console.warn(output);
else console.info(output);
}
export const logger = Object.fromEntries(
levels.map((level) => [level, (message, meta) => write(level, message, meta)]),
);

View File

@ -0,0 +1,10 @@
import path from "node:path";
export function safeFilename(value, fallback = "transcript") {
return (
path
.basename(String(value ?? ""))
.replace(/[^a-z0-9._-]+/gi, "-")
.replace(/^-+|-+$/g, "") || fallback
);
}

View File

@ -0,0 +1,57 @@
import { z } from "zod";
export const registerSchema = z.object({
body: z.object({
fullName: z.string().trim().min(2).max(140),
username: z
.string()
.trim()
.min(3)
.max(40)
.regex(/^[a-z0-9_]+$/i, "Use letters, numbers, or underscores"),
email: z.string().trim().email().max(180),
password: z.string().min(8).max(128),
}),
});
export const loginSchema = z.object({
body: z
.object({
identifier: z.string().trim().min(1).optional(),
email: z.string().trim().min(1).optional(),
password: z.string().min(1),
})
.refine((input) => input.identifier || input.email, {
path: ["identifier"],
message: "Email or username is required",
})
.transform((input) => ({
identifier: input.identifier ?? input.email,
password: input.password,
})),
});
export const forgotPasswordSchema = z.object({
body: z.object({
email: z.string().trim().email(),
}),
});
export const updateProfileSchema = z.object({
body: z.object({
fullName: z.string().trim().min(2).max(140),
username: z
.string()
.trim()
.min(3)
.max(40)
.regex(/^[a-z0-9_]+$/i, "Use letters, numbers, or underscores"),
}),
});
export const changePasswordSchema = z.object({
body: z.object({
currentPassword: z.string().min(1),
newPassword: z.string().min(8).max(128),
}),
});

View File

@ -0,0 +1,40 @@
import { z } from "zod";
export const transcriptListSchema = z.object({
query: z.object({
q: z.string().optional(),
sender: z.string().optional(),
receiver: z.string().optional(),
date: z.string().optional(),
status: z.enum(["queued", "processing", "completed", "failed"]).optional(),
}),
});
export const updateTranscriptSchema = z.object({
params: z.object({
id: z.string().regex(/^\d+$/),
}),
body: z.object({
title: z.string().trim().max(220).optional(),
transcriptText: z.string().trim().min(1),
}),
});
export const transcriptIdSchema = z.object({
params: z.object({
id: z.string().regex(/^\d+$/),
}),
});
export const sendTranscriptSchema = z.object({
body: z.object({
transcriptId: z.coerce.number().int().positive(),
receiverId: z.coerce.number().int().positive(),
}),
});
export const transcriptionJobSchema = z.object({
params: z.object({
id: z.string().regex(/^\d+$/),
}),
});

View File

@ -0,0 +1,8 @@
import { z } from "zod";
export const userSearchSchema = z.object({
query: z.object({
q: z.string().optional().default(""),
limit: z.coerce.number().int().min(1).max(50).optional().default(25),
}),
});

46
docs/API.md Normal file
View File

@ -0,0 +1,46 @@
# API Guide
Base path: `/api/v1`
All JSON responses follow:
```json
{
"success": true,
"message": "Transcript created",
"data": {}
}
```
## Auth
- `POST /auth/register`
- `POST /auth/login`
- `POST /auth/refresh`
- `GET /auth/me`
- `POST /auth/logout`
- `PATCH /auth/profile`
- `PATCH /auth/password`
- `POST /auth/forgot-password`
## Audio And Transcription
- `POST /audio/transcribe` accepts multipart field `audio`
- `GET /transcriptions/:id/status` returns queued, processing, completed, or failed
- `GET /transcriptions/whisper/health` checks Whisper VM and queue state
## Transcripts
- `GET /transcripts`
- `GET /transcripts/inbox`
- `GET /transcripts/sent`
- `GET /transcripts/:id`
- `PATCH /transcripts/:id`
- `DELETE /transcripts/:id`
- `POST /transcripts/send`
- `GET /transcripts/:id/audio`
- `GET /transcripts/:id/download`
## Users
- `GET /users?q=kevin`

32
docs/REMOTE_DB.md Normal file
View File

@ -0,0 +1,32 @@
# Remote MySQL Setup
Create a database user that can connect from the API host:
```sql
CREATE DATABASE orphion CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'orphion'@'%' IDENTIFIED BY 'NexaVault2026!Blue';
GRANT ALL PRIVILEGES ON orphion.* TO 'orphion'@'%';
FLUSH PRIVILEGES;
```
Configure:
```env
DB_HOST=172.16.10.64
DB_PORT=3306
DB_NAME=orphion
DB_USER=orphion
DB_PASSWORD=NexaVault2026!Blue
```
For the VM setup, run the database/user bootstrap from this project:
```sh
sh scripts/bootstrap-vm-mysql.sh <vm-user>@172.16.10.64
```
That command runs the VM MySQL grant setup and then applies the project schema through:
```sh
npm run migrate
```

46
docs/STORAGE.md Normal file
View File

@ -0,0 +1,46 @@
# Storage Configuration
Audio is not kept in the API project. Uploads land in a temporary folder and are copied or streamed
to the configured storage adapter before the temp file is removed.
## Local Development
The sample env stores audio in the repository-level ignored `storage/audio` directory:
```env
STORAGE_DRIVER=local
STORAGE_BASE_PATH=../storage/audio
```
## SMB/NFS Mounted Storage
Mount your storage on the API machine and configure:
```env
STORAGE_DRIVER=smb
STORAGE_BASE_PATH=/mnt/orphion-audio
```
Use `STORAGE_DRIVER=nfs` for the same mounted-filesystem adapter.
## HTTP Storage Server
The HTTP adapter uses `PUT`, `GET`, and `DELETE` against a file server:
```env
STORAGE_DRIVER=http
STORAGE_HTTP_BASE_URL=http://192.168.X.X:9000/audio
STORAGE_HTTP_TOKEN=optional-bearer-token
```
## S3-Compatible Storage
```env
STORAGE_DRIVER=s3
S3_ENDPOINT=http://192.168.X.X:9000
S3_REGION=us-east-1
S3_BUCKET=orphion-audio
S3_ACCESS_KEY_ID=...
S3_SECRET_ACCESS_KEY=...
S3_FORCE_PATH_STYLE=true
```

32
docs/WHISPER_VM.md Normal file
View File

@ -0,0 +1,32 @@
# Faster-Whisper VM Setup
The sample env expects the Whisper service to run on the VM at `172.16.10.64:8000`.
The backend expects a Whisper-compatible HTTP service:
```env
WHISPER_VM_IP=172.16.10.64
WHISPER_VM_PORT=8000
WHISPER_API_URL=http://172.16.10.64:8000
WHISPER_TRANSCRIBE_PATH=/transcribe
WHISPER_HEALTH_PATH=/health
WHISPER_FILE_FIELD=file
WHISPER_ALLOW_MOCK=false
```
Expected endpoints:
- `GET /health` returns any 2xx status when the VM is ready.
- `POST /transcribe` accepts multipart audio and returns one of:
```json
{
"transcript_text": "Meeting transcript...",
"language": "en",
"duration": 123.45,
"timestamps": [{ "start": 0, "end": 5, "text": "Hello" }]
}
```
The API retries failed requests, applies `WHISPER_TIMEOUT_MS`, and marks jobs as failed when the VM
is unavailable.

14
ecosystem.config.cjs Normal file
View File

@ -0,0 +1,14 @@
module.exports = {
apps: [
{
name: "orphion-api",
cwd: "./backend",
script: "src/index.js",
instances: 1,
exec_mode: "fork",
env: {
NODE_ENV: "production",
},
},
],
};

49
eslint.config.js Normal file
View File

@ -0,0 +1,49 @@
import js from "@eslint/js";
import eslintPluginPrettier from "eslint-plugin-prettier/recommended";
import globals from "globals";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import tseslint from "typescript-eslint";
export default tseslint.config(
{
ignores: [
"node_modules",
"frontend/dist",
"backend/.tmp",
"storage",
"frontend/src/routeTree.gen.ts",
"package-lock.json",
],
},
{
extends: [js.configs.recommended],
files: ["backend/src/**/*.js", "scripts/**/*.js"],
languageOptions: {
ecmaVersion: 2022,
sourceType: "module",
globals: globals.node,
},
rules: {
"no-unused-vars": ["warn", { argsIgnorePattern: "^_" }],
},
},
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ["frontend/src/**/*.{ts,tsx}", "frontend/vite.config.ts"],
languageOptions: {
ecmaVersion: 2022,
globals: globals.browser,
},
plugins: {
"react-hooks": reactHooks,
"react-refresh": reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
"react-refresh/only-export-components": "off",
"@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_" }],
},
},
eslintPluginPrettier,
);

4
frontend/.env.example Normal file
View File

@ -0,0 +1,4 @@
VITE_API_BASE_URL=
VITE_API_PREFIX=/api/v1
VITE_API_PORT=4000
VITE_ORPHION_SERVICE_HOST=127.0.0.1

22
frontend/components.json Normal file
View File

@ -0,0 +1,22 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"css": "src/styles.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
}

19
frontend/index.html Normal file
View File

@ -0,0 +1,19 @@
<!doctype html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Record, transcribe, and share meeting voice notes." />
<title>Orphion — Meeting Intelligence Portal</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Space+Grotesk:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap"
/>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

69
frontend/package.json Normal file
View File

@ -0,0 +1,69 @@
{
"name": "@orphion/frontend",
"private": true,
"type": "module",
"sideEffects": false,
"scripts": {
"dev": "vite dev --host 0.0.0.0",
"build": "vite build",
"preview": "vite preview --host 0.0.0.0",
"lint": "eslint ."
},
"dependencies": {
"@hookform/resolvers": "^5.2.2",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-aspect-ratio": "^1.1.8",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-context-menu": "^2.2.16",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-menubar": "^1.1.16",
"@radix-ui/react-navigation-menu": "^1.2.14",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8",
"@tailwindcss/vite": "^4.2.1",
"@tanstack/react-query": "^5.83.0",
"@tanstack/react-router": "^1.168.25",
"@tanstack/router-plugin": "^1.167.28",
"@vitejs/plugin-react": "^5.0.4",
"axios": "^1.13.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"embla-carousel-react": "^8.6.0",
"framer-motion": "^12.38.0",
"input-otp": "^1.4.2",
"lucide-react": "^0.575.0",
"react": "^19.2.0",
"react-day-picker": "^9.14.0",
"react-dom": "^19.2.0",
"react-hook-form": "^7.71.2",
"react-resizable-panels": "^4.6.5",
"recharts": "^2.15.4",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.1",
"tw-animate-css": "^1.3.4",
"vaul": "^1.1.2",
"vite": "^7.3.1",
"vite-tsconfig-paths": "^6.0.2",
"zod": "^4.4.3"
}
}

View File

@ -0,0 +1,51 @@
import * as React from "react";
import * as AccordionPrimitive from "@radix-ui/react-accordion";
import { ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";
const Accordion = AccordionPrimitive.Root;
const AccordionItem = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
>(({ className, ...props }, ref) => (
<AccordionPrimitive.Item ref={ref} className={cn("border-b", className)} {...props} />
));
AccordionItem.displayName = "AccordionItem";
const AccordionTrigger = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
"flex flex-1 items-center justify-between py-4 text-sm font-medium cursor-pointer transition-all hover:underline text-left [&[data-state=open]>svg]:rotate-180",
className,
)}
{...props}
>
{children}
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
));
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;
const AccordionContent = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Content
ref={ref}
className="overflow-hidden text-sm data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
{...props}
>
<div className={cn("pb-4 pt-0", className)}>{children}</div>
</AccordionPrimitive.Content>
));
AccordionContent.displayName = AccordionPrimitive.Content.displayName;
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };

View File

@ -0,0 +1,115 @@
import * as React from "react";
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
import { cn } from "@/lib/utils";
import { buttonVariants } from "@/components/ui/button";
const AlertDialog = AlertDialogPrimitive.Root;
const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
const AlertDialogPortal = AlertDialogPrimitive.Portal;
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className,
)}
{...props}
ref={ref}
/>
));
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 sm:rounded-lg",
className,
)}
{...props}
/>
</AlertDialogPortal>
));
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col space-y-2 text-center sm:text-left", className)} {...props} />
);
AlertDialogHeader.displayName = "AlertDialogHeader";
const AlertDialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
{...props}
/>
);
AlertDialogFooter.displayName = "AlertDialogFooter";
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold", className)}
{...props}
/>
));
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName;
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action ref={ref} className={cn(buttonVariants(), className)} {...props} />
));
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(buttonVariants({ variant: "outline" }), "mt-2 sm:mt-0", className)}
{...props}
/>
));
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
};

View File

@ -0,0 +1,49 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
},
);
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div ref={ref} role="alert" className={cn(alertVariants({ variant }), className)} {...props} />
));
Alert.displayName = "Alert";
const AlertTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props}
/>
),
);
AlertTitle.displayName = "AlertTitle";
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("text-sm [&_p]:leading-relaxed", className)} {...props} />
));
AlertDescription.displayName = "AlertDescription";
export { Alert, AlertTitle, AlertDescription };

View File

@ -0,0 +1,5 @@
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio";
const AspectRatio = AspectRatioPrimitive.Root;
export { AspectRatio };

View File

@ -0,0 +1,47 @@
"use client";
import * as React from "react";
import * as AvatarPrimitive from "@radix-ui/react-avatar";
import { cn } from "@/lib/utils";
const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn("relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full", className)}
{...props}
/>
));
Avatar.displayName = AvatarPrimitive.Root.displayName;
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
className={cn("aspect-square h-full w-full", className)}
{...props}
/>
));
AvatarImage.displayName = AvatarPrimitive.Image.displayName;
const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
"flex h-full w-full items-center justify-center rounded-full bg-muted",
className,
)}
{...props}
/>
));
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
export { Avatar, AvatarImage, AvatarFallback };

View File

@ -0,0 +1,32 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default: "border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
},
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
}
export { Badge, badgeVariants };

View File

@ -0,0 +1,101 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { ChevronRight, MoreHorizontal } from "lucide-react";
import { cn } from "@/lib/utils";
const Breadcrumb = React.forwardRef<
HTMLElement,
React.ComponentPropsWithoutRef<"nav"> & {
separator?: React.ReactNode;
}
>(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />);
Breadcrumb.displayName = "Breadcrumb";
const BreadcrumbList = React.forwardRef<HTMLOListElement, React.ComponentPropsWithoutRef<"ol">>(
({ className, ...props }, ref) => (
<ol
ref={ref}
className={cn(
"flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5",
className,
)}
{...props}
/>
),
);
BreadcrumbList.displayName = "BreadcrumbList";
const BreadcrumbItem = React.forwardRef<HTMLLIElement, React.ComponentPropsWithoutRef<"li">>(
({ className, ...props }, ref) => (
<li ref={ref} className={cn("inline-flex items-center gap-1.5", className)} {...props} />
),
);
BreadcrumbItem.displayName = "BreadcrumbItem";
const BreadcrumbLink = React.forwardRef<
HTMLAnchorElement,
React.ComponentPropsWithoutRef<"a"> & {
asChild?: boolean;
}
>(({ asChild, className, ...props }, ref) => {
const Comp = asChild ? Slot : "a";
return (
<Comp
ref={ref}
className={cn("transition-colors hover:text-foreground", className)}
{...props}
/>
);
});
BreadcrumbLink.displayName = "BreadcrumbLink";
const BreadcrumbPage = React.forwardRef<HTMLSpanElement, React.ComponentPropsWithoutRef<"span">>(
({ className, ...props }, ref) => (
<span
ref={ref}
role="link"
aria-disabled="true"
aria-current="page"
className={cn("font-normal text-foreground", className)}
{...props}
/>
),
);
BreadcrumbPage.displayName = "BreadcrumbPage";
const BreadcrumbSeparator = ({ children, className, ...props }: React.ComponentProps<"li">) => (
<li
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:w-3.5 [&>svg]:h-3.5", className)}
{...props}
>
{children ?? <ChevronRight />}
</li>
);
BreadcrumbSeparator.displayName = "BreadcrumbSeparator";
const BreadcrumbEllipsis = ({ className, ...props }: React.ComponentProps<"span">) => (
<span
role="presentation"
aria-hidden="true"
className={cn("flex h-9 w-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More</span>
</span>
);
BreadcrumbEllipsis.displayName = "BreadcrumbElipssis";
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
};

View File

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

View File

@ -0,0 +1,177 @@
"use client";
import * as React from "react";
import { ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker";
import { cn } from "@/lib/utils";
import { Button, buttonVariants } from "@/components/ui/button";
function Calendar({
className,
classNames,
showOutsideDays = true,
captionLayout = "label",
buttonVariant = "ghost",
formatters,
components,
...props
}: React.ComponentProps<typeof DayPicker> & {
buttonVariant?: React.ComponentProps<typeof Button>["variant"];
}) {
const defaultClassNames = getDefaultClassNames();
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn(
"bg-background group/calendar p-3 [--cell-size:2rem] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
className,
)}
captionLayout={captionLayout}
formatters={{
formatMonthDropdown: (date) => date.toLocaleString("default", { month: "short" }),
...formatters,
}}
classNames={{
root: cn("w-fit", defaultClassNames.root),
months: cn("relative flex flex-col gap-4 md:flex-row", defaultClassNames.months),
month: cn("flex w-full flex-col gap-4", defaultClassNames.month),
nav: cn(
"absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1",
defaultClassNames.nav,
),
button_previous: cn(
buttonVariants({ variant: buttonVariant }),
"h-(--cell-size) w-(--cell-size) select-none p-0 aria-disabled:opacity-50",
defaultClassNames.button_previous,
),
button_next: cn(
buttonVariants({ variant: buttonVariant }),
"h-(--cell-size) w-(--cell-size) select-none p-0 aria-disabled:opacity-50",
defaultClassNames.button_next,
),
month_caption: cn(
"flex h-(--cell-size) w-full items-center justify-center px-(--cell-size)",
defaultClassNames.month_caption,
),
dropdowns: cn(
"flex h-(--cell-size) w-full items-center justify-center gap-1.5 text-sm font-medium",
defaultClassNames.dropdowns,
),
dropdown_root: cn(
"has-focus:border-ring border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] relative rounded-md border",
defaultClassNames.dropdown_root,
),
dropdown: cn("bg-popover absolute inset-0 opacity-0", defaultClassNames.dropdown),
caption_label: cn(
"select-none font-medium",
captionLayout === "label"
? "text-sm"
: "[&>svg]:text-muted-foreground flex h-8 items-center gap-1 rounded-md pl-2 pr-1 text-sm [&>svg]:size-3.5",
defaultClassNames.caption_label,
),
table: "w-full border-collapse",
weekdays: cn("flex", defaultClassNames.weekdays),
weekday: cn(
"text-muted-foreground flex-1 select-none rounded-md text-[0.8rem] font-normal",
defaultClassNames.weekday,
),
week: cn("mt-2 flex w-full", defaultClassNames.week),
week_number_header: cn("w-(--cell-size) select-none", defaultClassNames.week_number_header),
week_number: cn(
"text-muted-foreground select-none text-[0.8rem]",
defaultClassNames.week_number,
),
day: cn(
"group/day relative aspect-square h-full w-full select-none p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md",
defaultClassNames.day,
),
range_start: cn("bg-accent rounded-l-md", defaultClassNames.range_start),
range_middle: cn("rounded-none", defaultClassNames.range_middle),
range_end: cn("bg-accent rounded-r-md", defaultClassNames.range_end),
today: cn(
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
defaultClassNames.today,
),
outside: cn(
"text-muted-foreground aria-selected:text-muted-foreground",
defaultClassNames.outside,
),
disabled: cn("text-muted-foreground opacity-50", defaultClassNames.disabled),
hidden: cn("invisible", defaultClassNames.hidden),
...classNames,
}}
components={{
Root: ({ className, rootRef, ...props }) => {
return <div data-slot="calendar" ref={rootRef} className={cn(className)} {...props} />;
},
Chevron: ({ className, orientation, ...props }) => {
if (orientation === "left") {
return <ChevronLeftIcon className={cn("size-4", className)} {...props} />;
}
if (orientation === "right") {
return <ChevronRightIcon className={cn("size-4", className)} {...props} />;
}
return <ChevronDownIcon className={cn("size-4", className)} {...props} />;
},
DayButton: CalendarDayButton,
WeekNumber: ({ children, ...props }) => {
return (
<td {...props}>
<div className="flex size-(--cell-size) items-center justify-center text-center">
{children}
</div>
</td>
);
},
...components,
}}
{...props}
/>
);
}
function CalendarDayButton({
className,
day,
modifiers,
...props
}: React.ComponentProps<typeof DayButton>) {
const defaultClassNames = getDefaultClassNames();
const ref = React.useRef<HTMLButtonElement>(null);
React.useEffect(() => {
if (modifiers.focused) ref.current?.focus();
}, [modifiers.focused]);
return (
<Button
ref={ref}
variant="ghost"
size="icon"
data-day={day.date.toLocaleDateString()}
data-selected-single={
modifiers.selected &&
!modifiers.range_start &&
!modifiers.range_end &&
!modifiers.range_middle
}
data-range-start={modifiers.range_start}
data-range-end={modifiers.range_end}
data-range-middle={modifiers.range_middle}
className={cn(
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 flex aspect-square h-auto w-full min-w-(--cell-size) flex-col gap-1 font-normal leading-none data-[range-end=true]:rounded-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] [&>span]:text-xs [&>span]:opacity-70",
defaultClassNames.day,
className,
)}
{...props}
/>
);
}
export { Calendar, CalendarDayButton };

View File

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

View File

@ -0,0 +1,240 @@
import * as React from "react";
import useEmblaCarousel, { type UseEmblaCarouselType } from "embla-carousel-react";
import { ArrowLeft, ArrowRight } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
type CarouselApi = UseEmblaCarouselType[1];
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
type CarouselOptions = UseCarouselParameters[0];
type CarouselPlugin = UseCarouselParameters[1];
type CarouselProps = {
opts?: CarouselOptions;
plugins?: CarouselPlugin;
orientation?: "horizontal" | "vertical";
setApi?: (api: CarouselApi) => void;
};
type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0];
api: ReturnType<typeof useEmblaCarousel>[1];
scrollPrev: () => void;
scrollNext: () => void;
canScrollPrev: boolean;
canScrollNext: boolean;
} & CarouselProps;
const CarouselContext = React.createContext<CarouselContextProps | null>(null);
function useCarousel() {
const context = React.useContext(CarouselContext);
if (!context) {
throw new Error("useCarousel must be used within a <Carousel />");
}
return context;
}
const Carousel = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & CarouselProps
>(({ orientation = "horizontal", opts, setApi, plugins, className, children, ...props }, ref) => {
const [carouselRef, api] = useEmblaCarousel(
{
...opts,
axis: orientation === "horizontal" ? "x" : "y",
},
plugins,
);
const [canScrollPrev, setCanScrollPrev] = React.useState(false);
const [canScrollNext, setCanScrollNext] = React.useState(false);
const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) {
return;
}
setCanScrollPrev(api.canScrollPrev());
setCanScrollNext(api.canScrollNext());
}, []);
const scrollPrev = React.useCallback(() => {
api?.scrollPrev();
}, [api]);
const scrollNext = React.useCallback(() => {
api?.scrollNext();
}, [api]);
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "ArrowLeft") {
event.preventDefault();
scrollPrev();
} else if (event.key === "ArrowRight") {
event.preventDefault();
scrollNext();
}
},
[scrollPrev, scrollNext],
);
React.useEffect(() => {
if (!api || !setApi) {
return;
}
setApi(api);
}, [api, setApi]);
React.useEffect(() => {
if (!api) {
return;
}
onSelect(api);
api.on("reInit", onSelect);
api.on("select", onSelect);
return () => {
api?.off("select", onSelect);
};
}, [api, onSelect]);
return (
<CarouselContext.Provider
value={{
carouselRef,
api: api,
opts,
orientation: orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}}
>
<div
ref={ref}
onKeyDownCapture={handleKeyDown}
className={cn("relative", className)}
role="region"
aria-roledescription="carousel"
{...props}
>
{children}
</div>
</CarouselContext.Provider>
);
});
Carousel.displayName = "Carousel";
const CarouselContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => {
const { carouselRef, orientation } = useCarousel();
return (
<div ref={carouselRef} className="overflow-hidden">
<div
ref={ref}
className={cn(
"flex",
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
className,
)}
{...props}
/>
</div>
);
},
);
CarouselContent.displayName = "CarouselContent";
const CarouselItem = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => {
const { orientation } = useCarousel();
return (
<div
ref={ref}
role="group"
aria-roledescription="slide"
className={cn(
"min-w-0 shrink-0 grow-0 basis-full",
orientation === "horizontal" ? "pl-4" : "pt-4",
className,
)}
{...props}
/>
);
},
);
CarouselItem.displayName = "CarouselItem";
const CarouselPrevious = React.forwardRef<HTMLButtonElement, React.ComponentProps<typeof Button>>(
({ className, variant = "outline", size = "icon", ...props }, ref) => {
const { orientation, scrollPrev, canScrollPrev } = useCarousel();
return (
<Button
ref={ref}
variant={variant}
size={size}
className={cn(
"absolute h-8 w-8 rounded-full",
orientation === "horizontal"
? "-left-12 top-1/2 -translate-y-1/2"
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
className,
)}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}
>
<ArrowLeft className="h-4 w-4" />
<span className="sr-only">Previous slide</span>
</Button>
);
},
);
CarouselPrevious.displayName = "CarouselPrevious";
const CarouselNext = React.forwardRef<HTMLButtonElement, React.ComponentProps<typeof Button>>(
({ className, variant = "outline", size = "icon", ...props }, ref) => {
const { orientation, scrollNext, canScrollNext } = useCarousel();
return (
<Button
ref={ref}
variant={variant}
size={size}
className={cn(
"absolute h-8 w-8 rounded-full",
orientation === "horizontal"
? "-right-12 top-1/2 -translate-y-1/2"
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
className,
)}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}
>
<ArrowRight className="h-4 w-4" />
<span className="sr-only">Next slide</span>
</Button>
);
},
);
CarouselNext.displayName = "CarouselNext";
export {
type CarouselApi,
Carousel,
CarouselContent,
CarouselItem,
CarouselPrevious,
CarouselNext,
};

View File

@ -0,0 +1,331 @@
import * as React from "react";
import * as RechartsPrimitive from "recharts";
import { cn } from "@/lib/utils";
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const;
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode;
icon?: React.ComponentType;
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
);
};
type ChartContextProps = {
config: ChartConfig;
};
const ChartContext = React.createContext<ChartContextProps | null>(null);
function useChart() {
const context = React.useContext(ChartContext);
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />");
}
return context;
}
const ChartContainer = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
config: ChartConfig;
children: React.ComponentProps<typeof RechartsPrimitive.ResponsiveContainer>["children"];
}
>(({ id, className, children, config, ...props }, ref) => {
const uniqueId = React.useId();
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`;
return (
<ChartContext.Provider value={{ config }}>
<div
data-chart={chartId}
ref={ref}
className={cn(
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
className,
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>{children}</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
);
});
ChartContainer.displayName = "Chart";
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(([, config]) => config.theme || config.color);
if (!colorConfig.length) {
return null;
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color = itemConfig.theme?.[theme as keyof typeof itemConfig.theme] || itemConfig.color;
return color ? ` --color-${key}: ${color};` : null;
})
.join("\n")}
}
`,
)
.join("\n"),
}}
/>
);
};
const ChartTooltip = RechartsPrimitive.Tooltip;
const ChartTooltipContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean;
hideIndicator?: boolean;
indicator?: "line" | "dot" | "dashed";
nameKey?: string;
labelKey?: string;
}
>(
(
{
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
},
ref,
) => {
const { config } = useChart();
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null;
}
const [item] = payload;
const key = `${labelKey || item?.dataKey || item?.name || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const value =
!labelKey && typeof label === "string"
? config[label as keyof typeof config]?.label || label
: itemConfig?.label;
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>{labelFormatter(value, payload)}</div>
);
}
if (!value) {
return null;
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>;
}, [label, labelFormatter, payload, hideLabel, labelClassName, config, labelKey]);
if (!active || !payload?.length) {
return null;
}
const nestLabel = payload.length === 1 && indicator !== "dot";
return (
<div
ref={ref}
className={cn(
"grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
className,
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload
.filter((item) => item.type !== "none")
.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const indicatorColor = color || item.payload.fill || item.color;
return (
<div
key={item.dataKey}
className={cn(
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
indicator === "dot" && "items-center",
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
{
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
},
)}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center",
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label || item.name}
</span>
</div>
{item.value && (
<span className="font-mono font-medium tabular-nums text-foreground">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
);
})}
</div>
</div>
);
},
);
ChartTooltipContent.displayName = "ChartTooltip";
const ChartLegend = RechartsPrimitive.Legend;
const ChartLegendContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean;
nameKey?: string;
}
>(({ className, hideIcon = false, payload, verticalAlign = "bottom", nameKey }, ref) => {
const { config } = useChart();
if (!payload?.length) {
return null;
}
return (
<div
ref={ref}
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className,
)}
>
{payload
.filter((item) => item.type !== "none")
.map((item) => {
const key = `${nameKey || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
return (
<div
key={item.value}
className={cn(
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground",
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
);
})}
</div>
);
});
ChartLegendContent.displayName = "ChartLegend";
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(config: ChartConfig, payload: unknown, key: string) {
if (typeof payload !== "object" || payload === null) {
return undefined;
}
const payloadPayload =
"payload" in payload && typeof payload.payload === "object" && payload.payload !== null
? payload.payload
: undefined;
let configLabelKey: string = key;
if (key in payload && typeof payload[key as keyof typeof payload] === "string") {
configLabelKey = payload[key as keyof typeof payload] as string;
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[key as keyof typeof payloadPayload] as string;
}
return configLabelKey in config ? config[configLabelKey] : config[key as keyof typeof config];
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
};

View File

@ -0,0 +1,26 @@
import * as React from "react";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { Check } from "lucide-react";
import { cn } from "@/lib/utils";
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"grid place-content-center peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow cursor-pointer focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className,
)}
{...props}
>
<CheckboxPrimitive.Indicator className={cn("grid place-content-center text-current")}>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
));
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
export { Checkbox };

View File

@ -0,0 +1,11 @@
"use client";
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
const Collapsible = CollapsiblePrimitive.Root;
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger;
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent;
export { Collapsible, CollapsibleTrigger, CollapsibleContent };

View File

@ -0,0 +1,143 @@
"use client";
import * as React from "react";
import { type DialogProps } from "@radix-ui/react-dialog";
import { Command as CommandPrimitive } from "cmdk";
import { Search } from "lucide-react";
import { cn } from "@/lib/utils";
import { Dialog, DialogContent } from "@/components/ui/dialog";
const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
className,
)}
{...props}
/>
));
Command.displayName = CommandPrimitive.displayName;
const CommandDialog = ({ children, ...props }: DialogProps) => {
return (
<Dialog {...props}>
<DialogContent className="overflow-hidden p-0">
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
);
};
const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
ref={ref}
className={cn(
"flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
/>
</div>
));
CommandInput.displayName = CommandPrimitive.Input.displayName;
const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
{...props}
/>
));
CommandList.displayName = CommandPrimitive.List.displayName;
const CommandEmpty = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => (
<CommandPrimitive.Empty ref={ref} className="py-6 text-center text-sm" {...props} />
));
CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
className,
)}
{...props}
/>
));
CommandGroup.displayName = CommandPrimitive.Group.displayName;
const CommandSeparator = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator
ref={ref}
className={cn("-mx-1 h-px bg-border", className)}
{...props}
/>
));
CommandSeparator.displayName = CommandPrimitive.Separator.displayName;
const CommandItem = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
className,
)}
{...props}
/>
));
CommandItem.displayName = CommandPrimitive.Item.displayName;
const CommandShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest text-muted-foreground", className)}
{...props}
/>
);
};
CommandShortcut.displayName = "CommandShortcut";
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
};

View File

@ -0,0 +1,187 @@
import * as React from "react";
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu";
import { Check, ChevronRight, Circle } from "lucide-react";
import { cn } from "@/lib/utils";
const ContextMenu = ContextMenuPrimitive.Root;
const ContextMenuTrigger = ContextMenuPrimitive.Trigger;
const ContextMenuGroup = ContextMenuPrimitive.Group;
const ContextMenuPortal = ContextMenuPrimitive.Portal;
const ContextMenuSub = ContextMenuPrimitive.Sub;
const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup;
const ContextMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<ContextMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
inset && "pl-8",
className,
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</ContextMenuPrimitive.SubTrigger>
));
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName;
const ContextMenuSubContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 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 origin-(--radix-context-menu-content-transform-origin)",
className,
)}
{...props}
/>
));
ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName;
const ContextMenuContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
ref={ref}
className={cn(
"z-50 max-h-(--radix-context-menu-content-available-height) min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 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 origin-(--radix-context-menu-content-transform-origin)",
className,
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
));
ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName;
const ContextMenuItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className,
)}
{...props}
/>
));
ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName;
const ContextMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<ContextMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
));
ContextMenuCheckboxItem.displayName = ContextMenuPrimitive.CheckboxItem.displayName;
const ContextMenuRadioItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<ContextMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<Circle className="h-4 w-4 fill-current" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
));
ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName;
const ContextMenuLabel = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-sm font-semibold text-foreground", inset && "pl-8", className)}
{...props}
/>
));
ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName;
const ContextMenuSeparator = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
));
ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName;
const ContextMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest text-muted-foreground", className)}
{...props}
/>
);
};
ContextMenuShortcut.displayName = "ContextMenuShortcut";
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
};

View File

@ -0,0 +1,104 @@
"use client";
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className,
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 sm:rounded-lg",
className,
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background cursor-pointer transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)} {...props} />
);
DialogHeader.displayName = "DialogHeader";
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
{...props}
/>
);
DialogFooter.displayName = "DialogFooter";
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogTrigger,
DialogClose,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
};

View File

@ -0,0 +1,98 @@
import * as React from "react";
import { Drawer as DrawerPrimitive } from "vaul";
import { cn } from "@/lib/utils";
const Drawer = ({
shouldScaleBackground = true,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) => (
<DrawerPrimitive.Root shouldScaleBackground={shouldScaleBackground} {...props} />
);
Drawer.displayName = "Drawer";
const DrawerTrigger = DrawerPrimitive.Trigger;
const DrawerPortal = DrawerPrimitive.Portal;
const DrawerClose = DrawerPrimitive.Close;
const DrawerOverlay = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Overlay
ref={ref}
className={cn("fixed inset-0 z-50 bg-black/80", className)}
{...props}
/>
));
DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName;
const DrawerContent = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DrawerPortal>
<DrawerOverlay />
<DrawerPrimitive.Content
ref={ref}
className={cn(
"fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background",
className,
)}
{...props}
>
<div className="mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted" />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
));
DrawerContent.displayName = "DrawerContent";
const DrawerHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("grid gap-1.5 p-4 text-center sm:text-left", className)} {...props} />
);
DrawerHeader.displayName = "DrawerHeader";
const DrawerFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("mt-auto flex flex-col gap-2 p-4", className)} {...props} />
);
DrawerFooter.displayName = "DrawerFooter";
const DrawerTitle = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Title>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
{...props}
/>
));
DrawerTitle.displayName = DrawerPrimitive.Title.displayName;
const DrawerDescription = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Description>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
DrawerDescription.displayName = DrawerPrimitive.Description.displayName;
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
};

View File

@ -0,0 +1,188 @@
"use client";
import * as React from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { Check, ChevronRight, Circle } from "lucide-react";
import { cn } from "@/lib/utils";
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
inset && "pl-8",
className,
)}
{...props}
>
{children}
<ChevronRight className="ml-auto" />
</DropdownMenuPrimitive.SubTrigger>
));
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 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 origin-(--radix-dropdown-menu-content-transform-origin)",
className,
)}
{...props}
/>
));
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName;
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 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 origin-(--radix-dropdown-menu-content-transform-origin)",
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
inset && "pl-8",
className,
)}
{...props}
/>
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
));
DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName;
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
));
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-sm font-semibold", inset && "pl-8", className)}
{...props}
/>
));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span className={cn("ml-auto text-xs tracking-widest opacity-60", className)} {...props} />
);
};
DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
};

View File

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

View File

@ -0,0 +1,27 @@
import * as React from "react";
import * as HoverCardPrimitive from "@radix-ui/react-hover-card";
import { cn } from "@/lib/utils";
const HoverCard = HoverCardPrimitive.Root;
const HoverCardTrigger = HoverCardPrimitive.Trigger;
const HoverCardContent = React.forwardRef<
React.ElementRef<typeof HoverCardPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<HoverCardPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 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 origin-(--radix-hover-card-content-transform-origin)",
className,
)}
{...props}
/>
));
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName;
export { HoverCard, HoverCardTrigger, HoverCardContent };

View File

@ -0,0 +1,69 @@
import * as React from "react";
import { OTPInput, OTPInputContext } from "input-otp";
import { Minus } from "lucide-react";
import { cn } from "@/lib/utils";
const InputOTP = React.forwardRef<
React.ElementRef<typeof OTPInput>,
React.ComponentPropsWithoutRef<typeof OTPInput>
>(({ className, containerClassName, ...props }, ref) => (
<OTPInput
ref={ref}
containerClassName={cn(
"flex items-center gap-2 has-[:disabled]:opacity-50",
containerClassName,
)}
className={cn("disabled:cursor-not-allowed", className)}
{...props}
/>
));
InputOTP.displayName = "InputOTP";
const InputOTPGroup = React.forwardRef<
React.ElementRef<"div">,
React.ComponentPropsWithoutRef<"div">
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex items-center", className)} {...props} />
));
InputOTPGroup.displayName = "InputOTPGroup";
const InputOTPSlot = React.forwardRef<
React.ElementRef<"div">,
React.ComponentPropsWithoutRef<"div"> & { index: number }
>(({ index, className, ...props }, ref) => {
const inputOTPContext = React.useContext(OTPInputContext);
const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index];
return (
<div
ref={ref}
className={cn(
"relative flex h-9 w-9 items-center justify-center border-y border-r border-input text-sm shadow-sm transition-all first:rounded-l-md first:border-l last:rounded-r-md",
isActive && "z-10 ring-1 ring-ring",
className,
)}
{...props}
>
{char}
{hasFakeCaret && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="h-4 w-px animate-caret-blink bg-foreground duration-1000" />
</div>
)}
</div>
);
});
InputOTPSlot.displayName = "InputOTPSlot";
const InputOTPSeparator = React.forwardRef<
React.ElementRef<"div">,
React.ComponentPropsWithoutRef<"div">
>(({ ...props }, ref) => (
<div ref={ref} role="separator" {...props}>
<Minus />
</div>
));
InputOTPSeparator.displayName = "InputOTPSeparator";
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };

View File

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

View File

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

View File

@ -0,0 +1,229 @@
import * as React from "react";
import * as MenubarPrimitive from "@radix-ui/react-menubar";
import { Check, ChevronRight, Circle } from "lucide-react";
import { cn } from "@/lib/utils";
function MenubarMenu({ ...props }: React.ComponentProps<typeof MenubarPrimitive.Menu>) {
return <MenubarPrimitive.Menu {...props} />;
}
function MenubarGroup({ ...props }: React.ComponentProps<typeof MenubarPrimitive.Group>) {
return <MenubarPrimitive.Group {...props} />;
}
function MenubarPortal({ ...props }: React.ComponentProps<typeof MenubarPrimitive.Portal>) {
return <MenubarPrimitive.Portal {...props} />;
}
function MenubarRadioGroup({ ...props }: React.ComponentProps<typeof MenubarPrimitive.RadioGroup>) {
return <MenubarPrimitive.RadioGroup {...props} />;
}
function MenubarSub({ ...props }: React.ComponentProps<typeof MenubarPrimitive.Sub>) {
return <MenubarPrimitive.Sub data-slot="menubar-sub" {...props} />;
}
const Menubar = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Root>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.Root
ref={ref}
className={cn(
"flex h-9 items-center space-x-1 rounded-md border bg-background p-1 shadow-sm",
className,
)}
{...props}
/>
));
Menubar.displayName = MenubarPrimitive.Root.displayName;
const MenubarTrigger = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.Trigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-3 py-1 text-sm font-medium outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
className,
)}
{...props}
/>
));
MenubarTrigger.displayName = MenubarPrimitive.Trigger.displayName;
const MenubarSubTrigger = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<MenubarPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
inset && "pl-8",
className,
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</MenubarPrimitive.SubTrigger>
));
MenubarSubTrigger.displayName = MenubarPrimitive.SubTrigger.displayName;
const MenubarSubContent = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 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 origin-(--radix-menubar-content-transform-origin)",
className,
)}
{...props}
/>
));
MenubarSubContent.displayName = MenubarPrimitive.SubContent.displayName;
const MenubarContent = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Content>
>(({ className, align = "start", alignOffset = -4, sideOffset = 8, ...props }, ref) => (
<MenubarPrimitive.Portal>
<MenubarPrimitive.Content
ref={ref}
align={align}
alignOffset={alignOffset}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[12rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 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 origin-(--radix-menubar-content-transform-origin)",
className,
)}
{...props}
/>
</MenubarPrimitive.Portal>
));
MenubarContent.displayName = MenubarPrimitive.Content.displayName;
const MenubarItem = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Item> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<MenubarPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className,
)}
{...props}
/>
));
MenubarItem.displayName = MenubarPrimitive.Item.displayName;
const MenubarCheckboxItem = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<MenubarPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<MenubarPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.CheckboxItem>
));
MenubarCheckboxItem.displayName = MenubarPrimitive.CheckboxItem.displayName;
const MenubarRadioItem = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<MenubarPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<MenubarPrimitive.ItemIndicator>
<Circle className="h-4 w-4 fill-current" />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.RadioItem>
));
MenubarRadioItem.displayName = MenubarPrimitive.RadioItem.displayName;
const MenubarLabel = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<MenubarPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-sm font-semibold", inset && "pl-8", className)}
{...props}
/>
));
MenubarLabel.displayName = MenubarPrimitive.Label.displayName;
const MenubarSeparator = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Separator>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
));
MenubarSeparator.displayName = MenubarPrimitive.Separator.displayName;
const MenubarShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest text-muted-foreground", className)}
{...props}
/>
);
};
MenubarShortcut.displayname = "MenubarShortcut";
export {
Menubar,
MenubarMenu,
MenubarTrigger,
MenubarContent,
MenubarItem,
MenubarSeparator,
MenubarLabel,
MenubarCheckboxItem,
MenubarRadioGroup,
MenubarRadioItem,
MenubarPortal,
MenubarSubContent,
MenubarSubTrigger,
MenubarGroup,
MenubarSub,
MenubarShortcut,
};

View File

@ -0,0 +1,120 @@
import * as React from "react";
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu";
import { cva } from "class-variance-authority";
import { ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";
const NavigationMenu = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<NavigationMenuPrimitive.Root
ref={ref}
className={cn("relative z-10 flex max-w-max flex-1 items-center justify-center", className)}
{...props}
>
{children}
<NavigationMenuViewport />
</NavigationMenuPrimitive.Root>
));
NavigationMenu.displayName = NavigationMenuPrimitive.Root.displayName;
const NavigationMenuList = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.List>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.List>
>(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.List
ref={ref}
className={cn("group flex flex-1 list-none items-center justify-center space-x-1", className)}
{...props}
/>
));
NavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName;
const NavigationMenuItem = NavigationMenuPrimitive.Item;
const navigationMenuTriggerStyle = cva(
"group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium cursor-pointer transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 disabled:cursor-not-allowed data-[state=open]:text-accent-foreground data-[state=open]:bg-accent/50 data-[state=open]:hover:bg-accent data-[state=open]:focus:bg-accent",
);
const NavigationMenuTrigger = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<NavigationMenuPrimitive.Trigger
ref={ref}
className={cn(navigationMenuTriggerStyle(), "group", className)}
{...props}
>
{children}{" "}
<ChevronDown
className="relative top-[1px] ml-1 h-3 w-3 transition duration-300 group-data-[state=open]:rotate-180"
aria-hidden="true"
/>
</NavigationMenuPrimitive.Trigger>
));
NavigationMenuTrigger.displayName = NavigationMenuPrimitive.Trigger.displayName;
const NavigationMenuContent = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Content>
>(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.Content
ref={ref}
className={cn(
"left-0 top-0 w-full data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 md:absolute md:w-auto ",
className,
)}
{...props}
/>
));
NavigationMenuContent.displayName = NavigationMenuPrimitive.Content.displayName;
const NavigationMenuLink = NavigationMenuPrimitive.Link;
const NavigationMenuViewport = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Viewport>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Viewport>
>(({ className, ...props }, ref) => (
<div className={cn("absolute left-0 top-full flex justify-center")}>
<NavigationMenuPrimitive.Viewport
className={cn(
"origin-top-center relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border bg-popover text-popover-foreground shadow data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 md:w-[var(--radix-navigation-menu-viewport-width)]",
className,
)}
ref={ref}
{...props}
/>
</div>
));
NavigationMenuViewport.displayName = NavigationMenuPrimitive.Viewport.displayName;
const NavigationMenuIndicator = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Indicator>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Indicator>
>(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.Indicator
ref={ref}
className={cn(
"top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in",
className,
)}
{...props}
>
<div className="relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm bg-border shadow-md" />
</NavigationMenuPrimitive.Indicator>
));
NavigationMenuIndicator.displayName = NavigationMenuPrimitive.Indicator.displayName;
export {
navigationMenuTriggerStyle,
NavigationMenu,
NavigationMenuList,
NavigationMenuItem,
NavigationMenuContent,
NavigationMenuTrigger,
NavigationMenuLink,
NavigationMenuIndicator,
NavigationMenuViewport,
};

View File

@ -0,0 +1,98 @@
import * as React from "react";
import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react";
import { cn } from "@/lib/utils";
import { ButtonProps, buttonVariants } from "@/components/ui/button";
const Pagination = ({ className, ...props }: React.ComponentProps<"nav">) => (
<nav
role="navigation"
aria-label="pagination"
className={cn("mx-auto flex w-full justify-center", className)}
{...props}
/>
);
Pagination.displayName = "Pagination";
const PaginationContent = React.forwardRef<HTMLUListElement, React.ComponentProps<"ul">>(
({ className, ...props }, ref) => (
<ul ref={ref} className={cn("flex flex-row items-center gap-1", className)} {...props} />
),
);
PaginationContent.displayName = "PaginationContent";
const PaginationItem = React.forwardRef<HTMLLIElement, React.ComponentProps<"li">>(
({ className, ...props }, ref) => <li ref={ref} className={cn("", className)} {...props} />,
);
PaginationItem.displayName = "PaginationItem";
type PaginationLinkProps = {
isActive?: boolean;
} & Pick<ButtonProps, "size"> &
React.ComponentProps<"a">;
const PaginationLink = ({ className, isActive, size = "icon", ...props }: PaginationLinkProps) => (
<a
aria-current={isActive ? "page" : undefined}
className={cn(
buttonVariants({
variant: isActive ? "outline" : "ghost",
size,
}),
className,
)}
{...props}
/>
);
PaginationLink.displayName = "PaginationLink";
const PaginationPrevious = ({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink
aria-label="Go to previous page"
size="default"
className={cn("gap-1 pl-2.5", className)}
{...props}
>
<ChevronLeft className="h-4 w-4" />
<span>Previous</span>
</PaginationLink>
);
PaginationPrevious.displayName = "PaginationPrevious";
const PaginationNext = ({ className, ...props }: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink
aria-label="Go to next page"
size="default"
className={cn("gap-1 pr-2.5", className)}
{...props}
>
<span>Next</span>
<ChevronRight className="h-4 w-4" />
</PaginationLink>
);
PaginationNext.displayName = "PaginationNext";
const PaginationEllipsis = ({ className, ...props }: React.ComponentProps<"span">) => (
<span
aria-hidden
className={cn("flex h-9 w-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More pages</span>
</span>
);
PaginationEllipsis.displayName = "PaginationEllipsis";
export {
Pagination,
PaginationContent,
PaginationLink,
PaginationItem,
PaginationPrevious,
PaginationNext,
PaginationEllipsis,
};

View File

@ -0,0 +1,31 @@
import * as React from "react";
import * as PopoverPrimitive from "@radix-ui/react-popover";
import { cn } from "@/lib/utils";
const Popover = PopoverPrimitive.Root;
const PopoverTrigger = PopoverPrimitive.Trigger;
const PopoverAnchor = PopoverPrimitive.Anchor;
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 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 origin-(--radix-popover-content-transform-origin)",
className,
)}
{...props}
/>
</PopoverPrimitive.Portal>
));
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };

View File

@ -0,0 +1,25 @@
"use client";
import * as React from "react";
import * as ProgressPrimitive from "@radix-ui/react-progress";
import { cn } from "@/lib/utils";
const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
>(({ className, value, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn("relative h-2 w-full overflow-hidden rounded-full bg-primary/20", className)}
{...props}
>
<ProgressPrimitive.Indicator
className="h-full w-full flex-1 bg-primary transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
));
Progress.displayName = ProgressPrimitive.Root.displayName;
export { Progress };

View File

@ -0,0 +1,36 @@
import * as React from "react";
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group";
import { Circle } from "lucide-react";
import { cn } from "@/lib/utils";
const RadioGroup = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
>(({ className, ...props }, ref) => {
return <RadioGroupPrimitive.Root className={cn("grid gap-2", className)} {...props} ref={ref} />;
});
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName;
const RadioGroupItem = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Item
ref={ref}
className={cn(
"aspect-square h-4 w-4 rounded-full border border-primary text-primary shadow cursor-pointer focus:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
>
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
<Circle className="h-3.5 w-3.5 fill-primary" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
);
});
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName;
export { RadioGroup, RadioGroupItem };

View File

@ -0,0 +1,37 @@
import { GripVertical } from "lucide-react";
import { Group, Panel, Separator } from "react-resizable-panels";
import { cn } from "@/lib/utils";
const ResizablePanelGroup = ({ className, ...props }: React.ComponentProps<typeof Group>) => (
<Group
className={cn("flex h-full w-full data-[panel-group-direction=vertical]:flex-col", className)}
{...props}
/>
);
const ResizablePanel = Panel;
const ResizableHandle = ({
withHandle,
className,
...props
}: React.ComponentProps<typeof Separator> & {
withHandle?: boolean;
}) => (
<Separator
className={cn(
"relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90",
className,
)}
{...props}
>
{withHandle && (
<div className="z-10 flex h-4 w-3 items-center justify-center rounded-sm border bg-border">
<GripVertical className="h-2.5 w-2.5" />
</div>
)}
</Separator>
);
export { ResizablePanelGroup, ResizablePanel, ResizableHandle };

View File

@ -0,0 +1,44 @@
import * as React from "react";
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
import { cn } from "@/lib/utils";
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn("relative overflow-hidden", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
));
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = "vertical", ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
"flex touch-none select-none transition-colors",
orientation === "vertical" && "h-full w-2.5 border-l border-l-transparent p-[1px]",
orientation === "horizontal" && "h-2.5 flex-col border-t border-t-transparent p-[1px]",
className,
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
));
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
export { ScrollArea, ScrollBar };

View File

@ -0,0 +1,152 @@
"use client";
import * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select";
import { Check, ChevronDown, ChevronUp } from "lucide-react";
import { cn } from "@/lib/utils";
const Select = SelectPrimitive.Root;
const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value;
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background cursor-pointer data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn("flex cursor-default items-center justify-center py-1", className)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
));
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn("flex cursor-default items-center justify-center py-1", className)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
));
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName;
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 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 origin-(--radix-select-content-transform-origin)",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className,
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]",
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
));
SelectContent.displayName = SelectPrimitive.Content.displayName;
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
{...props}
/>
));
SelectLabel.displayName = SelectPrimitive.Label.displayName;
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
{...props}
>
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
));
SelectItem.displayName = SelectPrimitive.Item.displayName;
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
));
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
};

View File

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

Some files were not shown because too many files have changed in this diff Show More