Add Next.js frontend with WebLLM, OpenAI support - Add complete Next.js frontend with Tailwind CSS and shadcn/ui - Integrate WebLLM for client-side browser-based translations - Add OpenAI provider support with gpt-4o-mini default - Add Context & Glossary page for LLM customization - Reorganize settings: Translation Services includes all providers - Add system prompt and glossary support for all LLMs - Remove test files and requirements-test.txt
This commit is contained in:
parent
a4ecd3e0ec
commit
8c7716bf4d
41
frontend/.gitignore
vendored
Normal file
41
frontend/.gitignore
vendored
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.*
|
||||||
|
.yarn/*
|
||||||
|
!.yarn/patches
|
||||||
|
!.yarn/plugins
|
||||||
|
!.yarn/releases
|
||||||
|
!.yarn/versions
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# env files (can opt-in for committing if needed)
|
||||||
|
.env*
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
36
frontend/README.md
Normal file
36
frontend/README.md
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
First, run the development server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
# or
|
||||||
|
yarn dev
|
||||||
|
# or
|
||||||
|
pnpm dev
|
||||||
|
# or
|
||||||
|
bun dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||||
|
|
||||||
|
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||||
|
|
||||||
|
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||||
|
|
||||||
|
## Learn More
|
||||||
|
|
||||||
|
To learn more about Next.js, take a look at the following resources:
|
||||||
|
|
||||||
|
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||||
|
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||||
|
|
||||||
|
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||||
|
|
||||||
|
## Deploy on Vercel
|
||||||
|
|
||||||
|
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||||
|
|
||||||
|
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||||
22
frontend/components.json
Normal file
22
frontend/components.json
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "new-york",
|
||||||
|
"rsc": true,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "",
|
||||||
|
"css": "src/app/globals.css",
|
||||||
|
"baseColor": "neutral",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"iconLibrary": "lucide",
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks"
|
||||||
|
},
|
||||||
|
"registries": {}
|
||||||
|
}
|
||||||
18
frontend/eslint.config.mjs
Normal file
18
frontend/eslint.config.mjs
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { defineConfig, globalIgnores } from "eslint/config";
|
||||||
|
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||||
|
import nextTs from "eslint-config-next/typescript";
|
||||||
|
|
||||||
|
const eslintConfig = defineConfig([
|
||||||
|
...nextVitals,
|
||||||
|
...nextTs,
|
||||||
|
// Override default ignores of eslint-config-next.
|
||||||
|
globalIgnores([
|
||||||
|
// Default ignores of eslint-config-next:
|
||||||
|
".next/**",
|
||||||
|
"out/**",
|
||||||
|
"build/**",
|
||||||
|
"next-env.d.ts",
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export default eslintConfig;
|
||||||
7
frontend/next.config.ts
Normal file
7
frontend/next.config.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
/* config options here */
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
8050
frontend/package-lock.json
generated
Normal file
8050
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
47
frontend/package.json
Normal file
47
frontend/package.json
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
{
|
||||||
|
"name": "frontend",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "eslint"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@mlc-ai/web-llm": "^0.2.80",
|
||||||
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
|
"@radix-ui/react-label": "^2.1.8",
|
||||||
|
"@radix-ui/react-progress": "^1.1.8",
|
||||||
|
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||||
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
|
"@radix-ui/react-separator": "^1.1.8",
|
||||||
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
|
"axios": "^1.13.2",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"lucide-react": "^0.555.0",
|
||||||
|
"next": "16.0.6",
|
||||||
|
"react": "19.2.0",
|
||||||
|
"react-dom": "19.2.0",
|
||||||
|
"react-dropzone": "^14.3.8",
|
||||||
|
"tailwind-merge": "^3.4.0",
|
||||||
|
"zustand": "^5.0.9"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@types/node": "^20",
|
||||||
|
"@types/react": "^19",
|
||||||
|
"@types/react-dom": "^19",
|
||||||
|
"eslint": "^9",
|
||||||
|
"eslint-config-next": "16.0.6",
|
||||||
|
"tailwindcss": "^4",
|
||||||
|
"tw-animate-css": "^1.4.0",
|
||||||
|
"typescript": "^5"
|
||||||
|
}
|
||||||
|
}
|
||||||
7
frontend/postcss.config.mjs
Normal file
7
frontend/postcss.config.mjs
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
1
frontend/public/file.svg
Normal file
1
frontend/public/file.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||||
|
After Width: | Height: | Size: 391 B |
1
frontend/public/globe.svg
Normal file
1
frontend/public/globe.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
1
frontend/public/next.svg
Normal file
1
frontend/public/next.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
1
frontend/public/vercel.svg
Normal file
1
frontend/public/vercel.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||||
|
After Width: | Height: | Size: 128 B |
1
frontend/public/window.svg
Normal file
1
frontend/public/window.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||||
|
After Width: | Height: | Size: 385 B |
BIN
frontend/src/app/favicon.ico
Normal file
BIN
frontend/src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
122
frontend/src/app/globals.css
Normal file
122
frontend/src/app/globals.css
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
@import "tw-animate-css";
|
||||||
|
|
||||||
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
--color-background: var(--background);
|
||||||
|
--color-foreground: var(--foreground);
|
||||||
|
--font-sans: var(--font-geist-sans);
|
||||||
|
--font-mono: var(--font-geist-mono);
|
||||||
|
--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: #262626;
|
||||||
|
--foreground: oklch(0.985 0 0);
|
||||||
|
--card: #2d2d2d;
|
||||||
|
--card-foreground: oklch(0.985 0 0);
|
||||||
|
--popover: #2d2d2d;
|
||||||
|
--popover-foreground: oklch(0.985 0 0);
|
||||||
|
--primary: oklch(0.922 0 0);
|
||||||
|
--primary-foreground: oklch(0.205 0 0);
|
||||||
|
--secondary: #333333;
|
||||||
|
--secondary-foreground: oklch(0.985 0 0);
|
||||||
|
--muted: #333333;
|
||||||
|
--muted-foreground: oklch(0.708 0 0);
|
||||||
|
--accent: #333333;
|
||||||
|
--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: #1f1f1f;
|
||||||
|
--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: #333333;
|
||||||
|
--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;
|
||||||
|
}
|
||||||
|
}
|
||||||
32
frontend/src/app/layout.tsx
Normal file
32
frontend/src/app/layout.tsx
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { Inter } from "next/font/google";
|
||||||
|
import "./globals.css";
|
||||||
|
import { Sidebar } from "@/components/sidebar";
|
||||||
|
|
||||||
|
const inter = Inter({
|
||||||
|
subsets: ["latin"],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Translate Co. - Document Translation",
|
||||||
|
description: "Translate Excel, Word, and PowerPoint documents while preserving formatting",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: Readonly<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
}>) {
|
||||||
|
return (
|
||||||
|
<html lang="en" className="dark">
|
||||||
|
<body className={`${inter.className} bg-[#262626] text-zinc-100 antialiased`}>
|
||||||
|
<Sidebar />
|
||||||
|
<main className="ml-64 min-h-screen p-8">
|
||||||
|
<div className="max-w-6xl mx-auto">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
49
frontend/src/app/page.tsx
Normal file
49
frontend/src/app/page.tsx
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { FileUploader } from "@/components/file-uploader";
|
||||||
|
import { useTranslationStore } from "@/lib/store";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Settings } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
const { settings } = useTranslationStore();
|
||||||
|
|
||||||
|
const providerNames: Record<string, string> = {
|
||||||
|
google: "Google Translate",
|
||||||
|
ollama: "Ollama",
|
||||||
|
deepl: "DeepL",
|
||||||
|
libre: "LibreTranslate",
|
||||||
|
webllm: "WebLLM",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-white">Translate Documents</h1>
|
||||||
|
<p className="text-zinc-400 mt-1">
|
||||||
|
Upload and translate Excel, Word, and PowerPoint files while preserving all formatting.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Current Configuration Badge */}
|
||||||
|
<Link href="/settings/services" className="flex items-center gap-2 px-3 py-2 rounded-lg bg-zinc-800/50 border border-zinc-700 hover:bg-zinc-800 transition-colors">
|
||||||
|
<Settings className="h-4 w-4 text-zinc-400" />
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant="outline" className="border-teal-500/50 text-teal-400 text-xs">
|
||||||
|
{providerNames[settings.defaultProvider]}
|
||||||
|
</Badge>
|
||||||
|
{settings.defaultProvider === "ollama" && settings.ollamaModel && (
|
||||||
|
<Badge variant="outline" className="border-zinc-600 text-zinc-400 text-xs">
|
||||||
|
{settings.ollamaModel}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FileUploader />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
239
frontend/src/app/settings/context/page.tsx
Normal file
239
frontend/src/app/settings/context/page.tsx
Normal file
@ -0,0 +1,239 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { useTranslationStore } from "@/lib/store";
|
||||||
|
import { Save, Loader2, Brain, BookOpen, Sparkles, Trash2 } from "lucide-react";
|
||||||
|
|
||||||
|
export default function ContextGlossaryPage() {
|
||||||
|
const { settings, updateSettings, applyPreset, clearContext } = useTranslationStore();
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
|
||||||
|
const [localSettings, setLocalSettings] = useState({
|
||||||
|
systemPrompt: settings.systemPrompt,
|
||||||
|
glossary: settings.glossary,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLocalSettings({
|
||||||
|
systemPrompt: settings.systemPrompt,
|
||||||
|
glossary: settings.glossary,
|
||||||
|
});
|
||||||
|
}, [settings]);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setIsSaving(true);
|
||||||
|
try {
|
||||||
|
updateSettings(localSettings);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleApplyPreset = (preset: 'hvac' | 'it' | 'legal' | 'medical') => {
|
||||||
|
applyPreset(preset);
|
||||||
|
// Need to get the updated values from the store after applying preset
|
||||||
|
setTimeout(() => {
|
||||||
|
setLocalSettings({
|
||||||
|
systemPrompt: useTranslationStore.getState().settings.systemPrompt,
|
||||||
|
glossary: useTranslationStore.getState().settings.glossary,
|
||||||
|
});
|
||||||
|
}, 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClear = () => {
|
||||||
|
clearContext();
|
||||||
|
setLocalSettings({
|
||||||
|
systemPrompt: "",
|
||||||
|
glossary: "",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check which LLM providers are configured
|
||||||
|
const isOllamaConfigured = settings.ollamaUrl && settings.ollamaModel;
|
||||||
|
const isOpenAIConfigured = !!settings.openaiApiKey;
|
||||||
|
const isWebLLMAvailable = typeof window !== 'undefined' && 'gpu' in navigator;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-white">Context & Glossary</h1>
|
||||||
|
<p className="text-zinc-400 mt-1">
|
||||||
|
Configure translation context and glossary for LLM-based providers.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* LLM Provider Status */}
|
||||||
|
<div className="flex flex-wrap gap-2 mt-3">
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={`${isOllamaConfigured ? 'border-green-500 text-green-400' : 'border-zinc-600 text-zinc-500'}`}
|
||||||
|
>
|
||||||
|
🤖 Ollama {isOllamaConfigured ? '✓' : '○'}
|
||||||
|
</Badge>
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={`${isOpenAIConfigured ? 'border-green-500 text-green-400' : 'border-zinc-600 text-zinc-500'}`}
|
||||||
|
>
|
||||||
|
🧠 OpenAI {isOpenAIConfigured ? '✓' : '○'}
|
||||||
|
</Badge>
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={`${isWebLLMAvailable ? 'border-green-500 text-green-400' : 'border-zinc-600 text-zinc-500'}`}
|
||||||
|
>
|
||||||
|
💻 WebLLM {isWebLLMAvailable ? '✓' : '○'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info Banner */}
|
||||||
|
<div className="p-4 rounded-lg bg-teal-500/10 border border-teal-500/30">
|
||||||
|
<p className="text-teal-400 text-sm flex items-center gap-2">
|
||||||
|
<Sparkles className="h-4 w-4" />
|
||||||
|
<span>
|
||||||
|
<strong>Context & Glossary</strong> settings apply to all LLM providers:
|
||||||
|
<strong> Ollama</strong>, <strong>OpenAI</strong>, and <strong>WebLLM</strong>.
|
||||||
|
Use them to improve translation quality with domain-specific instructions.
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
{/* Left Column */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* System Prompt */}
|
||||||
|
<Card className="border-zinc-800 bg-zinc-900/50">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-white flex items-center gap-2">
|
||||||
|
<Brain className="h-5 w-5 text-teal-400" />
|
||||||
|
System Prompt
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Instructions for the LLM to follow during translation.
|
||||||
|
Works with Ollama, OpenAI, and WebLLM.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<Textarea
|
||||||
|
id="system-prompt"
|
||||||
|
value={localSettings.systemPrompt}
|
||||||
|
onChange={(e) =>
|
||||||
|
setLocalSettings({ ...localSettings, systemPrompt: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder="Example: You are translating technical HVAC documents. Use precise engineering terminology. Maintain consistency with industry standards..."
|
||||||
|
className="bg-zinc-800 border-zinc-700 text-white placeholder:text-zinc-500 min-h-[200px] resize-y"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-zinc-500">
|
||||||
|
💡 Tip: Include domain context, tone preferences, or specific terminology rules.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Presets */}
|
||||||
|
<Card className="border-zinc-800 bg-zinc-900/50">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-white">Quick Presets</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Load pre-configured prompts & glossaries for common domains.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => handleApplyPreset("hvac")}
|
||||||
|
className="border-zinc-700 text-zinc-300 hover:bg-zinc-800 hover:text-teal-400 justify-start"
|
||||||
|
>
|
||||||
|
🔧 HVAC / Engineering
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => handleApplyPreset("it")}
|
||||||
|
className="border-zinc-700 text-zinc-300 hover:bg-zinc-800 hover:text-teal-400 justify-start"
|
||||||
|
>
|
||||||
|
💻 IT / Software
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => handleApplyPreset("legal")}
|
||||||
|
className="border-zinc-700 text-zinc-300 hover:bg-zinc-800 hover:text-teal-400 justify-start"
|
||||||
|
>
|
||||||
|
⚖️ Legal / Contracts
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => handleApplyPreset("medical")}
|
||||||
|
className="border-zinc-700 text-zinc-300 hover:bg-zinc-800 hover:text-teal-400 justify-start"
|
||||||
|
>
|
||||||
|
🏥 Medical / Healthcare
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={handleClear}
|
||||||
|
className="w-full mt-3 text-red-400 hover:text-red-300 hover:bg-red-500/10"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 mr-2" />
|
||||||
|
Clear All
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Column */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Glossary */}
|
||||||
|
<Card className="border-zinc-800 bg-zinc-900/50">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-white flex items-center gap-2">
|
||||||
|
<BookOpen className="h-5 w-5 text-teal-400" />
|
||||||
|
Technical Glossary
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Define specific term translations. Format: source=target (one per line).
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<Textarea
|
||||||
|
id="glossary"
|
||||||
|
value={localSettings.glossary}
|
||||||
|
onChange={(e) =>
|
||||||
|
setLocalSettings({ ...localSettings, glossary: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder="pression statique=static pressure récupérateur=heat recovery unit ventilo-connecteur=fan coil unit gaine=duct diffuseur=diffuser"
|
||||||
|
className="bg-zinc-800 border-zinc-700 text-white placeholder:text-zinc-500 min-h-[280px] resize-y font-mono text-sm"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-zinc-500">
|
||||||
|
💡 The glossary is included in the system prompt to guide translations.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Save Button */}
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={isSaving}
|
||||||
|
className="bg-teal-600 hover:bg-teal-700 text-white px-8"
|
||||||
|
>
|
||||||
|
{isSaving ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Saving...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Save className="mr-2 h-4 w-4" />
|
||||||
|
Save Settings
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
247
frontend/src/app/settings/page.tsx
Normal file
247
frontend/src/app/settings/page.tsx
Normal file
@ -0,0 +1,247 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { useTranslationStore } from "@/lib/store";
|
||||||
|
import { languages } from "@/lib/api";
|
||||||
|
import { Save, Loader2, Settings, Globe, Trash2 } from "lucide-react";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
|
||||||
|
export default function GeneralSettingsPage() {
|
||||||
|
const { settings, updateSettings } = useTranslationStore();
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [isClearing, setIsClearing] = useState(false);
|
||||||
|
const [defaultLanguage, setDefaultLanguage] = useState(settings.defaultTargetLanguage);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setDefaultLanguage(settings.defaultTargetLanguage);
|
||||||
|
}, [settings.defaultTargetLanguage]);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setIsSaving(true);
|
||||||
|
try {
|
||||||
|
updateSettings({ defaultTargetLanguage: defaultLanguage });
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClearCache = async () => {
|
||||||
|
setIsClearing(true);
|
||||||
|
try {
|
||||||
|
// Clear localStorage
|
||||||
|
localStorage.removeItem('translation-settings');
|
||||||
|
// Clear sessionStorage
|
||||||
|
sessionStorage.clear();
|
||||||
|
// Clear any cached files/blobs
|
||||||
|
if ('caches' in window) {
|
||||||
|
const cacheNames = await caches.keys();
|
||||||
|
await Promise.all(cacheNames.map(name => caches.delete(name)));
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
|
// Reload to reset state
|
||||||
|
window.location.reload();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error clearing cache:', error);
|
||||||
|
setIsClearing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-white">General Settings</h1>
|
||||||
|
<p className="text-zinc-400 mt-1">
|
||||||
|
Configure general application settings and preferences.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="border-zinc-800 bg-zinc-900/50">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Settings className="h-6 w-6 text-teal-400" />
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-white">Application Settings</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
General configuration options
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="default-language" className="text-zinc-300">
|
||||||
|
Default Target Language
|
||||||
|
</Label>
|
||||||
|
<Select value={defaultLanguage} onValueChange={setDefaultLanguage}>
|
||||||
|
<SelectTrigger className="bg-zinc-800 border-zinc-700 text-white">
|
||||||
|
<SelectValue placeholder="Select default language" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="bg-zinc-800 border-zinc-700 max-h-[300px]">
|
||||||
|
{languages.map((lang) => (
|
||||||
|
<SelectItem
|
||||||
|
key={lang.code}
|
||||||
|
value={lang.code}
|
||||||
|
className="text-white hover:bg-zinc-700"
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span>{lang.flag}</span>
|
||||||
|
<span>{lang.name}</span>
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-xs text-zinc-500">
|
||||||
|
This language will be pre-selected when translating documents
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Supported Formats */}
|
||||||
|
<Card className="border-zinc-800 bg-zinc-900/50">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Globe className="h-6 w-6 text-teal-400" />
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-white">Supported Formats</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Document types that can be translated
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div className="p-4 rounded-lg border border-zinc-800 bg-zinc-800/30">
|
||||||
|
<div className="text-2xl mb-2">📊</div>
|
||||||
|
<h3 className="font-medium text-white">Excel</h3>
|
||||||
|
<p className="text-xs text-zinc-500 mt-1">.xlsx, .xls</p>
|
||||||
|
<div className="flex flex-wrap gap-1 mt-2">
|
||||||
|
<Badge variant="outline" className="border-zinc-700 text-zinc-400 text-xs">
|
||||||
|
Formulas
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="outline" className="border-zinc-700 text-zinc-400 text-xs">
|
||||||
|
Styles
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="outline" className="border-zinc-700 text-zinc-400 text-xs">
|
||||||
|
Images
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 rounded-lg border border-zinc-800 bg-zinc-800/30">
|
||||||
|
<div className="text-2xl mb-2">📝</div>
|
||||||
|
<h3 className="font-medium text-white">Word</h3>
|
||||||
|
<p className="text-xs text-zinc-500 mt-1">.docx, .doc</p>
|
||||||
|
<div className="flex flex-wrap gap-1 mt-2">
|
||||||
|
<Badge variant="outline" className="border-zinc-700 text-zinc-400 text-xs">
|
||||||
|
Headers
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="outline" className="border-zinc-700 text-zinc-400 text-xs">
|
||||||
|
Tables
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="outline" className="border-zinc-700 text-zinc-400 text-xs">
|
||||||
|
Images
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 rounded-lg border border-zinc-800 bg-zinc-800/30">
|
||||||
|
<div className="text-2xl mb-2">📽️</div>
|
||||||
|
<h3 className="font-medium text-white">PowerPoint</h3>
|
||||||
|
<p className="text-xs text-zinc-500 mt-1">.pptx, .ppt</p>
|
||||||
|
<div className="flex flex-wrap gap-1 mt-2">
|
||||||
|
<Badge variant="outline" className="border-zinc-700 text-zinc-400 text-xs">
|
||||||
|
Slides
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="outline" className="border-zinc-700 text-zinc-400 text-xs">
|
||||||
|
Notes
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="outline" className="border-zinc-700 text-zinc-400 text-xs">
|
||||||
|
Images
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* API Status */}
|
||||||
|
<Card className="border-zinc-800 bg-zinc-900/50">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-white">API Information</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Backend server connection details
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between p-3 rounded-lg bg-zinc-800/50">
|
||||||
|
<span className="text-zinc-400">API Endpoint</span>
|
||||||
|
<code className="text-teal-400 text-sm">http://localhost:8000</code>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between p-3 rounded-lg bg-zinc-800/50">
|
||||||
|
<span className="text-zinc-400">Health Check</span>
|
||||||
|
<code className="text-teal-400 text-sm">/health</code>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between p-3 rounded-lg bg-zinc-800/50">
|
||||||
|
<span className="text-zinc-400">Translate Endpoint</span>
|
||||||
|
<code className="text-teal-400 text-sm">/translate</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Save Button */}
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<Button
|
||||||
|
onClick={handleClearCache}
|
||||||
|
disabled={isClearing}
|
||||||
|
variant="destructive"
|
||||||
|
className="bg-red-600 hover:bg-red-700 text-white px-6"
|
||||||
|
>
|
||||||
|
{isClearing ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Clearing...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
|
Clear Cache
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={isSaving}
|
||||||
|
className="bg-teal-600 hover:bg-teal-700 text-white px-8"
|
||||||
|
>
|
||||||
|
{isSaving ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Saving...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Save className="mr-2 h-4 w-4" />
|
||||||
|
Save Settings
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
722
frontend/src/app/settings/services/page.tsx
Normal file
722
frontend/src/app/settings/services/page.tsx
Normal file
@ -0,0 +1,722 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { useTranslationStore, webllmModels, openaiModels } from "@/lib/store";
|
||||||
|
import { providers, testOpenAIConnection, testOllamaConnection, getOllamaModels, type OllamaModel } from "@/lib/api";
|
||||||
|
import { useWebLLM } from "@/lib/webllm";
|
||||||
|
import { Save, Loader2, Cloud, Check, ExternalLink, Wifi, CheckCircle, XCircle, Download, Trash2, Cpu, Server, RefreshCw } from "lucide-react";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Progress } from "@/components/ui/progress";
|
||||||
|
|
||||||
|
export default function TranslationServicesPage() {
|
||||||
|
const { settings, updateSettings } = useTranslationStore();
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [selectedProvider, setSelectedProvider] = useState(settings.defaultProvider);
|
||||||
|
const [translateImages, setTranslateImages] = useState(settings.translateImages);
|
||||||
|
|
||||||
|
// Provider-specific states
|
||||||
|
const [deeplApiKey, setDeeplApiKey] = useState(settings.deeplApiKey);
|
||||||
|
const [openaiApiKey, setOpenaiApiKey] = useState(settings.openaiApiKey);
|
||||||
|
const [openaiModel, setOpenaiModel] = useState(settings.openaiModel);
|
||||||
|
const [libreUrl, setLibreUrl] = useState(settings.libreTranslateUrl);
|
||||||
|
const [webllmModel, setWebllmModel] = useState(settings.webllmModel);
|
||||||
|
|
||||||
|
// Ollama states
|
||||||
|
const [ollamaUrl, setOllamaUrl] = useState(settings.ollamaUrl);
|
||||||
|
const [ollamaModel, setOllamaModel] = useState(settings.ollamaModel);
|
||||||
|
const [ollamaModels, setOllamaModels] = useState<OllamaModel[]>([]);
|
||||||
|
const [loadingOllamaModels, setLoadingOllamaModels] = useState(false);
|
||||||
|
const [ollamaTestStatus, setOllamaTestStatus] = useState<"idle" | "testing" | "success" | "error">("idle");
|
||||||
|
const [ollamaTestMessage, setOllamaTestMessage] = useState("");
|
||||||
|
|
||||||
|
// OpenAI connection test state
|
||||||
|
const [openaiTestStatus, setOpenaiTestStatus] = useState<"idle" | "testing" | "success" | "error">("idle");
|
||||||
|
const [openaiTestMessage, setOpenaiTestMessage] = useState("");
|
||||||
|
|
||||||
|
// WebLLM hook
|
||||||
|
const webllm = useWebLLM();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedProvider(settings.defaultProvider);
|
||||||
|
setTranslateImages(settings.translateImages);
|
||||||
|
setDeeplApiKey(settings.deeplApiKey);
|
||||||
|
setOpenaiApiKey(settings.openaiApiKey);
|
||||||
|
setOpenaiModel(settings.openaiModel);
|
||||||
|
setLibreUrl(settings.libreTranslateUrl);
|
||||||
|
setWebllmModel(settings.webllmModel);
|
||||||
|
setOllamaUrl(settings.ollamaUrl);
|
||||||
|
setOllamaModel(settings.ollamaModel);
|
||||||
|
}, [settings]);
|
||||||
|
|
||||||
|
// Load Ollama models when provider is selected
|
||||||
|
const loadOllamaModels = async () => {
|
||||||
|
setLoadingOllamaModels(true);
|
||||||
|
try {
|
||||||
|
const models = await getOllamaModels(ollamaUrl);
|
||||||
|
setOllamaModels(models);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load Ollama models:", error);
|
||||||
|
} finally {
|
||||||
|
setLoadingOllamaModels(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedProvider === "ollama") {
|
||||||
|
loadOllamaModels();
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [selectedProvider]);
|
||||||
|
|
||||||
|
const handleTestOllama = async () => {
|
||||||
|
setOllamaTestStatus("testing");
|
||||||
|
setOllamaTestMessage("");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await testOllamaConnection(ollamaUrl);
|
||||||
|
setOllamaTestStatus(result.success ? "success" : "error");
|
||||||
|
setOllamaTestMessage(result.message);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
await loadOllamaModels();
|
||||||
|
updateSettings({ ollamaUrl, ollamaModel });
|
||||||
|
setOllamaTestMessage(result.message + " - Settings saved!");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setOllamaTestStatus("error");
|
||||||
|
setOllamaTestMessage("Connection test failed");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTestOpenAI = async () => {
|
||||||
|
if (!openaiApiKey.trim()) {
|
||||||
|
setOpenaiTestStatus("error");
|
||||||
|
setOpenaiTestMessage("Please enter an API key first");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setOpenaiTestStatus("testing");
|
||||||
|
setOpenaiTestMessage("");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await testOpenAIConnection(openaiApiKey);
|
||||||
|
setOpenaiTestStatus(result.success ? "success" : "error");
|
||||||
|
setOpenaiTestMessage(result.message);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
updateSettings({ openaiApiKey, openaiModel });
|
||||||
|
setOpenaiTestMessage(result.message + " - Settings saved!");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setOpenaiTestStatus("error");
|
||||||
|
setOpenaiTestMessage("Connection test failed");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setIsSaving(true);
|
||||||
|
try {
|
||||||
|
updateSettings({
|
||||||
|
defaultProvider: selectedProvider,
|
||||||
|
translateImages,
|
||||||
|
deeplApiKey,
|
||||||
|
openaiApiKey,
|
||||||
|
openaiModel,
|
||||||
|
libreTranslateUrl: libreUrl,
|
||||||
|
webllmModel,
|
||||||
|
ollamaUrl,
|
||||||
|
ollamaModel,
|
||||||
|
});
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-white">Translation Services</h1>
|
||||||
|
<p className="text-zinc-400 mt-1">
|
||||||
|
Select and configure your preferred translation provider.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Provider Selection */}
|
||||||
|
<Card className="border-zinc-800 bg-zinc-900/50">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Cloud className="h-6 w-6 text-teal-400" />
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-white">Choose Provider</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Select your default translation service
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{providers.map((provider) => (
|
||||||
|
<div
|
||||||
|
key={provider.id}
|
||||||
|
onClick={() => setSelectedProvider(provider.id as typeof selectedProvider)}
|
||||||
|
tabIndex={-1}
|
||||||
|
className={`
|
||||||
|
relative p-4 rounded-lg border-2 cursor-pointer transition-all
|
||||||
|
${
|
||||||
|
selectedProvider === provider.id
|
||||||
|
? "border-teal-500 bg-teal-500/10"
|
||||||
|
: "border-zinc-700 hover:border-zinc-600 bg-zinc-800/50"
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{selectedProvider === provider.id && (
|
||||||
|
<div className="absolute top-2 right-2">
|
||||||
|
<Check className="h-5 w-5 text-teal-400" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="text-2xl mb-2">{provider.icon}</div>
|
||||||
|
<h3 className="font-medium text-white">{provider.name}</h3>
|
||||||
|
<p className="text-xs text-zinc-500 mt-1">{provider.description}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Google - No config needed */}
|
||||||
|
{selectedProvider === "google" && (
|
||||||
|
<Card className="border-zinc-800 bg-zinc-900/50 border-l-4 border-l-green-500">
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<CheckCircle className="h-6 w-6 text-green-400" />
|
||||||
|
<div>
|
||||||
|
<p className="text-white font-medium">Ready to use!</p>
|
||||||
|
<p className="text-sm text-zinc-400">
|
||||||
|
Google Translate works out of the box. No configuration needed.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Ollama Settings */}
|
||||||
|
{selectedProvider === "ollama" && (
|
||||||
|
<Card className="border-zinc-800 bg-zinc-900/50">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Server className="h-5 w-5 text-orange-400" />
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-white">Ollama Configuration</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Connect to your local Ollama server
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{ollamaTestStatus !== "idle" && ollamaTestStatus !== "testing" && (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={
|
||||||
|
ollamaTestStatus === "success"
|
||||||
|
? "border-green-500 text-green-400"
|
||||||
|
: "border-red-500 text-red-400"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{ollamaTestStatus === "success" && <CheckCircle className="h-3 w-3 mr-1" />}
|
||||||
|
{ollamaTestStatus === "error" && <XCircle className="h-3 w-3 mr-1" />}
|
||||||
|
{ollamaTestStatus === "success" ? "Connected" : "Error"}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="ollama-url" className="text-zinc-300">
|
||||||
|
Server URL
|
||||||
|
</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
id="ollama-url"
|
||||||
|
value={ollamaUrl}
|
||||||
|
onChange={(e) => setOllamaUrl(e.target.value)}
|
||||||
|
placeholder="http://localhost:11434"
|
||||||
|
className="bg-zinc-800 border-zinc-700 text-white placeholder:text-zinc-500"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleTestOllama}
|
||||||
|
disabled={ollamaTestStatus === "testing"}
|
||||||
|
className="border-zinc-700 text-zinc-300 hover:bg-zinc-800"
|
||||||
|
>
|
||||||
|
{ollamaTestStatus === "testing" ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Wifi className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{ollamaTestMessage && (
|
||||||
|
<p className={`text-xs ${ollamaTestStatus === "success" ? "text-green-400" : "text-red-400"}`}>
|
||||||
|
{ollamaTestMessage}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label htmlFor="ollama-model" className="text-zinc-300">
|
||||||
|
Model
|
||||||
|
</Label>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={loadOllamaModels}
|
||||||
|
disabled={loadingOllamaModels}
|
||||||
|
className="text-zinc-400 hover:text-teal-400 h-7 px-2"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`h-3 w-3 mr-1 ${loadingOllamaModels ? "animate-spin" : ""}`} />
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Select
|
||||||
|
value={ollamaModel}
|
||||||
|
onValueChange={setOllamaModel}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="bg-zinc-800 border-zinc-700 text-white">
|
||||||
|
<SelectValue placeholder="Select a model" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="bg-zinc-800 border-zinc-700">
|
||||||
|
{ollamaModels.length > 0 ? (
|
||||||
|
ollamaModels.map((model) => (
|
||||||
|
<SelectItem
|
||||||
|
key={model.name}
|
||||||
|
value={model.name}
|
||||||
|
className="text-white hover:bg-zinc-700"
|
||||||
|
>
|
||||||
|
{model.name}
|
||||||
|
</SelectItem>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<SelectItem value={ollamaModel} className="text-white">
|
||||||
|
{ollamaModel || "No models found"}
|
||||||
|
</SelectItem>
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-xs text-zinc-500">
|
||||||
|
Don't have Ollama? Install it from{" "}
|
||||||
|
<a
|
||||||
|
href="https://ollama.ai"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-teal-400 hover:underline"
|
||||||
|
>
|
||||||
|
ollama.ai
|
||||||
|
</a>
|
||||||
|
{" "}then run: <code className="bg-zinc-800 px-1 rounded">ollama pull llama3.2</code>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* WebLLM Settings */}
|
||||||
|
{selectedProvider === "webllm" && (
|
||||||
|
<Card className="border-zinc-800 bg-zinc-900/50">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-white flex items-center gap-2">
|
||||||
|
<Cpu className="h-5 w-5 text-teal-400" />
|
||||||
|
WebLLM Settings
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Run AI models directly in your browser using WebGPU - no server required!
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{/* WebGPU Support Check */}
|
||||||
|
{!webllm.isWebGPUSupported() && (
|
||||||
|
<div className="p-4 rounded-lg bg-red-500/10 border border-red-500/30">
|
||||||
|
<p className="text-red-400 text-sm">
|
||||||
|
⚠️ WebGPU is not supported in this browser. Please use Chrome 113+, Edge 113+, or another WebGPU-compatible browser.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="webllm-model" className="text-zinc-300">
|
||||||
|
Model
|
||||||
|
</Label>
|
||||||
|
<Select value={webllmModel} onValueChange={setWebllmModel}>
|
||||||
|
<SelectTrigger className="bg-zinc-800 border-zinc-700 text-white">
|
||||||
|
<SelectValue placeholder="Select a model" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="bg-zinc-800 border-zinc-700">
|
||||||
|
{webllmModels.map((model) => (
|
||||||
|
<SelectItem
|
||||||
|
key={model.id}
|
||||||
|
value={model.id}
|
||||||
|
className="text-white hover:bg-zinc-700"
|
||||||
|
>
|
||||||
|
<span className="flex items-center justify-between gap-4">
|
||||||
|
<span>{model.name}</span>
|
||||||
|
<Badge variant="outline" className="border-zinc-600 text-zinc-400 text-xs ml-2">
|
||||||
|
{model.size}
|
||||||
|
</Badge>
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Model Loading Status */}
|
||||||
|
{webllm.isLoading && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-zinc-400">{webllm.loadStatus}</span>
|
||||||
|
<span className="text-teal-400">{webllm.loadProgress}%</span>
|
||||||
|
</div>
|
||||||
|
<Progress value={webllm.loadProgress} className="h-2" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{webllm.isLoaded && (
|
||||||
|
<div className="p-3 rounded-lg bg-green-500/10 border border-green-500/30">
|
||||||
|
<p className="text-green-400 text-sm flex items-center gap-2">
|
||||||
|
<CheckCircle className="h-4 w-4" />
|
||||||
|
Model loaded: {webllm.currentModel}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{webllm.error && (
|
||||||
|
<div className="p-3 rounded-lg bg-red-500/10 border border-red-500/30">
|
||||||
|
<p className="text-red-400 text-sm flex items-center gap-2">
|
||||||
|
<XCircle className="h-4 w-4" />
|
||||||
|
{webllm.error}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Button
|
||||||
|
onClick={() => webllm.loadModel(webllmModel)}
|
||||||
|
disabled={webllm.isLoading || !webllm.isWebGPUSupported()}
|
||||||
|
className="bg-teal-600 hover:bg-teal-700 text-white flex-1"
|
||||||
|
>
|
||||||
|
{webllm.isLoading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Loading...
|
||||||
|
</>
|
||||||
|
) : webllm.isLoaded && webllm.currentModel === webllmModel ? (
|
||||||
|
<>
|
||||||
|
<CheckCircle className="mr-2 h-4 w-4" />
|
||||||
|
Loaded
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Download className="mr-2 h-4 w-4" />
|
||||||
|
Load Model
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => webllm.clearCache()}
|
||||||
|
variant="destructive"
|
||||||
|
className="bg-red-600 hover:bg-red-700"
|
||||||
|
>
|
||||||
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
|
Clear Cache
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-zinc-500">
|
||||||
|
💡 Models are downloaded once and cached in your browser (~1-5GB depending on model).
|
||||||
|
Loading may take a minute on first use.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* DeepL Settings */}
|
||||||
|
{selectedProvider === "deepl" && (
|
||||||
|
<Card className="border-zinc-800 bg-zinc-900/50">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-white">DeepL Settings</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Configure your DeepL API credentials
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="deepl-key" className="text-zinc-300">
|
||||||
|
API Key
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="deepl-key"
|
||||||
|
type="password"
|
||||||
|
value={deeplApiKey}
|
||||||
|
onChange={(e) => setDeeplApiKey(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.stopPropagation()}
|
||||||
|
placeholder="Enter your DeepL API key"
|
||||||
|
className="bg-zinc-800 border-zinc-700 text-white placeholder:text-zinc-500"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-zinc-500">
|
||||||
|
Get your API key from{" "}
|
||||||
|
<a
|
||||||
|
href="https://www.deepl.com/pro-api"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-teal-400 hover:underline"
|
||||||
|
>
|
||||||
|
deepl.com/pro-api
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* LibreTranslate Settings */}
|
||||||
|
{selectedProvider === "libre" && (
|
||||||
|
<Card className="border-zinc-800 bg-zinc-900/50">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-white">LibreTranslate Settings</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Configure your LibreTranslate server (open-source, self-hosted)
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="libre-url" className="text-zinc-300">
|
||||||
|
Server URL
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="libre-url"
|
||||||
|
value={libreUrl}
|
||||||
|
onChange={(e) => setLibreUrl(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.stopPropagation()}
|
||||||
|
placeholder="https://libretranslate.com"
|
||||||
|
className="bg-zinc-800 border-zinc-700 text-white placeholder:text-zinc-500"
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col gap-1 text-xs text-zinc-500">
|
||||||
|
<p>Public instances (free but rate-limited):</p>
|
||||||
|
<div className="flex flex-wrap gap-2 mt-1">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 text-xs border-zinc-700 text-zinc-400 hover:text-teal-400"
|
||||||
|
onClick={() => setLibreUrl("https://libretranslate.com")}
|
||||||
|
>
|
||||||
|
libretranslate.com <ExternalLink className="h-3 w-3 ml-1" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 text-xs border-zinc-700 text-zinc-400 hover:text-teal-400"
|
||||||
|
onClick={() => setLibreUrl("https://translate.argosopentech.com")}
|
||||||
|
>
|
||||||
|
argosopentech.com <ExternalLink className="h-3 w-3 ml-1" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2">
|
||||||
|
Or{" "}
|
||||||
|
<a
|
||||||
|
href="https://github.com/LibreTranslate/LibreTranslate"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-teal-400 hover:underline"
|
||||||
|
>
|
||||||
|
self-host your own instance
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* OpenAI Settings */}
|
||||||
|
{selectedProvider === "openai" && (
|
||||||
|
<Card className="border-zinc-800 bg-zinc-900/50">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-white">OpenAI Settings</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Configure your OpenAI API for GPT-4 Vision translations
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
{openaiTestStatus !== "idle" && openaiTestStatus !== "testing" && (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={
|
||||||
|
openaiTestStatus === "success"
|
||||||
|
? "border-green-500 text-green-400"
|
||||||
|
: "border-red-500 text-red-400"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{openaiTestStatus === "success" && <CheckCircle className="h-3 w-3 mr-1" />}
|
||||||
|
{openaiTestStatus === "error" && <XCircle className="h-3 w-3 mr-1" />}
|
||||||
|
{openaiTestStatus === "success" ? "Connected" : "Error"}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="openai-key" className="text-zinc-300">
|
||||||
|
API Key
|
||||||
|
</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
id="openai-key"
|
||||||
|
type="password"
|
||||||
|
value={openaiApiKey}
|
||||||
|
onChange={(e) => setOpenaiApiKey(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.stopPropagation()}
|
||||||
|
placeholder="sk-..."
|
||||||
|
className="bg-zinc-800 border-zinc-700 text-white placeholder:text-zinc-500"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleTestOpenAI}
|
||||||
|
disabled={openaiTestStatus === "testing"}
|
||||||
|
className="border-zinc-700 text-zinc-300 hover:bg-zinc-800"
|
||||||
|
>
|
||||||
|
{openaiTestStatus === "testing" ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Wifi className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{openaiTestMessage && (
|
||||||
|
<p className={`text-xs ${openaiTestStatus === "success" ? "text-green-400" : "text-red-400"}`}>
|
||||||
|
{openaiTestMessage}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-zinc-500">
|
||||||
|
Get your API key from{" "}
|
||||||
|
<a
|
||||||
|
href="https://platform.openai.com/api-keys"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-teal-400 hover:underline"
|
||||||
|
>
|
||||||
|
platform.openai.com
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="openai-model" className="text-zinc-300">
|
||||||
|
Model
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
value={openaiModel}
|
||||||
|
onValueChange={setOpenaiModel}
|
||||||
|
>
|
||||||
|
<SelectTrigger
|
||||||
|
id="openai-model"
|
||||||
|
className="bg-zinc-800 border-zinc-700 text-white"
|
||||||
|
>
|
||||||
|
<SelectValue placeholder="Select a model" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="bg-zinc-800 border-zinc-700">
|
||||||
|
{openaiModels.map((model) => (
|
||||||
|
<SelectItem
|
||||||
|
key={model.id}
|
||||||
|
value={model.id}
|
||||||
|
className="text-white hover:bg-zinc-700"
|
||||||
|
>
|
||||||
|
<span className="flex items-center justify-between gap-4">
|
||||||
|
<span>{model.name}</span>
|
||||||
|
{model.vision && (
|
||||||
|
<Badge variant="outline" className="border-teal-600 text-teal-400 text-xs ml-2">
|
||||||
|
Vision
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-xs text-zinc-500">
|
||||||
|
Models with Vision can translate text in images
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Image Translation - Only for Ollama and OpenAI */}
|
||||||
|
{(selectedProvider === "ollama" || selectedProvider === "openai") && (
|
||||||
|
<Card className="border-zinc-800 bg-zinc-900/50">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-white">Advanced Options</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Additional translation features
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex items-center justify-between rounded-lg border border-zinc-800 p-4">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Label className="text-zinc-300">Translate Images by Default</Label>
|
||||||
|
<Badge variant="outline" className="border-teal-600 text-teal-400 text-xs">
|
||||||
|
Vision Models
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-zinc-500">
|
||||||
|
Extract and translate text from embedded images using vision models
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={translateImages}
|
||||||
|
onCheckedChange={setTranslateImages}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Save Button */}
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={isSaving}
|
||||||
|
className="bg-teal-600 hover:bg-teal-700 text-white px-8"
|
||||||
|
>
|
||||||
|
{isSaving ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Saving...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Save className="mr-2 h-4 w-4" />
|
||||||
|
Save Settings
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
494
frontend/src/components/file-uploader.tsx
Normal file
494
frontend/src/components/file-uploader.tsx
Normal file
@ -0,0 +1,494 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useCallback, useEffect } from "react";
|
||||||
|
import { useDropzone } from "react-dropzone";
|
||||||
|
import { Upload, FileText, FileSpreadsheet, Presentation, X, Download, Loader2, Cpu, AlertTriangle } from "lucide-react";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Progress } from "@/components/ui/progress";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { useTranslationStore } from "@/lib/store";
|
||||||
|
import { translateDocument, languages, providers, extractTextsFromDocument, reconstructDocument, TranslatedText } from "@/lib/api";
|
||||||
|
import { useWebLLM } from "@/lib/webllm";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const fileIcons: Record<string, React.ElementType> = {
|
||||||
|
xlsx: FileSpreadsheet,
|
||||||
|
xls: FileSpreadsheet,
|
||||||
|
docx: FileText,
|
||||||
|
doc: FileText,
|
||||||
|
pptx: Presentation,
|
||||||
|
ppt: Presentation,
|
||||||
|
};
|
||||||
|
|
||||||
|
type ProviderType = "google" | "ollama" | "deepl" | "libre" | "webllm" | "openai";
|
||||||
|
|
||||||
|
export function FileUploader() {
|
||||||
|
const { settings, isTranslating, progress, setTranslating, setProgress } = useTranslationStore();
|
||||||
|
const webllm = useWebLLM();
|
||||||
|
|
||||||
|
const [file, setFile] = useState<File | null>(null);
|
||||||
|
const [targetLanguage, setTargetLanguage] = useState(settings.defaultTargetLanguage);
|
||||||
|
const [provider, setProvider] = useState<ProviderType>(settings.defaultProvider);
|
||||||
|
const [translateImages, setTranslateImages] = useState(settings.translateImages);
|
||||||
|
const [downloadUrl, setDownloadUrl] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [translationStatus, setTranslationStatus] = useState<string>("");
|
||||||
|
|
||||||
|
// Sync with store settings when they change
|
||||||
|
useEffect(() => {
|
||||||
|
setTargetLanguage(settings.defaultTargetLanguage);
|
||||||
|
setProvider(settings.defaultProvider);
|
||||||
|
setTranslateImages(settings.translateImages);
|
||||||
|
}, [settings.defaultTargetLanguage, settings.defaultProvider, settings.translateImages]);
|
||||||
|
|
||||||
|
const onDrop = useCallback((acceptedFiles: File[]) => {
|
||||||
|
if (acceptedFiles.length > 0) {
|
||||||
|
setFile(acceptedFiles[0]);
|
||||||
|
setDownloadUrl(null);
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||||
|
onDrop,
|
||||||
|
accept: {
|
||||||
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": [".xlsx"],
|
||||||
|
"application/vnd.ms-excel": [".xls"],
|
||||||
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": [".docx"],
|
||||||
|
"application/msword": [".doc"],
|
||||||
|
"application/vnd.openxmlformats-officedocument.presentationml.presentation": [".pptx"],
|
||||||
|
"application/vnd.ms-powerpoint": [".ppt"],
|
||||||
|
},
|
||||||
|
multiple: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const getFileExtension = (filename: string) => {
|
||||||
|
return filename.split(".").pop()?.toLowerCase() || "";
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFileIcon = (filename: string) => {
|
||||||
|
const ext = getFileExtension(filename);
|
||||||
|
return fileIcons[ext] || FileText;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatFileSize = (bytes: number) => {
|
||||||
|
if (bytes < 1024) return bytes + " B";
|
||||||
|
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB";
|
||||||
|
return (bytes / (1024 * 1024)).toFixed(1) + " MB";
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTranslate = async () => {
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
// Validate provider-specific requirements
|
||||||
|
if (provider === "openai" && !settings.openaiApiKey) {
|
||||||
|
setError("OpenAI API key not configured. Go to Settings > Translation Services to add your API key.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (provider === "deepl" && !settings.deeplApiKey) {
|
||||||
|
setError("DeepL API key not configured. Go to Settings > Translation Services to add your API key.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebLLM specific validation
|
||||||
|
if (provider === "webllm") {
|
||||||
|
if (!webllm.isWebGPUSupported()) {
|
||||||
|
setError("WebGPU is not supported in this browser. Please use Chrome 113+ or Edge 113+.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!webllm.isLoaded) {
|
||||||
|
setError("WebLLM model not loaded. Go to Settings > Translation Services to load a model first.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setTranslating(true);
|
||||||
|
setProgress(0);
|
||||||
|
setError(null);
|
||||||
|
setDownloadUrl(null);
|
||||||
|
setTranslationStatus("");
|
||||||
|
|
||||||
|
try {
|
||||||
|
// For WebLLM, use client-side translation
|
||||||
|
if (provider === "webllm") {
|
||||||
|
await handleWebLLMTranslation();
|
||||||
|
} else {
|
||||||
|
await handleServerTranslation();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Translation failed");
|
||||||
|
} finally {
|
||||||
|
setTranslating(false);
|
||||||
|
setTranslationStatus("");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get language name from code
|
||||||
|
const getLanguageName = (code: string): string => {
|
||||||
|
const lang = languages.find(l => l.code === code);
|
||||||
|
return lang ? lang.name : code;
|
||||||
|
};
|
||||||
|
|
||||||
|
// WebLLM client-side translation
|
||||||
|
const handleWebLLMTranslation = async () => {
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Step 1: Extract texts from document
|
||||||
|
setTranslationStatus("Extracting texts from document...");
|
||||||
|
setProgress(5);
|
||||||
|
const extractResult = await extractTextsFromDocument(file);
|
||||||
|
|
||||||
|
if (extractResult.texts.length === 0) {
|
||||||
|
throw new Error("No translatable text found in document");
|
||||||
|
}
|
||||||
|
|
||||||
|
setTranslationStatus(`Found ${extractResult.texts.length} texts to translate`);
|
||||||
|
setProgress(10);
|
||||||
|
|
||||||
|
// Step 2: Translate each text using WebLLM
|
||||||
|
const translations: TranslatedText[] = [];
|
||||||
|
const totalTexts = extractResult.texts.length;
|
||||||
|
const langName = getLanguageName(targetLanguage);
|
||||||
|
|
||||||
|
for (let i = 0; i < totalTexts; i++) {
|
||||||
|
const item = extractResult.texts[i];
|
||||||
|
setTranslationStatus(`Translating ${i + 1}/${totalTexts}: "${item.text.substring(0, 30)}..."`);
|
||||||
|
|
||||||
|
const translatedText = await webllm.translate(
|
||||||
|
item.text,
|
||||||
|
langName,
|
||||||
|
settings.systemPrompt || undefined,
|
||||||
|
settings.glossary || undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
translations.push({
|
||||||
|
id: item.id,
|
||||||
|
translated_text: translatedText,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update progress (10% for extraction, 80% for translation, 10% for reconstruction)
|
||||||
|
const translationProgress = 10 + (80 * (i + 1) / totalTexts);
|
||||||
|
setProgress(translationProgress);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Reconstruct document with translations
|
||||||
|
setTranslationStatus("Reconstructing document...");
|
||||||
|
setProgress(92);
|
||||||
|
const blob = await reconstructDocument(
|
||||||
|
extractResult.session_id,
|
||||||
|
translations,
|
||||||
|
targetLanguage
|
||||||
|
);
|
||||||
|
|
||||||
|
setProgress(100);
|
||||||
|
setTranslationStatus("Translation complete!");
|
||||||
|
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
setDownloadUrl(url);
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Server-side translation (existing logic)
|
||||||
|
const handleServerTranslation = async () => {
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
// Simulate progress for UX
|
||||||
|
let currentProgress = 0;
|
||||||
|
const progressInterval = setInterval(() => {
|
||||||
|
currentProgress = Math.min(currentProgress + Math.random() * 10, 90);
|
||||||
|
setProgress(currentProgress);
|
||||||
|
}, 500);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const blob = await translateDocument({
|
||||||
|
file,
|
||||||
|
targetLanguage,
|
||||||
|
provider,
|
||||||
|
ollamaModel: settings.ollamaModel,
|
||||||
|
translateImages: translateImages || settings.translateImages,
|
||||||
|
systemPrompt: settings.systemPrompt,
|
||||||
|
glossary: settings.glossary,
|
||||||
|
libreUrl: settings.libreTranslateUrl,
|
||||||
|
openaiApiKey: settings.openaiApiKey,
|
||||||
|
openaiModel: settings.openaiModel,
|
||||||
|
});
|
||||||
|
|
||||||
|
clearInterval(progressInterval);
|
||||||
|
setProgress(100);
|
||||||
|
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
setDownloadUrl(url);
|
||||||
|
} catch (err) {
|
||||||
|
clearInterval(progressInterval);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownload = () => {
|
||||||
|
if (!downloadUrl || !file) return;
|
||||||
|
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = downloadUrl;
|
||||||
|
const ext = getFileExtension(file.name);
|
||||||
|
const baseName = file.name.replace(`.${ext}`, "");
|
||||||
|
a.download = `${baseName}_translated.${ext}`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeFile = () => {
|
||||||
|
setFile(null);
|
||||||
|
setDownloadUrl(null);
|
||||||
|
setError(null);
|
||||||
|
setProgress(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const FileIcon = file ? getFileIcon(file.name) : FileText;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* File Drop Zone */}
|
||||||
|
<Card className="border-zinc-800 bg-zinc-900/50">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-white">Upload Document</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Drag and drop or click to select a file (Excel, Word, PowerPoint)
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{!file ? (
|
||||||
|
<div
|
||||||
|
{...getRootProps()}
|
||||||
|
className={cn(
|
||||||
|
"border-2 border-dashed rounded-xl p-12 text-center cursor-pointer transition-all",
|
||||||
|
isDragActive
|
||||||
|
? "border-teal-500 bg-teal-500/10"
|
||||||
|
: "border-zinc-700 hover:border-zinc-600 hover:bg-zinc-800/50"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<input {...getInputProps()} />
|
||||||
|
<Upload className="h-12 w-12 mx-auto mb-4 text-zinc-500" />
|
||||||
|
<p className="text-zinc-400 mb-2">
|
||||||
|
{isDragActive
|
||||||
|
? "Drop the file here..."
|
||||||
|
: "Drag & drop a document here, or click to select"}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-zinc-600">
|
||||||
|
Supports: .xlsx, .docx, .pptx
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-4 p-4 bg-zinc-800/50 rounded-lg">
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-zinc-700">
|
||||||
|
<FileIcon className="h-6 w-6 text-teal-400" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-white truncate">
|
||||||
|
{file.name}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-zinc-500">
|
||||||
|
{formatFileSize(file.size)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Badge variant="outline" className="border-zinc-700 text-zinc-400">
|
||||||
|
{getFileExtension(file.name).toUpperCase()}
|
||||||
|
</Badge>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={removeFile}
|
||||||
|
className="text-zinc-500 hover:text-red-400"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Translation Options */}
|
||||||
|
<Card className="border-zinc-800 bg-zinc-900/50">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-white">Translation Options</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Configure your translation preferences
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
{/* Target Language */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="language" className="text-zinc-300">Target Language</Label>
|
||||||
|
<Select value={targetLanguage} onValueChange={setTargetLanguage}>
|
||||||
|
<SelectTrigger id="language" className="bg-zinc-800 border-zinc-700 text-white">
|
||||||
|
<SelectValue placeholder="Select language" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="bg-zinc-800 border-zinc-700">
|
||||||
|
{languages.map((lang) => (
|
||||||
|
<SelectItem
|
||||||
|
key={lang.code}
|
||||||
|
value={lang.code}
|
||||||
|
className="text-white hover:bg-zinc-700"
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span>{lang.flag}</span>
|
||||||
|
<span>{lang.name}</span>
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Provider */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="provider" className="text-zinc-300">Translation Provider</Label>
|
||||||
|
<Select value={provider} onValueChange={(value) => setProvider(value as ProviderType)}>
|
||||||
|
<SelectTrigger id="provider" className="bg-zinc-800 border-zinc-700 text-white">
|
||||||
|
<SelectValue placeholder="Select provider" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="bg-zinc-800 border-zinc-700">
|
||||||
|
{providers.map((prov) => (
|
||||||
|
<SelectItem
|
||||||
|
key={prov.id}
|
||||||
|
value={prov.id}
|
||||||
|
className="text-white hover:bg-zinc-700"
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span>{prov.icon}</span>
|
||||||
|
<span>{prov.name}</span>
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{/* Warning if API key not configured */}
|
||||||
|
{provider === "openai" && !settings.openaiApiKey && (
|
||||||
|
<p className="text-xs text-amber-400 mt-1">
|
||||||
|
⚠️ OpenAI API key not configured. Go to Settings → Translation Services
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{provider === "deepl" && !settings.deeplApiKey && (
|
||||||
|
<p className="text-xs text-amber-400 mt-1">
|
||||||
|
⚠️ DeepL API key not configured. Go to Settings → Translation Services
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{provider === "webllm" && !webllm.isLoaded && (
|
||||||
|
<p className="text-xs text-amber-400 mt-1">
|
||||||
|
⚠️ WebLLM model not loaded. Go to Settings → Translation Services to load a model
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{provider === "webllm" && webllm.isLoaded && (
|
||||||
|
<p className="text-xs text-green-400 mt-1 flex items-center gap-1">
|
||||||
|
<Cpu className="h-3 w-3" />
|
||||||
|
Model ready: {webllm.currentModel}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{provider === "webllm" && !webllm.isWebGPUSupported() && (
|
||||||
|
<p className="text-xs text-red-400 mt-1 flex items-center gap-1">
|
||||||
|
<AlertTriangle className="h-3 w-3" />
|
||||||
|
WebGPU not supported in this browser
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Image Translation Toggle */}
|
||||||
|
{(provider === "ollama" || provider === "openai") && (
|
||||||
|
<div className="flex items-center justify-between rounded-lg border border-zinc-800 p-4">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<Label className="text-zinc-300">Translate Images</Label>
|
||||||
|
<p className="text-xs text-zinc-500">
|
||||||
|
Extract and translate text from embedded images using vision model
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={translateImages}
|
||||||
|
onCheckedChange={setTranslateImages}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Translate Button */}
|
||||||
|
<Button
|
||||||
|
onClick={handleTranslate}
|
||||||
|
disabled={!file || isTranslating}
|
||||||
|
className="w-full bg-teal-600 hover:bg-teal-700 text-white h-12"
|
||||||
|
>
|
||||||
|
{isTranslating ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Translating...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Upload className="mr-2 h-4 w-4" />
|
||||||
|
Translate Document
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Progress Bar */}
|
||||||
|
{isTranslating && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-zinc-400">
|
||||||
|
{translationStatus || "Processing..."}
|
||||||
|
</span>
|
||||||
|
<span className="text-teal-400">{Math.round(progress)}%</span>
|
||||||
|
</div>
|
||||||
|
<Progress value={progress} className="h-2" />
|
||||||
|
{provider === "webllm" && (
|
||||||
|
<p className="text-xs text-zinc-500 flex items-center gap-1">
|
||||||
|
<Cpu className="h-3 w-3" />
|
||||||
|
Translating locally with WebLLM...
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error */}
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-lg bg-red-500/10 border border-red-500/30 p-4">
|
||||||
|
<p className="text-sm text-red-400">{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Download Section */}
|
||||||
|
{downloadUrl && (
|
||||||
|
<Card className="border-teal-500/30 bg-teal-500/5">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-teal-400 flex items-center gap-2">
|
||||||
|
<Download className="h-5 w-5" />
|
||||||
|
Translation Complete
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Your document has been translated successfully
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Button
|
||||||
|
onClick={handleDownload}
|
||||||
|
className="w-full bg-teal-600 hover:bg-teal-700 text-white h-12"
|
||||||
|
>
|
||||||
|
<Download className="mr-2 h-4 w-4" />
|
||||||
|
Download Translated Document
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
105
frontend/src/components/sidebar.tsx
Normal file
105
frontend/src/components/sidebar.tsx
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
Settings,
|
||||||
|
Cloud,
|
||||||
|
BookText,
|
||||||
|
Upload,
|
||||||
|
} from "lucide-react";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
|
|
||||||
|
const navigation = [
|
||||||
|
{
|
||||||
|
name: "Translate",
|
||||||
|
href: "/",
|
||||||
|
icon: Upload,
|
||||||
|
description: "Translate documents",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "General Settings",
|
||||||
|
href: "/settings",
|
||||||
|
icon: Settings,
|
||||||
|
description: "Configure general settings",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Translation Services",
|
||||||
|
href: "/settings/services",
|
||||||
|
icon: Cloud,
|
||||||
|
description: "Configure translation providers",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Context & Glossary",
|
||||||
|
href: "/settings/context",
|
||||||
|
icon: BookText,
|
||||||
|
description: "System prompts and glossary",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function Sidebar() {
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TooltipProvider>
|
||||||
|
<aside className="fixed left-0 top-0 z-40 h-screen w-64 border-r border-zinc-800 bg-[#1a1a1a]">
|
||||||
|
{/* Logo */}
|
||||||
|
<div className="flex h-16 items-center gap-3 border-b border-zinc-800 px-6">
|
||||||
|
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-teal-500 text-white font-bold">
|
||||||
|
文A
|
||||||
|
</div>
|
||||||
|
<span className="text-lg font-semibold text-white">Translate Co.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation */}
|
||||||
|
<nav className="flex flex-col gap-1 p-4">
|
||||||
|
{navigation.map((item) => {
|
||||||
|
const isActive = pathname === item.href;
|
||||||
|
const Icon = item.icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip key={item.name}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Link
|
||||||
|
href={item.href}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors",
|
||||||
|
isActive
|
||||||
|
? "bg-teal-500/10 text-teal-400"
|
||||||
|
: "text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="h-5 w-5" />
|
||||||
|
<span>{item.name}</span>
|
||||||
|
</Link>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="right">
|
||||||
|
<p>{item.description}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* User section at bottom */}
|
||||||
|
<div className="absolute bottom-0 left-0 right-0 border-t border-zinc-800 p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-teal-600 text-white text-sm font-medium">
|
||||||
|
U
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-sm font-medium text-white">User</span>
|
||||||
|
<span className="text-xs text-zinc-500">Translator</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</TooltipProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
66
frontend/src/components/ui/alert.tsx
Normal file
66
frontend/src/components/ui/alert.tsx
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const alertVariants = cva(
|
||||||
|
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-card text-card-foreground",
|
||||||
|
destructive:
|
||||||
|
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Alert({
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert"
|
||||||
|
role="alert"
|
||||||
|
className={cn(alertVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-title"
|
||||||
|
className={cn(
|
||||||
|
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-description"
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Alert, AlertTitle, AlertDescription }
|
||||||
46
frontend/src/components/ui/badge.tsx
Normal file
46
frontend/src/components/ui/badge.tsx
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Slot } from "@radix-ui/react-slot"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-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 transition-[color,box-shadow] overflow-hidden",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default:
|
||||||
|
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||||
|
secondary:
|
||||||
|
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||||
|
destructive:
|
||||||
|
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||||
|
outline:
|
||||||
|
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Badge({
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span"> &
|
||||||
|
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||||
|
const Comp = asChild ? Slot : "span"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="badge"
|
||||||
|
className={cn(badgeVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants }
|
||||||
60
frontend/src/components/ui/button.tsx
Normal file
60
frontend/src/components/ui/button.tsx
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Slot } from "@radix-ui/react-slot"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium 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 }
|
||||||
92
frontend/src/components/ui/card.tsx
Normal file
92
frontend/src/components/ui/card.tsx
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
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,
|
||||||
|
CardHeader,
|
||||||
|
CardFooter,
|
||||||
|
CardTitle,
|
||||||
|
CardAction,
|
||||||
|
CardDescription,
|
||||||
|
CardContent,
|
||||||
|
}
|
||||||
32
frontend/src/components/ui/checkbox.tsx
Normal file
32
frontend/src/components/ui/checkbox.tsx
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||||
|
import { CheckIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Checkbox({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<CheckboxPrimitive.Root
|
||||||
|
data-slot="checkbox"
|
||||||
|
className={cn(
|
||||||
|
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<CheckboxPrimitive.Indicator
|
||||||
|
data-slot="checkbox-indicator"
|
||||||
|
className="grid place-content-center text-current transition-none"
|
||||||
|
>
|
||||||
|
<CheckIcon className="size-3.5" />
|
||||||
|
</CheckboxPrimitive.Indicator>
|
||||||
|
</CheckboxPrimitive.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Checkbox }
|
||||||
143
frontend/src/components/ui/dialog.tsx
Normal file
143
frontend/src/components/ui/dialog.tsx
Normal file
@ -0,0 +1,143 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||||
|
import { XIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Dialog({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||||
|
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||||
|
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||||
|
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogClose({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||||
|
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
data-slot="dialog-overlay"
|
||||||
|
className={cn(
|
||||||
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
showCloseButton = true,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||||
|
showCloseButton?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DialogPortal data-slot="dialog-portal">
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
data-slot="dialog-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-background 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 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{showCloseButton && (
|
||||||
|
<DialogPrimitive.Close
|
||||||
|
data-slot="dialog-close"
|
||||||
|
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||||
|
>
|
||||||
|
<XIcon />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
)}
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPortal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="dialog-header"
|
||||||
|
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="dialog-footer"
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
data-slot="dialog-title"
|
||||||
|
className={cn("text-lg leading-none font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
data-slot="dialog-description"
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogPortal,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
}
|
||||||
257
frontend/src/components/ui/dropdown-menu.tsx
Normal file
257
frontend/src/components/ui/dropdown-menu.tsx
Normal file
@ -0,0 +1,257 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||||
|
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function DropdownMenu({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||||
|
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Trigger
|
||||||
|
data-slot="dropdown-menu-trigger"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuContent({
|
||||||
|
className,
|
||||||
|
sideOffset = 4,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Portal>
|
||||||
|
<DropdownMenuPrimitive.Content
|
||||||
|
data-slot="dropdown-menu-content"
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
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 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</DropdownMenuPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuGroup({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuItem({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
variant = "default",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||||
|
inset?: boolean
|
||||||
|
variant?: "default" | "destructive"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Item
|
||||||
|
data-slot="dropdown-menu-item"
|
||||||
|
data-inset={inset}
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuCheckboxItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
checked,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.CheckboxItem
|
||||||
|
data-slot="dropdown-menu-checkbox-item"
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 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",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
checked={checked}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<CheckIcon className="size-4" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.CheckboxItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuRadioGroup({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.RadioGroup
|
||||||
|
data-slot="dropdown-menu-radio-group"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuRadioItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.RadioItem
|
||||||
|
data-slot="dropdown-menu-radio-item"
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 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",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<CircleIcon className="size-2 fill-current" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.RadioItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuLabel({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Label
|
||||||
|
data-slot="dropdown-menu-label"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Separator
|
||||||
|
data-slot="dropdown-menu-separator"
|
||||||
|
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuShortcut({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="dropdown-menu-shortcut"
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSub({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||||
|
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSubTrigger({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.SubTrigger
|
||||||
|
data-slot="dropdown-menu-sub-trigger"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronRightIcon className="ml-auto size-4" />
|
||||||
|
</DropdownMenuPrimitive.SubTrigger>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSubContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.SubContent
|
||||||
|
data-slot="dropdown-menu-sub-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 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuPortal,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuShortcut,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
}
|
||||||
21
frontend/src/components/ui/input.tsx
Normal file
21
frontend/src/components/ui/input.tsx
Normal file
@ -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 }
|
||||||
24
frontend/src/components/ui/label.tsx
Normal file
24
frontend/src/components/ui/label.tsx
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||||
|
|
||||||
|
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 }
|
||||||
31
frontend/src/components/ui/progress.tsx
Normal file
31
frontend/src/components/ui/progress.tsx
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as ProgressPrimitive from "@radix-ui/react-progress"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Progress({
|
||||||
|
className,
|
||||||
|
value,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<ProgressPrimitive.Root
|
||||||
|
data-slot="progress"
|
||||||
|
className={cn(
|
||||||
|
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ProgressPrimitive.Indicator
|
||||||
|
data-slot="progress-indicator"
|
||||||
|
className="bg-primary h-full w-full flex-1 transition-all"
|
||||||
|
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||||
|
/>
|
||||||
|
</ProgressPrimitive.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Progress }
|
||||||
58
frontend/src/components/ui/scroll-area.tsx
Normal file
58
frontend/src/components/ui/scroll-area.tsx
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function ScrollArea({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<ScrollAreaPrimitive.Root
|
||||||
|
data-slot="scroll-area"
|
||||||
|
className={cn("relative", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ScrollAreaPrimitive.Viewport
|
||||||
|
data-slot="scroll-area-viewport"
|
||||||
|
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ScrollAreaPrimitive.Viewport>
|
||||||
|
<ScrollBar />
|
||||||
|
<ScrollAreaPrimitive.Corner />
|
||||||
|
</ScrollAreaPrimitive.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ScrollBar({
|
||||||
|
className,
|
||||||
|
orientation = "vertical",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
|
||||||
|
return (
|
||||||
|
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||||
|
data-slot="scroll-area-scrollbar"
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"flex touch-none p-px transition-colors select-none",
|
||||||
|
orientation === "vertical" &&
|
||||||
|
"h-full w-2.5 border-l border-l-transparent",
|
||||||
|
orientation === "horizontal" &&
|
||||||
|
"h-2.5 flex-col border-t border-t-transparent",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ScrollAreaPrimitive.ScrollAreaThumb
|
||||||
|
data-slot="scroll-area-thumb"
|
||||||
|
className="bg-border relative flex-1 rounded-full"
|
||||||
|
/>
|
||||||
|
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ScrollArea, ScrollBar }
|
||||||
187
frontend/src/components/ui/select.tsx
Normal file
187
frontend/src/components/ui/select.tsx
Normal file
@ -0,0 +1,187 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||||
|
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-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,
|
||||||
|
}
|
||||||
28
frontend/src/components/ui/separator.tsx
Normal file
28
frontend/src/components/ui/separator.tsx
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Separator({
|
||||||
|
className,
|
||||||
|
orientation = "horizontal",
|
||||||
|
decorative = true,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<SeparatorPrimitive.Root
|
||||||
|
data-slot="separator"
|
||||||
|
decorative={decorative}
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Separator }
|
||||||
31
frontend/src/components/ui/switch.tsx
Normal file
31
frontend/src/components/ui/switch.tsx
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as SwitchPrimitive from "@radix-ui/react-switch"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Switch({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<SwitchPrimitive.Root
|
||||||
|
data-slot="switch"
|
||||||
|
className={cn(
|
||||||
|
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SwitchPrimitive.Thumb
|
||||||
|
data-slot="switch-thumb"
|
||||||
|
className={cn(
|
||||||
|
"bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SwitchPrimitive.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Switch }
|
||||||
66
frontend/src/components/ui/tabs.tsx
Normal file
66
frontend/src/components/ui/tabs.tsx
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Tabs({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.Root
|
||||||
|
data-slot="tabs"
|
||||||
|
className={cn("flex flex-col gap-2", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabsList({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TabsPrimitive.List>) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.List
|
||||||
|
data-slot="tabs-list"
|
||||||
|
className={cn(
|
||||||
|
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabsTrigger({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.Trigger
|
||||||
|
data-slot="tabs-trigger"
|
||||||
|
className={cn(
|
||||||
|
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabsContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.Content
|
||||||
|
data-slot="tabs-content"
|
||||||
|
className={cn("flex-1 outline-none", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||||
18
frontend/src/components/ui/textarea.tsx
Normal file
18
frontend/src/components/ui/textarea.tsx
Normal file
@ -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 }
|
||||||
61
frontend/src/components/ui/tooltip.tsx
Normal file
61
frontend/src/components/ui/tooltip.tsx
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function TooltipProvider({
|
||||||
|
delayDuration = 0,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
||||||
|
return (
|
||||||
|
<TooltipPrimitive.Provider
|
||||||
|
data-slot="tooltip-provider"
|
||||||
|
delayDuration={delayDuration}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Tooltip({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<TooltipProvider>
|
||||||
|
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||||
|
</TooltipProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TooltipTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||||
|
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function TooltipContent({
|
||||||
|
className,
|
||||||
|
sideOffset = 0,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<TooltipPrimitive.Portal>
|
||||||
|
<TooltipPrimitive.Content
|
||||||
|
data-slot="tooltip-content"
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
|
||||||
|
</TooltipPrimitive.Content>
|
||||||
|
</TooltipPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||||
34
frontend/tsconfig.json
Normal file
34
frontend/tsconfig.json
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2017",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"next-env.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".next/types/**/*.ts",
|
||||||
|
".next/dev/types/**/*.ts",
|
||||||
|
"**/*.mts"
|
||||||
|
],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
291
main.py
291
main.py
@ -19,6 +19,27 @@ from utils import file_handler, handle_translation_error, DocumentProcessingErro
|
|||||||
logging.basicConfig(level=logging.INFO)
|
logging.basicConfig(level=logging.INFO)
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def build_full_prompt(system_prompt: str, glossary: str) -> str:
|
||||||
|
"""Combine system prompt and glossary into a single prompt for LLM translation."""
|
||||||
|
parts = []
|
||||||
|
|
||||||
|
# Add system prompt if provided
|
||||||
|
if system_prompt and system_prompt.strip():
|
||||||
|
parts.append(system_prompt.strip())
|
||||||
|
|
||||||
|
# Add glossary if provided
|
||||||
|
if glossary and glossary.strip():
|
||||||
|
glossary_section = """
|
||||||
|
TECHNICAL GLOSSARY - Use these exact translations for the following terms:
|
||||||
|
{}
|
||||||
|
|
||||||
|
Always use the translations from this glossary when you encounter these terms.""".format(glossary.strip())
|
||||||
|
parts.append(glossary_section)
|
||||||
|
|
||||||
|
return "\n\n".join(parts) if parts else ""
|
||||||
|
|
||||||
|
|
||||||
# Ensure necessary directories exist
|
# Ensure necessary directories exist
|
||||||
config.ensure_directories()
|
config.ensure_directories()
|
||||||
|
|
||||||
@ -110,10 +131,14 @@ async def translate_document(
|
|||||||
file: UploadFile = File(..., description="Document file to translate (.xlsx, .docx, or .pptx)"),
|
file: UploadFile = File(..., description="Document file to translate (.xlsx, .docx, or .pptx)"),
|
||||||
target_language: str = Form(..., description="Target language code (e.g., 'es', 'fr', 'de')"),
|
target_language: str = Form(..., description="Target language code (e.g., 'es', 'fr', 'de')"),
|
||||||
source_language: str = Form(default="auto", description="Source language code (default: auto-detect)"),
|
source_language: str = Form(default="auto", description="Source language code (default: auto-detect)"),
|
||||||
provider: str = Form(default="google", description="Translation provider (google, ollama, deepl, libre)"),
|
provider: str = Form(default="google", description="Translation provider (google, ollama, deepl, libre, openai)"),
|
||||||
translate_images: bool = Form(default=False, description="Translate images with multimodal Ollama model"),
|
translate_images: bool = Form(default=False, description="Translate images with multimodal Ollama/OpenAI model"),
|
||||||
ollama_model: str = Form(default="", description="Ollama model to use (also used for vision if multimodal)"),
|
ollama_model: str = Form(default="", description="Ollama model to use (also used for vision if multimodal)"),
|
||||||
system_prompt: str = Form(default="", description="Custom system prompt with context, glossary, or instructions for LLM translation"),
|
system_prompt: str = Form(default="", description="Custom system prompt with context or instructions for LLM translation"),
|
||||||
|
glossary: str = Form(default="", description="Technical glossary (format: source=target, one per line)"),
|
||||||
|
libre_url: str = Form(default="https://libretranslate.com", description="LibreTranslate server URL"),
|
||||||
|
openai_api_key: str = Form(default="", description="OpenAI API key"),
|
||||||
|
openai_model: str = Form(default="gpt-4o-mini", description="OpenAI model to use (gpt-4o-mini is cheapest with vision)"),
|
||||||
cleanup: bool = Form(default=True, description="Delete input file after translation")
|
cleanup: bool = Form(default=True, description="Delete input file after translation")
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
@ -156,18 +181,32 @@ async def translate_document(
|
|||||||
logger.info(f"Saved input file to: {input_path}")
|
logger.info(f"Saved input file to: {input_path}")
|
||||||
|
|
||||||
# Configure translation provider
|
# Configure translation provider
|
||||||
from services.translation_service import GoogleTranslationProvider, DeepLTranslationProvider, LibreTranslationProvider, OllamaTranslationProvider, translation_service
|
from services.translation_service import GoogleTranslationProvider, DeepLTranslationProvider, LibreTranslationProvider, OllamaTranslationProvider, OpenAITranslationProvider, translation_service
|
||||||
|
|
||||||
if provider.lower() == "deepl":
|
if provider.lower() == "deepl":
|
||||||
if not config.DEEPL_API_KEY:
|
if not config.DEEPL_API_KEY:
|
||||||
raise HTTPException(status_code=400, detail="DeepL API key not configured")
|
raise HTTPException(status_code=400, detail="DeepL API key not configured")
|
||||||
translation_provider = DeepLTranslationProvider(config.DEEPL_API_KEY)
|
translation_provider = DeepLTranslationProvider(config.DEEPL_API_KEY)
|
||||||
elif provider.lower() == "libre":
|
elif provider.lower() == "libre":
|
||||||
translation_provider = LibreTranslationProvider()
|
libre_server = libre_url.strip() if libre_url else "https://libretranslate.com"
|
||||||
|
logger.info(f"Using LibreTranslate server: {libre_server}")
|
||||||
|
translation_provider = LibreTranslationProvider(libre_server)
|
||||||
|
elif provider.lower() == "openai":
|
||||||
|
api_key = openai_api_key.strip() if openai_api_key else ""
|
||||||
|
if not api_key:
|
||||||
|
raise HTTPException(status_code=400, detail="OpenAI API key not provided")
|
||||||
|
model_to_use = openai_model.strip() if openai_model else "gpt-4o-mini"
|
||||||
|
# Combine system prompt and glossary
|
||||||
|
custom_prompt = build_full_prompt(system_prompt, glossary)
|
||||||
|
logger.info(f"Using OpenAI model: {model_to_use}")
|
||||||
|
if custom_prompt:
|
||||||
|
logger.info(f"Custom system prompt provided ({len(custom_prompt)} chars)")
|
||||||
|
translation_provider = OpenAITranslationProvider(api_key, model_to_use, custom_prompt)
|
||||||
elif provider.lower() == "ollama":
|
elif provider.lower() == "ollama":
|
||||||
# Use the same model for text and vision (multimodal models like gemma3, qwen3-vl)
|
# Use the same model for text and vision (multimodal models like gemma3, qwen3-vl)
|
||||||
model_to_use = ollama_model.strip() if ollama_model else config.OLLAMA_MODEL
|
model_to_use = ollama_model.strip() if ollama_model else config.OLLAMA_MODEL
|
||||||
custom_prompt = system_prompt.strip() if system_prompt else ""
|
# Combine system prompt and glossary
|
||||||
|
custom_prompt = build_full_prompt(system_prompt, glossary)
|
||||||
logger.info(f"Using Ollama model: {model_to_use} (text + vision)")
|
logger.info(f"Using Ollama model: {model_to_use} (text + vision)")
|
||||||
if custom_prompt:
|
if custom_prompt:
|
||||||
logger.info(f"Custom system prompt provided ({len(custom_prompt)} chars)")
|
logger.info(f"Custom system prompt provided ({len(custom_prompt)} chars)")
|
||||||
@ -378,6 +417,246 @@ async def configure_ollama(base_url: str = Form(...), model: str = Form(...)):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/extract-texts")
|
||||||
|
async def extract_texts_from_document(
|
||||||
|
file: UploadFile = File(..., description="Document file to extract texts from"),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Extract all translatable texts from a document for client-side translation (WebLLM).
|
||||||
|
Returns a list of texts and a session ID to use for reconstruction.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
- **file**: The document file to extract texts from
|
||||||
|
|
||||||
|
**Returns:**
|
||||||
|
- session_id: Unique ID to reference this extraction
|
||||||
|
- texts: Array of texts to translate
|
||||||
|
- file_type: Type of the document
|
||||||
|
"""
|
||||||
|
import uuid
|
||||||
|
import json
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Validate file extension
|
||||||
|
file_extension = file_handler.validate_file_extension(file.filename)
|
||||||
|
logger.info(f"Extracting texts from {file_extension} file: {file.filename}")
|
||||||
|
|
||||||
|
# Validate file size
|
||||||
|
file_handler.validate_file_size(file)
|
||||||
|
|
||||||
|
# Generate session ID
|
||||||
|
session_id = str(uuid.uuid4())
|
||||||
|
|
||||||
|
# Save uploaded file
|
||||||
|
input_filename = f"session_{session_id}{file_extension}"
|
||||||
|
input_path = config.UPLOAD_DIR / input_filename
|
||||||
|
await file_handler.save_upload_file(file, input_path)
|
||||||
|
|
||||||
|
# Extract texts based on file type
|
||||||
|
texts = []
|
||||||
|
|
||||||
|
if file_extension == ".xlsx":
|
||||||
|
from openpyxl import load_workbook
|
||||||
|
wb = load_workbook(input_path)
|
||||||
|
for sheet in wb.worksheets:
|
||||||
|
for row in sheet.iter_rows():
|
||||||
|
for cell in row:
|
||||||
|
if cell.value and isinstance(cell.value, str) and cell.value.strip():
|
||||||
|
texts.append({
|
||||||
|
"id": f"{sheet.title}!{cell.coordinate}",
|
||||||
|
"text": cell.value
|
||||||
|
})
|
||||||
|
wb.close()
|
||||||
|
elif file_extension == ".docx":
|
||||||
|
from docx import Document
|
||||||
|
doc = Document(input_path)
|
||||||
|
para_idx = 0
|
||||||
|
for para in doc.paragraphs:
|
||||||
|
if para.text.strip():
|
||||||
|
texts.append({
|
||||||
|
"id": f"para_{para_idx}",
|
||||||
|
"text": para.text
|
||||||
|
})
|
||||||
|
para_idx += 1
|
||||||
|
# Also extract from tables
|
||||||
|
table_idx = 0
|
||||||
|
for table in doc.tables:
|
||||||
|
for row_idx, row in enumerate(table.rows):
|
||||||
|
for cell_idx, cell in enumerate(row.cells):
|
||||||
|
if cell.text.strip():
|
||||||
|
texts.append({
|
||||||
|
"id": f"table_{table_idx}_r{row_idx}_c{cell_idx}",
|
||||||
|
"text": cell.text
|
||||||
|
})
|
||||||
|
table_idx += 1
|
||||||
|
elif file_extension == ".pptx":
|
||||||
|
from pptx import Presentation
|
||||||
|
prs = Presentation(input_path)
|
||||||
|
for slide_idx, slide in enumerate(prs.slides):
|
||||||
|
for shape_idx, shape in enumerate(slide.shapes):
|
||||||
|
if shape.has_text_frame:
|
||||||
|
for para_idx, para in enumerate(shape.text_frame.paragraphs):
|
||||||
|
for run_idx, run in enumerate(para.runs):
|
||||||
|
if run.text.strip():
|
||||||
|
texts.append({
|
||||||
|
"id": f"slide_{slide_idx}_shape_{shape_idx}_para_{para_idx}_run_{run_idx}",
|
||||||
|
"text": run.text
|
||||||
|
})
|
||||||
|
|
||||||
|
# Save session metadata
|
||||||
|
session_data = {
|
||||||
|
"original_filename": file.filename,
|
||||||
|
"file_extension": file_extension,
|
||||||
|
"input_path": str(input_path),
|
||||||
|
"text_count": len(texts)
|
||||||
|
}
|
||||||
|
session_file = config.UPLOAD_DIR / f"session_{session_id}.json"
|
||||||
|
with open(session_file, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(session_data, f)
|
||||||
|
|
||||||
|
logger.info(f"Extracted {len(texts)} texts from {file.filename}, session: {session_id}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"session_id": session_id,
|
||||||
|
"texts": texts,
|
||||||
|
"file_type": file_extension,
|
||||||
|
"text_count": len(texts)
|
||||||
|
}
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Text extraction error: {str(e)}", exc_info=True)
|
||||||
|
raise HTTPException(status_code=500, detail=f"Failed to extract texts: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/reconstruct-document")
|
||||||
|
async def reconstruct_document(
|
||||||
|
session_id: str = Form(..., description="Session ID from extract-texts"),
|
||||||
|
translations: str = Form(..., description="JSON array of {id, translated_text} objects"),
|
||||||
|
target_language: str = Form(..., description="Target language code"),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Reconstruct a document with translated texts.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
- **session_id**: The session ID from extract-texts
|
||||||
|
- **translations**: JSON array of translations with matching IDs
|
||||||
|
- **target_language**: Target language for filename
|
||||||
|
|
||||||
|
**Returns:**
|
||||||
|
- Translated document file
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Load session data
|
||||||
|
session_file = config.UPLOAD_DIR / f"session_{session_id}.json"
|
||||||
|
if not session_file.exists():
|
||||||
|
raise HTTPException(status_code=404, detail="Session not found or expired")
|
||||||
|
|
||||||
|
with open(session_file, "r", encoding="utf-8") as f:
|
||||||
|
session_data = json.load(f)
|
||||||
|
|
||||||
|
input_path = Path(session_data["input_path"])
|
||||||
|
file_extension = session_data["file_extension"]
|
||||||
|
original_filename = session_data["original_filename"]
|
||||||
|
|
||||||
|
if not input_path.exists():
|
||||||
|
raise HTTPException(status_code=404, detail="Source file not found or expired")
|
||||||
|
|
||||||
|
# Parse translations
|
||||||
|
translation_list = json.loads(translations)
|
||||||
|
translation_map = {t["id"]: t["translated_text"] for t in translation_list}
|
||||||
|
|
||||||
|
# Generate output path
|
||||||
|
output_filename = file_handler.generate_unique_filename(original_filename, "translated")
|
||||||
|
output_path = config.OUTPUT_DIR / output_filename
|
||||||
|
|
||||||
|
# Reconstruct based on file type
|
||||||
|
if file_extension == ".xlsx":
|
||||||
|
from openpyxl import load_workbook
|
||||||
|
import shutil
|
||||||
|
shutil.copy(input_path, output_path)
|
||||||
|
wb = load_workbook(output_path)
|
||||||
|
for sheet in wb.worksheets:
|
||||||
|
for row in sheet.iter_rows():
|
||||||
|
for cell in row:
|
||||||
|
cell_id = f"{sheet.title}!{cell.coordinate}"
|
||||||
|
if cell_id in translation_map:
|
||||||
|
cell.value = translation_map[cell_id]
|
||||||
|
wb.save(output_path)
|
||||||
|
wb.close()
|
||||||
|
|
||||||
|
elif file_extension == ".docx":
|
||||||
|
from docx import Document
|
||||||
|
import shutil
|
||||||
|
shutil.copy(input_path, output_path)
|
||||||
|
doc = Document(output_path)
|
||||||
|
para_idx = 0
|
||||||
|
for para in doc.paragraphs:
|
||||||
|
para_id = f"para_{para_idx}"
|
||||||
|
if para_id in translation_map and para.text.strip():
|
||||||
|
# Replace text while keeping formatting
|
||||||
|
for run in para.runs:
|
||||||
|
run.text = ""
|
||||||
|
if para.runs:
|
||||||
|
para.runs[0].text = translation_map[para_id]
|
||||||
|
else:
|
||||||
|
para.text = translation_map[para_id]
|
||||||
|
para_idx += 1
|
||||||
|
# Also handle tables
|
||||||
|
table_idx = 0
|
||||||
|
for table in doc.tables:
|
||||||
|
for row_idx, row in enumerate(table.rows):
|
||||||
|
for cell_idx, cell in enumerate(row.cells):
|
||||||
|
cell_id = f"table_{table_idx}_r{row_idx}_c{cell_idx}"
|
||||||
|
if cell_id in translation_map:
|
||||||
|
# Clear and set new text
|
||||||
|
for para in cell.paragraphs:
|
||||||
|
for run in para.runs:
|
||||||
|
run.text = ""
|
||||||
|
if cell.paragraphs and cell.paragraphs[0].runs:
|
||||||
|
cell.paragraphs[0].runs[0].text = translation_map[cell_id]
|
||||||
|
elif cell.paragraphs:
|
||||||
|
cell.paragraphs[0].text = translation_map[cell_id]
|
||||||
|
table_idx += 1
|
||||||
|
doc.save(output_path)
|
||||||
|
|
||||||
|
elif file_extension == ".pptx":
|
||||||
|
from pptx import Presentation
|
||||||
|
import shutil
|
||||||
|
shutil.copy(input_path, output_path)
|
||||||
|
prs = Presentation(output_path)
|
||||||
|
for slide_idx, slide in enumerate(prs.slides):
|
||||||
|
for shape_idx, shape in enumerate(slide.shapes):
|
||||||
|
if shape.has_text_frame:
|
||||||
|
for para_idx, para in enumerate(shape.text_frame.paragraphs):
|
||||||
|
for run_idx, run in enumerate(para.runs):
|
||||||
|
run_id = f"slide_{slide_idx}_shape_{shape_idx}_para_{para_idx}_run_{run_idx}"
|
||||||
|
if run_id in translation_map:
|
||||||
|
run.text = translation_map[run_id]
|
||||||
|
prs.save(output_path)
|
||||||
|
|
||||||
|
# Cleanup session files
|
||||||
|
file_handler.cleanup_file(input_path)
|
||||||
|
file_handler.cleanup_file(session_file)
|
||||||
|
|
||||||
|
logger.info(f"Reconstructed document: {output_path}")
|
||||||
|
|
||||||
|
return FileResponse(
|
||||||
|
path=output_path,
|
||||||
|
filename=f"translated_{original_filename}",
|
||||||
|
media_type="application/octet-stream"
|
||||||
|
)
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Reconstruction error: {str(e)}", exc_info=True)
|
||||||
|
raise HTTPException(status_code=500, detail=f"Failed to reconstruct document: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
import uvicorn
|
import uvicorn
|
||||||
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)
|
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)
|
||||||
|
|||||||
@ -1,5 +0,0 @@
|
|||||||
# Testing requirements
|
|
||||||
requests==2.31.0
|
|
||||||
pytest==7.4.3
|
|
||||||
pytest-asyncio==0.23.2
|
|
||||||
httpx==0.26.0
|
|
||||||
@ -13,3 +13,4 @@ matplotlib==3.8.2
|
|||||||
pandas==2.1.4
|
pandas==2.1.4
|
||||||
requests==2.31.0
|
requests==2.31.0
|
||||||
ipykernel==6.27.1
|
ipykernel==6.27.1
|
||||||
|
openai>=1.0.0
|
||||||
|
|||||||
@ -54,15 +54,19 @@ class DeepLTranslationProvider(TranslationProvider):
|
|||||||
class LibreTranslationProvider(TranslationProvider):
|
class LibreTranslationProvider(TranslationProvider):
|
||||||
"""LibreTranslate implementation"""
|
"""LibreTranslate implementation"""
|
||||||
|
|
||||||
|
def __init__(self, custom_url: str = "https://libretranslate.com"):
|
||||||
|
self.custom_url = custom_url
|
||||||
|
|
||||||
def translate(self, text: str, target_language: str, source_language: str = 'auto') -> str:
|
def translate(self, text: str, target_language: str, source_language: str = 'auto') -> str:
|
||||||
if not text or not text.strip():
|
if not text or not text.strip():
|
||||||
return text
|
return text
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# LibreTranslator doesn't need API key for self-hosted instances
|
# LibreTranslator supports custom URL for self-hosted or public instances
|
||||||
translator = LibreTranslator(source=source_language, target=target_language, custom_url="http://localhost:5000")
|
translator = LibreTranslator(source=source_language, target=target_language, custom_url=self.custom_url)
|
||||||
return translator.translate(text)
|
return translator.translate(text)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
print(f"LibreTranslate error: {e}")
|
||||||
# Fail silently and return original text
|
# Fail silently and return original text
|
||||||
return text
|
return text
|
||||||
|
|
||||||
@ -188,6 +192,97 @@ class WebLLMTranslationProvider(TranslationProvider):
|
|||||||
return text
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
class OpenAITranslationProvider(TranslationProvider):
|
||||||
|
"""OpenAI GPT translation implementation with vision support"""
|
||||||
|
|
||||||
|
def __init__(self, api_key: str, model: str = "gpt-4o-mini", system_prompt: str = ""):
|
||||||
|
self.api_key = api_key
|
||||||
|
self.model = model
|
||||||
|
self.custom_system_prompt = system_prompt
|
||||||
|
|
||||||
|
def translate(self, text: str, target_language: str, source_language: str = 'auto') -> str:
|
||||||
|
if not text or not text.strip():
|
||||||
|
return text
|
||||||
|
|
||||||
|
# Skip very short text or numbers only
|
||||||
|
if len(text.strip()) < 2 or text.strip().isdigit():
|
||||||
|
return text
|
||||||
|
|
||||||
|
try:
|
||||||
|
import openai
|
||||||
|
client = openai.OpenAI(api_key=self.api_key)
|
||||||
|
|
||||||
|
# Build system prompt with custom context if provided
|
||||||
|
base_prompt = f"You are a translator. Translate the user's text to {target_language}. Return ONLY the translation, nothing else."
|
||||||
|
|
||||||
|
if self.custom_system_prompt:
|
||||||
|
system_content = f"""{base_prompt}
|
||||||
|
|
||||||
|
ADDITIONAL CONTEXT AND INSTRUCTIONS:
|
||||||
|
{self.custom_system_prompt}"""
|
||||||
|
else:
|
||||||
|
system_content = base_prompt
|
||||||
|
|
||||||
|
response = client.chat.completions.create(
|
||||||
|
model=self.model,
|
||||||
|
messages=[
|
||||||
|
{"role": "system", "content": system_content},
|
||||||
|
{"role": "user", "content": text}
|
||||||
|
],
|
||||||
|
temperature=0.3,
|
||||||
|
max_tokens=500
|
||||||
|
)
|
||||||
|
|
||||||
|
translated = response.choices[0].message.content.strip()
|
||||||
|
return translated if translated else text
|
||||||
|
except Exception as e:
|
||||||
|
print(f"OpenAI translation error: {e}")
|
||||||
|
return text
|
||||||
|
|
||||||
|
def translate_image(self, image_path: str, target_language: str) -> str:
|
||||||
|
"""Translate text within an image using OpenAI vision model"""
|
||||||
|
import base64
|
||||||
|
|
||||||
|
try:
|
||||||
|
import openai
|
||||||
|
client = openai.OpenAI(api_key=self.api_key)
|
||||||
|
|
||||||
|
# Read and encode image
|
||||||
|
with open(image_path, 'rb') as img_file:
|
||||||
|
image_data = base64.b64encode(img_file.read()).decode('utf-8')
|
||||||
|
|
||||||
|
# Determine image type from extension
|
||||||
|
ext = image_path.lower().split('.')[-1]
|
||||||
|
media_type = f"image/{ext}" if ext in ['png', 'jpg', 'jpeg', 'gif', 'webp'] else "image/png"
|
||||||
|
|
||||||
|
response = client.chat.completions.create(
|
||||||
|
model=self.model, # gpt-4o and gpt-4o-mini support vision
|
||||||
|
messages=[
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"text": f"Extract all text from this image and translate it to {target_language}. Return ONLY the translated text, preserving the structure and formatting."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "image_url",
|
||||||
|
"image_url": {
|
||||||
|
"url": f"data:{media_type};base64,{image_data}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
max_tokens=1000
|
||||||
|
)
|
||||||
|
|
||||||
|
return response.choices[0].message.content.strip()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"OpenAI vision translation error: {e}")
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
class TranslationService:
|
class TranslationService:
|
||||||
"""Main translation service that delegates to the configured provider"""
|
"""Main translation service that delegates to the configured provider"""
|
||||||
|
|
||||||
@ -224,7 +319,7 @@ class TranslationService:
|
|||||||
|
|
||||||
def translate_image(self, image_path: str, target_language: str) -> str:
|
def translate_image(self, image_path: str, target_language: str) -> str:
|
||||||
"""
|
"""
|
||||||
Translate text in an image using vision model (Ollama only)
|
Translate text in an image using vision model (Ollama or OpenAI)
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
image_path: Path to image file
|
image_path: Path to image file
|
||||||
@ -236,9 +331,11 @@ class TranslationService:
|
|||||||
if not self.translate_images:
|
if not self.translate_images:
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
# Only Ollama supports image translation
|
# Ollama and OpenAI support image translation
|
||||||
if isinstance(self.provider, OllamaTranslationProvider):
|
if isinstance(self.provider, OllamaTranslationProvider):
|
||||||
return self.provider.translate_image(image_path, target_language)
|
return self.provider.translate_image(image_path, target_language)
|
||||||
|
elif isinstance(self.provider, OpenAITranslationProvider):
|
||||||
|
return self.provider.translate_image(image_path, target_language)
|
||||||
|
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user