projects
This commit is contained in:
@@ -0,0 +1,34 @@
|
|||||||
|
# dependencies (bun install)
|
||||||
|
node_modules
|
||||||
|
|
||||||
|
# output
|
||||||
|
out
|
||||||
|
dist
|
||||||
|
*.tgz
|
||||||
|
|
||||||
|
# code coverage
|
||||||
|
coverage
|
||||||
|
*.lcov
|
||||||
|
|
||||||
|
# logs
|
||||||
|
logs
|
||||||
|
_.log
|
||||||
|
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||||
|
|
||||||
|
# dotenv environment variable files
|
||||||
|
.env
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
.env.local
|
||||||
|
|
||||||
|
# caches
|
||||||
|
.eslintcache
|
||||||
|
.cache
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# IntelliJ based IDEs
|
||||||
|
.idea
|
||||||
|
|
||||||
|
# Finder (MacOS) folder config
|
||||||
|
.DS_Store
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"type": "group",
|
||||||
|
"id": "ptpl-2643",
|
||||||
|
"direction": "horizontal",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"node": {
|
||||||
|
"type": "panel",
|
||||||
|
"id": "ptpl-2644",
|
||||||
|
"appType": "officerdev/chat"
|
||||||
|
},
|
||||||
|
"size": 38.1480587374
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"node": {
|
||||||
|
"type": "group",
|
||||||
|
"id": "p-1771998863601-133",
|
||||||
|
"direction": "vertical",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"node": {
|
||||||
|
"type": "panel",
|
||||||
|
"id": "ptpl-2645",
|
||||||
|
"appType": "officerdev/preview"
|
||||||
|
},
|
||||||
|
"size": 70.4331757899
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"node": {
|
||||||
|
"type": "panel",
|
||||||
|
"id": "p-1771998863601-132",
|
||||||
|
"appType": "officerdev/terminal"
|
||||||
|
},
|
||||||
|
"size": 29.5668242101
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"size": 61.8519412626
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"name": "simple-app-template",
|
||||||
|
"projectType": "app",
|
||||||
|
"hasBackend": false,
|
||||||
|
"hasAuth": false,
|
||||||
|
"templateIdx": 3
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# bun-react-tailwind-shadcn-template
|
||||||
|
|
||||||
|
To install dependencies:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun install
|
||||||
|
```
|
||||||
|
|
||||||
|
To start a development server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun dev
|
||||||
|
```
|
||||||
|
|
||||||
|
To run for production:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun start
|
||||||
|
```
|
||||||
|
|
||||||
|
This project was created using `bun init` in bun v1.3.9. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime.
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
#!/usr/bin/env bun
|
||||||
|
import plugin from "bun-plugin-tailwind";
|
||||||
|
import { existsSync } from "fs";
|
||||||
|
import { rm } from "fs/promises";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
if (process.argv.includes("--help") || process.argv.includes("-h")) {
|
||||||
|
console.log(`
|
||||||
|
🏗️ Bun Build Script
|
||||||
|
|
||||||
|
Usage: bun run build.ts [options]
|
||||||
|
|
||||||
|
Common Options:
|
||||||
|
--outdir <path> Output directory (default: "dist")
|
||||||
|
--minify Enable minification (or --minify.whitespace, --minify.syntax, etc)
|
||||||
|
--sourcemap <type> Sourcemap type: none|linked|inline|external
|
||||||
|
--target <target> Build target: browser|bun|node
|
||||||
|
--format <format> Output format: esm|cjs|iife
|
||||||
|
--splitting Enable code splitting
|
||||||
|
--packages <type> Package handling: bundle|external
|
||||||
|
--public-path <path> Public path for assets
|
||||||
|
--env <mode> Environment handling: inline|disable|prefix*
|
||||||
|
--conditions <list> Package.json export conditions (comma separated)
|
||||||
|
--external <list> External packages (comma separated)
|
||||||
|
--banner <text> Add banner text to output
|
||||||
|
--footer <text> Add footer text to output
|
||||||
|
--define <obj> Define global constants (e.g. --define.VERSION=1.0.0)
|
||||||
|
--help, -h Show this help message
|
||||||
|
|
||||||
|
Example:
|
||||||
|
bun run build.ts --outdir=dist --minify --sourcemap=linked --external=react,react-dom
|
||||||
|
`);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const toCamelCase = (str: string): string => str.replace(/-([a-z])/g, g => g[1].toUpperCase());
|
||||||
|
|
||||||
|
const parseValue = (value: string): any => {
|
||||||
|
if (value === "true") return true;
|
||||||
|
if (value === "false") return false;
|
||||||
|
|
||||||
|
if (/^\d+$/.test(value)) return parseInt(value, 10);
|
||||||
|
if (/^\d*\.\d+$/.test(value)) return parseFloat(value);
|
||||||
|
|
||||||
|
if (value.includes(",")) return value.split(",").map(v => v.trim());
|
||||||
|
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
|
function parseArgs(): Partial<Bun.BuildConfig> {
|
||||||
|
const config: Partial<Bun.BuildConfig> = {};
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
|
||||||
|
for (let i = 0; i < args.length; i++) {
|
||||||
|
const arg = args[i];
|
||||||
|
if (arg === undefined) continue;
|
||||||
|
if (!arg.startsWith("--")) continue;
|
||||||
|
|
||||||
|
if (arg.startsWith("--no-")) {
|
||||||
|
const key = toCamelCase(arg.slice(5));
|
||||||
|
config[key] = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!arg.includes("=") && (i === args.length - 1 || args[i + 1]?.startsWith("--"))) {
|
||||||
|
const key = toCamelCase(arg.slice(2));
|
||||||
|
config[key] = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let key: string;
|
||||||
|
let value: string;
|
||||||
|
|
||||||
|
if (arg.includes("=")) {
|
||||||
|
[key, value] = arg.slice(2).split("=", 2) as [string, string];
|
||||||
|
} else {
|
||||||
|
key = arg.slice(2);
|
||||||
|
value = args[++i] ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
key = toCamelCase(key);
|
||||||
|
|
||||||
|
if (key.includes(".")) {
|
||||||
|
const [parentKey, childKey] = key.split(".");
|
||||||
|
config[parentKey] = config[parentKey] || {};
|
||||||
|
config[parentKey][childKey] = parseValue(value);
|
||||||
|
} else {
|
||||||
|
config[key] = parseValue(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatFileSize = (bytes: number): string => {
|
||||||
|
const units = ["B", "KB", "MB", "GB"];
|
||||||
|
let size = bytes;
|
||||||
|
let unitIndex = 0;
|
||||||
|
|
||||||
|
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||||
|
size /= 1024;
|
||||||
|
unitIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${size.toFixed(2)} ${units[unitIndex]}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log("\n🚀 Starting build process...\n");
|
||||||
|
|
||||||
|
const cliConfig = parseArgs();
|
||||||
|
const outdir = cliConfig.outdir || path.join(process.cwd(), "dist");
|
||||||
|
|
||||||
|
if (existsSync(outdir)) {
|
||||||
|
console.log(`🗑️ Cleaning previous build at ${outdir}`);
|
||||||
|
await rm(outdir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = performance.now();
|
||||||
|
|
||||||
|
const entrypoints = [...new Bun.Glob("**.html").scanSync("src")]
|
||||||
|
.map(a => path.resolve("src", a))
|
||||||
|
.filter(dir => !dir.includes("node_modules"));
|
||||||
|
console.log(`📄 Found ${entrypoints.length} HTML ${entrypoints.length === 1 ? "file" : "files"} to process\n`);
|
||||||
|
|
||||||
|
const result = await Bun.build({
|
||||||
|
entrypoints,
|
||||||
|
outdir,
|
||||||
|
plugins: [plugin],
|
||||||
|
minify: true,
|
||||||
|
target: "browser",
|
||||||
|
sourcemap: "linked",
|
||||||
|
define: {
|
||||||
|
"process.env.NODE_ENV": JSON.stringify("production"),
|
||||||
|
},
|
||||||
|
...cliConfig,
|
||||||
|
});
|
||||||
|
|
||||||
|
const end = performance.now();
|
||||||
|
|
||||||
|
const outputTable = result.outputs.map(output => ({
|
||||||
|
File: path.relative(process.cwd(), output.path),
|
||||||
|
Type: output.kind,
|
||||||
|
Size: formatFileSize(output.size),
|
||||||
|
}));
|
||||||
|
|
||||||
|
console.table(outputTable);
|
||||||
|
const buildTime = (end - start).toFixed(2);
|
||||||
|
|
||||||
|
console.log(`\n✅ Build completed in ${buildTime}ms\n`);
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
// Generated by `bun init`
|
||||||
|
|
||||||
|
declare module "*.svg" {
|
||||||
|
/**
|
||||||
|
* A path to the SVG file
|
||||||
|
*/
|
||||||
|
const path: `${string}.svg`;
|
||||||
|
export = path;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module "*.module.css" {
|
||||||
|
/**
|
||||||
|
* A record of class names to their corresponding CSS module classes
|
||||||
|
*/
|
||||||
|
const classes: { readonly [key: string]: string };
|
||||||
|
export = classes;
|
||||||
|
}
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
{
|
||||||
|
"lockfileVersion": 1,
|
||||||
|
"configVersion": 1,
|
||||||
|
"workspaces": {
|
||||||
|
"": {
|
||||||
|
"name": "bun-react-template",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-label": "^2.1.7",
|
||||||
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
|
"bun-plugin-tailwind": "^0.1.2",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"lucide-react": "^0.545.0",
|
||||||
|
"react": "^19",
|
||||||
|
"react-dom": "^19",
|
||||||
|
"tailwind-merge": "^3.3.1",
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bun": "latest",
|
||||||
|
"@types/react": "^19",
|
||||||
|
"@types/react-dom": "^19",
|
||||||
|
"tailwindcss": "^4.1.11",
|
||||||
|
"tw-animate-css": "^1.4.0",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"packages": {
|
||||||
|
"@floating-ui/core": ["@floating-ui/core@1.7.4", "", { "dependencies": { "@floating-ui/utils": "^0.2.10" } }, "sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg=="],
|
||||||
|
|
||||||
|
"@floating-ui/dom": ["@floating-ui/dom@1.7.5", "", { "dependencies": { "@floating-ui/core": "^1.7.4", "@floating-ui/utils": "^0.2.10" } }, "sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg=="],
|
||||||
|
|
||||||
|
"@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.7", "", { "dependencies": { "@floating-ui/dom": "^1.7.5" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-0tLRojf/1Go2JgEVm+3Frg9A3IW8bJgKgdO0BN5RkF//ufuz2joZM63Npau2ff3J6lUVYgDSNzNkR+aH3IVfjg=="],
|
||||||
|
|
||||||
|
"@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="],
|
||||||
|
|
||||||
|
"@oven/bun-darwin-aarch64": ["@oven/bun-darwin-aarch64@1.3.9", "", { "os": "darwin", "cpu": "arm64" }, "sha512-df7smckMWSUfaT5mzwN9Lfpd3ZGkOqo+vmQ8VV2a32gl14v6uZ/qeeo+1RlANXn8M0uzXPWWCkrKZIWSZUR0qw=="],
|
||||||
|
|
||||||
|
"@oven/bun-darwin-x64": ["@oven/bun-darwin-x64@1.3.9", "", { "os": "darwin", "cpu": "x64" }, "sha512-YiLxfsPzQqaVvT2a+nxH9do0YfUjrlxF3tKP0b1DDgvfgCcVKGsrQH3Wa82qHgL4dnT8h2bqi94JxXESEuPmcA=="],
|
||||||
|
|
||||||
|
"@oven/bun-darwin-x64-baseline": ["@oven/bun-darwin-x64-baseline@1.3.9", "", { "os": "darwin", "cpu": "x64" }, "sha512-XbhsA2XAFzvFr0vPSV6SNqGxab4xHKdPmVTLqoSHAx9tffrSq/012BDptOskulwnD+YNsrJUx2D2Ve1xvfgGcg=="],
|
||||||
|
|
||||||
|
"@oven/bun-linux-aarch64": ["@oven/bun-linux-aarch64@1.3.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-VaNQTu0Up4gnwZLQ6/Hmho6jAlLxTQ1PwxEth8EsXHf82FOXXPV5OCQ6KC9mmmocjKlmWFaIGebThrOy8DUo4g=="],
|
||||||
|
|
||||||
|
"@oven/bun-linux-aarch64-musl": ["@oven/bun-linux-aarch64-musl@1.3.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-t8uimCVBTw5f9K2QTZE5wN6UOrFETNrh/Xr7qtXT9nAOzaOnIFvYA+HcHbGfi31fRlCVfTxqm/EiCwJ1gEw9YQ=="],
|
||||||
|
|
||||||
|
"@oven/bun-linux-x64": ["@oven/bun-linux-x64@1.3.9", "", { "os": "linux", "cpu": "x64" }, "sha512-oQyAW3+ugulvXTZ+XYeUMmNPR94sJeMokfHQoKwPvVwhVkgRuMhcLGV2ZesHCADVu30Oz2MFXbgdC8x4/o9dRg=="],
|
||||||
|
|
||||||
|
"@oven/bun-linux-x64-baseline": ["@oven/bun-linux-x64-baseline@1.3.9", "", { "os": "linux", "cpu": "x64" }, "sha512-nZ12g22cy7pEOBwAxz2tp0wVqekaCn9QRKuGTHqOdLlyAqR4SCdErDvDhUWd51bIyHTQoCmj72TegGTgG0WNPw=="],
|
||||||
|
|
||||||
|
"@oven/bun-linux-x64-musl": ["@oven/bun-linux-x64-musl@1.3.9", "", { "os": "linux", "cpu": "x64" }, "sha512-4ZjIUgCxEyKwcKXideB5sX0KJpnHTZtu778w73VNq2uNH2fNpMZv98+DBgJyQ9OfFoRhmKn1bmLmSefvnHzI9w=="],
|
||||||
|
|
||||||
|
"@oven/bun-linux-x64-musl-baseline": ["@oven/bun-linux-x64-musl-baseline@1.3.9", "", { "os": "linux", "cpu": "x64" }, "sha512-3FXQgtYFsT0YOmAdMcJn56pLM5kzSl6y942rJJIl5l2KummB9Ea3J/vMJMzQk7NCAGhleZGWU/pJSS/uXKGa7w=="],
|
||||||
|
|
||||||
|
"@oven/bun-windows-x64": ["@oven/bun-windows-x64@1.3.9", "", { "os": "win32", "cpu": "x64" }, "sha512-/d6vAmgKvkoYlsGPsRPlPmOK1slPis/F40UG02pYwypTH0wmY0smgzdFqR4YmryxFh17XrW1kITv+U99Oajk9Q=="],
|
||||||
|
|
||||||
|
"@oven/bun-windows-x64-baseline": ["@oven/bun-windows-x64-baseline@1.3.9", "", { "os": "win32", "cpu": "x64" }, "sha512-a/+hSrrDpMD7THyXvE2KJy1skxzAD0cnW4K1WjuI/91VqsphjNzvf5t/ZgxEVL4wb6f+hKrSJ5J3aH47zPr61g=="],
|
||||||
|
|
||||||
|
"@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="],
|
||||||
|
|
||||||
|
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.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-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "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-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-compose-refs": ["@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-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "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-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1" }, "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-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-id": ["@radix-ui/react-id@1.1.1", "", { "dependencies": { "@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-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-label": ["@radix-ui/react-label@2.1.8", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.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-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.8", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "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-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "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-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="],
|
||||||
|
|
||||||
|
"@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-select": ["@radix-ui/react-select@2.2.6", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.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-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "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-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="],
|
||||||
|
|
||||||
|
"@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=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-use-effect-event": ["@radix-ui/react-use-effect-event@0.0.2", "", { "dependencies": { "@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-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-use-escape-keydown": ["@radix-ui/react-use-escape-keydown@1.1.1", "", { "dependencies": { "@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-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-use-layout-effect": ["@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-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-use-previous": ["@radix-ui/react-use-previous@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-use-rect": ["@radix-ui/react-use-rect@1.1.1", "", { "dependencies": { "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.1", "", { "dependencies": { "@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-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-visually-hidden": ["@radix-ui/react-visually-hidden@1.2.3", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.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-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug=="],
|
||||||
|
|
||||||
|
"@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="],
|
||||||
|
|
||||||
|
"@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="],
|
||||||
|
|
||||||
|
"@types/node": ["@types/node@25.3.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A=="],
|
||||||
|
|
||||||
|
"@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="],
|
||||||
|
|
||||||
|
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
|
||||||
|
|
||||||
|
"aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="],
|
||||||
|
|
||||||
|
"bun": ["bun@1.3.9", "", { "optionalDependencies": { "@oven/bun-darwin-aarch64": "1.3.9", "@oven/bun-darwin-x64": "1.3.9", "@oven/bun-darwin-x64-baseline": "1.3.9", "@oven/bun-linux-aarch64": "1.3.9", "@oven/bun-linux-aarch64-musl": "1.3.9", "@oven/bun-linux-x64": "1.3.9", "@oven/bun-linux-x64-baseline": "1.3.9", "@oven/bun-linux-x64-musl": "1.3.9", "@oven/bun-linux-x64-musl-baseline": "1.3.9", "@oven/bun-windows-x64": "1.3.9", "@oven/bun-windows-x64-baseline": "1.3.9" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ], "bin": { "bun": "bin/bun.exe", "bunx": "bin/bunx.exe" } }, "sha512-v5hkh1us7sMNjfimWE70flYbD5I1/qWQaqmJ45q2qk5H/7muQVa478LSVRSFyGTBUBog2LsPQnfIRdjyWJRY+A=="],
|
||||||
|
|
||||||
|
"bun-plugin-tailwind": ["bun-plugin-tailwind@0.1.2", "", { "peerDependencies": { "bun": ">=1.0.0" } }, "sha512-41jNC1tZRSK3s1o7pTNrLuQG8kL/0vR/JgiTmZAJ1eHwe0w5j6HFPKeqEk0WAD13jfrUC7+ULuewFBBCoADPpg=="],
|
||||||
|
|
||||||
|
"bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="],
|
||||||
|
|
||||||
|
"class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
|
||||||
|
|
||||||
|
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
||||||
|
|
||||||
|
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||||
|
|
||||||
|
"detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="],
|
||||||
|
|
||||||
|
"get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="],
|
||||||
|
|
||||||
|
"lucide-react": ["lucide-react@0.545.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-7r1/yUuflQDSt4f1bpn5ZAocyIxcTyVyBBChSVtBKn5M+392cPmI5YJMWOJKk/HUWGm5wg83chlAZtCcGbEZtw=="],
|
||||||
|
|
||||||
|
"react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="],
|
||||||
|
|
||||||
|
"react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="],
|
||||||
|
|
||||||
|
"react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="],
|
||||||
|
|
||||||
|
"react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="],
|
||||||
|
|
||||||
|
"react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
|
||||||
|
|
||||||
|
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
|
||||||
|
|
||||||
|
"tailwind-merge": ["tailwind-merge@3.5.0", "", {}, "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A=="],
|
||||||
|
|
||||||
|
"tailwindcss": ["tailwindcss@4.2.1", "", {}, "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw=="],
|
||||||
|
|
||||||
|
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||||
|
|
||||||
|
"tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="],
|
||||||
|
|
||||||
|
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
||||||
|
|
||||||
|
"use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="],
|
||||||
|
|
||||||
|
"use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-arrow/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "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-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-collection/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "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-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-collection/@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=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-dismissable-layer/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "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-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-focus-scope/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "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-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-popper/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "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-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-portal/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "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-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-select/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "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-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-select/@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=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-visually-hidden/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "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-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-arrow/@radix-ui/react-primitive/@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=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-dismissable-layer/@radix-ui/react-primitive/@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=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-focus-scope/@radix-ui/react-primitive/@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=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-popper/@radix-ui/react-primitive/@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=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-portal/@radix-ui/react-primitive/@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=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-visually-hidden/@radix-ui/react-primitive/@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=="],
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
[serve.static]
|
||||||
|
plugins = ["bun-plugin-tailwind"]
|
||||||
|
env = "BUN_PUBLIC_*"
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "new-york",
|
||||||
|
"rsc": false,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "",
|
||||||
|
"css": "styles/globals.css",
|
||||||
|
"baseColor": "neutral",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks"
|
||||||
|
},
|
||||||
|
"iconLibrary": "lucide"
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"name": "bun-react-template",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "bun --hot src/index.ts",
|
||||||
|
"start": "NODE_ENV=production bun src/index.ts",
|
||||||
|
"build": "bun run build.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-label": "^2.1.7",
|
||||||
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
|
"bun-plugin-tailwind": "^0.1.2",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"lucide-react": "^0.545.0",
|
||||||
|
"react": "^19",
|
||||||
|
"react-dom": "^19",
|
||||||
|
"tailwind-merge": "^3.3.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^19",
|
||||||
|
"@types/react-dom": "^19",
|
||||||
|
"@types/bun": "latest",
|
||||||
|
"tailwindcss": "^4.1.11",
|
||||||
|
"tw-animate-css": "^1.4.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import "./index.css";
|
||||||
|
|
||||||
|
export function App() {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-screen">
|
||||||
|
<h1>Let's build together</h1>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { Slot } from "@radix-ui/react-slot";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||||
|
outline:
|
||||||
|
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||||
|
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
|
ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||||
|
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||||
|
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||||
|
icon: "size-9",
|
||||||
|
"icon-sm": "size-8",
|
||||||
|
"icon-lg": "size-10",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
function Button({
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
size,
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"button"> &
|
||||||
|
VariantProps<typeof buttonVariants> & {
|
||||||
|
asChild?: boolean;
|
||||||
|
}) {
|
||||||
|
const Comp = asChild ? Slot : "button";
|
||||||
|
|
||||||
|
return <Comp data-slot="button" className={cn(buttonVariants({ variant, size, className }))} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Button, buttonVariants };
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card"
|
||||||
|
className={cn("bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-header"
|
||||||
|
className={cn(
|
||||||
|
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return <div data-slot="card-title" className={cn("leading-none font-semibold", className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return <div data-slot="card-description" className={cn("text-muted-foreground text-sm", className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-action"
|
||||||
|
className={cn("col-start-2 row-span-2 row-start-1 self-start justify-self-end", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return <div data-slot="card-content" className={cn("px-6", className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div data-slot="card-footer" className={cn("flex items-center px-6 [.border-t]:pt-6", className)} {...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardTitle };
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
data-slot="input"
|
||||||
|
className={cn(
|
||||||
|
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
|
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||||
|
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Input };
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function Label({ className, ...props }: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<LabelPrimitive.Root
|
||||||
|
data-slot="label"
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Label };
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||||
|
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function Select({ ...props }: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||||
|
return <SelectPrimitive.Root data-slot="select" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectGroup({ ...props }: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||||
|
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectValue({ ...props }: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||||
|
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectTrigger({
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||||
|
size?: "sm" | "default";
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Trigger
|
||||||
|
data-slot="select-trigger"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SelectPrimitive.Icon asChild>
|
||||||
|
<ChevronDownIcon className="size-4 opacity-50" />
|
||||||
|
</SelectPrimitive.Icon>
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
position = "popper",
|
||||||
|
align = "center",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Portal>
|
||||||
|
<SelectPrimitive.Content
|
||||||
|
data-slot="select-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground 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 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
|
||||||
|
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}
|
||||||
|
align={align}
|
||||||
|
{...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)] scroll-my-1",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SelectPrimitive.Viewport>
|
||||||
|
<SelectScrollDownButton />
|
||||||
|
</SelectPrimitive.Content>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectLabel({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Label
|
||||||
|
data-slot="select-label"
|
||||||
|
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectItem({ className, children, ...props }: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
data-slot="select-item"
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute right-2 flex size-3.5 items-center justify-center">
|
||||||
|
<SelectPrimitive.ItemIndicator>
|
||||||
|
<CheckIcon className="size-4" />
|
||||||
|
</SelectPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
|
</SelectPrimitive.Item>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectSeparator({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Separator
|
||||||
|
data-slot="select-separator"
|
||||||
|
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectScrollUpButton({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.ScrollUpButton
|
||||||
|
data-slot="select-scroll-up-button"
|
||||||
|
className={cn("flex cursor-default items-center justify-center py-1", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronUpIcon className="size-4" />
|
||||||
|
</SelectPrimitive.ScrollUpButton>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectScrollDownButton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.ScrollDownButton
|
||||||
|
data-slot="select-scroll-down-button"
|
||||||
|
className={cn("flex cursor-default items-center justify-center py-1", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronDownIcon className="size-4" />
|
||||||
|
</SelectPrimitive.ScrollDownButton>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectLabel,
|
||||||
|
SelectScrollDownButton,
|
||||||
|
SelectScrollUpButton,
|
||||||
|
SelectSeparator,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
};
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
data-slot="textarea"
|
||||||
|
className={cn(
|
||||||
|
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Textarea };
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* This file is the entry point for the React app.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { App } from "./App";
|
||||||
|
|
||||||
|
const elem = document.getElementById("root")!;
|
||||||
|
createRoot(elem).render(<App />);
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
@import "../styles/globals.css";
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
:root {
|
||||||
|
@apply font-sans;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
@apply grid place-items-center min-w-[320px] min-h-screen relative m-0 bg-background text-foreground;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* cool Bun background animation 😎 */
|
||||||
|
/* body::before {
|
||||||
|
content: "";
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: -1;
|
||||||
|
opacity: 0.05;
|
||||||
|
background: url("./logo.svg");
|
||||||
|
background-size: 256px;
|
||||||
|
transform: rotate(-12deg) scale(1.35);
|
||||||
|
animation: slide 30s linear infinite;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slide {
|
||||||
|
from {
|
||||||
|
background-position: 0 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
background-position: 256px 224px;
|
||||||
|
}
|
||||||
|
} */
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion) {
|
||||||
|
*,
|
||||||
|
::before,
|
||||||
|
::after {
|
||||||
|
animation: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Template</title>
|
||||||
|
<script type="module" src="./frontend.tsx" async></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { serve } from "bun";
|
||||||
|
import index from "./index.html";
|
||||||
|
|
||||||
|
const server = serve({
|
||||||
|
routes: {
|
||||||
|
// Serve index.html for all unmatched routes.
|
||||||
|
"/*": index,
|
||||||
|
|
||||||
|
"/api/hello": {
|
||||||
|
async GET(req) {
|
||||||
|
return Response.json({
|
||||||
|
message: "Hello, world!",
|
||||||
|
method: "GET",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
async PUT(req) {
|
||||||
|
return Response.json({
|
||||||
|
message: "Hello, world!",
|
||||||
|
method: "PUT",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
"/api/hello/:name": async req => {
|
||||||
|
const name = req.params.name;
|
||||||
|
return Response.json({
|
||||||
|
message: `Hello, ${name}!`,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
development: process.env.NODE_ENV !== "production" && {
|
||||||
|
// Enable browser hot reloading in development
|
||||||
|
hmr: true,
|
||||||
|
|
||||||
|
// Echo console logs from the browser to the server
|
||||||
|
console: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`🚀 Server running at ${server.url}`);
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { type ClassValue, clsx } from "clsx";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
@import "tw-animate-css";
|
||||||
|
|
||||||
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
--color-background: var(--background);
|
||||||
|
--color-foreground: var(--foreground);
|
||||||
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
|
--color-sidebar-accent: var(--sidebar-accent);
|
||||||
|
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||||
|
--color-sidebar-primary: var(--sidebar-primary);
|
||||||
|
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||||
|
--color-sidebar: var(--sidebar);
|
||||||
|
--color-chart-5: var(--chart-5);
|
||||||
|
--color-chart-4: var(--chart-4);
|
||||||
|
--color-chart-3: var(--chart-3);
|
||||||
|
--color-chart-2: var(--chart-2);
|
||||||
|
--color-chart-1: var(--chart-1);
|
||||||
|
--color-ring: var(--ring);
|
||||||
|
--color-input: var(--input);
|
||||||
|
--color-border: var(--border);
|
||||||
|
--color-destructive: var(--destructive);
|
||||||
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
|
--color-accent: var(--accent);
|
||||||
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
|
--color-muted: var(--muted);
|
||||||
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
|
--color-secondary: var(--secondary);
|
||||||
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
|
--color-primary: var(--primary);
|
||||||
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
|
--color-popover: var(--popover);
|
||||||
|
--color-card-foreground: var(--card-foreground);
|
||||||
|
--color-card: var(--card);
|
||||||
|
--radius-sm: calc(var(--radius) - 4px);
|
||||||
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
|
--radius-lg: var(--radius);
|
||||||
|
--radius-xl: calc(var(--radius) + 4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--radius: 0.625rem;
|
||||||
|
--background: oklch(1 0 0);
|
||||||
|
--foreground: oklch(0.145 0 0);
|
||||||
|
--card: oklch(1 0 0);
|
||||||
|
--card-foreground: oklch(0.145 0 0);
|
||||||
|
--popover: oklch(1 0 0);
|
||||||
|
--popover-foreground: oklch(0.145 0 0);
|
||||||
|
--primary: oklch(0.205 0 0);
|
||||||
|
--primary-foreground: oklch(0.985 0 0);
|
||||||
|
--secondary: oklch(0.97 0 0);
|
||||||
|
--secondary-foreground: oklch(0.205 0 0);
|
||||||
|
--muted: oklch(0.97 0 0);
|
||||||
|
--muted-foreground: oklch(0.556 0 0);
|
||||||
|
--accent: oklch(0.97 0 0);
|
||||||
|
--accent-foreground: oklch(0.205 0 0);
|
||||||
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
|
--border: oklch(0.922 0 0);
|
||||||
|
--input: oklch(0.922 0 0);
|
||||||
|
--ring: oklch(0.708 0 0);
|
||||||
|
--chart-1: oklch(0.646 0.222 41.116);
|
||||||
|
--chart-2: oklch(0.6 0.118 184.704);
|
||||||
|
--chart-3: oklch(0.398 0.07 227.392);
|
||||||
|
--chart-4: oklch(0.828 0.189 84.429);
|
||||||
|
--chart-5: oklch(0.769 0.188 70.08);
|
||||||
|
--sidebar: oklch(0.985 0 0);
|
||||||
|
--sidebar-foreground: oklch(0.145 0 0);
|
||||||
|
--sidebar-primary: oklch(0.205 0 0);
|
||||||
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-accent: oklch(0.97 0 0);
|
||||||
|
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||||
|
--sidebar-border: oklch(0.922 0 0);
|
||||||
|
--sidebar-ring: oklch(0.708 0 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: oklch(0.145 0 0);
|
||||||
|
--foreground: oklch(0.985 0 0);
|
||||||
|
--card: oklch(0.205 0 0);
|
||||||
|
--card-foreground: oklch(0.985 0 0);
|
||||||
|
--popover: oklch(0.205 0 0);
|
||||||
|
--popover-foreground: oklch(0.985 0 0);
|
||||||
|
--primary: oklch(0.922 0 0);
|
||||||
|
--primary-foreground: oklch(0.205 0 0);
|
||||||
|
--secondary: oklch(0.269 0 0);
|
||||||
|
--secondary-foreground: oklch(0.985 0 0);
|
||||||
|
--muted: oklch(0.269 0 0);
|
||||||
|
--muted-foreground: oklch(0.708 0 0);
|
||||||
|
--accent: oklch(0.269 0 0);
|
||||||
|
--accent-foreground: oklch(0.985 0 0);
|
||||||
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
|
--border: oklch(1 0 0 / 10%);
|
||||||
|
--input: oklch(1 0 0 / 15%);
|
||||||
|
--ring: oklch(0.556 0 0);
|
||||||
|
--chart-1: oklch(0.488 0.243 264.376);
|
||||||
|
--chart-2: oklch(0.696 0.17 162.48);
|
||||||
|
--chart-3: oklch(0.769 0.188 70.08);
|
||||||
|
--chart-4: oklch(0.627 0.265 303.9);
|
||||||
|
--chart-5: oklch(0.645 0.246 16.439);
|
||||||
|
--sidebar: oklch(0.205 0 0);
|
||||||
|
--sidebar-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||||
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-accent: oklch(0.269 0 0);
|
||||||
|
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-border: oklch(1 0 0 / 10%);
|
||||||
|
--sidebar-ring: oklch(0.556 0 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border outline-ring/50;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
// Environment setup & latest features
|
||||||
|
"lib": ["ESNext", "DOM"],
|
||||||
|
"target": "ESNext",
|
||||||
|
"module": "Preserve",
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"allowJs": true,
|
||||||
|
|
||||||
|
// Bundler mode
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
// Best practices
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"noImplicitOverride": true,
|
||||||
|
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
},
|
||||||
|
|
||||||
|
// Some stricter flags (disabled by default)
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false,
|
||||||
|
"noPropertyAccessFromIndexSignature": false
|
||||||
|
},
|
||||||
|
|
||||||
|
"exclude": ["dist", "node_modules"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
import { RefreshCw, Play, Square, Loader2 } from 'lucide-react';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
|
import { useClient } from 'hooks/useClient';
|
||||||
|
import { useSettings } from 'state/useSettings';
|
||||||
|
|
||||||
|
type VoiceGroup = { label: string; voices: string[] };
|
||||||
|
|
||||||
|
type TtsConfig = {
|
||||||
|
provider: string;
|
||||||
|
url: string;
|
||||||
|
apiKey?: string;
|
||||||
|
model: string;
|
||||||
|
voice: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SERVER_DEFAULT = '__server_default__';
|
||||||
|
|
||||||
|
export const VoicePreference = () => {
|
||||||
|
const client = useClient();
|
||||||
|
const { settings, saveSettings } = useSettings();
|
||||||
|
const [voices, setVoices] = useState<string[]>([]);
|
||||||
|
const [groups, setGroups] = useState<VoiceGroup[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [serverConfig, setServerConfig] = useState<TtsConfig | null>(null);
|
||||||
|
const [listening, setListening] = useState<'idle' | 'loading' | 'playing'>('idle');
|
||||||
|
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||||
|
|
||||||
|
const fetchConfig = async () => {
|
||||||
|
try {
|
||||||
|
const config = await client.get<TtsConfig | null>('/server-settings/tts');
|
||||||
|
setServerConfig(config);
|
||||||
|
return config;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchVoices = async (config: TtsConfig) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await client.post<{ voices?: string[]; groups?: VoiceGroup[] }>('/server-settings/tts/voices', {
|
||||||
|
provider: config.provider,
|
||||||
|
url: config.url,
|
||||||
|
apiKey: config.apiKey || undefined,
|
||||||
|
model: config.model || undefined,
|
||||||
|
});
|
||||||
|
setVoices(res.voices ?? []);
|
||||||
|
setGroups(res.groups ?? []);
|
||||||
|
} catch {
|
||||||
|
setVoices([]);
|
||||||
|
setGroups([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchConfig().then((config) => {
|
||||||
|
if (config) fetchVoices(config);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleChange = (value: string) => {
|
||||||
|
const voice = value === SERVER_DEFAULT ? null : value;
|
||||||
|
saveSettings({ ...settings, tts: { voice } });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRefresh = () => {
|
||||||
|
if (serverConfig) fetchVoices(serverConfig);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleListen = async () => {
|
||||||
|
if (listening === 'loading') return;
|
||||||
|
if (listening === 'playing') {
|
||||||
|
audioRef.current?.pause();
|
||||||
|
audioRef.current = null;
|
||||||
|
setListening('idle');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!serverConfig) return;
|
||||||
|
setListening('loading');
|
||||||
|
try {
|
||||||
|
const effectiveVoice = settings.tts.voice ?? serverConfig.voice;
|
||||||
|
const res = await fetch(`${client.baseUrl}/server-settings/tts/test`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${client.token}` },
|
||||||
|
body: JSON.stringify({ ...serverConfig, voice: effectiveVoice }),
|
||||||
|
});
|
||||||
|
if (!res.ok || res.headers.get('content-type')?.includes('json')) {
|
||||||
|
setListening('idle');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const blob = await res.blob();
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const audio = new Audio(url);
|
||||||
|
audioRef.current = audio;
|
||||||
|
audio.onended = () => { audioRef.current = null; setListening('idle'); URL.revokeObjectURL(url); };
|
||||||
|
audio.onerror = () => { audioRef.current = null; setListening('idle'); URL.revokeObjectURL(url); };
|
||||||
|
await audio.play();
|
||||||
|
setListening('playing');
|
||||||
|
} catch {
|
||||||
|
setListening('idle');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectedValue = settings.tts.voice ?? SERVER_DEFAULT;
|
||||||
|
const prettify = (v: string) => v.replace(/^[a-z]{2}_/, '').replace(/^\w/, (c) => c.toUpperCase());
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-4">
|
||||||
|
<Label className="grid gap-2">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="text-duck-dark/70 dark:text-foreground/70">Voice</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleRefresh}
|
||||||
|
disabled={loading}
|
||||||
|
className="p-0.5 rounded hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer transition-colors disabled:opacity-40"
|
||||||
|
title="Refresh voices"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`h-3 w-3 text-duck-dark/50 dark:text-foreground/50 ${loading ? 'animate-spin' : ''}`} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Select value={selectedValue} onValueChange={handleChange}>
|
||||||
|
<SelectTrigger className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark dark:text-foreground">
|
||||||
|
<SelectValue placeholder="Server default" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="z-[600] max-h-[300px]">
|
||||||
|
<SelectItem value={SERVER_DEFAULT}>Server default{serverConfig?.voice ? ` (${prettify(serverConfig.voice)})` : ''}</SelectItem>
|
||||||
|
{groups.length > 0
|
||||||
|
? groups.map((g) => (
|
||||||
|
<SelectGroup key={g.label}>
|
||||||
|
<SelectLabel className="text-xs font-semibold text-duck-dark/50 dark:text-foreground/50">{g.label}</SelectLabel>
|
||||||
|
{g.voices.map((v) => (
|
||||||
|
<SelectItem key={v} value={v}>{prettify(v)}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectGroup>
|
||||||
|
))
|
||||||
|
: voices.map((v) => (
|
||||||
|
<SelectItem key={v} value={v}>{v}</SelectItem>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleListen}
|
||||||
|
disabled={listening === 'loading' || !serverConfig}
|
||||||
|
className="shrink-0 h-11 w-11 flex items-center justify-center rounded-md border border-duck-dark/20 bg-background/60 hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer transition-colors disabled:opacity-40 disabled:cursor-default"
|
||||||
|
title={listening === 'playing' ? 'Stop' : 'Listen'}
|
||||||
|
>
|
||||||
|
{listening === 'loading' ? (
|
||||||
|
<Loader2 className="h-4 w-4 text-duck-dark/70 dark:text-foreground/70 animate-spin" />
|
||||||
|
) : listening === 'playing' ? (
|
||||||
|
<Square className="h-4 w-4 text-duck-dark/70 dark:text-foreground/70" />
|
||||||
|
) : (
|
||||||
|
<Play className="h-4 w-4 text-duck-dark/70 dark:text-foreground/70" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-duck-dark/40 dark:text-foreground/40">Choose a voice for text-to-speech. Leave as server default to use the admin-configured voice.</span>
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { User, Lock, Globe, Bot, LayoutGrid } from 'lucide-react';
|
import { User, Lock, Globe, Bot, LayoutGrid, Volume2 } from 'lucide-react';
|
||||||
import type { LayoutNode, PanelComponents } from 'officerdev';
|
import type { LayoutNode, PanelComponents } from 'officerdev';
|
||||||
import { WorkspaceLayout } from 'officerdev';
|
import { WorkspaceLayout } from 'officerdev';
|
||||||
|
|
||||||
@@ -9,6 +9,7 @@ import { ChangePassword } from './ChangePassword';
|
|||||||
import { Languages } from './Languages';
|
import { Languages } from './Languages';
|
||||||
import { AIModels } from './AIModels';
|
import { AIModels } from './AIModels';
|
||||||
import { DockSettings } from './DockSettings';
|
import { DockSettings } from './DockSettings';
|
||||||
|
import { VoicePreference } from './VoicePreference';
|
||||||
|
|
||||||
const GLOBAL_KEY = 'PROFILE_SETTINGS_SELECTED';
|
const GLOBAL_KEY = 'PROFILE_SETTINGS_SELECTED';
|
||||||
|
|
||||||
@@ -17,6 +18,7 @@ const sections: SettingsSection[] = [
|
|||||||
{ key: 'change-password', icon: Lock, title: 'Change Password', description: 'Update your password', content: <ChangePassword /> },
|
{ key: 'change-password', icon: Lock, title: 'Change Password', description: 'Update your password', content: <ChangePassword /> },
|
||||||
{ key: 'ai-models', icon: Bot, title: 'AI Models', description: 'Default models for chat, projects, and tasks', content: <AIModels /> },
|
{ key: 'ai-models', icon: Bot, title: 'AI Models', description: 'Default models for chat, projects, and tasks', content: <AIModels /> },
|
||||||
{ key: 'languages', icon: Globe, title: 'Languages', description: 'Spoken, default, and translation', content: <Languages /> },
|
{ key: 'languages', icon: Globe, title: 'Languages', description: 'Spoken, default, and translation', content: <Languages /> },
|
||||||
|
{ key: 'voice', icon: Volume2, title: 'Voice', description: 'Text-to-speech voice preference', content: <VoicePreference /> },
|
||||||
{ key: 'dock', icon: LayoutGrid, title: 'Dock', description: 'Choose and reorder dock items', content: <DockSettings /> },
|
{ key: 'dock', icon: LayoutGrid, title: 'Dock', description: 'Choose and reorder dock items', content: <DockSettings /> },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { RefreshCw } from 'lucide-react';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
import { useClient } from 'hooks/useClient';
|
import { useClient } from 'hooks/useClient';
|
||||||
|
|
||||||
type Provider = 'openai' | 'elevenlabs';
|
type Provider = 'openai' | 'elevenlabs';
|
||||||
@@ -39,20 +39,23 @@ export const TTSSection = () => {
|
|||||||
const [model, setModel] = useState('kokoro');
|
const [model, setModel] = useState('kokoro');
|
||||||
const [voice, setVoice] = useState('af_heart');
|
const [voice, setVoice] = useState('af_heart');
|
||||||
const [voices, setVoices] = useState<string[]>([]);
|
const [voices, setVoices] = useState<string[]>([]);
|
||||||
|
const [voiceGroups, setVoiceGroups] = useState<{ label: string; voices: string[] }[]>([]);
|
||||||
const [voicesLoading, setVoicesLoading] = useState(false);
|
const [voicesLoading, setVoicesLoading] = useState(false);
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const [isTesting, setIsTesting] = useState(false);
|
const [isTesting, setIsTesting] = useState(false);
|
||||||
|
|
||||||
const fetchVoices = async (p: Provider, u: string, key: string) => {
|
const fetchVoices = async (p: Provider, u: string, key: string, m?: string) => {
|
||||||
setVoicesLoading(true);
|
setVoicesLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await client.post<{ voices?: string[]; error?: string }>('/server-settings/tts/voices', {
|
const res = await client.post<{ voices?: string[]; groups?: { label: string; voices: string[] }[]; error?: string }>('/server-settings/tts/voices', {
|
||||||
provider: p,
|
provider: p,
|
||||||
url: u,
|
url: u,
|
||||||
apiKey: key || undefined,
|
apiKey: key || undefined,
|
||||||
|
model: m || undefined,
|
||||||
});
|
});
|
||||||
if (res.voices) setVoices(res.voices);
|
if (res.voices) setVoices(res.voices);
|
||||||
else setVoices([]);
|
else setVoices([]);
|
||||||
|
setVoiceGroups(res.groups ?? []);
|
||||||
} catch {
|
} catch {
|
||||||
setVoices([]);
|
setVoices([]);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -67,7 +70,7 @@ export const TTSSection = () => {
|
|||||||
setApiKey(config.apiKey ?? '');
|
setApiKey(config.apiKey ?? '');
|
||||||
setModel(config.model);
|
setModel(config.model);
|
||||||
setVoice(config.voice);
|
setVoice(config.voice);
|
||||||
fetchVoices(config.provider, config.url ?? '', config.apiKey ?? '');
|
fetchVoices(config.provider, config.url ?? '', config.apiKey ?? '', config.model);
|
||||||
}, [config]);
|
}, [config]);
|
||||||
|
|
||||||
const handleProviderChange = (v: Provider) => {
|
const handleProviderChange = (v: Provider) => {
|
||||||
@@ -189,7 +192,7 @@ export const TTSSection = () => {
|
|||||||
<span className="text-duck-dark/70 dark:text-foreground/70">Voice</span>
|
<span className="text-duck-dark/70 dark:text-foreground/70">Voice</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => fetchVoices(provider, url, apiKey)}
|
onClick={() => fetchVoices(provider, url, apiKey, model)}
|
||||||
disabled={voicesLoading}
|
disabled={voicesLoading}
|
||||||
className="p-0.5 rounded hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer transition-colors disabled:opacity-40"
|
className="p-0.5 rounded hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer transition-colors disabled:opacity-40"
|
||||||
title="Refresh voices"
|
title="Refresh voices"
|
||||||
@@ -203,9 +206,19 @@ export const TTSSection = () => {
|
|||||||
<SelectValue placeholder="Select a voice" />
|
<SelectValue placeholder="Select a voice" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent className="z-[600] max-h-[300px]">
|
<SelectContent className="z-[600] max-h-[300px]">
|
||||||
{voices.map((v) => (
|
{voiceGroups.length > 0
|
||||||
<SelectItem key={v} value={v}>{v}</SelectItem>
|
? voiceGroups.map((g) => (
|
||||||
))}
|
<SelectGroup key={g.label}>
|
||||||
|
<SelectLabel className="text-xs font-semibold text-duck-dark/50 dark:text-foreground/50">{g.label}</SelectLabel>
|
||||||
|
{g.voices.map((v) => (
|
||||||
|
<SelectItem key={v} value={v}>{v.replace(/^[a-z]{2}_/, '').replace(/^\w/, (c) => c.toUpperCase())}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectGroup>
|
||||||
|
))
|
||||||
|
: voices.map((v) => (
|
||||||
|
<SelectItem key={v} value={v}>{v}</SelectItem>
|
||||||
|
))
|
||||||
|
}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -9,6 +9,17 @@ import { readTtsConfig } from '@@/api/server-settings/tts';
|
|||||||
import { readSttConfig } from '@@/api/server-settings/stt';
|
import { readSttConfig } from '@@/api/server-settings/stt';
|
||||||
import { readOcrConfig } from '@@/api/server-settings/ocr';
|
import { readOcrConfig } from '@@/api/server-settings/ocr';
|
||||||
|
|
||||||
|
async function getUserTtsVoice(email: string): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const settingsFile = Bun.file(getUserSettingsFile(email));
|
||||||
|
if (await settingsFile.exists()) {
|
||||||
|
const settings = (await settingsFile.json()) as { tts?: { voice?: string | null } };
|
||||||
|
return settings.tts?.voice ?? null;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const DEFAULT_HOME_DIRS = ['Downloads', 'Documents', 'Music', 'Video', 'Pictures', 'Onboarding'];
|
const DEFAULT_HOME_DIRS = ['Downloads', 'Documents', 'Music', 'Video', 'Pictures', 'Onboarding'];
|
||||||
const OLD_CACHE_DIRS = ['ocr', 'tts', 'transcriptions', 'audio', 'video'];
|
const OLD_CACHE_DIRS = ['ocr', 'tts', 'transcriptions', 'audio', 'video'];
|
||||||
const ONBOARDING_SEED = join(DATA_PATH, 'Onboarding');
|
const ONBOARDING_SEED = join(DATA_PATH, 'Onboarding');
|
||||||
@@ -373,18 +384,22 @@ router.post('/tts', async (ctx) => {
|
|||||||
const s = await stat(absPath);
|
const s = await stat(absPath);
|
||||||
if (s.isDirectory()) throw errors.BAD_REQUEST('Cannot read aloud a directory');
|
if (s.isDirectory()) throw errors.BAD_REQUEST('Cannot read aloud a directory');
|
||||||
|
|
||||||
|
const ttsConfig = await readTtsConfig();
|
||||||
|
if (!ttsConfig) throw errors.BAD_REQUEST('TTS not configured — set it up in Settings → Text to Speech');
|
||||||
|
|
||||||
|
const userVoice = await getUserTtsVoice(user.email);
|
||||||
|
const voice = userVoice ?? ttsConfig.voice;
|
||||||
|
|
||||||
const userDataDir = getUserDataDir(user.email);
|
const userDataDir = getUserDataDir(user.email);
|
||||||
const { dir, name } = parsePath(filePath.replace(/^\/+/, ''));
|
const { dir, name } = parsePath(filePath.replace(/^\/+/, ''));
|
||||||
const cacheRel = dir ? `cache/tts/${dir}/${name}.mp3` : `cache/tts/${name}.mp3`;
|
const voicePrefix = `${voice}/`;
|
||||||
|
const cacheRel = dir ? `cache/tts/${voicePrefix}${dir}/${name}.mp3` : `cache/tts/${voicePrefix}${name}.mp3`;
|
||||||
const cacheAbs = resolve(userDataDir, cacheRel);
|
const cacheAbs = resolve(userDataDir, cacheRel);
|
||||||
|
|
||||||
if (existsSync(cacheAbs)) {
|
if (existsSync(cacheAbs)) {
|
||||||
return ctx.json({ audioPath: cacheRel, audioRoot: 'user-data' });
|
return ctx.json({ audioPath: cacheRel, audioRoot: 'user-data' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const ttsConfig = await readTtsConfig();
|
|
||||||
if (!ttsConfig) throw errors.BAD_REQUEST('TTS not configured — set it up in Settings → Text to Speech');
|
|
||||||
|
|
||||||
const content = await readFile(absPath, 'utf-8');
|
const content = await readFile(absPath, 'utf-8');
|
||||||
|
|
||||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||||
@@ -392,7 +407,7 @@ router.post('/tts', async (ctx) => {
|
|||||||
|
|
||||||
if (ttsConfig.provider === 'elevenlabs') {
|
if (ttsConfig.provider === 'elevenlabs') {
|
||||||
if (!ttsConfig.apiKey) throw errors.BAD_REQUEST('ElevenLabs API key not configured');
|
if (!ttsConfig.apiKey) throw errors.BAD_REQUEST('ElevenLabs API key not configured');
|
||||||
res = await fetch(`https://api.elevenlabs.io/v1/text-to-speech/${encodeURIComponent(ttsConfig.voice)}`, {
|
res = await fetch(`https://api.elevenlabs.io/v1/text-to-speech/${encodeURIComponent(voice)}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json', 'xi-api-key': ttsConfig.apiKey },
|
headers: { 'Content-Type': 'application/json', 'xi-api-key': ttsConfig.apiKey },
|
||||||
body: JSON.stringify({ text: content, model_id: ttsConfig.model }),
|
body: JSON.stringify({ text: content, model_id: ttsConfig.model }),
|
||||||
@@ -402,7 +417,7 @@ router.post('/tts', async (ctx) => {
|
|||||||
res = await fetch(`${ttsConfig.url.replace(/\/+$/, '')}/v1/audio/speech`, {
|
res = await fetch(`${ttsConfig.url.replace(/\/+$/, '')}/v1/audio/speech`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers,
|
headers,
|
||||||
body: JSON.stringify({ model: ttsConfig.model, input: content, voice: ttsConfig.voice, response_format: 'mp3' }),
|
body: JSON.stringify({ model: ttsConfig.model, input: content, voice, response_format: 'mp3' }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (!res.ok) throw errors.BAD_REQUEST('TTS request failed');
|
if (!res.ok) throw errors.BAD_REQUEST('TTS request failed');
|
||||||
@@ -421,23 +436,26 @@ router.post('/tts-text', async (ctx) => {
|
|||||||
if (!text) throw errors.BAD_REQUEST('text is required');
|
if (!text) throw errors.BAD_REQUEST('text is required');
|
||||||
if (!id) throw errors.BAD_REQUEST('id is required');
|
if (!id) throw errors.BAD_REQUEST('id is required');
|
||||||
|
|
||||||
|
const ttsConfig = await readTtsConfig();
|
||||||
|
if (!ttsConfig) throw errors.BAD_REQUEST('TTS not configured — set it up in Settings → Text to Speech');
|
||||||
|
|
||||||
|
const userVoice = await getUserTtsVoice(user.email);
|
||||||
|
const voice = userVoice ?? ttsConfig.voice;
|
||||||
|
|
||||||
const userDataDir = getUserDataDir(user.email);
|
const userDataDir = getUserDataDir(user.email);
|
||||||
const cacheRel = `cache/tts/chat/${id}.mp3`;
|
const cacheRel = `cache/tts/chat/${voice}/${id}.mp3`;
|
||||||
const cacheAbs = resolve(userDataDir, cacheRel);
|
const cacheAbs = resolve(userDataDir, cacheRel);
|
||||||
|
|
||||||
if (existsSync(cacheAbs)) {
|
if (existsSync(cacheAbs)) {
|
||||||
return ctx.json({ audioPath: cacheRel, audioRoot: 'user-data' });
|
return ctx.json({ audioPath: cacheRel, audioRoot: 'user-data' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const ttsConfig = await readTtsConfig();
|
|
||||||
if (!ttsConfig) throw errors.BAD_REQUEST('TTS not configured — set it up in Settings → Text to Speech');
|
|
||||||
|
|
||||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||||
let res: Response;
|
let res: Response;
|
||||||
|
|
||||||
if (ttsConfig.provider === 'elevenlabs') {
|
if (ttsConfig.provider === 'elevenlabs') {
|
||||||
if (!ttsConfig.apiKey) throw errors.BAD_REQUEST('ElevenLabs API key not configured');
|
if (!ttsConfig.apiKey) throw errors.BAD_REQUEST('ElevenLabs API key not configured');
|
||||||
res = await fetch(`https://api.elevenlabs.io/v1/text-to-speech/${encodeURIComponent(ttsConfig.voice)}`, {
|
res = await fetch(`https://api.elevenlabs.io/v1/text-to-speech/${encodeURIComponent(voice)}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json', 'xi-api-key': ttsConfig.apiKey },
|
headers: { 'Content-Type': 'application/json', 'xi-api-key': ttsConfig.apiKey },
|
||||||
body: JSON.stringify({ text, model_id: ttsConfig.model }),
|
body: JSON.stringify({ text, model_id: ttsConfig.model }),
|
||||||
@@ -447,7 +465,7 @@ router.post('/tts-text', async (ctx) => {
|
|||||||
res = await fetch(`${ttsConfig.url.replace(/\/+$/, '')}/v1/audio/speech`, {
|
res = await fetch(`${ttsConfig.url.replace(/\/+$/, '')}/v1/audio/speech`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers,
|
headers,
|
||||||
body: JSON.stringify({ model: ttsConfig.model, input: text, voice: ttsConfig.voice, response_format: 'mp3' }),
|
body: JSON.stringify({ model: ttsConfig.model, input: text, voice, response_format: 'mp3' }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (!res.ok) throw errors.BAD_REQUEST('TTS request failed');
|
if (!res.ok) throw errors.BAD_REQUEST('TTS request failed');
|
||||||
|
|||||||
@@ -414,12 +414,18 @@ function parsePiEvent(event: Record<string, unknown>, currentStreamBuffer: strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'agent_end': {
|
case 'agent_end': {
|
||||||
// Pi doesn't provide cost info in agent_end, use zeros
|
const cost: MessageCost = { inputTokens: 0, outputTokens: 0, totalUSD: 0 };
|
||||||
const cost: MessageCost = {
|
const messages = event.messages as Array<Record<string, unknown>> | undefined;
|
||||||
inputTokens: 0,
|
if (messages) {
|
||||||
outputTokens: 0,
|
for (const msg of messages) {
|
||||||
totalUSD: 0,
|
const usage = msg.usage as Record<string, unknown> | undefined;
|
||||||
};
|
if (!usage) continue;
|
||||||
|
cost.inputTokens += (usage.input as number) ?? 0;
|
||||||
|
cost.outputTokens += (usage.output as number) ?? 0;
|
||||||
|
const usageCost = usage.cost as Record<string, unknown> | undefined;
|
||||||
|
if (usageCost) cost.totalUSD += (usageCost.total as number) ?? 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
return { type: 'result', cost };
|
return { type: 'result', cost };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ ttsRouter.put('/', async (ctx) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
ttsRouter.post('/voices', async (ctx) => {
|
ttsRouter.post('/voices', async (ctx) => {
|
||||||
const body = await ctx.req.json<{ provider: string; url?: string; apiKey?: string }>();
|
const body = await ctx.req.json<{ provider: string; url?: string; apiKey?: string; model?: string }>();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (body.provider === 'elevenlabs') {
|
if (body.provider === 'elevenlabs') {
|
||||||
@@ -67,20 +67,88 @@ ttsRouter.post('/voices', async (ctx) => {
|
|||||||
return ctx.json({ voices: json.voices.map((v) => v.voice_id) });
|
return ctx.json({ voices: json.voices.map((v) => v.voice_id) });
|
||||||
}
|
}
|
||||||
|
|
||||||
// OpenAI-compatible
|
// OpenAI-compatible: try local server first, fallback to HuggingFace
|
||||||
if (!body.url) return ctx.json({ error: 'URL required' }, 400);
|
if (!body.url) return ctx.json({ error: 'URL required' }, 400);
|
||||||
const headers: Record<string, string> = {};
|
const headers: Record<string, string> = {};
|
||||||
if (body.apiKey) headers['Authorization'] = `Bearer ${body.apiKey}`;
|
if (body.apiKey) headers['Authorization'] = `Bearer ${body.apiKey}`;
|
||||||
const res = await fetch(`${body.url.replace(/\/+$/, '')}/v1/audio/voices`, { headers });
|
|
||||||
if (!res.ok) return ctx.json({ error: `Voices fetch failed: ${res.status}` }, 500);
|
// Try /v1/audio/voices on the local server
|
||||||
const json = (await res.json()) as { voices: string[] };
|
const localRes = await fetch(`${body.url.replace(/\/+$/, '')}/v1/audio/voices`, { headers }).catch(() => null);
|
||||||
return ctx.json({ voices: json.voices });
|
if (localRes?.ok) {
|
||||||
|
const json = (await localRes.json()) as { voices: string[] };
|
||||||
|
return ctx.json({ voices: json.voices });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: get model repo from /v1/models, then list voices from HuggingFace
|
||||||
|
const modelsRes = await fetch(`${body.url.replace(/\/+$/, '')}/v1/models`, { headers }).catch(() => null);
|
||||||
|
if (modelsRes?.ok) {
|
||||||
|
const modelsJson = (await modelsRes.json()) as { data?: { id: string }[] };
|
||||||
|
const repoId = modelsJson.data?.[0]?.id;
|
||||||
|
if (repoId) {
|
||||||
|
const result = await fetchHuggingFaceVoices(repoId);
|
||||||
|
if (result.flat.length > 0) return ctx.json({ voices: result.flat, groups: result.groups });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last resort: try model field as HuggingFace repo ID directly
|
||||||
|
if (body.model && body.model.includes('/')) {
|
||||||
|
const result = await fetchHuggingFaceVoices(body.model);
|
||||||
|
if (result.flat.length > 0) return ctx.json({ voices: result.flat, groups: result.groups });
|
||||||
|
}
|
||||||
|
|
||||||
|
return ctx.json({ error: 'Could not fetch voices from server or HuggingFace' }, 500);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||||
return ctx.json({ error: message }, 500);
|
return ctx.json({ error: message }, 500);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const VOICE_GROUP_LABELS: Record<string, string> = {
|
||||||
|
af: 'American Female',
|
||||||
|
am: 'American Male',
|
||||||
|
bf: 'British Female',
|
||||||
|
bm: 'British Male',
|
||||||
|
ef: 'Spanish Female',
|
||||||
|
em: 'Spanish Male',
|
||||||
|
ff: 'French Female',
|
||||||
|
hf: 'Hindi Female',
|
||||||
|
hm: 'Hindi Male',
|
||||||
|
if: 'Italian Female',
|
||||||
|
im: 'Italian Male',
|
||||||
|
jf: 'Japanese Female',
|
||||||
|
jm: 'Japanese Male',
|
||||||
|
pf: 'Brazilian Portuguese Female',
|
||||||
|
pm: 'Brazilian Portuguese Male',
|
||||||
|
zf: 'Mandarin Chinese Female',
|
||||||
|
zm: 'Mandarin Chinese Male',
|
||||||
|
};
|
||||||
|
|
||||||
|
type VoiceGroup = { label: string; voices: string[] };
|
||||||
|
|
||||||
|
async function fetchHuggingFaceVoices(repoId: string): Promise<{ flat: string[]; groups: VoiceGroup[] }> {
|
||||||
|
const res = await fetch(`https://huggingface.co/api/models/${repoId}/tree/main/voices`);
|
||||||
|
if (!res.ok) return { flat: [], groups: [] };
|
||||||
|
const files = (await res.json()) as { path: string; type: string }[];
|
||||||
|
const names = new Set<string>();
|
||||||
|
for (const f of files) {
|
||||||
|
if (f.type !== 'file') continue;
|
||||||
|
const name = f.path.replace('voices/', '').replace(/\.(pt|safetensors)$/, '');
|
||||||
|
names.add(name);
|
||||||
|
}
|
||||||
|
const sorted = [...names].sort();
|
||||||
|
const groupMap = new Map<string, string[]>();
|
||||||
|
for (const name of sorted) {
|
||||||
|
const prefix = name.slice(0, 2);
|
||||||
|
if (!groupMap.has(prefix)) groupMap.set(prefix, []);
|
||||||
|
groupMap.get(prefix)!.push(name);
|
||||||
|
}
|
||||||
|
const groups: VoiceGroup[] = [];
|
||||||
|
for (const [prefix, voices] of groupMap) {
|
||||||
|
groups.push({ label: VOICE_GROUP_LABELS[prefix] ?? prefix, voices });
|
||||||
|
}
|
||||||
|
return { flat: sorted, groups };
|
||||||
|
}
|
||||||
|
|
||||||
ttsRouter.post('/test', async (ctx) => {
|
ttsRouter.post('/test', async (ctx) => {
|
||||||
const body = await ctx.req.json<TtsConfig>();
|
const body = await ctx.req.json<TtsConfig>();
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import { mkdir, readdir, rm } from 'node:fs/promises';
|
import { mkdir, readdir, rm, cp } from 'node:fs/promises';
|
||||||
import { join } from 'node:path';
|
import { join, resolve } from 'node:path';
|
||||||
|
import { readdirSync } from 'node:fs';
|
||||||
import { createRouter } from '@@/create-router';
|
import { createRouter } from '@@/create-router';
|
||||||
import { getDirs, migrateFromState, migrateHomepageToScreens, readAllWorkspacesState, resolveKey, writeJsonFile } from './utils';
|
import { getDirs, migrateFromState, migrateHomepageToScreens, readAllWorkspacesState, resolveKey, writeJsonFile } from './utils';
|
||||||
|
|
||||||
|
const TEMPLATES_DIR = resolve(import.meta.dir, '../../../../seed/project-templates');
|
||||||
|
|
||||||
export const workspacesRouter = createRouter();
|
export const workspacesRouter = createRouter();
|
||||||
|
|
||||||
// GET /workspaces
|
// GET /workspaces
|
||||||
@@ -48,8 +51,25 @@ workspacesRouter.patch('/', async (ctx) => {
|
|||||||
await writeJsonFile(metaFile, value);
|
await writeJsonFile(metaFile, value);
|
||||||
|
|
||||||
if (isNew) {
|
if (isNew) {
|
||||||
const proc = Bun.spawn(['git', 'init', projectDir]);
|
const meta = value as Record<string, unknown>;
|
||||||
await proc.exited;
|
if (meta.projectType === 'app') {
|
||||||
|
const templateDir = join(TEMPLATES_DIR, 'simple-app-template');
|
||||||
|
const entries = readdirSync(templateDir);
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry === '.git' || entry === '.officerdev') continue;
|
||||||
|
await cp(join(templateDir, entry), join(projectDir, entry), { recursive: true });
|
||||||
|
}
|
||||||
|
const pkgPath = join(projectDir, 'package.json');
|
||||||
|
const pkg = await Bun.file(pkgPath).json().catch(() => null);
|
||||||
|
if (pkg) {
|
||||||
|
pkg.name = slug;
|
||||||
|
await Bun.write(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
|
||||||
|
}
|
||||||
|
const install = Bun.spawn(['bun', 'install'], { cwd: projectDir, stdout: 'ignore', stderr: 'ignore' });
|
||||||
|
await install.exited;
|
||||||
|
}
|
||||||
|
const gitInit = Bun.spawn(['git', 'init', projectDir], { stdout: 'ignore', stderr: 'ignore' });
|
||||||
|
await gitInit.exited;
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ const ReadAloudButton = ({ id, text }: { id: string; text: string }) => {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
className="p-1 rounded text-duck-dark/30 hover:text-duck-dark/60 transition-colors cursor-pointer"
|
className="p-1 rounded text-duck-dark dark:text-white opacity-60 hover:opacity-100 transition-opacity cursor-pointer"
|
||||||
>
|
>
|
||||||
{state === 'loading' && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
|
{state === 'loading' && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
|
||||||
{state === 'playing' && <Square className="h-3.5 w-3.5" />}
|
{state === 'playing' && <Square className="h-3.5 w-3.5" />}
|
||||||
@@ -133,9 +133,9 @@ export const MessageBubble = ({ message, onAnswer }: MessageBubbleProps) => {
|
|||||||
{injectImages(assistantText)}
|
{injectImages(assistantText)}
|
||||||
</ReactMarkdown>
|
</ReactMarkdown>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="flex justify-end -mb-1 -mr-1">
|
||||||
<div className="flex justify-end mt-0.5">
|
<ReadAloudButton id={message.id ?? crypto.randomUUID()} text={assistantText} />
|
||||||
<ReadAloudButton id={message.id ?? crypto.randomUUID()} text={assistantText} />
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,11 +8,13 @@ export type PreviewContextValue = {
|
|||||||
port: number | null;
|
port: number | null;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
|
stopped: boolean;
|
||||||
isSuperAdmin: boolean;
|
isSuperAdmin: boolean;
|
||||||
iframeKey: number;
|
iframeKey: number;
|
||||||
projects: ProjectDefinition[];
|
projects: ProjectDefinition[];
|
||||||
startServer: (slug: string) => void;
|
startServer: (slug: string) => void;
|
||||||
stopServer: () => void;
|
stopServer: () => void;
|
||||||
|
restartServer: () => void;
|
||||||
refresh: () => void;
|
refresh: () => void;
|
||||||
setSelectedSlug: (slug: string | null) => void;
|
setSelectedSlug: (slug: string | null) => void;
|
||||||
clearError: () => void;
|
clearError: () => void;
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { Globe, RefreshCw, Square } from 'lucide-react';
|
import { Globe, RefreshCw, Square, Play } from 'lucide-react';
|
||||||
import { usePreview } from './PreviewContext';
|
import { usePreview } from './PreviewContext';
|
||||||
|
|
||||||
export const PreviewHeader = () => {
|
export const PreviewHeader = () => {
|
||||||
const { slug, url, port, isSuperAdmin, refresh, stopServer } = usePreview();
|
const { slug, url, port, stopped, isSuperAdmin, refresh, stopServer, restartServer } = usePreview();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -32,6 +32,16 @@ export const PreviewHeader = () => {
|
|||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{!url && stopped && slug && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={restartServer}
|
||||||
|
className="p-0.5 rounded hover:bg-black/10 transition-colors cursor-pointer shrink-0"
|
||||||
|
title="Start server"
|
||||||
|
>
|
||||||
|
<Play className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export const PreviewProvider = ({ children }: PreviewProviderProps) => {
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [iframeKey, setIframeKey] = useState(0);
|
const [iframeKey, setIframeKey] = useState(0);
|
||||||
|
const [stopped, setStopped] = useState(false);
|
||||||
|
|
||||||
const isSuperAdmin = user?.role === 'Super Admin';
|
const isSuperAdmin = user?.role === 'Super Admin';
|
||||||
const cwdSlug = extractSlug(cwd);
|
const cwdSlug = extractSlug(cwd);
|
||||||
@@ -31,6 +32,7 @@ export const PreviewProvider = ({ children }: PreviewProviderProps) => {
|
|||||||
const startServer = useCallback(async (targetSlug: string) => {
|
const startServer = useCallback(async (targetSlug: string) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
setStopped(false);
|
||||||
try {
|
try {
|
||||||
const res = await client.post<DevServerResponse>('/dev-server/start', { slug: targetSlug });
|
const res = await client.post<DevServerResponse>('/dev-server/start', { slug: targetSlug });
|
||||||
setUrl(res.url);
|
setUrl(res.url);
|
||||||
@@ -52,8 +54,14 @@ export const PreviewProvider = ({ children }: PreviewProviderProps) => {
|
|||||||
}
|
}
|
||||||
setUrl(null);
|
setUrl(null);
|
||||||
setPort(null);
|
setPort(null);
|
||||||
|
setStopped(true);
|
||||||
}, [slug, client]);
|
}, [slug, client]);
|
||||||
|
|
||||||
|
const restartServer = useCallback(() => {
|
||||||
|
if (!slug) return;
|
||||||
|
startServer(slug);
|
||||||
|
}, [slug, startServer]);
|
||||||
|
|
||||||
const refresh = useCallback(() => setIframeKey((k) => k + 1), []);
|
const refresh = useCallback(() => setIframeKey((k) => k + 1), []);
|
||||||
|
|
||||||
const clearError = useCallback(() => {
|
const clearError = useCallback(() => {
|
||||||
@@ -108,8 +116,8 @@ export const PreviewProvider = ({ children }: PreviewProviderProps) => {
|
|||||||
return (
|
return (
|
||||||
<PreviewContext
|
<PreviewContext
|
||||||
value={{
|
value={{
|
||||||
slug, cwdSlug, url, port, loading, error, isSuperAdmin, iframeKey, projects,
|
slug, cwdSlug, url, port, loading, error, stopped, isSuperAdmin, iframeKey, projects,
|
||||||
startServer, stopServer, refresh, setSelectedSlug, clearError,
|
startServer, stopServer, restartServer, refresh, setSelectedSlug, clearError,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Link, useNavigate } from 'react-router';
|
import { Link, useNavigate } from 'react-router';
|
||||||
import { FolderKanban, Plus, ArrowRight, Type, Rocket, FileText, Layout } from 'lucide-react';
|
import { FolderKanban, Plus, ArrowRight, Type, Rocket, FileText, Layout, Loader2 } from 'lucide-react';
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import { useGlobal } from 'hooks/useGlobal';
|
import { useGlobal } from 'hooks/useGlobal';
|
||||||
import { useClient } from 'hooks/useClient';
|
import { useClient } from 'hooks/useClient';
|
||||||
@@ -34,10 +34,6 @@ type LayoutTemplate = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const templates: LayoutTemplate[] = [
|
const templates: LayoutTemplate[] = [
|
||||||
{
|
|
||||||
name: 'Single',
|
|
||||||
layout: () => ({ type: 'panel', id: tplUid(), appType: null }),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: '2 Columns',
|
name: '2 Columns',
|
||||||
layout: () => ({
|
layout: () => ({
|
||||||
@@ -142,6 +138,40 @@ const templates: LayoutTemplate[] = [
|
|||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'Cols + Split Bottom',
|
||||||
|
layout: () => ({
|
||||||
|
type: 'group',
|
||||||
|
id: tplUid(),
|
||||||
|
direction: 'vertical',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
node: {
|
||||||
|
type: 'group',
|
||||||
|
id: tplUid(),
|
||||||
|
direction: 'horizontal',
|
||||||
|
children: [
|
||||||
|
{ node: { type: 'panel', id: tplUid(), appType: null }, size: 50 },
|
||||||
|
{ node: { type: 'panel', id: tplUid(), appType: null }, size: 50 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
size: 70,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
node: {
|
||||||
|
type: 'group',
|
||||||
|
id: tplUid(),
|
||||||
|
direction: 'horizontal',
|
||||||
|
children: [
|
||||||
|
{ node: { type: 'panel', id: tplUid(), appType: null }, size: 50 },
|
||||||
|
{ node: { type: 'panel', id: tplUid(), appType: null }, size: 50 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
size: 30,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// --- Template Thumbnails ---
|
// --- Template Thumbnails ---
|
||||||
@@ -233,8 +263,8 @@ const TemplatePanel = () => (
|
|||||||
// --- Panel: Details (Name + Description + Project Type + Backend/Auth toggles) ---
|
// --- Panel: Details (Name + Description + Project Type + Backend/Auth toggles) ---
|
||||||
|
|
||||||
const PROJECT_TYPE_OPTIONS: { value: ProjectType; label: string }[] = [
|
const PROJECT_TYPE_OPTIONS: { value: ProjectType; label: string }[] = [
|
||||||
{ value: 'landing-page', label: 'Landing Page' },
|
// { value: 'landing-page', label: 'Landing Page' },
|
||||||
{ value: 'website', label: 'Website' },
|
// { value: 'website', label: 'Website' },
|
||||||
{ value: 'app', label: 'App' },
|
{ value: 'app', label: 'App' },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -294,7 +324,7 @@ const DetailsPanel = () => {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{projectType === 'app' && (
|
{/* {projectType === 'app' && (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<label className="flex items-center gap-2 cursor-pointer">
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
<input
|
<input
|
||||||
@@ -315,7 +345,7 @@ const DetailsPanel = () => {
|
|||||||
<span className="text-xs text-duck-dark/60">Has Auth</span>
|
<span className="text-xs text-duck-dark/60">Has Auth</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)} */}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -323,6 +353,21 @@ const DetailsPanel = () => {
|
|||||||
|
|
||||||
// --- Panel: Create ---
|
// --- Panel: Create ---
|
||||||
|
|
||||||
|
const assignDefaultApps = (layout: LayoutNode, apps: string[]): LayoutNode => {
|
||||||
|
const remaining = [...apps];
|
||||||
|
const walk = (node: LayoutNode): LayoutNode => {
|
||||||
|
if (remaining.length === 0) return node;
|
||||||
|
if (node.type === 'panel' && node.appType === null) {
|
||||||
|
return { ...node, appType: remaining.shift()! };
|
||||||
|
}
|
||||||
|
if (node.type === 'group') {
|
||||||
|
return { ...node, children: node.children.map((c) => ({ ...c, node: walk(c.node) })) };
|
||||||
|
}
|
||||||
|
return node;
|
||||||
|
};
|
||||||
|
return walk(layout);
|
||||||
|
};
|
||||||
|
|
||||||
const CreatePanel = () => {
|
const CreatePanel = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@@ -339,13 +384,14 @@ const CreatePanel = () => {
|
|||||||
const [editingId, setEditingId] = useGlobal<string | null>(EDITING_PROJECT, null);
|
const [editingId, setEditingId] = useGlobal<string | null>(EDITING_PROJECT, null);
|
||||||
const [, setCreating] = useGlobal<boolean>(CREATING_PROJECT, false);
|
const [, setCreating] = useGlobal<boolean>(CREATING_PROJECT, false);
|
||||||
const [, setSelected] = useGlobal<string | null>(SELECTED_PROJECT, null);
|
const [, setSelected] = useGlobal<string | null>(SELECTED_PROJECT, null);
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
const isEditing = !!editingId;
|
const isEditing = !!editingId;
|
||||||
const slug = isEditing ? editingId : slugify(name.trim()) || generateSlug();
|
const slug = isEditing ? editingId : slugify(name.trim()) || generateSlug();
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const handleSubmit = async () => {
|
||||||
const trimmed = name.trim();
|
const trimmed = name.trim();
|
||||||
if (!trimmed) return;
|
if (!trimmed || isSubmitting) return;
|
||||||
const desc = description.trim();
|
const desc = description.trim();
|
||||||
|
|
||||||
const meta = {
|
const meta = {
|
||||||
@@ -370,17 +416,23 @@ const CreatePanel = () => {
|
|||||||
let id = slugify(trimmed) || generateSlug();
|
let id = slugify(trimmed) || generateSlug();
|
||||||
while (existingIds.has(id)) id = `${id}-${generateSlug(1)}`;
|
while (existingIds.has(id)) id = `${id}-${generateSlug(1)}`;
|
||||||
|
|
||||||
setName('');
|
setIsSubmitting(true);
|
||||||
setDescription('');
|
const finalLayout = projectType === 'app'
|
||||||
setTemplateIdx(0);
|
? assignDefaultApps(previewLayout, ['officerdev/chat', 'officerdev/preview'])
|
||||||
|
: previewLayout;
|
||||||
|
|
||||||
client
|
try {
|
||||||
.patch('/workspaces', { [`proj-meta-${id}`]: meta, [`proj-layout-${id}`]: previewLayout })
|
const res = await client.patch('/workspaces', { [`proj-meta-${id}`]: meta, [`proj-layout-${id}`]: finalLayout });
|
||||||
.then((res) => {
|
queryClient.setQueryData(['WORKSPACES_STATE'], res);
|
||||||
queryClient.setQueryData(['WORKSPACES_STATE'], res);
|
setName('');
|
||||||
navigate(`/projects/${id}`);
|
setDescription('');
|
||||||
})
|
setTemplateIdx(0);
|
||||||
.catch(() => {});
|
navigate(`/projects/${id}`);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -394,11 +446,11 @@ const CreatePanel = () => {
|
|||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
disabled={!name.trim()}
|
disabled={!name.trim() || isSubmitting}
|
||||||
className="bg-emerald-500 hover:bg-emerald-500/90 cursor-pointer disabled:opacity-40"
|
className="bg-emerald-500 hover:bg-emerald-500/90 cursor-pointer disabled:opacity-40"
|
||||||
>
|
>
|
||||||
<Plus className="h-4 w-4 mr-1" />
|
{isSubmitting ? <Loader2 className="h-4 w-4 mr-1 animate-spin" /> : <Plus className="h-4 w-4 mr-1" />}
|
||||||
{isEditing ? 'Update Project' : 'Create Project'}
|
{isSubmitting ? 'Creating...' : isEditing ? 'Update Project' : 'Create Project'}
|
||||||
</Button>
|
</Button>
|
||||||
{isEditing && (
|
{isEditing && (
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const mergeWithDefaults = (saved: Partial<UserSettings>): UserSettings => ({
|
|||||||
tasks: { ...DEFAULT_SETTINGS.tasks, ...saved.tasks },
|
tasks: { ...DEFAULT_SETTINGS.tasks, ...saved.tasks },
|
||||||
appearance: { ...DEFAULT_SETTINGS.appearance, ...saved.appearance },
|
appearance: { ...DEFAULT_SETTINGS.appearance, ...saved.appearance },
|
||||||
languages: { ...DEFAULT_SETTINGS.languages, ...saved.languages },
|
languages: { ...DEFAULT_SETTINGS.languages, ...saved.languages },
|
||||||
|
tts: { ...DEFAULT_SETTINGS.tts, ...saved.tts },
|
||||||
onboarding: { ...DEFAULT_SETTINGS.onboarding, ...saved.onboarding },
|
onboarding: { ...DEFAULT_SETTINGS.onboarding, ...saved.onboarding },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -73,6 +74,9 @@ export type UserSettings = {
|
|||||||
default: string;
|
default: string;
|
||||||
translateTo: string;
|
translateTo: string;
|
||||||
};
|
};
|
||||||
|
tts: {
|
||||||
|
voice: string | null;
|
||||||
|
};
|
||||||
onboarding: {
|
onboarding: {
|
||||||
complete: boolean;
|
complete: boolean;
|
||||||
};
|
};
|
||||||
@@ -107,6 +111,9 @@ export const DEFAULT_SETTINGS: UserSettings = {
|
|||||||
default: 'en',
|
default: 'en',
|
||||||
translateTo: 'en',
|
translateTo: 'en',
|
||||||
},
|
},
|
||||||
|
tts: {
|
||||||
|
voice: null,
|
||||||
|
},
|
||||||
onboarding: {
|
onboarding: {
|
||||||
complete: false,
|
complete: false,
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user