mirror of
https://github.com/soconnor0919/beenvoice.git
synced 2025-12-11 08:34:43 -05:00
feat: Implement dynamic accent color selection and refactor appearance settings
This commit is contained in:
@@ -7,6 +7,12 @@ HOSTNAME=0.0.0.0
|
||||
AUTH_SECRET=replace-with-strong-secret
|
||||
|
||||
# Database (Postgres)
|
||||
# These are required for Docker container initialization
|
||||
POSTGRES_USER=beenvoice
|
||||
POSTGRES_PASSWORD=beenvoice
|
||||
POSTGRES_DB=beenvoice
|
||||
|
||||
# Connect string for the app
|
||||
DATABASE_URL=postgres://beenvoice:beenvoice@db:5432/beenvoice
|
||||
# Disable SSL for Docker local Postgres; set to false or remove for managed Postgres
|
||||
DB_DISABLE_SSL=true
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -35,6 +35,8 @@ yarn-error.log*
|
||||
# do not commit any .env files to git, except for the .env.example file. https://create.t3.gg/en/usage/env-variables#using-environment-variables
|
||||
.env
|
||||
.env*.local
|
||||
.env*.production
|
||||
.env*.vercel
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
@@ -25,7 +25,7 @@ A modern, professional invoicing application built for freelancers and small bus
|
||||
- **Authentication**: NextAuth.js with email/password
|
||||
- **UI Components**: shadcn/ui with Tailwind CSS
|
||||
- **Styling**: Geist font family
|
||||
- **Package Manager**: Bun (with npm fallback)
|
||||
- **Package Manager**: Bun
|
||||
|
||||
## 📦 Installation
|
||||
|
||||
@@ -44,11 +44,8 @@ A modern, professional invoicing application built for freelancers and small bus
|
||||
|
||||
2. **Install dependencies**
|
||||
```bash
|
||||
# Using Bun (recommended)
|
||||
```bash
|
||||
bun install
|
||||
|
||||
# Or using npm
|
||||
npm install
|
||||
```
|
||||
|
||||
3. **Set up environment variables**
|
||||
|
||||
11
bun.lock
11
bun.lock
@@ -23,6 +23,7 @@
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@react-pdf/renderer": "^4.3.1",
|
||||
"@t3-oss/env-nextjs": "^0.12.0",
|
||||
"@tanstack/react-query": "^5.90.10",
|
||||
@@ -48,7 +49,6 @@
|
||||
"lucide-react": "^0.525.0",
|
||||
"next": "^15.5.6",
|
||||
"next-auth": "5.0.0-beta.25",
|
||||
"next-themes": "^0.3.0",
|
||||
"pg": "^8.16.3",
|
||||
"react": "^19.2.0",
|
||||
"react-day-picker": "^9.11.2",
|
||||
@@ -72,6 +72,7 @@
|
||||
"@types/raf": "^3.4.3",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"dotenv": "^17.2.3",
|
||||
"drizzle-kit": "^0.30.6",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-config-next": "^15.5.6",
|
||||
@@ -354,6 +355,8 @@
|
||||
|
||||
"@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.13", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A=="],
|
||||
|
||||
"@radix-ui/react-tooltip": ["@radix-ui/react-tooltip@1.2.8", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg=="],
|
||||
|
||||
"@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg=="],
|
||||
|
||||
"@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.2", "", { "dependencies": { "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg=="],
|
||||
@@ -806,6 +809,8 @@
|
||||
|
||||
"domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="],
|
||||
|
||||
"dotenv": ["dotenv@17.2.3", "", {}, "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w=="],
|
||||
|
||||
"drizzle-kit": ["drizzle-kit@0.30.6", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.19.7", "esbuild-register": "^3.5.0", "gel": "^2.0.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-U4wWit0fyZuGuP7iNmRleQyK2V8wCuv57vf5l3MnG4z4fzNTjY/U13M8owyQ5RavqvqxBifWORaR3wIUzlN64g=="],
|
||||
|
||||
"drizzle-orm": ["drizzle-orm@0.44.7", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-quIpnYznjU9lHshEOAYLoZ9s3jweleHlZIAWR/jX9gAWNg/JhQ1wj0KGRf7/Zm+obRrYd9GjPVJg790QY9N5AQ=="],
|
||||
@@ -1152,8 +1157,6 @@
|
||||
|
||||
"next-auth": ["next-auth@5.0.0-beta.25", "", { "dependencies": { "@auth/core": "0.37.2" }, "peerDependencies": { "@simplewebauthn/browser": "^9.0.1", "@simplewebauthn/server": "^9.0.2", "next": "^14.0.0-0 || ^15.0.0-0", "nodemailer": "^6.6.5", "react": "^18.2.0 || ^19.0.0-0" }, "optionalPeers": ["@simplewebauthn/browser", "@simplewebauthn/server", "nodemailer"] }, "sha512-2dJJw1sHQl2qxCrRk+KTQbeH+izFbGFPuJj5eGgBZFYyiYYtvlrBeUw1E/OJJxTRjuxbSYGnCTkUIRsIIW0bog=="],
|
||||
|
||||
"next-themes": ["next-themes@0.3.0", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18", "react-dom": "^16.8 || ^17 || ^18" } }, "sha512-/QHIrsYpd6Kfk7xakK4svpDI5mmXP0gfvCoJdGpZQ2TOrQZmsW0QxjaiLn8wbIKjtm4BTSqLoix4lxYYOnLJ/w=="],
|
||||
|
||||
"normalize-svg-path": ["normalize-svg-path@1.1.0", "", { "dependencies": { "svg-arc-to-cubic-bezier": "^3.0.0" } }, "sha512-r9KHKG2UUeB5LoTouwDzBy2VxXlHsiM6fyLQvnJa0S5hrhzqElH/CH7TUGhT1fVvIYBIKf3OpY4YJ4CK+iaqHg=="],
|
||||
|
||||
"oauth4webapi": ["oauth4webapi@3.5.5", "", {}, "sha512-1K88D2GiAydGblHo39NBro5TebGXa+7tYoyIbxvqv3+haDDry7CBE1eSYuNbOSsYCCU6y0gdynVZAkm4YPw4hg=="],
|
||||
@@ -1542,6 +1545,8 @@
|
||||
|
||||
"@radix-ui/react-separator/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="],
|
||||
|
||||
"@radix-ui/react-tooltip/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@react-pdf/reconciler/scheduler": ["scheduler@0.25.0-rc-603e6108-20241029", "", {}, "sha512-pFwF6H1XrSdYYNLfOcGlM28/j8CGLu8IvdrxqhjWULe2bPcKiKW4CV+OWqR/9fT52mywx65l7ysNkjLKBda7eA=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.7.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg=="],
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
services:
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
image: postgres:17-alpine
|
||||
container_name: beenvoice-db
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_USER}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
POSTGRES_DB: ${POSTGRES_DB}
|
||||
env_file:
|
||||
- .env.local
|
||||
volumes:
|
||||
- beenvoice_pg_data:/var/lib/postgresql/data
|
||||
ports:
|
||||
@@ -16,30 +14,6 @@ services:
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
image: beenvoice-app:latest
|
||||
container_name: beenvoice-app
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
# no memory constraint
|
||||
AUTH_SECRET: ${AUTH_SECRET}
|
||||
RESEND_API_KEY: ${RESEND_API_KEY}
|
||||
RESEND_DOMAIN: ${RESEND_DOMAIN}
|
||||
DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
|
||||
DB_DISABLE_SSL: "true"
|
||||
PORT: ${PORT}
|
||||
HOSTNAME: 0.0.0.0
|
||||
SKIP_ENV_VALIDATION: "1"
|
||||
ports:
|
||||
- "${PORT:-3000}:3000"
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
beenvoice_pg_data:
|
||||
driver: local
|
||||
|
||||
@@ -1,12 +1,22 @@
|
||||
import type { Config } from "drizzle-kit";
|
||||
import * as dotenv from "dotenv";
|
||||
// Load .env.local if it exists
|
||||
dotenv.config({ path: ".env.local" });
|
||||
// Load .env if it exists (fallback)
|
||||
dotenv.config({ path: ".env" });
|
||||
|
||||
// Use a relative import; path alias "~" may not resolve in CLI context
|
||||
import { env } from "./src/env.js";
|
||||
// import { env } from "./src/env.js";
|
||||
|
||||
if (!process.env.DATABASE_URL) {
|
||||
throw new Error("DATABASE_URL is not set");
|
||||
}
|
||||
|
||||
export default {
|
||||
schema: "./src/server/db/schema.ts",
|
||||
dialect: "postgresql",
|
||||
dbCredentials: {
|
||||
url: env.DATABASE_URL,
|
||||
url: process.env.DATABASE_URL,
|
||||
},
|
||||
tablesFilter: ["beenvoice_*"],
|
||||
} satisfies Config;
|
||||
|
||||
12
package.json
12
package.json
@@ -10,8 +10,9 @@
|
||||
"db:migrate": "drizzle-kit migrate",
|
||||
"db:push": "drizzle-kit push",
|
||||
"db:studio": "drizzle-kit studio",
|
||||
"docker-up": "colima start && docker-compose up -d",
|
||||
"docker-down": "docker-compose down && colima stop",
|
||||
"db:clone": "./scripts/clone-local.sh",
|
||||
"docker:up": "colima start && docker-compose up -d",
|
||||
"docker:down": "docker-compose down && colima stop",
|
||||
"deploy": "drizzle-kit push && next build",
|
||||
"dev": "next dev --turbo",
|
||||
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
|
||||
@@ -41,6 +42,7 @@
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@react-pdf/renderer": "^4.3.1",
|
||||
"@t3-oss/env-nextjs": "^0.12.0",
|
||||
"@tanstack/react-query": "^5.90.10",
|
||||
@@ -66,7 +68,6 @@
|
||||
"lucide-react": "^0.525.0",
|
||||
"next": "^15.5.6",
|
||||
"next-auth": "5.0.0-beta.25",
|
||||
"next-themes": "^0.3.0",
|
||||
"pg": "^8.16.3",
|
||||
"react": "^19.2.0",
|
||||
"react-day-picker": "^9.11.2",
|
||||
@@ -85,11 +86,12 @@
|
||||
"@tailwindcss/postcss": "^4.1.17",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/file-saver": "^2.0.7",
|
||||
"@types/pg": "^8.15.6",
|
||||
"@types/node": "^20.19.25",
|
||||
"@types/pg": "^8.15.6",
|
||||
"@types/raf": "^3.4.3",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"dotenv": "^17.2.3",
|
||||
"drizzle-kit": "^0.30.6",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-config-next": "^15.5.6",
|
||||
@@ -113,4 +115,4 @@
|
||||
"sharp",
|
||||
"unrs-resolver"
|
||||
]
|
||||
}
|
||||
}
|
||||
71
scripts/clone-local.sh
Executable file
71
scripts/clone-local.sh
Executable file
@@ -0,0 +1,71 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Function to read a variable from a specific env file
|
||||
read_env_var() {
|
||||
local file="$1"
|
||||
local var="$2"
|
||||
if [ -f "$file" ]; then
|
||||
grep "^$var=" "$file" | cut -d '=' -f2- | tr -d '"' | tr -d "'"
|
||||
fi
|
||||
}
|
||||
|
||||
# 1. Get Production URL
|
||||
# Priority: Argument > .env.production > .env
|
||||
PROD_DB_URL="$1"
|
||||
|
||||
if [ -z "$PROD_DB_URL" ]; then
|
||||
echo "Checking .env.production for DATABASE_URL..."
|
||||
PROD_DB_URL=$(read_env_var ".env.production" "DATABASE_URL")
|
||||
fi
|
||||
|
||||
if [ -z "$PROD_DB_URL" ]; then
|
||||
echo "Checking .env for PROD_DATABASE_URL..."
|
||||
PROD_DB_URL=$(read_env_var ".env" "PROD_DATABASE_URL")
|
||||
fi
|
||||
|
||||
if [ -z "$PROD_DB_URL" ]; then
|
||||
echo "Error: Could not find production database URL."
|
||||
echo "Please provide it as an argument, or set DATABASE_URL in .env.production, or PROD_DATABASE_URL in .env"
|
||||
echo "Usage: $0 <PROD_DATABASE_URL>"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 2. Get Target URL
|
||||
# Priority: .env.local > .env
|
||||
TARGET_DB_URL=$(read_env_var ".env.local" "DATABASE_URL")
|
||||
if [ -z "$TARGET_DB_URL" ]; then TARGET_DB_URL=$(read_env_var ".env" "DATABASE_URL"); fi
|
||||
|
||||
if [ -z "$TARGET_DB_URL" ]; then
|
||||
echo "Error: Could not find target DATABASE_URL in .env.local or .env"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Configuration:"
|
||||
echo " Source: Production (Found in env/arg)"
|
||||
echo " Target: $TARGET_DB_URL"
|
||||
echo
|
||||
echo "⚠️ WARNING: This will OVERWRITE the target database at the above URL."
|
||||
echo "This is a one-time migration script."
|
||||
read -p "Are you sure you want to continue? (y/N) " -n 1 -r
|
||||
echo
|
||||
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||
echo "Aborted."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Cloning database..."
|
||||
|
||||
# Pipe pg_dump from a temporary container to another temporary container running psql
|
||||
# This avoids needing local tools and ensures consistent environment
|
||||
docker run --rm -i postgres:17-alpine pg_dump "$PROD_DB_URL" \
|
||||
--clean --if-exists \
|
||||
--no-owner --no-privileges \
|
||||
--format=plain \
|
||||
| docker run --rm -i postgres:17-alpine psql "$TARGET_DB_URL"
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "✅ Database cloned successfully!"
|
||||
else
|
||||
echo "❌ Database clone failed."
|
||||
exit 1
|
||||
fi
|
||||
@@ -0,0 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { ModeSwitcher } from "./mode-switcher";
|
||||
import { ThemeSelector } from "./theme-selector";
|
||||
|
||||
export function AppearanceSettings() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<ModeSwitcher />
|
||||
<ThemeSelector />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useTheme } from "next-themes";
|
||||
import { Check, Palette } from "lucide-react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "~/components/ui/dropdown-menu";
|
||||
import { Button } from "~/components/ui/button";
|
||||
|
||||
export function ColorThemeSelector() {
|
||||
const { theme, setTheme } = useTheme();
|
||||
|
||||
const themes = [
|
||||
{ name: "theme-ocean", label: "Ocean" },
|
||||
{ name: "theme-sunset", label: "Sunset" },
|
||||
{ name: "theme-forest", label: "Forest" },
|
||||
];
|
||||
|
||||
const currentTheme = themes.find((t) => t.name === theme)?.label ?? "Ocean";
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1.5">
|
||||
<label className="font-medium">Color Theme</label>
|
||||
<p className="text-muted-foreground text-xs leading-snug">
|
||||
Select a color theme for the application.
|
||||
</p>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" className="w-40 justify-between">
|
||||
<span>{currentTheme}</span>
|
||||
<Palette className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
{themes.map((t) => (
|
||||
<DropdownMenuItem
|
||||
key={t.name}
|
||||
className="flex justify-between"
|
||||
onClick={() => setTheme(t.name)}
|
||||
>
|
||||
<span>{t.label}</span>
|
||||
{theme === t.name && <Check className="h-4 w-4" />}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useTheme } from "next-themes";
|
||||
import { useTheme } from "~/components/providers/theme-provider";
|
||||
import { Sun, Moon, Laptop } from "lucide-react";
|
||||
import { Tabs, TabsList, TabsTrigger } from "~/components/ui/tabs";
|
||||
|
||||
@@ -9,13 +9,19 @@ export function ModeSwitcher() {
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1.5">
|
||||
<div className="space-y-1.5">
|
||||
<label className="font-medium">Appearance</label>
|
||||
<p className="text-muted-foreground text-xs leading-snug">
|
||||
Select a light or dark mode, or sync with your system.
|
||||
{theme === "system"
|
||||
? "Follows system preference"
|
||||
: `Currently in ${theme} mode`}
|
||||
</p>
|
||||
</div>
|
||||
<Tabs defaultValue={theme} onValueChange={setTheme} className="w-auto">
|
||||
<Tabs
|
||||
value={theme}
|
||||
onValueChange={(value) => setTheme(value as "light" | "dark" | "system")}
|
||||
className="w-auto"
|
||||
>
|
||||
<TabsList>
|
||||
<TabsTrigger value="light">
|
||||
<Sun className="h-4 w-4" />
|
||||
|
||||
@@ -62,8 +62,7 @@ import { Textarea } from "~/components/ui/textarea";
|
||||
import { api } from "~/trpc/react";
|
||||
import { Switch } from "~/components/ui/switch";
|
||||
import { Slider } from "~/components/ui/slider";
|
||||
import { ModeSwitcher } from "./mode-switcher";
|
||||
import { ColorThemeSelector } from "./color-theme-selector";
|
||||
import { AppearanceSettings } from "./appearance-settings";
|
||||
import { useAnimationPreferences } from "~/components/providers/animation-preferences-provider";
|
||||
|
||||
export function SettingsContent() {
|
||||
@@ -630,8 +629,7 @@ export function SettingsContent() {
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<ModeSwitcher />
|
||||
<ColorThemeSelector />
|
||||
<AppearanceSettings />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
||||
@@ -1,55 +1,61 @@
|
||||
"use client";
|
||||
|
||||
import { useTheme } from "next-themes";
|
||||
import { Check, Palette } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { Check } from "lucide-react";
|
||||
import { cn } from "~/lib/utils";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "~/components/ui/dropdown-menu";
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "~/components/ui/tooltip";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { useColorTheme } from "~/components/providers/color-theme-provider";
|
||||
|
||||
const themes = [
|
||||
{ name: "slate", hex: "#64748b" },
|
||||
{ name: "blue", hex: "#3b82f6" },
|
||||
{ name: "green", hex: "#22c55e" },
|
||||
{ name: "rose", hex: "#be123c" },
|
||||
{ name: "orange", hex: "#ea580c" },
|
||||
];
|
||||
|
||||
export function ThemeSelector() {
|
||||
const { theme, setTheme } = useTheme();
|
||||
|
||||
const themes = [
|
||||
{ name: "light", label: "Light" },
|
||||
{ name: "dark", label: "Dark" },
|
||||
{ name: "theme-sunset", label: "Sunset" },
|
||||
{ name: "theme-forest", label: "Forest" },
|
||||
];
|
||||
const { colorTheme, setColorTheme } = useColorTheme();
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1.5">
|
||||
<label className="font-medium">Theme</label>
|
||||
<p className="text-muted-foreground text-xs leading-snug">
|
||||
Select a theme for the application.
|
||||
</p>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" className="w-40 justify-between">
|
||||
<span>
|
||||
{themes.find((t) => t.name === theme)?.label ?? "Light"}
|
||||
</span>
|
||||
<Palette className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<div className="space-y-1.5">
|
||||
<label className="font-medium">Theme</label>
|
||||
<p className="text-muted-foreground text-xs leading-snug">
|
||||
Select a theme for the application.
|
||||
</p>
|
||||
<div className="flex items-center gap-2 pt-2">
|
||||
<TooltipProvider>
|
||||
{themes.map((t) => (
|
||||
<DropdownMenuItem
|
||||
key={t.name}
|
||||
className="flex justify-between"
|
||||
onClick={() => setTheme(t.name)}
|
||||
>
|
||||
<span>{t.label}</span>
|
||||
{theme === t.name && <Check className="h-4 w-4" />}
|
||||
</DropdownMenuItem>
|
||||
<Tooltip key={t.name}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"h-8 w-8 rounded-full border-2",
|
||||
colorTheme === t.name && "border-primary",
|
||||
)}
|
||||
onClick={() => setColorTheme(t.name as any)}
|
||||
style={{ backgroundColor: t.hex }}
|
||||
>
|
||||
{colorTheme === t.name && (
|
||||
<Check className="h-4 w-4 text-white" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p className="capitalize">{t.name}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Toaster } from "~/components/ui/sonner";
|
||||
import { AnimationPreferencesProvider } from "~/components/providers/animation-preferences-provider";
|
||||
|
||||
import { ThemeProvider } from "~/components/providers/theme-provider";
|
||||
import { ColorThemeProvider } from "~/components/providers/color-theme-provider";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "beenvoice - Invoicing Made Simple",
|
||||
@@ -46,27 +47,106 @@ export default function RootLayout({
|
||||
className={`${geistSans.variable} ${geistMono.variable} ${instrumentSerif.variable}`}
|
||||
>
|
||||
<head>
|
||||
{/* Inline early animation preference script to avoid FOUC */}
|
||||
{/* Inline early theme and animation preference script to avoid FOUC */}
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `(function(){try{var STORAGE_KEY='bv.animation.prefs';var raw=localStorage.getItem(STORAGE_KEY);var prefersReduced=false;var speed=1;if(raw){try{var parsed=JSON.parse(raw);if(typeof parsed.prefersReducedMotion==='boolean'){prefersReduced=parsed.prefersReducedMotion;}else{prefersReduced=window.matchMedia&&window.matchMedia('(prefers-reduced-motion: reduce)').matches;}if(typeof parsed.animationSpeedMultiplier==='number'){speed=parsed.animationSpeedMultiplier;if(isNaN(speed)||speed<0.25||speed>4)speed=1;}}catch(e){}}else{prefersReduced=window.matchMedia&&window.matchMedia('(prefers-reduced-motion: reduce)').matches;}var root=document.documentElement;if(prefersReduced)root.classList.add('user-reduce-motion');function apply(fast,normal,slow){root.style.setProperty('--animation-speed-fast',fast+'s');root.style.setProperty('--animation-speed-normal',normal+'s');root.style.setProperty('--animation-speed-slow',slow+'s');}if(prefersReduced){apply(0.01,0.01,0.01);}else{var fast=(0.15/speed).toFixed(4);var normal=(0.30/speed).toFixed(4);var slow=(0.50/speed).toFixed(4);apply(fast,normal,slow);}}catch(_e){}})();`,
|
||||
__html: `(function(){
|
||||
try {
|
||||
var root = document.documentElement;
|
||||
|
||||
// Mode theme persistence (light/dark/system)
|
||||
var modeTheme = localStorage.getItem('theme');
|
||||
var systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
|
||||
root.classList.remove('light', 'dark');
|
||||
|
||||
if (modeTheme === 'dark' || modeTheme === 'light') {
|
||||
root.classList.add(modeTheme);
|
||||
} else {
|
||||
// Default to system if no preference or 'system'
|
||||
root.classList.add(systemTheme);
|
||||
}
|
||||
|
||||
// Color theme persistence (custom accent colors)
|
||||
var customColor = localStorage.getItem('customThemeColor');
|
||||
var isCustom = localStorage.getItem('isCustomTheme') === 'true';
|
||||
|
||||
if (isCustom && customColor) {
|
||||
try {
|
||||
var themeData = JSON.parse(customColor);
|
||||
if (themeData && themeData.colors && themeData.colors.light) {
|
||||
// Apply saved colors directly
|
||||
for (var key in themeData.colors.light) {
|
||||
if (themeData.colors.light.hasOwnProperty(key)) {
|
||||
root.style.setProperty(key, themeData.colors.light[key]);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Fallback logic omitted for brevity, relying on provider for full recovery
|
||||
}
|
||||
} else {
|
||||
// Apply preset color theme
|
||||
var colorTheme = localStorage.getItem('color-theme');
|
||||
if (colorTheme) {
|
||||
root.classList.add(colorTheme);
|
||||
} else {
|
||||
root.classList.add('slate'); // Default
|
||||
}
|
||||
}
|
||||
|
||||
// Animation preferences script (existing)
|
||||
var STORAGE_KEY='bv.animation.prefs';
|
||||
var raw=localStorage.getItem(STORAGE_KEY);
|
||||
var prefersReduced=false;
|
||||
var speed=1;
|
||||
if(raw){
|
||||
try{
|
||||
var parsed=JSON.parse(raw);
|
||||
if(typeof parsed.prefersReducedMotion==='boolean'){
|
||||
prefersReduced=parsed.prefersReducedMotion;
|
||||
}else{
|
||||
prefersReduced=window.matchMedia&&window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
}
|
||||
if(typeof parsed.animationSpeedMultiplier==='number'){
|
||||
speed=parsed.animationSpeedMultiplier;
|
||||
if(isNaN(speed)||speed<0.25||speed>4)speed=1;
|
||||
}
|
||||
}catch(e){}
|
||||
}else{
|
||||
prefersReduced=window.matchMedia&&window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
}
|
||||
|
||||
if(prefersReduced)root.classList.add('user-reduce-motion');
|
||||
function apply(fast,normal,slow){
|
||||
root.style.setProperty('--animation-speed-fast',fast+'s');
|
||||
root.style.setProperty('--animation-speed-normal',normal+'s');
|
||||
root.style.setProperty('--animation-speed-slow',slow+'s');
|
||||
}
|
||||
if(prefersReduced){
|
||||
apply(0.01,0.01,0.01);
|
||||
}else{
|
||||
var fast=(0.15/speed).toFixed(4);
|
||||
var normal=(0.30/speed).toFixed(4);
|
||||
var slow=(0.50/speed).toFixed(4);
|
||||
apply(fast,normal,slow);
|
||||
}
|
||||
} catch(_e) {}
|
||||
})();`,
|
||||
}}
|
||||
/>
|
||||
</head>
|
||||
<Analytics />
|
||||
<body className="bg-background text-foreground relative min-h-screen overflow-x-hidden font-sans antialiased">
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="theme-ocean"
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
<TRPCReactProvider>
|
||||
<AnimationPreferencesProvider>
|
||||
{children}
|
||||
</AnimationPreferencesProvider>
|
||||
<Toaster />
|
||||
</TRPCReactProvider>
|
||||
<ThemeProvider>
|
||||
<ColorThemeProvider>
|
||||
<TRPCReactProvider>
|
||||
<AnimationPreferencesProvider>
|
||||
{children}
|
||||
</AnimationPreferencesProvider>
|
||||
<Toaster />
|
||||
</TRPCReactProvider>
|
||||
</ColorThemeProvider>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
156
src/components/providers/color-theme-provider.tsx
Normal file
156
src/components/providers/color-theme-provider.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { useTheme } from "./theme-provider";
|
||||
import { generateAccentColors } from "~/lib/color-utils";
|
||||
|
||||
type ColorTheme = "slate" | "blue" | "green" | "rose" | "orange" | "custom";
|
||||
|
||||
interface ColorThemeContextType {
|
||||
colorTheme: ColorTheme;
|
||||
setColorTheme: (theme: ColorTheme, customColor?: string) => void;
|
||||
customColor?: string;
|
||||
}
|
||||
|
||||
const ColorThemeContext = React.createContext<
|
||||
ColorThemeContextType | undefined
|
||||
>(undefined);
|
||||
|
||||
export function useColorTheme() {
|
||||
const context = React.useContext(ColorThemeContext);
|
||||
if (!context) {
|
||||
throw new Error("useColorTheme must be used within a ColorThemeProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
interface ColorThemeProviderProps {
|
||||
children: React.ReactNode;
|
||||
defaultColorTheme?: ColorTheme;
|
||||
}
|
||||
|
||||
export function ColorThemeProvider({
|
||||
children,
|
||||
defaultColorTheme = "slate",
|
||||
}: ColorThemeProviderProps) {
|
||||
const [colorTheme, setColorThemeState] =
|
||||
React.useState<ColorTheme>(defaultColorTheme);
|
||||
const [customColor, setCustomColor] = React.useState<string | undefined>();
|
||||
const { theme: modeTheme } = useTheme();
|
||||
|
||||
const setColorTheme = React.useCallback(
|
||||
(theme: ColorTheme, customColor?: string) => {
|
||||
const root = document.documentElement;
|
||||
const themes: ColorTheme[] = ["slate", "blue", "green", "rose", "orange"];
|
||||
|
||||
// Clear any existing custom styles
|
||||
const customProps = [
|
||||
"--primary",
|
||||
"--accent",
|
||||
"--ring",
|
||||
"--secondary",
|
||||
"--muted",
|
||||
];
|
||||
customProps.forEach((prop) => {
|
||||
if (root.style.getPropertyValue(prop)) {
|
||||
root.style.removeProperty(prop);
|
||||
}
|
||||
});
|
||||
|
||||
// Remove all theme classes
|
||||
root.classList.remove(...themes);
|
||||
|
||||
if (theme === "custom" && customColor) {
|
||||
try {
|
||||
const colors = generateAccentColors(customColor);
|
||||
const themeColors = modeTheme === "dark" ? colors.dark : colors.light;
|
||||
|
||||
Object.entries(themeColors).forEach(([key, value]) => {
|
||||
root.style.setProperty(key, value);
|
||||
});
|
||||
|
||||
setColorThemeState("custom");
|
||||
setCustomColor(customColor);
|
||||
|
||||
// Persist custom theme
|
||||
const themeData = {
|
||||
color: customColor,
|
||||
timestamp: Date.now(),
|
||||
colors: colors,
|
||||
};
|
||||
localStorage.setItem("customThemeColor", JSON.stringify(themeData));
|
||||
localStorage.setItem("isCustomTheme", "true");
|
||||
localStorage.removeItem("color-theme");
|
||||
} catch (error) {
|
||||
console.error("Failed to apply custom theme:", error);
|
||||
// Fallback to default
|
||||
setColorThemeState(defaultColorTheme);
|
||||
setCustomColor(undefined);
|
||||
root.classList.add(defaultColorTheme);
|
||||
localStorage.setItem("color-theme", defaultColorTheme);
|
||||
}
|
||||
} else {
|
||||
// Apply preset color theme by setting the appropriate class
|
||||
setColorThemeState(theme);
|
||||
setCustomColor(undefined);
|
||||
root.classList.add(theme);
|
||||
|
||||
// Clear custom theme storage
|
||||
localStorage.removeItem("customThemeColor");
|
||||
localStorage.removeItem("isCustomTheme");
|
||||
|
||||
// Persist preset theme
|
||||
localStorage.setItem("color-theme", theme);
|
||||
}
|
||||
},
|
||||
[modeTheme, defaultColorTheme],
|
||||
);
|
||||
|
||||
// Load saved theme on mount
|
||||
React.useEffect(() => {
|
||||
try {
|
||||
const isCustom = localStorage.getItem("isCustomTheme") === "true";
|
||||
const savedThemeData = localStorage.getItem("customThemeColor");
|
||||
const savedColorTheme = localStorage.getItem("color-theme") as ColorTheme | null;
|
||||
|
||||
if (isCustom && savedThemeData) {
|
||||
const themeData = JSON.parse(savedThemeData);
|
||||
if (themeData && themeData.color && themeData.colors) {
|
||||
setColorTheme("custom", themeData.color);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (savedColorTheme) {
|
||||
setColorTheme(savedColorTheme);
|
||||
} else {
|
||||
setColorTheme(defaultColorTheme);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load theme:", error);
|
||||
setColorTheme(defaultColorTheme);
|
||||
}
|
||||
}, [setColorTheme, defaultColorTheme]);
|
||||
|
||||
// Re-apply custom theme when mode changes
|
||||
React.useEffect(() => {
|
||||
if (colorTheme === "custom" && customColor) {
|
||||
setColorTheme("custom", customColor);
|
||||
}
|
||||
}, [modeTheme, colorTheme, customColor, setColorTheme]);
|
||||
|
||||
const value = React.useMemo(
|
||||
() => ({
|
||||
colorTheme,
|
||||
setColorTheme,
|
||||
customColor,
|
||||
}),
|
||||
[colorTheme, customColor, setColorTheme],
|
||||
);
|
||||
|
||||
return (
|
||||
<ColorThemeContext.Provider value={value}>
|
||||
{children}
|
||||
</ColorThemeContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +1,83 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { ThemeProvider as NextThemesProvider } from "next-themes";
|
||||
import { type ThemeProviderProps } from "next-themes/dist/types";
|
||||
|
||||
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
||||
type Theme = "dark" | "light" | "system";
|
||||
|
||||
type ThemeProviderProps = {
|
||||
children: React.ReactNode;
|
||||
defaultTheme?: Theme;
|
||||
storageKey?: string;
|
||||
};
|
||||
|
||||
type ThemeProviderState = {
|
||||
theme: Theme;
|
||||
setTheme: (theme: Theme) => void;
|
||||
};
|
||||
|
||||
const initialState: ThemeProviderState = {
|
||||
theme: "system",
|
||||
setTheme: () => null,
|
||||
};
|
||||
|
||||
const ThemeProviderContext = React.createContext<ThemeProviderState>(initialState);
|
||||
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
defaultTheme = "system",
|
||||
storageKey = "theme",
|
||||
...props
|
||||
}: ThemeProviderProps) {
|
||||
const [theme, setTheme] = React.useState<Theme>(defaultTheme);
|
||||
|
||||
React.useEffect(() => {
|
||||
const savedTheme = localStorage.getItem(storageKey) as Theme | null;
|
||||
if (savedTheme) {
|
||||
setTheme(savedTheme);
|
||||
}
|
||||
}, [storageKey]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const root = window.document.documentElement;
|
||||
|
||||
root.classList.remove("light", "dark");
|
||||
|
||||
if (theme === "system") {
|
||||
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)")
|
||||
.matches
|
||||
? "dark"
|
||||
: "light";
|
||||
|
||||
root.classList.add(systemTheme);
|
||||
return;
|
||||
}
|
||||
|
||||
root.classList.add(theme);
|
||||
}, [theme]);
|
||||
|
||||
const value = React.useMemo(
|
||||
() => ({
|
||||
theme,
|
||||
setTheme: (theme: Theme) => {
|
||||
localStorage.setItem(storageKey, theme);
|
||||
setTheme(theme);
|
||||
},
|
||||
}),
|
||||
[theme, storageKey]
|
||||
);
|
||||
|
||||
return (
|
||||
<NextThemesProvider
|
||||
{...props}
|
||||
themes={["light", "dark", "theme-ocean", "theme-sunset", "theme-forest"]}
|
||||
>
|
||||
<ThemeProviderContext.Provider {...props} value={value}>
|
||||
{children}
|
||||
</NextThemesProvider>
|
||||
</ThemeProviderContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export const useTheme = () => {
|
||||
const context = React.useContext(ThemeProviderContext);
|
||||
|
||||
if (context === undefined)
|
||||
throw new Error("useTheme must be used within a ThemeProvider");
|
||||
|
||||
return context;
|
||||
};
|
||||
|
||||
147
src/components/theme/accent-color-switcher.tsx
Normal file
147
src/components/theme/accent-color-switcher.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Check, Palette } from "lucide-react";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "~/components/ui/popover";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { useColorTheme } from "~/components/providers/color-theme-provider";
|
||||
|
||||
const presetColors = [
|
||||
{ name: "Slate", hex: "#64748b" },
|
||||
{ name: "Blue", hex: "#3b82f6" },
|
||||
{ name: "Green", hex: "#22c55e" },
|
||||
{ name: "Rose", hex: "#be123c" },
|
||||
{ name: "Orange", hex: "#ea580c" },
|
||||
{ name: "Purple", hex: "#8b5cf6" },
|
||||
{ name: "Teal", hex: "#14b8a6" },
|
||||
{ name: "Pink", hex: "#ec4899" },
|
||||
];
|
||||
|
||||
export function AccentColorSwitcher() {
|
||||
const {
|
||||
colorTheme,
|
||||
setColorTheme,
|
||||
customColor: savedCustomColor,
|
||||
} = useColorTheme();
|
||||
const [customColorInput, setCustomColorInput] = React.useState("");
|
||||
const [isCustom, setIsCustom] = React.useState(colorTheme === "custom");
|
||||
|
||||
React.useEffect(() => {
|
||||
setIsCustom(colorTheme === "custom");
|
||||
if (savedCustomColor) {
|
||||
setCustomColorInput(savedCustomColor);
|
||||
}
|
||||
}, [colorTheme, savedCustomColor]);
|
||||
|
||||
const handleColorChange = (color: { name: string; hex: string }) => {
|
||||
setColorTheme(color.name.toLowerCase() as any);
|
||||
};
|
||||
|
||||
const handleCustomColorSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (/^#[0-9A-F]{6}$/i.test(customColorInput)) {
|
||||
setColorTheme("custom", customColorInput);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setColorTheme("slate");
|
||||
setCustomColorInput("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label>Accent Color</Label>
|
||||
<p className="text-muted-foreground text-xs leading-snug">
|
||||
Choose an accent color for your theme or create a custom one.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{presetColors.map((color) => (
|
||||
<Button
|
||||
key={color.name}
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"h-10 w-10 rounded-lg border-2",
|
||||
colorTheme === color.name.toLowerCase() &&
|
||||
!isCustom &&
|
||||
"border-primary ring-primary ring-2 ring-offset-2",
|
||||
isCustom && "opacity-50",
|
||||
)}
|
||||
onClick={() => handleColorChange(color)}
|
||||
style={{ backgroundColor: color.hex }}
|
||||
title={color.name}
|
||||
>
|
||||
{colorTheme === color.name.toLowerCase() && !isCustom && (
|
||||
<Check className="h-4 w-4 text-white" />
|
||||
)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" className="w-full">
|
||||
<Palette className="mr-2 h-4 w-4" />
|
||||
Custom Color
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-80">
|
||||
<form onSubmit={handleCustomColorSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="custom-color">Hex Color</Label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
id="custom-color"
|
||||
type="text"
|
||||
placeholder="#FF6B6B"
|
||||
value={customColorInput}
|
||||
onChange={(e) => setCustomColorInput(e.target.value)}
|
||||
className="flex-1 rounded-md border px-3 py-2"
|
||||
/>
|
||||
<input
|
||||
type="color"
|
||||
value={customColorInput}
|
||||
onChange={(e) => setCustomColorInput(e.target.value)}
|
||||
className="h-10 w-12 cursor-pointer rounded-md border"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button type="submit" className="w-full">
|
||||
Apply Custom Color
|
||||
</Button>
|
||||
</form>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{isCustom && (
|
||||
<div className="bg-muted flex items-center gap-2 rounded-lg p-3">
|
||||
<div
|
||||
className="h-6 w-6 rounded border"
|
||||
style={{ backgroundColor: savedCustomColor }}
|
||||
/>
|
||||
<span className="text-sm font-medium">Custom Theme Active</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleReset}
|
||||
className="ml-auto"
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,39 +2,23 @@
|
||||
|
||||
import * as React from "react";
|
||||
import { Moon, Sun } from "lucide-react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { useTheme } from "~/components/providers/theme-provider";
|
||||
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "~/components/ui/dropdown-menu";
|
||||
|
||||
export function ThemeSwitcher() {
|
||||
const { setTheme } = useTheme();
|
||||
const { setTheme, theme } = useTheme();
|
||||
|
||||
const toggleMode = () => {
|
||||
const newMode = theme === "dark" ? "light" : "dark";
|
||||
setTheme(newMode);
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="icon">
|
||||
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
||||
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
||||
<span className="sr-only">Toggle theme</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => setTheme("light")}>
|
||||
Light
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme("dark")}>
|
||||
Dark
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme("system")}>
|
||||
System
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Button variant="outline" size="icon" onClick={toggleMode}>
|
||||
<Sun className="h-[1.2rem] w-[1.2rem] scale-100 rotate-0 transition-all dark:scale-0 dark:-rotate-90" />
|
||||
<Moon className="absolute h-[1.2rem] w-[1.2rem] scale-0 rotate-90 transition-all dark:scale-100 dark:rotate-0" />
|
||||
<span className="sr-only">Toggle theme</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
61
src/components/ui/tooltip.tsx
Normal file
61
src/components/ui/tooltip.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
|
||||
|
||||
import { cn } from "~/lib/utils"
|
||||
|
||||
function TooltipProvider({
|
||||
delayDuration = 0,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
||||
return (
|
||||
<TooltipPrimitive.Provider
|
||||
data-slot="tooltip-provider"
|
||||
delayDuration={delayDuration}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Tooltip({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function TooltipTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
||||
}
|
||||
|
||||
function TooltipContent({
|
||||
className,
|
||||
sideOffset = 0,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-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 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||
@@ -12,7 +12,10 @@ export const env = createEnv({
|
||||
? z.string()
|
||||
: z.string().optional(),
|
||||
DATABASE_URL: z.string().url(),
|
||||
RESEND_API_KEY: z.string().min(1),
|
||||
RESEND_API_KEY:
|
||||
process.env.NODE_ENV === "production"
|
||||
? z.string().min(1)
|
||||
: z.string().min(1).optional(),
|
||||
RESEND_DOMAIN: z.string().optional(),
|
||||
NODE_ENV: z
|
||||
.enum(["development", "test", "production"])
|
||||
|
||||
274
src/lib/color-utils.ts
Normal file
274
src/lib/color-utils.ts
Normal file
@@ -0,0 +1,274 @@
|
||||
type Oklch = {
|
||||
l: number;
|
||||
c: number;
|
||||
h: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts a hexadecimal color string to an Oklch color object.
|
||||
*
|
||||
* @param {string} hex - The hexadecimal color string (e.g., "#RRGGBB", "RRGGBB", "#RGB", "RGB").
|
||||
* @returns {Oklch} The Oklch color object.
|
||||
* @throws {Error} If the hex color format is invalid.
|
||||
*/
|
||||
export function hexToOklch(hex: string): Oklch {
|
||||
const rgb = hexToRgb(hex);
|
||||
const linear_rgb = rgb.map(srgbToLinearRgb) as [number, number, number];
|
||||
const xyz = linearRgbToXyz(linear_rgb);
|
||||
const oklab = xyzToOklab(xyz);
|
||||
const oklch = oklabToOklch(oklab);
|
||||
|
||||
return {
|
||||
l: oklch[0] || 0,
|
||||
c: oklch[1] || 0,
|
||||
h: oklch[2] || 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function generateAccentColors(hex: string) {
|
||||
const base = hexToOklch(hex);
|
||||
|
||||
const light = {
|
||||
"--background": `oklch(0.99 ${base.c * 0.05} ${base.h})`,
|
||||
"--foreground": `oklch(0.1 ${base.c * 0.1} ${base.h})`,
|
||||
"--card": `oklch(1 ${base.c * 0.02} ${base.h})`,
|
||||
"--card-foreground": `oklch(0.1 ${base.c * 0.1} ${base.h})`,
|
||||
"--popover": `oklch(1 ${base.c * 0.02} ${base.h})`,
|
||||
"--popover-foreground": `oklch(0.1 ${base.c * 0.1} ${base.h})`,
|
||||
"--primary": `oklch(0.6 ${base.c} ${base.h})`,
|
||||
"--primary-foreground": `oklch(${base.l > 0.6 ? 0.1 : 0.98} ${
|
||||
base.c * 0.2
|
||||
} ${base.h})`,
|
||||
"--secondary": `oklch(0.9 ${base.c * 0.4} ${base.h})`,
|
||||
"--secondary-foreground": `oklch(0.1 ${base.c * 0.8} ${base.h})`,
|
||||
"--muted": `oklch(0.95 ${base.c * 0.2} ${base.h})`,
|
||||
"--muted-foreground": `oklch(0.5 ${base.c * 0.4} ${base.h})`,
|
||||
"--accent": `oklch(0.98 ${base.c * 0.6} ${base.h})`,
|
||||
"--accent-foreground": `oklch(0.1 ${base.c * 0.8} ${base.h})`,
|
||||
"--destructive": "oklch(0.58 0.24 28)",
|
||||
"--destructive-foreground": "oklch(0.98 0.01 230)",
|
||||
"--success": "oklch(0.55 0.15 142)",
|
||||
"--success-foreground": "oklch(0.98 0.01 230)",
|
||||
"--warning": "oklch(0.65 0.15 38)",
|
||||
"--warning-foreground": "oklch(0.2 0.03 230)",
|
||||
"--border": `oklch(0.9 ${base.c * 0.3} ${base.h})`,
|
||||
"--input": `oklch(0.9 ${base.c * 0.3} ${base.h})`,
|
||||
"--ring": `oklch(0.6 ${base.c} ${base.h})`,
|
||||
"--sidebar": `oklch(0.98 ${base.c * 0.05} ${base.h})`,
|
||||
"--sidebar-foreground": `oklch(0.1 ${base.c * 0.1} ${base.h})`,
|
||||
"--sidebar-primary": `oklch(0.6 ${base.c} ${base.h})`,
|
||||
"--sidebar-primary-foreground": `oklch(${base.l > 0.6 ? 0.1 : 0.98} ${
|
||||
base.c * 0.2
|
||||
} ${base.h})`,
|
||||
"--sidebar-accent": `oklch(0.9 ${base.c * 0.4} ${base.h})`,
|
||||
"--sidebar-accent-foreground": `oklch(0.1 ${base.c * 0.8} ${base.h})`,
|
||||
"--sidebar-border": `oklch(0.9 ${base.c * 0.3} ${base.h})`,
|
||||
"--sidebar-ring": `oklch(0.6 ${base.c} ${base.h})`,
|
||||
"--navbar": `oklch(1 ${base.c * 0.02} ${base.h})`,
|
||||
"--navbar-foreground": `oklch(0.1 ${base.c * 0.1} ${base.h})`,
|
||||
"--navbar-border": `oklch(0.9 ${base.c * 0.3} ${base.h})`,
|
||||
};
|
||||
|
||||
const dark = {
|
||||
"--background": `oklch(0.1 ${base.c * 0.1} ${base.h})`,
|
||||
"--foreground": `oklch(0.95 ${base.c * 0.05} ${base.h})`,
|
||||
"--card": `oklch(0.15 ${base.c * 0.15} ${base.h})`,
|
||||
"--card-foreground": `oklch(0.95 ${base.c * 0.05} ${base.h})`,
|
||||
"--popover": `oklch(0.17 ${base.c * 0.2} ${base.h})`,
|
||||
"--popover-foreground": `oklch(0.95 ${base.c * 0.05} ${base.h})`,
|
||||
"--primary": `oklch(0.7 ${base.c} ${base.h})`,
|
||||
"--primary-foreground": `oklch(${base.l > 0.6 ? 0.1 : 0.98} ${
|
||||
base.c * 0.2
|
||||
} ${base.h})`,
|
||||
"--secondary": `oklch(0.3 ${base.c * 0.7} ${base.h})`,
|
||||
"--secondary-foreground": `oklch(${base.l > 0.6 ? 0.1 : 0.98} ${
|
||||
base.c * 0.2
|
||||
} ${base.h})`,
|
||||
"--muted": `oklch(0.25 ${base.c * 0.3} ${base.h})`,
|
||||
"--muted-foreground": `oklch(0.7 ${base.c * 0.2} ${base.h})`,
|
||||
"--accent": `oklch(0.3 ${base.c * 0.5} ${base.h})`,
|
||||
"--accent-foreground": `oklch(0.95 ${base.c * 0.05} ${base.h})`,
|
||||
"--destructive": "oklch(0.7 0.19 22)",
|
||||
"--destructive-foreground": "oklch(0.2 0.03 230)",
|
||||
"--success": "oklch(0.6 0.15 142)",
|
||||
"--success-foreground": "oklch(0.98 0.01 230)",
|
||||
"--warning": "oklch(0.7 0.15 38)",
|
||||
"--warning-foreground": "oklch(0.2 0.03 230)",
|
||||
"--border": `oklch(0.28 ${base.c * 0.4} ${base.h})`,
|
||||
"--input": `oklch(0.35 ${base.c * 0.4} ${base.h})`,
|
||||
"--ring": `oklch(0.7 ${base.c} ${base.h})`,
|
||||
"--sidebar": `oklch(0.1 ${base.c * 0.1} ${base.h})`,
|
||||
"--sidebar-foreground": `oklch(0.95 ${base.c * 0.05} ${base.h})`,
|
||||
"--sidebar-primary": `oklch(0.7 ${base.c} ${base.h})`,
|
||||
"--sidebar-primary-foreground": `oklch(${base.l > 0.6 ? 0.1 : 0.98} ${
|
||||
base.c * 0.2
|
||||
} ${base.h})`,
|
||||
"--sidebar-accent": `oklch(0.3 ${base.c * 0.7} ${base.h})`,
|
||||
"--sidebar-accent-foreground": `oklch(0.95 ${base.c * 0.05} ${base.h})`,
|
||||
"--sidebar-border": `oklch(0.28 ${base.c * 0.4} ${base.h})`,
|
||||
"--sidebar-ring": `oklch(0.7 ${base.c} ${base.h})`,
|
||||
"--navbar": `oklch(0.15 ${base.c * 0.15} ${base.h})`,
|
||||
"--navbar-foreground": `oklch(0.95 ${base.c * 0.05} ${base.h})`,
|
||||
"--navbar-border": `oklch(0.28 ${base.c * 0.4} ${base.h})`,
|
||||
};
|
||||
|
||||
return { light, dark };
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a hexadecimal color string to an array of R, G, B components (0-255).
|
||||
* Supports "#RRGGBB", "RRGGBB", "#RGB", "RGB" formats.
|
||||
* @param {string} hex - The hexadecimal color string.
|
||||
* @returns {number[]} An array [r, g, b].
|
||||
* @throws {Error} If the hex color format is invalid.
|
||||
*/
|
||||
function hexToRgb(hex: string): [number, number, number] {
|
||||
let r = 0,
|
||||
g = 0,
|
||||
b = 0;
|
||||
|
||||
// Remove '#' if present
|
||||
if (hex.startsWith("#")) {
|
||||
hex = hex.slice(1);
|
||||
}
|
||||
|
||||
// Handle 3-digit hex (e.g., "F0C" -> "FF00CC")
|
||||
if (hex.length === 3) {
|
||||
const chars = hex.split("");
|
||||
if (
|
||||
chars.length === 3 &&
|
||||
chars.every((char) => /^[0-9A-Fa-f]$/.test(char))
|
||||
) {
|
||||
r = parseInt(chars[0]! + chars[0]!, 16);
|
||||
g = parseInt(chars[1]! + chars[1]!, 16);
|
||||
b = parseInt(chars[2]! + chars[2]!, 16);
|
||||
} else {
|
||||
throw new Error("Invalid 3-digit hex color format.");
|
||||
}
|
||||
}
|
||||
// Handle 6-digit hex (e.g., "FF00CC")
|
||||
else if (hex.length === 6) {
|
||||
const rStr = hex.substring(0, 2);
|
||||
const gStr = hex.substring(2, 4);
|
||||
const bStr = hex.substring(4, 6);
|
||||
|
||||
if (
|
||||
/^[0-9A-Fa-f]{2}$/.test(rStr) &&
|
||||
/^[0-9A-Fa-f]{2}$/.test(gStr) &&
|
||||
/^[0-9A-Fa-f]{2}$/.test(bStr)
|
||||
) {
|
||||
r = parseInt(rStr, 16);
|
||||
g = parseInt(gStr, 16);
|
||||
b = parseInt(bStr, 16);
|
||||
} else {
|
||||
throw new Error("Invalid 6-digit hex color format.");
|
||||
}
|
||||
} else {
|
||||
throw new Error("Invalid hex color format. Use #RRGGBB or #RGB.");
|
||||
}
|
||||
|
||||
return [r, g, b];
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts an sRGB component (0-255) to a linear sRGB component (0-1).
|
||||
* @param {number} c - The sRGB component value (0-255).
|
||||
* @returns {number} The linear sRGB component value (0-1).
|
||||
*/
|
||||
function srgbToLinearRgb(c: number) {
|
||||
c /= 255; // Normalize to [0, 1]
|
||||
// Apply the sRGB gamma correction formula.
|
||||
if (c <= 0.04045) {
|
||||
return c / 12.92;
|
||||
} else {
|
||||
return Math.pow((c + 0.055) / 1.055, 2.4);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Multiplies a 3x3 matrix by a 3-element vector.
|
||||
* @param {number[][]} matrix - The 3x3 matrix.
|
||||
* @param {number[]} vector - The 3-element vector.
|
||||
* @returns {number[]} The resulting 3-element vector.
|
||||
*/
|
||||
function multiplyMatrix(
|
||||
matrix: number[][],
|
||||
vector: number[],
|
||||
): [number, number, number] {
|
||||
const result = new Array(matrix.length).fill(0);
|
||||
for (let i = 0; i < matrix.length; i++) {
|
||||
for (let j = 0; j < vector.length; j++) {
|
||||
result[i]! += matrix[i]![j]! * vector[j]!;
|
||||
}
|
||||
}
|
||||
return [result[0]!, result[1]!, result[2]!];
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts linear sRGB values to CIE XYZ values (D65 white point).
|
||||
* @param {number[]} rgb_linear - An array [r, g, b] of linear sRGB components (0-1).
|
||||
* @returns {number[]} An array [X, Y, Z] of CIE XYZ components.
|
||||
*/
|
||||
function linearRgbToXyz(
|
||||
rgb_linear: [number, number, number],
|
||||
): [number, number, number] {
|
||||
// Standard sRGB to XYZ D65 conversion matrix.
|
||||
const M_srgb_to_xyz = [
|
||||
[0.4123908, 0.35758434, 0.18048079],
|
||||
[0.21263901, 0.71516868, 0.07219232],
|
||||
[0.01933082, 0.11919478, 0.95053215],
|
||||
];
|
||||
return multiplyMatrix(M_srgb_to_xyz, rgb_linear);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts CIE XYZ values to Oklab values.
|
||||
* @param {number[]} xyz - An array [X, Y, Z] of CIE XYZ components.
|
||||
* @returns {number[]} An array [L, a, b] of Oklab components.
|
||||
*/
|
||||
function xyzToOklab(xyz: [number, number, number]): [number, number, number] {
|
||||
// Convert XYZ to LMS (linear cone responses).
|
||||
const M_xyz_to_lms = [
|
||||
[0.81890226, 0.03298366, 0.05591174],
|
||||
[0.36186742, 0.638518, 0.00083942],
|
||||
[0, 0, 0.82521],
|
||||
];
|
||||
const lms = multiplyMatrix(M_xyz_to_lms, xyz);
|
||||
|
||||
// Apply cube root non-linearity to LMS values.
|
||||
const lms_prime = lms.map((val) => Math.cbrt(val)) as [
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
];
|
||||
|
||||
// Convert LMS' to Oklab.
|
||||
const M_lms_prime_to_oklab = [
|
||||
[0.2104542553, 0.793617785, -0.0040720468],
|
||||
[1.9779984951, -2.428592205, 0.4505937099],
|
||||
[0.0259040371, 0.7827717662, -0.808675766],
|
||||
];
|
||||
return multiplyMatrix(M_lms_prime_to_oklab, lms_prime);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts Oklab values to Oklch values.
|
||||
* @param {number[]} oklab - An array [L, a, b] of Oklab components (L in 0-1).
|
||||
* @returns {number[]} An array [L, C, h] of Oklch components (L in 0-100, h in degrees).
|
||||
*/
|
||||
function oklabToOklch(oklab: number[]): [number, number, number] {
|
||||
const L = oklab[0] ?? 0; // Oklab L is 0-1
|
||||
const a = oklab[1] ?? 0;
|
||||
const b = oklab[2] ?? 0;
|
||||
|
||||
const C = Math.sqrt(a * a + b * b); // Chroma
|
||||
let h = Math.atan2(b, a) * (180 / Math.PI); // Hue in degrees
|
||||
|
||||
// Normalize hue to [0, 360)
|
||||
if (h < 0) {
|
||||
h += 360;
|
||||
}
|
||||
|
||||
// Oklch L is typically scaled to 0-100.
|
||||
return [L, C, h];
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -14,11 +14,62 @@ export default {
|
||||
xs: "475px",
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ["var(--font-geist-sans)", "sans-serif"],
|
||||
sans: ["var(--font-geist-sans)", "Frutiger", "sans-serif"],
|
||||
mono: ["var(--font-geist-mono)", "monospace"],
|
||||
serif: ["var(--font-serif)", "serif"],
|
||||
},
|
||||
colors: {
|
||||
background: "var(--background)",
|
||||
foreground: "var(--foreground)",
|
||||
card: {
|
||||
DEFAULT: "var(--card)",
|
||||
foreground: "var(--card-foreground)",
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: "var(--popover)",
|
||||
foreground: "var(--popover-foreground)",
|
||||
},
|
||||
primary: {
|
||||
DEFAULT: "var(--primary)",
|
||||
foreground: "var(--primary-foreground)",
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: "var(--secondary)",
|
||||
foreground: "var(--secondary-foreground)",
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: "var(--muted)",
|
||||
foreground: "var(--muted-foreground)",
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: "var(--accent)",
|
||||
foreground: "var(--accent-foreground)",
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: "var(--destructive)",
|
||||
foreground: "var(--destructive-foreground)",
|
||||
},
|
||||
success: {
|
||||
DEFAULT: "var(--success)",
|
||||
foreground: "var(--success-foreground)",
|
||||
},
|
||||
warning: {
|
||||
DEFAULT: "var(--warning)",
|
||||
foreground: "var(--warning-foreground)",
|
||||
},
|
||||
border: "var(--border)",
|
||||
input: "var(--input)",
|
||||
ring: "var(--ring)",
|
||||
sidebar: {
|
||||
DEFAULT: "var(--sidebar)",
|
||||
foreground: "var(--sidebar-foreground)",
|
||||
primary: "var(--sidebar-primary)",
|
||||
"primary-foreground": "var(--sidebar-primary-foreground)",
|
||||
accent: "var(--sidebar-accent)",
|
||||
"accent-foreground": "var(--sidebar-accent-foreground)",
|
||||
border: "var(--sidebar-border)",
|
||||
ring: "var(--sidebar-ring)",
|
||||
},
|
||||
navbar: "var(--navbar)",
|
||||
"navbar-foreground": "var(--navbar-foreground)",
|
||||
"navbar-border": "var(--navbar-border)",
|
||||
|
||||
Reference in New Issue
Block a user