refactor: turborepo setup (#137)

This commit is contained in:
MengXi
2025-06-10 02:53:07 -07:00
committed by GitHub
commit 2d58fbd306
179 changed files with 12461 additions and 0 deletions
+32
View File
@@ -0,0 +1,32 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.output
stats.html
stats-*.json
.wxt
web-ext.config.ts
# Editor directories and files
.vscode/*
!.vscode/extensions.json
!.vscode/settings.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
.env
.env.*
coverage/
+3
View File
@@ -0,0 +1,3 @@
import { config } from '@repo/eslint-config/extension'
export default config
+86
View File
@@ -0,0 +1,86 @@
{
"name": "@read-frog/extension",
"type": "module",
"version": "0.10.2",
"private": true,
"description": "Read Frog browser extension for language learning",
"scripts": {
"build": "wxt build",
"build:firefox": "wxt build -b firefox",
"dev": "wxt",
"dev:edge": "wxt -b edge",
"dev:firefox": "wxt -b firefox",
"lint": "eslint",
"lint:fix": "eslint --fix",
"postinstall": "wxt prepare",
"test": "vitest run",
"test:cov": "vitest run --coverage",
"test:watch": "vitest",
"type-check": "tsc --noEmit",
"zip": "wxt zip",
"zip:firefox": "wxt zip -b firefox"
},
"dependencies": {
"@ai-sdk/deepseek": "^0.2.13",
"@ai-sdk/openai": "^1.3.21",
"@mozilla/readability": "^0.6.0",
"@openrouter/ai-sdk-provider": "^0.4.6",
"@radix-ui/react-alert-dialog": "^1.1.13",
"@radix-ui/react-checkbox": "^1.2.3",
"@radix-ui/react-dialog": "^1.1.13",
"@radix-ui/react-hover-card": "^1.1.14",
"@radix-ui/react-progress": "^1.1.4",
"@radix-ui/react-scroll-area": "^1.2.5",
"@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-separator": "^1.1.6",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.2",
"@radix-ui/react-tooltip": "^1.2.6",
"@tabler/icons-react": "^3.31.0",
"@tanstack/query-core": "^5.74.4",
"@tanstack/react-query": "^5.74.4",
"@tanstack/react-query-devtools": "^5.74.4",
"@webext-core/messaging": "^2.2.0",
"@wxt-dev/i18n": "^0.2.4",
"ai": "^4.3.13",
"case-anything": "^3.1.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"crypto-js": "^4.2.0",
"deepmerge": "^4.3.1",
"franc-min": "^6.2.0",
"hotkeys-js": "^3.13.10",
"jotai": "^2.12.3",
"lucide-react": "^0.507.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-router": "^7.6.0",
"sonner": "^2.0.3",
"tailwind-merge": "^3.2.0",
"tw-animate-css": "^1.2.7",
"zod": "^3.25.23"
},
"devDependencies": {
"@repo/eslint-config": "workspace:*",
"@tailwindcss/postcss": "^4.1.5",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@types/crypto-js": "^4.2.2",
"@types/jest": "^29.5.14",
"@types/react": "^19.1.2",
"@types/react-dom": "^19.1.3",
"@vitejs/plugin-react": "^4.4.1",
"@vitest/coverage-istanbul": "^3.1.3",
"@wxt-dev/module-react": "^1.1.3",
"autoprefixer": "^10.4.21",
"eruda": "^3.4.1",
"eslint": "^9.28.0",
"jsdom": "^26.1.0",
"postcss": "^8.5.3",
"postcss-rem-to-responsive-pixel": "^6.0.2",
"tailwindcss": "^4.1.5",
"typescript": "^5.8.3",
"vitest": "^3.1.3",
"wxt": "^0.20.7"
}
}
+11
View File
@@ -0,0 +1,11 @@
module.exports = {
plugins: {
'@tailwindcss/postcss': {},
'autoprefixer': {},
'postcss-rem-to-responsive-pixel': {
rootValue: 16,
propList: ['*'],
transformUnit: 'px',
},
},
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 429 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 801 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

+99
View File
@@ -0,0 +1,99 @@
// Chrome Console Debugger - Simulate Four Finger Touch
// Copy and paste into Chrome DevTools Console to run
console.log('🐸 Read Frog - 四手指触摸调试脚本')
// Create simulated touch points
function createTouch(x, y, id) {
return new Touch({
identifier: id,
target: document.body,
clientX: x,
clientY: y,
radiusX: 25,
radiusY: 25,
rotationAngle: 0,
force: 1,
})
}
function simulateFourFingerTap() {
console.log('🎯 Start simulating four finger touch...')
const centerX = window.innerWidth / 2
const centerY = window.innerHeight / 2
const offset = 50
const touches = [
createTouch(centerX - offset, centerY - offset, 1),
createTouch(centerX + offset, centerY - offset, 2),
createTouch(centerX - offset, centerY + offset, 3),
createTouch(centerX + offset, centerY + offset, 4),
]
const touchStartEvent = new TouchEvent('touchstart', {
touches,
targetTouches: touches,
changedTouches: touches,
bubbles: true,
cancelable: true,
})
document.dispatchEvent(touchStartEvent)
console.log('✅ TouchStart event triggered (4 fingers)')
setTimeout(() => {
const touchEndEvent = new TouchEvent('touchend', {
touches: [],
targetTouches: [],
changedTouches: touches,
bubbles: true,
cancelable: true,
})
document.dispatchEvent(touchEndEvent)
console.log('✅ TouchEnd event triggered')
console.log('🎉 Four finger touch simulation completed!')
}, 100)
}
function addDebugButton() {
const button = document.createElement('button')
button.innerHTML = '🐸 Test four finger touch'
button.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
z-index: 10000;
padding: 15px 20px;
background: #4CAF50;
color: white;
border: none;
border-radius: 8px;
font-size: 16px;
cursor: pointer;
box-shadow: 0 4px 8px rgba(0,0,0,0.2);
font-family: Arial, sans-serif;
`
button.classList.add('notranslate')
button.addEventListener('click', simulateFourFingerTap)
document.body.appendChild(button)
console.log('✅ Debug button added to top right corner')
}
// 立即执行
console.log('🎯 How to use:')
console.log('1. Run simulateFourFingerTap() to simulate four finger touch')
console.log('2. Run addDebugButton() to add a test button to the page')
console.log('')
console.log('💡 Quick test: Run the command below')
console.log('simulateFourFingerTap()')
// Add debug button automatically
addDebugButton()
// Export functions to global
window.simulateFourFingerTap = simulateFourFingerTap
window.addDebugButton = addDebugButton
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 MiB

+60
View File
@@ -0,0 +1,60 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg
t="1746996704863"
class="icon"
viewBox="0 0 1219 1024"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
width="238.0859375"
height="200"
p-id="3618"
>
<!-- 气泡黑色描边 -->
<path
d="M620.054335 997.952365l-177.169152-162.618664H272.727407A235.333407 235.333407 0 0 1 37.695564 600.264163V253.879625A235.333407 235.333407 0 0 1 272.727407 18.847782h692.769077a235.333407 235.333407 0 0 1 235.031843 235.031843v346.384538a235.333407 235.333407 0 0 1-235.031843 235.031843H784.067734z"
fill="#0F0F0F"
p-id="3619"
/>
<!-- 白色填充(仅位于描边内部) -->
<path
d="M620.054335 997.952365
m-347.289233-904.693539
a160.997754 160.997754 0 0 0 -160.846972 160.620799
v346.384538
a160.997754 160.997754 0 0 0 160.809277 160.809277
h198.957187
l146.522658 134.309295
135.28938 -134.271599
h212.18833
a160.997754 160.997754 0 0 0 160.809277 -160.809277
v-346.422234
a160.997754 160.997754 0 0 0 -160.809277 -160.809277
z"
fill="#FFFFFF"
/>
<!-- 省略号 -->
<path
d="M384.080103 472.438505a37.092435 37.092435 0 0 1-37.130131-37.13013v-49.45658a37.130131 37.130131 0 1 1 74.222566 0v49.45658a37.092435 37.092435 0 0 1-37.092435 37.13013z"
fill="#F15549"
p-id="3620"
/>
<path
d="M631.513786 472.438505a37.092435 37.092435 0 0 1-37.130131-37.13013v-49.45658a37.130131 37.130131 0 1 1 74.222566 0v49.45658a37.092435 37.092435 0 0 1-37.092435 37.13013z"
fill="#F15549"
p-id="3621"
/>
<path
d="M854.181484 472.438505a37.092435 37.092435 0 0 1-37.130131-37.13013v-49.45658a37.130131 37.130131 0 1 1 74.222566 0v49.45658a37.092435 37.092435 0 0 1-37.092435 37.13013z"
fill="#F15549"
p-id="3622"
/>
<!-- 背景装饰圆 -->
<path
d="M433.498988 1.168562a433.498988 433.498988 0 0 1 433.498987 433.498988 438.022455 438.022455 0 0 1-216.447929 375.334732 421.059452 421.059452 0 0 1-217.051058 58.164256 433.498988 433.498988 0 0 1 0-866.997976z"
fill="#FF8D84"
opacity=".19"
p-id="3623"
/>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

+1
View File
@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1746996676484" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="2593" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M0 512C0 229.2224 229.2224 0 512 0c282.7776 0 512 229.2224 512 512 0 282.7776-229.2224 512-512 512C229.2224 1024 0 794.7776 0 512z" fill="#7A8CD2" p-id="2594"></path><path d="M758.613333 281.207467a1.792 1.792 0 0 0-0.938666-0.8192 591.598933 591.598933 0 0 0-144.298667-44.032 2.218667 2.218667 0 0 0-2.321067 1.0752 398.114133 398.114133 0 0 0-17.954133 36.3008 548.437333 548.437333 0 0 0-162.082133 0 364.663467 364.663467 0 0 0-18.244267-36.317867 2.269867 2.269867 0 0 0-2.321067-1.0752 589.943467 589.943467 0 0 0-144.298666 44.032 2.048 2.048 0 0 0-0.955734 0.8192c-91.904 135.099733-117.077333 266.888533-104.721066 397.038933a2.3552 2.3552 0 0 0 0.9216 1.621334 591.5648 591.5648 0 0 0 177.015466 88.064 2.321067 2.321067 0 0 0 2.491734-0.8192 412.16 412.16 0 0 0 36.215466-57.9584 2.184533 2.184533 0 0 0-1.2288-3.072 390.212267 390.212267 0 0 1-55.296-25.941334 2.269867 2.269867 0 0 1-1.1264-1.792 2.218667 2.218667 0 0 1 0.887467-1.911466c3.7376-2.730667 7.389867-5.563733 10.990933-8.465067a2.2016 2.2016 0 0 1 2.286934-0.3072c116.0192 52.1216 241.629867 52.1216 356.266666 0a2.218667 2.218667 0 0 1 2.321067 0.273067c3.601067 2.9184 7.2704 5.751467 11.008 8.4992a2.2528 2.2528 0 0 1 0.904533 1.911466 2.2016 2.2016 0 0 1-1.092266 1.792 366.0288 366.0288 0 0 1-55.330134 25.9072 2.269867 2.269867 0 0 0-1.314133 1.297067 2.184533 2.184533 0 0 0 0.1024 1.809067 462.506667 462.506667 0 0 0 36.181333 57.941333 2.286933 2.286933 0 0 0 2.491734 0.836267 589.636267 589.636267 0 0 0 177.322666-88.064 2.269867 2.269867 0 0 0 0.904534-1.604267c14.7968-150.459733-24.746667-281.173333-104.789334-397.038933z m-364.168533 317.781333c-34.935467 0-63.709867-31.573333-63.709867-70.314667s28.2112-70.314667 63.709867-70.314666c35.771733 0 64.273067 31.829333 63.709867 70.314666 0 38.741333-28.228267 70.314667-63.709867 70.314667z m235.554133 0c-34.9184 0-63.709867-31.573333-63.709866-70.314667s28.228267-70.314667 63.709866-70.314666c35.771733 0 64.273067 31.829333 63.709867 70.314666 0 38.741333-27.938133 70.314667-63.709867 70.314667z" fill="#FFFFFF" p-id="2595"></path></svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 502 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

+7
View File
@@ -0,0 +1,7 @@
:root {
--text-xs: 10px;
--text-sm: 12px;
--text-base: 14px;
--text-lg: 16px;
--text-xl: 18px;
}
+128
View File
@@ -0,0 +1,128 @@
@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-warning: var(--warning);
--color-warning-border: var(--warning-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(69.6% 0.17 162.48);
--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);
--warning: oklch(97.3% 0.071 103.193);
--warning-border: oklch(85.2% 0.199 91.936);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(69.6% 0.17 162.48);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--warning: oklch(42.1% 0.095 57.708);
--warning-border: oklch(68.1% 0.162 75.834);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
+34
View File
@@ -0,0 +1,34 @@
export function APIConfigWarning({ className }: { className?: string }) {
return (
<div
className={cn(
'rounded-md border border-amber-500 bg-amber-100 px-2 py-1.5 text-center text-sm font-medium dark:bg-amber-900',
className,
)}
>
{i18n.t('noConfig.warningWithLink.youMust')}
{' '}
<a
href="https://readfrog.app/tutorial/api-key"
target="_blank"
rel="noreferrer"
className="underline"
>
{i18n.t('noConfig.warningWithLink.setTheAPIKey')}
</a>
{' '}
{i18n.t('noConfig.warningWithLink.firstOnThe')}
{' '}
<button
type="button"
className="cursor-pointer underline"
onClick={() => sendMessage('openOptionsPage', undefined)}
>
{i18n.t('noConfig.warningWithLink.optionsPage')}
</button>
{' '}
{i18n.t('noConfig.warningWithLink.page')}
.
</div>
)
}
+18
View File
@@ -0,0 +1,18 @@
import { cn } from '@/utils/tailwind'
function Container({ ref, className, children, ...props }: React.ComponentPropsWithoutRef<'div'> & { ref?: React.RefObject<HTMLDivElement | null> }) {
return (
<div
ref={ref}
className={cn(
'max-w-7xl mx-auto w-full px-6 md:px-10 lg:px-14',
className,
)}
{...props}
>
{children}
</div>
)
}
export default Container
+24
View File
@@ -0,0 +1,24 @@
import { cn } from '@/utils/tailwind'
interface LoadingDotsProps {
className?: string
}
export default function LoadingDots({ className }: LoadingDotsProps) {
return (
<div
className={cn('flex items-center justify-center gap-[3px]', className)}
>
{[...Array.from({ length: 3 })].map((_, i) => (
<div
// eslint-disable-next-line react/no-array-index-key
key={i}
className="h-1.5 w-1 animate-bounce rounded-full bg-black dark:bg-white"
style={{
animationDelay: `${i * 0.2}s`,
}}
/>
))}
</div>
)
}
+14
View File
@@ -0,0 +1,14 @@
export default function ProviderIcon({ logo, name, className }: { logo: string, name?: string, className?: string }) {
return (
<div className={cn('flex items-center gap-1.5', className)}>
<div className="size-5 rounded-full bg-white border border-border flex items-center justify-center">
<img
src={logo}
alt={name}
className="size-[11px]"
/>
</div>
{name && <span>{name}</span>}
</div>
)
}
@@ -0,0 +1,26 @@
import { AlertCircle } from 'lucide-react'
import { use } from 'react'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card'
import { ShadowWrapperContext } from '@/utils/react-shadow-host/create-shadow-host'
export function ErrorButton({ error }: { error: Error }) {
const shadowWrapper = use(ShadowWrapperContext)
return (
<HoverCard openDelay={0} closeDelay={0}>
<HoverCardTrigger asChild>
<AlertCircle className="size-3.5 text-red-500 hover:text-red-600 dark:hover:text-red-500" />
</HoverCardTrigger>
<HoverCardContent className="w-64" container={shadowWrapper} asChild>
<Alert variant="destructive">
<AlertCircle className="size-4" />
<AlertTitle>Translation Error</AlertTitle>
<AlertDescription>
{error.message}
</AlertDescription>
</Alert>
</HoverCardContent>
</HoverCard>
)
}
+12
View File
@@ -0,0 +1,12 @@
import type { TransNode } from '@/types/dom'
import { ErrorButton } from './error-button'
import { RetryButton } from './retry-button'
export function TranslationError({ node, error }: { node: TransNode | TransNode[], error: Error }) {
return (
<div className="inline-flex items-center justify-center gap-1 px-1.5">
<RetryButton node={node} />
<ErrorButton error={error} />
</div>
)
}
@@ -0,0 +1,37 @@
import type { TransNode } from '@/types/dom'
import { RotateCcw } from 'lucide-react'
import { use } from 'react'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { translateConsecutiveInlineNodes, translateNode } from '@/utils/host/translate/node-manipulation'
import { ShadowWrapperContext } from '@/utils/react-shadow-host/create-shadow-host'
export function RetryButton({ node }: { node: TransNode | TransNode[] }) {
const shadowWrapper = use(ShadowWrapperContext)
const handleRetry = async () => {
if (Array.isArray(node)) {
await translateConsecutiveInlineNodes(node)
}
else {
await translateNode(node)
}
}
return (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={handleRetry}
>
<RotateCcw
className="size-3.5 text-blue-500 hover:text-blue-600 dark:hover:text-blue-400"
/>
</button>
</TooltipTrigger>
<TooltipContent container={shadowWrapper} side="bottom">
Retry translation
</TooltipContent>
</Tooltip>
)
}
+155
View File
@@ -0,0 +1,155 @@
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog'
import * as React from 'react'
import { buttonVariants } from '@/components/ui/button'
import { cn } from '@/utils/tailwind'
function AlertDialog({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
}
function AlertDialogTrigger({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
)
}
function AlertDialogPortal({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
)
}
function AlertDialogOverlay({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
return (
<AlertDialogPrimitive.Overlay
data-slot="alert-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 AlertDialogContent({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
data-slot="alert-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}
/>
</AlertDialogPortal>
)
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<'div'>) {
return (
<div
data-slot="alert-dialog-header"
className={cn('flex flex-col gap-2 text-center sm:text-left', className)}
{...props}
/>
)
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<'div'>) {
return (
<div
data-slot="alert-dialog-footer"
className={cn(
'flex flex-col-reverse gap-2 sm:flex-row sm:justify-end',
className,
)}
{...props}
/>
)
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn('text-lg font-semibold', className)}
{...props}
/>
)
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
)
}
function AlertDialogAction({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
return (
<AlertDialogPrimitive.Action
className={cn(buttonVariants(), className)}
{...props}
/>
)
}
function AlertDialogCancel({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
return (
<AlertDialogPrimitive.Cancel
className={cn(buttonVariants({ variant: 'outline' }), className)}
{...props}
/>
)
}
export {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogTitle,
AlertDialogTrigger,
}
+66
View File
@@ -0,0 +1,66 @@
import type { VariantProps } from 'class-variance-authority'
import { cva } from 'class-variance-authority'
import * as React from 'react'
import { cn } from '@/utils/tailwind'
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-destructive [&>[data-slot=alert-title]]:text-destructive [&>[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, AlertDescription, AlertTitle }
+62
View File
@@ -0,0 +1,62 @@
import type { VariantProps } from 'class-variance-authority'
import { Slot } from '@radix-ui/react-slot'
import { cva } from 'class-variance-authority'
import * as React from 'react'
import { cn } from '@/utils/tailwind'
const buttonVariants = cva(
'cursor-pointer 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 shadow-xs hover:bg-primary/90',
destructive:
'bg-destructive text-white shadow-xs 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 shadow-xs hover:bg-secondary/80',
ghost:
'hover:bg-accent text-accent-foreground dark:hover:bg-accent/50',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-8 px-3 py-2 has-[>svg]:px-3',
sm: 'h-7 px-2 text-xs has-[>svg]:px-2 gap-x-1',
lg: 'h-9 px-7 has-[>svg]:px-4',
icon: 'size-8',
iconSm: 'size-6',
},
},
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
View File
@@ -0,0 +1,92 @@
import * as React from 'react'
import { cn } from '@/utils/tailwind'
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-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6',
className,
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-title"
className={cn('leading-none font-semibold', className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-description"
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-action"
className={cn(
'col-start-2 row-span-2 row-start-1 self-start justify-self-end',
className,
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-content"
className={cn('px-6', className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-footer"
className={cn('flex items-center px-6 [.border-t]:pt-6', className)}
{...props}
/>
)
}
export {
Card,
CardAction,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
}
+33
View File
@@ -0,0 +1,33 @@
'use client'
import * as CheckboxPrimitive from '@radix-ui/react-checkbox'
import { CheckIcon } from 'lucide-react'
import * as React from 'react'
import { cn } from '@/utils/tailwind'
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="flex items-center justify-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }
+45
View File
@@ -0,0 +1,45 @@
'use client'
import * as HoverCardPrimitive from '@radix-ui/react-hover-card'
import * as React from 'react'
function HoverCard({
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Root>) {
return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />
}
function HoverCardTrigger({
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {
return (
<HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
)
}
function HoverCardContent({
className,
align = 'center',
sideOffset = 4,
container,
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Content> & {
container?: HTMLElement | null
}) {
return (
<HoverCardPrimitive.Portal data-slot="hover-card-portal" container={container ?? document.body}>
<HoverCardPrimitive.Content
data-slot="hover-card-content"
align={align}
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 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden',
className,
)}
{...props}
/>
</HoverCardPrimitive.Portal>
)
}
export { HoverCard, HoverCardContent, HoverCardTrigger }
+21
View File
@@ -0,0 +1,21 @@
import * as React from 'react'
import { cn } from '@/utils/tailwind'
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 flex 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 }
+32
View File
@@ -0,0 +1,32 @@
'use client'
import * as ProgressPrimitive from '@radix-ui/react-progress'
import * as React from 'react'
import { cn } from '@/utils/tailwind'
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 }
+59
View File
@@ -0,0 +1,59 @@
'use client'
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area'
import * as React from 'react'
import { cn } from '@/utils/tailwind'
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 }
+197
View File
@@ -0,0 +1,197 @@
import * as SelectPrimitive from '@radix-ui/react-select'
import {
CheckIcon,
ChevronDown,
ChevronDownIcon,
ChevronUpIcon,
} from 'lucide-react'
import * as React from 'react'
import { cn } from '@/utils/tailwind'
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',
hideChevron = false,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: 'sm' | 'default'
hideChevron?: boolean
}) {
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}
{!hideChevron && (
<SelectPrimitive.Icon>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
)}
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = 'popper',
container,
...props
}: React.ComponentProps<typeof SelectPrimitive.Content> & {
container?: HTMLElement | null
}) {
return (
<SelectPrimitive.Portal container={container ?? document.body}>
<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',
container !== document.body && 'z-[2147483647]',
className,
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
'p-1',
position === 'popper'
&& 'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] 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
View File
@@ -0,0 +1,28 @@
'use client'
import * as SeparatorPrimitive from '@radix-ui/react-separator'
import * as React from 'react'
import { cn } from '@/utils/tailwind'
function Separator({
className,
orientation = 'horizontal',
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator-root"
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 }
+139
View File
@@ -0,0 +1,139 @@
'use client'
import * as SheetPrimitive from '@radix-ui/react-dialog'
import { XIcon } from 'lucide-react'
import * as React from 'react'
import { cn } from '@/utils/tailwind'
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-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 SheetContent({
className,
children,
side = 'right',
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: 'top' | 'right' | 'bottom' | 'left'
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
className={cn(
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
side === 'right'
&& 'data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm',
side === 'left'
&& 'data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm',
side === 'top'
&& 'data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b',
side === 'bottom'
&& 'data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t',
className,
)}
{...props}
>
{children}
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary 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">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="sheet-header"
className={cn('flex flex-col gap-1.5 p-4', className)}
{...props}
/>
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="sheet-footer"
className={cn('mt-auto flex flex-col gap-2 p-4', className)}
{...props}
/>
)
}
function SheetTitle({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn('text-foreground font-semibold', className)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
)
}
export {
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
}
+729
View File
@@ -0,0 +1,729 @@
'use client'
import type { VariantProps } from 'class-variance-authority'
import { Slot } from '@radix-ui/react-slot'
import { cva } from 'class-variance-authority'
import { PanelLeftIcon } from 'lucide-react'
import * as React from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Separator } from '@/components/ui/separator'
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet'
import { Skeleton } from '@/components/ui/skeleton'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { useIsMobile } from '@/hooks/use-mobile'
import { cn } from '@/utils/tailwind'
const SIDEBAR_COOKIE_NAME = 'sidebar_state'
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
const SIDEBAR_WIDTH = '16rem'
const SIDEBAR_WIDTH_MOBILE = '18rem'
const SIDEBAR_WIDTH_ICON = '3rem'
const SIDEBAR_KEYBOARD_SHORTCUT = 'b'
interface SidebarContextProps {
state: 'expanded' | 'collapsed'
open: boolean
setOpen: (open: boolean) => void
openMobile: boolean
setOpenMobile: (open: boolean) => void
isMobile: boolean
toggleSidebar: () => void
}
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
function useSidebar() {
const context = React.use(SidebarContext)
if (!context) {
throw new Error('useSidebar must be used within a SidebarProvider.')
}
return context
}
function SidebarProvider({
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
}: React.ComponentProps<'div'> & {
defaultOpen?: boolean
open?: boolean
onOpenChange?: (open: boolean) => void
}) {
const isMobile = useIsMobile()
const [openMobile, setOpenMobile] = React.useState(false)
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen)
const open = openProp ?? _open
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === 'function' ? value(open) : value
if (setOpenProp) {
setOpenProp(openState)
}
else {
_setOpen(openState)
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
},
[setOpenProp, open],
)
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile(open => !open) : setOpen(open => !open)
}, [isMobile, setOpen, setOpenMobile])
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === SIDEBAR_KEYBOARD_SHORTCUT
&& (event.metaKey || event.ctrlKey)
) {
event.preventDefault()
toggleSidebar()
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [toggleSidebar])
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? 'expanded' : 'collapsed'
const contextValue = React.useMemo<SidebarContextProps>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar],
)
return (
<SidebarContext value={contextValue}>
<TooltipProvider delayDuration={0}>
<div
data-slot="sidebar-wrapper"
style={
{
'--sidebar-width': SIDEBAR_WIDTH,
'--sidebar-width-icon': SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn(
'group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full',
className,
)}
{...props}
>
{children}
</div>
</TooltipProvider>
</SidebarContext>
)
}
function Sidebar({
side = 'left',
variant = 'sidebar',
collapsible = 'offcanvas',
className,
children,
...props
}: React.ComponentProps<'div'> & {
side?: 'left' | 'right'
variant?: 'sidebar' | 'floating' | 'inset'
collapsible?: 'offcanvas' | 'icon' | 'none'
}) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
if (collapsible === 'none') {
return (
<div
data-slot="sidebar"
className={cn(
'bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col',
className,
)}
{...props}
>
{children}
</div>
)
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
data-sidebar="sidebar"
data-slot="sidebar"
data-mobile="true"
className="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden"
style={
{
'--sidebar-width': SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<SheetHeader className="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
)
}
return (
<div
className="group peer text-sidebar-foreground hidden md:block"
data-state={state}
data-collapsible={state === 'collapsed' ? collapsible : ''}
data-variant={variant}
data-side={side}
data-slot="sidebar"
>
{/* This is what handles the sidebar gap on desktop */}
<div
data-slot="sidebar-gap"
className={cn(
'relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear',
'group-data-[collapsible=offcanvas]:w-0',
'group-data-[side=right]:rotate-180',
variant === 'floating' || variant === 'inset'
? 'group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]'
: 'group-data-[collapsible=icon]:w-(--sidebar-width-icon)',
)}
/>
<div
data-slot="sidebar-container"
className={cn(
'fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex',
side === 'left'
? 'left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]'
: 'right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]',
// Adjust the padding for floating and inset variants.
variant === 'floating' || variant === 'inset'
? 'p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]'
: 'group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l',
className,
)}
{...props}
>
<div
data-sidebar="sidebar"
data-slot="sidebar-inner"
className="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
>
{children}
</div>
</div>
</div>
)
}
function SidebarTrigger({
className,
onClick,
...props
}: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar()
return (
<Button
data-sidebar="trigger"
data-slot="sidebar-trigger"
variant="ghost"
size="icon"
className={cn('size-7 p-1.5', className)}
onClick={(event) => {
onClick?.(event)
toggleSidebar()
}}
{...props}
>
<PanelLeftIcon className="size-full" />
<span className="sr-only">Toggle Sidebar</span>
</Button>
)
}
function SidebarRail({ className, ...props }: React.ComponentProps<'button'>) {
const { toggleSidebar } = useSidebar()
return (
<button
type="button"
data-sidebar="rail"
data-slot="sidebar-rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
'hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex',
'in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize',
'[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize',
'hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full',
'[[data-side=left][data-collapsible=offcanvas]_&]:-right-2',
'[[data-side=right][data-collapsible=offcanvas]_&]:-left-2',
className,
)}
{...props}
/>
)
}
function SidebarInset({ className, ...props }: React.ComponentProps<'main'>) {
return (
<main
data-slot="sidebar-inset"
className={cn(
'bg-background relative flex w-full flex-1 flex-col',
'md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2',
className,
)}
{...props}
/>
)
}
function SidebarInput({
className,
...props
}: React.ComponentProps<typeof Input>) {
return (
<Input
data-slot="sidebar-input"
data-sidebar="input"
className={cn('bg-background h-8 w-full shadow-none', className)}
{...props}
/>
)
}
function SidebarHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="sidebar-header"
data-sidebar="header"
className={cn('flex flex-col gap-2 p-2', className)}
{...props}
/>
)
}
function SidebarFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="sidebar-footer"
data-sidebar="footer"
className={cn('flex flex-col gap-2 p-2', className)}
{...props}
/>
)
}
function SidebarSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="sidebar-separator"
data-sidebar="separator"
className={cn('bg-sidebar-border mx-2 w-auto', className)}
{...props}
/>
)
}
function SidebarContent({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="sidebar-content"
data-sidebar="content"
className={cn(
'flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden',
className,
)}
{...props}
/>
)
}
function SidebarGroup({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="sidebar-group"
data-sidebar="group"
className={cn('relative flex w-full min-w-0 flex-col p-2', className)}
{...props}
/>
)
}
function SidebarGroupLabel({
className,
asChild = false,
...props
}: React.ComponentProps<'div'> & { asChild?: boolean }) {
const Comp = asChild ? Slot : 'div'
return (
<Comp
data-slot="sidebar-group-label"
data-sidebar="group-label"
className={cn(
'text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
'group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0',
className,
)}
{...props}
/>
)
}
function SidebarGroupAction({
className,
asChild = false,
...props
}: React.ComponentProps<'button'> & { asChild?: boolean }) {
const Comp = asChild ? Slot : 'button'
return (
<Comp
data-slot="sidebar-group-action"
data-sidebar="group-action"
className={cn(
'text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
// Increases the hit area of the button on mobile.
'after:absolute after:-inset-2 md:after:hidden',
'group-data-[collapsible=icon]:hidden',
className,
)}
{...props}
/>
)
}
function SidebarGroupContent({
className,
...props
}: React.ComponentProps<'div'>) {
return (
<div
data-slot="sidebar-group-content"
data-sidebar="group-content"
className={cn('w-full text-sm', className)}
{...props}
/>
)
}
function SidebarMenu({ className, ...props }: React.ComponentProps<'ul'>) {
return (
<ul
data-slot="sidebar-menu"
data-sidebar="menu"
className={cn('flex w-full min-w-0 flex-col gap-1', className)}
{...props}
/>
)
}
function SidebarMenuItem({ className, ...props }: React.ComponentProps<'li'>) {
return (
<li
data-slot="sidebar-menu-item"
data-sidebar="menu-item"
className={cn('group/menu-item relative', className)}
{...props}
/>
)
}
const sidebarMenuButtonVariants = cva(
'peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0',
{
variants: {
variant: {
default: 'hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',
outline:
'bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]',
},
size: {
default: 'h-8 text-sm',
sm: 'h-7 text-xs',
lg: 'h-12 text-sm group-data-[collapsible=icon]:p-0!',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
)
function SidebarMenuButton({
asChild = false,
isActive = false,
variant = 'default',
size = 'default',
tooltip,
className,
...props
}: React.ComponentProps<'button'> & {
asChild?: boolean
isActive?: boolean
tooltip?: string | React.ComponentProps<typeof TooltipContent>
} & VariantProps<typeof sidebarMenuButtonVariants>) {
const Comp = asChild ? Slot : 'button'
const { isMobile, state } = useSidebar()
const button = (
<Comp
data-slot="sidebar-menu-button"
data-sidebar="menu-button"
data-size={size}
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
)
if (!tooltip) {
return button
}
if (typeof tooltip === 'string') {
tooltip = {
children: tooltip,
}
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent
side="right"
align="center"
hidden={state !== 'collapsed' || isMobile}
{...tooltip}
/>
</Tooltip>
)
}
function SidebarMenuAction({
className,
asChild = false,
showOnHover = false,
...props
}: React.ComponentProps<'button'> & {
asChild?: boolean
showOnHover?: boolean
}) {
const Comp = asChild ? Slot : 'button'
return (
<Comp
data-slot="sidebar-menu-action"
data-sidebar="menu-action"
className={cn(
'text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
// Increases the hit area of the button on mobile.
'after:absolute after:-inset-2 md:after:hidden',
'peer-data-[size=sm]/menu-button:top-1',
'peer-data-[size=default]/menu-button:top-1.5',
'peer-data-[size=lg]/menu-button:top-2.5',
'group-data-[collapsible=icon]:hidden',
showOnHover
&& 'peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0',
className,
)}
{...props}
/>
)
}
function SidebarMenuBadge({
className,
...props
}: React.ComponentProps<'div'>) {
return (
<div
data-slot="sidebar-menu-badge"
data-sidebar="menu-badge"
className={cn(
'text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none',
'peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground',
'peer-data-[size=sm]/menu-button:top-1',
'peer-data-[size=default]/menu-button:top-1.5',
'peer-data-[size=lg]/menu-button:top-2.5',
'group-data-[collapsible=icon]:hidden',
className,
)}
{...props}
/>
)
}
function SidebarMenuSkeleton({
className,
showIcon = false,
...props
}: React.ComponentProps<'div'> & {
showIcon?: boolean
}) {
// Random width between 50 to 90%.
const width = React.useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%`
}, [])
return (
<div
data-slot="sidebar-menu-skeleton"
data-sidebar="menu-skeleton"
className={cn('flex h-8 items-center gap-2 rounded-md px-2', className)}
{...props}
>
{showIcon && (
<Skeleton
className="size-4 rounded-md"
data-sidebar="menu-skeleton-icon"
/>
)}
<Skeleton
className="h-4 max-w-(--skeleton-width) flex-1"
data-sidebar="menu-skeleton-text"
style={
{
'--skeleton-width': width,
} as React.CSSProperties
}
/>
</div>
)
}
function SidebarMenuSub({ className, ...props }: React.ComponentProps<'ul'>) {
return (
<ul
data-slot="sidebar-menu-sub"
data-sidebar="menu-sub"
className={cn(
'border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5',
'group-data-[collapsible=icon]:hidden',
className,
)}
{...props}
/>
)
}
function SidebarMenuSubItem({
className,
...props
}: React.ComponentProps<'li'>) {
return (
<li
data-slot="sidebar-menu-sub-item"
data-sidebar="menu-sub-item"
className={cn('group/menu-sub-item relative', className)}
{...props}
/>
)
}
function SidebarMenuSubButton({
asChild = false,
size = 'md',
isActive = false,
className,
...props
}: React.ComponentProps<'a'> & {
asChild?: boolean
size?: 'sm' | 'md'
isActive?: boolean
}) {
const Comp = asChild ? Slot : 'a'
return (
<Comp
data-slot="sidebar-menu-sub-button"
data-sidebar="menu-sub-button"
data-size={size}
data-active={isActive}
className={cn(
'text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0',
'data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground',
size === 'sm' && 'text-xs',
size === 'md' && 'text-sm',
'group-data-[collapsible=icon]:hidden',
className,
)}
{...props}
/>
)
}
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
}
+13
View File
@@ -0,0 +1,13 @@
import { cn } from '@/utils/tailwind'
function Skeleton({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="skeleton"
className={cn('bg-accent animate-pulse rounded-md', className)}
{...props}
/>
)
}
export { Skeleton }
+32
View File
@@ -0,0 +1,32 @@
'use client'
import * as SwitchPrimitive from '@radix-ui/react-switch'
import * as React from 'react'
import { cn } from '@/utils/tailwind'
function Switch({
className,
...props
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
return (
<SwitchPrimitive.Root
data-slot="switch"
className={cn(
'peer focus-visible:ring-ring focus-visible:ring-offset-background data-[state=checked]:bg-primary data-[state=unchecked]:bg-input inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className={cn(
'bg-background pointer-events-none block h-4 w-4 rounded-full shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0',
)}
/>
</SwitchPrimitive.Root>
)
}
export { Switch }
+116
View File
@@ -0,0 +1,116 @@
'use client'
import * as React from 'react'
import { cn } from '@/utils/tailwind'
function Table({ className, ...props }: React.ComponentProps<'table'>) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn('w-full caption-bottom text-sm', className)}
{...props}
/>
</div>
)
}
function TableHeader({ className, ...props }: React.ComponentProps<'thead'>) {
return (
<thead
data-slot="table-header"
className={cn('[&_tr]:border-b', className)}
{...props}
/>
)
}
function TableBody({ className, ...props }: React.ComponentProps<'tbody'>) {
return (
<tbody
data-slot="table-body"
className={cn('[&_tr:last-child]:border-0', className)}
{...props}
/>
)
}
function TableFooter({ className, ...props }: React.ComponentProps<'tfoot'>) {
return (
<tfoot
data-slot="table-footer"
className={cn(
'bg-muted/50 border-t font-medium [&>tr]:last:border-b-0',
className,
)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<'tr'>) {
return (
<tr
data-slot="table-row"
className={cn(
'hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors',
className,
)}
{...props}
/>
)
}
function TableHead({ className, ...props }: React.ComponentProps<'th'>) {
return (
<th
data-slot="table-head"
className={cn(
'text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',
className,
)}
{...props}
/>
)
}
function TableCell({ className, ...props }: React.ComponentProps<'td'>) {
return (
<td
data-slot="table-cell"
className={cn(
'p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',
className,
)}
{...props}
/>
)
}
function TableCaption({
className,
...props
}: React.ComponentProps<'caption'>) {
return (
<caption
data-slot="table-caption"
className={cn('text-muted-foreground mt-4 text-sm', className)}
{...props}
/>
)
}
export {
Table,
TableBody,
TableCaption,
TableCell,
TableFooter,
TableHead,
TableHeader,
TableRow,
}
+62
View File
@@ -0,0 +1,62 @@
import * as TooltipPrimitive from '@radix-ui/react-tooltip'
import { cn } from '@/utils/tailwind'
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
// eslint-disable-next-line react/no-context-provider
<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,
container,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content> & {
container?: HTMLElement | null
}) {
return (
<TooltipPrimitive.Portal container={container ?? document.body}>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
'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-[2147483647] w-fit origin-(--radix-tooltip-content-transform-origin) rounded-sm bg-neutral-950 dark:bg-neutral-800 px-3 py-1.5 text-xs text-balance text-white',
className,
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-neutral-950 dark:bg-neutral-800 fill-neutral-950 dark:fill-neutral-800" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }
+40
View File
@@ -0,0 +1,40 @@
import { initializeConfig, loadAPIKeyFromEnv } from '@/utils/config/config'
import { CONFIG_SCHEMA_VERSION } from '@/utils/constants/config'
import { newUserGuide } from './new-user-guide'
import { setUpRequestQueue } from './request-queue'
import { translationMessage } from './translation'
export default defineBackground(() => {
logger.info('Hello background!', { id: browser.runtime.id })
browser.runtime.onInstalled.addListener(async (details) => {
if (details.reason === 'install') {
await storage.setItem<number>(
'local:__configSchemaVersion',
CONFIG_SCHEMA_VERSION,
)
}
await initializeConfig()
await loadAPIKeyFromEnv()
// Open tutorial page when extension is installed
if (details.reason === 'install') {
await browser.tabs.create({
url: 'https://readfrog.app/guide/step-1',
})
}
})
onMessage('openOptionsPage', () => {
logger.info('openOptionsPage')
browser.runtime.openOptionsPage()
})
onMessage('popupRequestReadArticle', async (message) => {
sendMessage('readArticle', undefined, message.data.tabId)
})
newUserGuide()
translationMessage()
setUpRequestQueue()
})
@@ -0,0 +1,36 @@
import { OFFICIAL_SITE_URL_PATTERNS } from '@/utils/constants/site'
let lastIsPinned = false
export function newUserGuide() {
guidePinExtension()
}
export async function guidePinExtension() {
onMessage('getPinState', async () => {
const { isOnToolbar } = await browser.action.getUserSettings()
return isOnToolbar
})
checkPinnedAndNotify()
if (browser.action.onUserSettingsChanged) {
browser.action.onUserSettingsChanged.addListener(checkPinnedAndNotify)
}
else {
setInterval(checkPinnedAndNotify, 1_000)
}
}
async function checkPinnedAndNotify() {
const { isOnToolbar } = await browser.action.getUserSettings() // Chrome 91+:contentReference[oaicite:0]{index=0}
if (isOnToolbar === lastIsPinned)
return
lastIsPinned = isOnToolbar
browser.tabs.query({ url: OFFICIAL_SITE_URL_PATTERNS }, (tabs) => {
for (const tab of tabs) {
sendMessage('pinStateChanged', { isPinned: isOnToolbar }, tab.id)
}
})
}
@@ -0,0 +1,34 @@
import { aiTranslate, googleTranslate, microsoftTranslate } from '@/utils/host/translate/api'
import { RequestQueue } from '@/utils/request/request-queue'
export function setUpRequestQueue() {
const requestQueue = new RequestQueue({
rate: 5,
capacity: 300,
timeoutMs: 20_000,
maxRetries: 2,
baseRetryDelayMs: 1_000,
})
onMessage('enqueueRequest', (message) => {
const { data } = message
// Create thunk based on type and params
let thunk: () => Promise<any>
switch (data.type) {
case 'googleTranslate':
thunk = () => googleTranslate(data.params.text, data.params.fromLang, data.params.toLang)
break
case 'microsoftTranslate':
thunk = () => microsoftTranslate(data.params.text, data.params.fromLang, data.params.toLang)
break
case 'aiTranslate':
thunk = () => aiTranslate(data.params.provider, data.params.modelString, data.params.prompt)
break
default:
throw new Error(`Unknown request type: ${data.type}`)
}
return requestQueue.enqueue(thunk, data.scheduleAt, data.hash)
})
}
+94
View File
@@ -0,0 +1,94 @@
import type { Config } from '@/types/config/config'
import { CONFIG_STORAGE_KEY } from '@/utils/constants/config'
import { shouldAutoEnable } from '@/utils/host/translate/auto-translation'
export function translationMessage() {
const tabPageTranslationState = new Map<number, { enabled: boolean, ports: Browser.runtime.Port[] }>()
browser.runtime.onConnect.addListener(async (port) => {
if (!port.name.startsWith('translation')) {
return
}
const tabId = port.sender?.tab?.id
const tabUrl = port.sender?.tab?.url
if (tabId == null)
return
const entry = ensureKeyInMap(tabPageTranslationState, tabId, () => ({
enabled: false,
ports: [] as Browser.runtime.Port[],
}))
const config = await storage.getItem<Config>(`local:${CONFIG_STORAGE_KEY}`)
const autoEnable = config && tabUrl && await shouldAutoEnable(tabUrl, config)
if (entry.ports.length === 0 && autoEnable) {
entry.enabled = true
}
entry.ports.push(port)
port.postMessage({ type: 'STATUS_PUSH', enabled: entry.enabled })
port.onMessage.addListener((message) => {
if (message.type === 'REQUEST_STATUS') {
const currentEntry = tabPageTranslationState.get(tabId)
port.postMessage({
type: 'STATUS_PUSH',
enabled: currentEntry?.enabled ?? false,
})
}
})
port.onDisconnect.addListener(() => {
const left = entry.ports.filter(p => p !== port)
if (left.length)
entry.ports = left
else tabPageTranslationState.delete(tabId)
})
})
onMessage('getEnablePageTranslation', (msg) => {
const { tabId } = msg.data
const enabled = tabPageTranslationState.get(tabId)?.enabled
return enabled
})
onMessage('setEnablePageTranslation', (msg) => {
const { tabId, enabled } = msg.data
setEnabled(tabId, enabled)
})
onMessage('setEnablePageTranslationOnContentScript', (msg) => {
const tabId = msg.sender?.tab?.id
const { enabled } = msg.data
if (typeof tabId === 'number')
setEnabled(tabId, enabled)
else
logger.error('tabId is not a number', msg)
})
onMessage('resetPageTranslationOnNavigation', async (msg) => {
const tabId = msg.sender?.tab?.id
const { url } = msg.data
if (typeof tabId === 'number') {
const config = await storage.getItem<Config>(`local:${CONFIG_STORAGE_KEY}`)
if (!config)
return
const shouldEnable = await shouldAutoEnable(url, config)
setEnabled(tabId, shouldEnable)
}
})
function setEnabled(tabId: number, enabled: boolean) {
const entry = ensureKeyInMap(tabPageTranslationState, tabId, () => ({
enabled: false,
ports: [] as Browser.runtime.Port[],
}))
entry.enabled = enabled
// 广播给本 tab 所有 content scripts
entry.ports.forEach(p => p.postMessage({ type: 'STATUS_PUSH', enabled }))
}
}
+39
View File
@@ -0,0 +1,39 @@
import type { Config } from '@/types/config/config'
import { kebabCase } from 'case-anything'
import { APP_NAME } from '@/utils/constants/app'
import { CONFIG_STORAGE_KEY } from '@/utils/constants/config'
import { OFFICIAL_SITE_URL_PATTERNS } from '@/utils/constants/site'
export default defineContentScript({
matches: OFFICIAL_SITE_URL_PATTERNS,
async main() {
onMessage('pinStateChanged', (msg) => {
window.postMessage({ source: `${kebabCase(APP_NAME)}-ext`, ...msg }, '*')
})
window.addEventListener('message', async (e) => {
if (e.source !== window)
return
const { source, type } = e.data || {}
if (source === 'read-frog-page' && type === 'getPinState') {
const isPinned = await sendMessage('getPinState', undefined)
window.postMessage({ source: `${kebabCase(APP_NAME)}-ext`, type: 'getPinState', data: { isPinned } }, '*')
}
else if (source === 'read-frog-page' && type === 'setTargetLanguage') {
const langCodeISO6393 = e.data.langCodeISO6393 ?? 'eng'
const config = await storage.getItem<Config>(`local:${CONFIG_STORAGE_KEY}`)
if (!config)
return
await storage.setItem<Config>(`local:${CONFIG_STORAGE_KEY}`, {
...config,
language: { ...config.language, targetCode: langCodeISO6393 },
})
}
else if (source === 'read-frog-page' && type === 'getTargetLanguage') {
const config = await storage.getItem<Config>(`local:${CONFIG_STORAGE_KEY}`)
const targetLanguage = config?.language.targetCode
window.postMessage({ source: `${kebabCase(APP_NAME)}-ext`, type: 'getTargetLanguage', data: { targetLanguage } }, '*')
}
})
},
})
+52
View File
@@ -0,0 +1,52 @@
// import eruda from 'eruda'
import { globalConfig, loadGlobalConfigPromise } from '@/utils/config/config'
import { shouldAutoEnable } from '@/utils/host/translate/auto-translation'
import { registerTranslationTriggers } from './translation-trigger'
import { PageTranslationManager } from './translation-trigger/page-translation'
import './listen'
import './style.css'
export default defineContentScript({
matches: ['*://*/*'],
async main() {
await loadGlobalConfigPromise
// eruda.init()
registerTranslationTriggers()
const port = browser.runtime.connect({ name: 'translation-host.content' })
const manager = new PageTranslationManager({
root: null,
rootMargin: '1000px',
threshold: 0.1,
})
const handleUrlChange = (from: string, to: string) => {
if (from !== to) {
logger.info('URL changed from', from, 'to', to)
if (manager.isActive) {
manager.stop()
}
// 通知 background script URL 已变化,让它决定是否自动启用翻译
sendMessage('resetPageTranslationOnNavigation', { url: to })
}
}
window.addEventListener('extension:urlchange', (e: any) => {
const { from, to, reason } = e.detail
logger.info('URL changed from', from, 'to', to, 'reason', reason)
handleUrlChange(from, to)
})
port.onMessage.addListener((msg) => {
logger.info('onMessage', msg)
if (msg.type !== 'STATUS_PUSH' || msg.enabled === manager.isActive)
return
msg.enabled ? manager.start() : manager.stop()
})
// ! Temporary code for browser has no port.onMessage.addListener api like Orion
const autoEnable = globalConfig && await shouldAutoEnable(window.location.href, globalConfig)
if (autoEnable && !manager.isActive)
manager.start()
},
})
+77
View File
@@ -0,0 +1,77 @@
(() => {
/** 向外抛出的统一事件名 */
const EVENT_NAME = 'extension:urlchange'
/** 检查两个URL的核心路径是否相同(只比较origin和pathname */
const isSamePage = (from: string, to: string) => {
try {
const fromUrl = new URL(from)
const toUrl = new URL(to)
// 只比较origin和pathname,忽略search和hash
return fromUrl.origin === toUrl.origin
&& fromUrl.pathname === toUrl.pathname
}
catch {
return false
}
}
/** 触发自定义事件并携带前后 URL */
const fire = (from: string, to: string, reason: string) => {
if (from === to)
return // 无变化
// 如果是同一个页面(只有search或hash变化),则不触发事件
if (isSamePage(from, to))
return
const ev = new CustomEvent(EVENT_NAME, { detail: { from, to, reason } })
window.dispatchEvent(ev)
}
/* ---------- 1. 补丁 pushState / replaceState ---------- */
let prev = location.href;
['pushState', 'replaceState'].forEach((fn) => {
const orig = history[fn as 'pushState']
history[fn as 'pushState'] = function (...args) {
orig.apply(this, args as any)
const now = location.href
fire(prev, now, 'pushState')
prev = now
}
})
/* ---------- 2. popstate / hashchange ---------- */
window.addEventListener('popstate', () => {
const now = location.href
fire(prev, now, 'popstate')
prev = now
})
window.addEventListener('hashchange', () => {
const now = location.href
fire(prev, now, 'hashchange')
prev = now
})
/* ---------- 3. 现代 Navigation API(仅 Chrome/Edge ---------- */
// 不拦截浏览器默认行为,只用于侦听
if ('navigation' in window) {
(window as any).navigation.addEventListener('navigate', (e: any) => {
const now = e.destination?.url ?? location.href
fire(prev, now, 'navigate')
prev = now
})
}
/* ---------- 4. 兜底轮询(可选,保证万无一失) ---------- */
if (!['chrome', 'edge'].includes(import.meta.env.BROWSER)) {
setInterval(() => {
const now = location.href
if (now !== prev) {
fire(prev, now, 'interval')
prev = now
}
}, 1000)
}
})()
+52
View File
@@ -0,0 +1,52 @@
:root {
--read-frog-primary: oklch(76.5% 0.177 163.223);
--read-frog-muted: oklch(0.97 0 0);
}
@media (prefers-color-scheme: dark) {
:root {
--read-frog-primary: oklch(59.6% 0.145 163.225);
--read-frog-muted: oklch(0.269 0 0);
}
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.read-frog-spinner {
border: 3px solid var(--read-frog-muted); /* 轨迹灰色 */
border-top: 3px solid var(--read-frog-primary); /* 主色调,首次翻译蓝色、缓存应改为绿色 */
border-radius: 50%;
width: 6px;
height: 6px;
margin: 0 4px;
animation: spin 1s linear infinite;
display: inline-block;
vertical-align: middle; /* 添加垂直居中对齐 */
box-sizing: content-box; /* 确保在所有网站上使用相同的盒模型 */
}
.read-frog-translated-content-wrapper {
word-break: break-word;
user-select: text;
}
.read-frog-translated-block-content {
display: inline-block;
margin: 8px 0 !important; /* 上下边距设置为8px,并强制覆盖其他样式 */
color: inherit; /* 保持文本颜色与周围文本一致 */
font-family: inherit;
}
.read-frog-translated-inline-content {
display: inline;
color: inherit;
font-family: inherit;
text-decoration: inherit;
}
@@ -0,0 +1,5 @@
import { registerNodeTranslationTriggers } from './node-translation'
export function registerTranslationTriggers() {
registerNodeTranslationTriggers()
}
@@ -0,0 +1,76 @@
import type { Point } from '@/types/dom'
import { globalConfig } from '@/utils/config/config'
import { isEditable } from '@/utils/host/dom/filter'
import { hideOrShowNodeTranslation } from '@/utils/host/translate/node-manipulation'
export function registerNodeTranslationTriggers() {
const mousePosition: Point = { x: 0, y: 0 }
const keyState = {
isHotkeyPressed: false,
isOtherKeyPressed: false,
}
const getHotkey = () => globalConfig?.translate.node.hotkey
const isEnabled = () => globalConfig?.translate.node.enabled
let timerId: NodeJS.Timeout | null = null // 延时触发的定时器
let actionTriggered = false
// Listen the hotkey means the user can't press or hold any other key during the hotkey is holding
document.addEventListener('keydown', (e) => {
if (!isEnabled())
return
if (e.target instanceof HTMLElement && isEditable(e.target))
return
if (e.key === getHotkey()) {
if (!keyState.isHotkeyPressed) {
keyState.isHotkeyPressed = true
// If user hold other key, it will trigger keyState.isOtherKeyPressed = true; later by repeat event
keyState.isOtherKeyPressed = false
timerId = setTimeout(() => {
if (!keyState.isOtherKeyPressed && keyState.isHotkeyPressed) {
hideOrShowNodeTranslation(mousePosition)
actionTriggered = true
}
timerId = null
}, 500) // 延迟 500ms,可根据需要调整
}
}
else if (keyState.isHotkeyPressed) {
// don't translate if user press other key
keyState.isOtherKeyPressed = true
if (timerId) {
clearTimeout(timerId)
timerId = null
}
}
})
document.addEventListener('keyup', (e) => {
if (!isEnabled())
return
if (e.target instanceof HTMLElement && isEditable(e.target))
return
if (e.key === getHotkey()) {
// translate if user release the hotkey and no other key is pressed
if (!keyState.isOtherKeyPressed) {
if (timerId) {
clearTimeout(timerId)
timerId = null
}
if (!actionTriggered) {
hideOrShowNodeTranslation(mousePosition)
}
}
actionTriggered = false
keyState.isHotkeyPressed = false
keyState.isOtherKeyPressed = false
}
})
document.body.addEventListener('mousemove', (event) => {
mousePosition.x = event.clientX
mousePosition.y = event.clientY
})
}
@@ -0,0 +1,377 @@
import { isDontWalkIntoElement, isHTMLElement, isIFrameElement } from '@/utils/host/dom/filter'
import { deepQueryTopLevelSelector, translateWalkedElement, walkAndLabelElement } from '@/utils/host/dom/traversal'
import { removeAllTranslatedWrapperNodes } from '@/utils/host/translate/node-manipulation'
import { sendMessage } from '@/utils/message'
// export function registerPageTranslationTriggers() {
// // Four-finger touch gesture to trigger translatePage
// let touchStartTime = 0
// let fourFingerTouchStarted = false
// let initialTouchPositions: Array<{ x: number, y: number }> = []
// document.addEventListener('touchstart', (e) => {
// if (e.touches.length === 4) {
// fourFingerTouchStarted = true
// touchStartTime = Date.now()
// // 记录初始触摸位置
// initialTouchPositions = Array.from(e.touches).map(touch => ({
// x: touch.clientX,
// y: touch.clientY,
// }))
// }
// else {
// fourFingerTouchStarted = false
// initialTouchPositions = []
// }
// }, { passive: true })
// document.addEventListener('touchend', (e) => {
// // Check if this was a four-finger tap (short duration touch)
// if (fourFingerTouchStarted && e.touches.length === 0) {
// const touchDuration = Date.now() - touchStartTime
// // Consider it a tap if touch duration is less than 500ms
// if (touchDuration < 500) {
// translatePage()
// }
// fourFingerTouchStarted = false
// initialTouchPositions = []
// }
// }, { passive: true })
// document.addEventListener('touchmove', (e) => {
// // Cancel four-finger gesture if fingers move too much or finger count changes
// if (!fourFingerTouchStarted)
// return
// // 检查手指数量是否改变
// if (e.touches.length !== 4) {
// fourFingerTouchStarted = false
// initialTouchPositions = []
// return
// }
// // 检查移动距离是否超过阈值(允许一定的移动容差)
// const MOVE_THRESHOLD = 30 // 30px 的移动阈值
// let hasMoveExceedThreshold = false
// for (let i = 0; i < Math.min(e.touches.length, initialTouchPositions.length); i++) {
// const currentTouch = e.touches[i]
// const initialPos = initialTouchPositions[i]
// const deltaX = Math.abs(currentTouch.clientX - initialPos.x)
// const deltaY = Math.abs(currentTouch.clientY - initialPos.y)
// const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY)
// if (distance > MOVE_THRESHOLD) {
// hasMoveExceedThreshold = true
// break
// }
// }
// if (hasMoveExceedThreshold) {
// fourFingerTouchStarted = false
// initialTouchPositions = []
// }
// }, { passive: true })
// }
const MAX_DURATION = 500
const MOVE_THRESHOLD2 = 30 * 30
export class PageTranslationManager {
private isAutoTranslated: boolean = false
private intersectionObserver: IntersectionObserver | null = null
private mutationObservers: MutationObserver[] = []
private id: string | null = null
private options: IntersectionObserverInit
private dontWalkIntoElementsCache = new WeakSet<HTMLElement>()
constructor(options: IntersectionObserverInit = { root: null, rootMargin: '0px', threshold: 0.1 }) {
this.options = options
this.registerPageTranslationTriggers()
}
get isActive(): boolean {
return this.isAutoTranslated
}
start(): void {
if (this.isAutoTranslated) {
console.warn('AutoTranslationManager is already active')
return
}
this.id = crypto.randomUUID()
this.isAutoTranslated = true
sendMessage('setEnablePageTranslationOnContentScript', {
enabled: true,
})
// Listen to existing elements when they enter the viewpoint
this.intersectionObserver = new IntersectionObserver((entries, observer) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
if (isHTMLElement(entry.target)) {
translateWalkedElement(entry.target, this.id!)
}
observer.unobserve(entry.target)
}
})
}, this.options)
// Initialize walkability state for existing elements
this.addDontWalkIntoElements(document.body)
this.observerTopLevelParagraphs(document.body)
// Start observing mutations from document.body and all shadow roots
this.observeMutations(document.body)
}
stop(): void {
if (!this.isAutoTranslated) {
return
}
this.isAutoTranslated = false
if (this.intersectionObserver) {
this.intersectionObserver.disconnect()
this.intersectionObserver = null
}
// Disconnect all mutation observers
this.mutationObservers.forEach(observer => observer.disconnect())
this.mutationObservers = []
this.dontWalkIntoElementsCache = new WeakSet()
removeAllTranslatedWrapperNodes()
this.id = null
sendMessage('setEnablePageTranslationOnContentScript', {
enabled: false,
})
}
private observerTopLevelParagraphs(container: HTMLElement): void {
const observer = this.intersectionObserver
if (!this.id || !observer)
return
walkAndLabelElement(container, this.id)
// if container itself has paragraph and the id
if (container.hasAttribute('data-read-frog-paragraph') && container.getAttribute('data-read-frog-walked') === this.id) {
observer.observe(container)
return
}
const paragraphs = this.collectParagraphElementsDeep(container, this.id)
const topLevelParagraphs = paragraphs.filter((el) => {
const ancestor = el.parentElement?.closest('[data-read-frog-paragraph]')
// keep it if either:
// • no paragraph ancestor at all, or
// • the ancestor is *not* inside container
return !ancestor || !container.contains(ancestor)
})
topLevelParagraphs.forEach(el => observer.observe(el))
}
/**
* Recursively collect elements with paragraph attributes from shadow roots and iframes
*/
private collectParagraphElementsDeep(container: HTMLElement, walkId: string): HTMLElement[] {
const result: HTMLElement[] = []
const collectFromContainer = (root: HTMLElement | Document | ShadowRoot) => {
const elements = root.querySelectorAll<HTMLElement>(`[data-read-frog-paragraph][data-read-frog-walked="${CSS.escape(walkId)}"]`)
result.push(...Array.from(elements))
}
const traverseElement = (element: HTMLElement) => {
if (element.shadowRoot) {
collectFromContainer(element.shadowRoot)
for (const child of element.shadowRoot.children) {
if (child instanceof HTMLElement) {
traverseElement(child)
}
}
}
if (isIFrameElement(element)) {
const iframeDocument = element.contentDocument
if (iframeDocument && iframeDocument.body) {
collectFromContainer(iframeDocument)
traverseElement(iframeDocument.body)
}
}
for (const child of element.children) {
if (child instanceof HTMLElement) {
traverseElement(child)
}
}
}
collectFromContainer(container)
traverseElement(container)
return result
}
private registerPageTranslationTriggers() {
let startTime = 0
let startTouches: TouchList | null = null
const reset = () => {
startTime = 0
startTouches = null
}
const onStart = (e: TouchEvent) => {
if (e.touches.length === 4) {
startTime = performance.now()
startTouches = e.touches
}
else {
reset()
}
}
const onMove = (e: TouchEvent) => {
if (!startTouches)
return
if (e.touches.length !== 4)
return reset()
for (let i = 0; i < 4; i++) {
const dx = e.touches[i].clientX - startTouches[i].clientX
const dy = e.touches[i].clientY - startTouches[i].clientY
if (dx * dx + dy * dy > MOVE_THRESHOLD2)
return reset()
}
}
const onEnd = () => {
if (!startTouches)
return
if (performance.now() - startTime < MAX_DURATION)
this.isAutoTranslated ? this.stop() : this.start()
reset()
}
document.addEventListener('touchstart', onStart, { passive: true })
document.addEventListener('touchmove', onMove, { passive: true })
document.addEventListener('touchend', onEnd, { passive: true })
document.addEventListener('touchcancel', reset, { passive: true })
// 供调用方卸载
return () => {
document.removeEventListener('touchstart', onStart)
document.removeEventListener('touchmove', onMove)
document.removeEventListener('touchend', onEnd)
document.removeEventListener('touchcancel', reset)
}
}
/**
* Handle style/class attribute changes and only trigger observation
* when element transitions from "don't walk into" to "walkable"
*/
private handleElementWalkabilityChange(element: HTMLElement): void {
const wasDontWalkInto = this.dontWalkIntoElementsCache.has(element)
const isDontWalkIntoNow = isDontWalkIntoElement(element)
// Update cache with current state
if (isDontWalkIntoNow) {
this.dontWalkIntoElementsCache.add(element)
}
else {
this.dontWalkIntoElementsCache.delete(element)
}
// Only trigger observation if element transitioned from "don't walk into" to "walkable"
// wasDontWalkInto === true means it was previously not walkable
// isDontWalkIntoNow === false means it's now walkable
if (wasDontWalkInto === true && isDontWalkIntoNow === false) {
this.observerTopLevelParagraphs(element)
}
}
/**
* Initialize walkability state for an element and its descendants
*/
private addDontWalkIntoElements(element: HTMLElement): void {
const dontWalkIntoElements = deepQueryTopLevelSelector(element, isDontWalkIntoElement)
dontWalkIntoElements.forEach(el => this.dontWalkIntoElementsCache.add(el))
}
/**
* Start observing mutations for a container and all its shadow roots
*/
private observeMutations(container: HTMLElement): void {
const mutationObserver = new MutationObserver((records) => {
for (const rec of records) {
if (rec.type === 'childList') {
rec.addedNodes.forEach((node) => {
if (isHTMLElement(node)) {
this.addDontWalkIntoElements(node)
this.observerTopLevelParagraphs(node)
this.observeIsolatedDescendantsMutations(node)
}
})
}
else if (
rec.type === 'attributes'
&& (rec.attributeName === 'style' || rec.attributeName === 'class')
) {
const el = rec.target
if (isHTMLElement(el)) {
this.handleElementWalkabilityChange(el)
}
}
}
})
mutationObserver.observe(container, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['style', 'class'],
})
this.mutationObservers.push(mutationObserver)
this.observeIsolatedDescendantsMutations(container)
}
/**
* Recursively find and observe shadow roots and iframes in an element and its descendants
* These can't be find as top level paragraph elements because isolated shadow roots and iframes are not
* considered as part of the document.
*/
private observeIsolatedDescendantsMutations(element: HTMLElement): void {
// Check if this element has a shadow root
if (element.shadowRoot) {
for (const child of element.shadowRoot.children) {
if (isHTMLElement(child)) {
this.observeMutations(child)
}
}
}
// Check iframes
if (isIFrameElement(element)) {
const iframeDocument = element.contentDocument
if (iframeDocument && iframeDocument.body) {
this.observeMutations(iframeDocument.body)
}
}
// Recursively check children
for (const child of element.children) {
if (isHTMLElement(child)) {
this.observeIsolatedDescendantsMutations(child)
}
}
}
}
@@ -0,0 +1,50 @@
import { Link } from 'react-router'
import readFrogLogo from '@/assets/icon/read-frog.png'
import {
Sidebar,
SidebarContent,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from '@/components/ui/sidebar'
import { version } from '../../../../../../package.json'
import { NAV_ITEMS } from './nav-items'
export function AppSidebar() {
return (
<Sidebar collapsible="icon">
<SidebarHeader className="group-data-[state=expanded]:px-5 group-data-[state=expanded]:pt-4 transition-all">
<a href="https://readfrog.app" className="flex items-center gap-2">
<img src={readFrogLogo} alt="Logo" className="h-8 w-8 shrink-0" />
<span className="text-md font-bold overflow-hidden truncate">{i18n.t('name')}</span>
<span className="text-xs text-muted-foreground overflow-hidden truncate">
{`v${version}`}
</span>
</a>
</SidebarHeader>
<SidebarContent className="group-data-[state=expanded]:px-2 transition-all">
<SidebarGroup>
<SidebarGroupLabel>Application</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{Object.entries(NAV_ITEMS).map(([key, item]) => (
<SidebarMenuItem key={key}>
<SidebarMenuButton asChild>
<Link to={item.url}>
<item.icon />
<span>{i18n.t(`options.${item.title}.title`)}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
</Sidebar>
)
}
@@ -0,0 +1,30 @@
import { Globe, Key, Settings } from 'lucide-react'
import { ApiKeysPage } from '../pages/api-keys'
import { GeneralPage } from '../pages/general'
import { TranslationPage } from '../pages/translation'
export const NAV_ITEMS = {
'': {
title: 'general',
url: '/',
icon: Settings,
component: GeneralPage,
},
'api-keys': {
title: 'apiKeys',
url: '/api-keys',
icon: Key,
component: ApiKeysPage,
},
'translation': {
title: 'translation',
url: '/translation',
icon: Globe,
component: TranslationPage,
},
} as const satisfies Record<string, {
title: string
url: string
icon: React.ComponentType<{ className?: string }>
component: React.ComponentType
}>
+13
View File
@@ -0,0 +1,13 @@
import { Route, Routes } from 'react-router'
import { NAV_ITEMS } from './app-sidebar/nav-items'
export default function App() {
return (
<Routes>
{Object.entries(NAV_ITEMS).map(([key, item]) => (
<Route key={key} path={item.url} element={<item.component />} />
))}
</Routes>
)
}
@@ -0,0 +1,13 @@
export function ConfigCard({ title, description, children, className }: { title: React.ReactNode, description: React.ReactNode, children: React.ReactNode, className?: string }) {
return (
<section className={cn('py-6 flex lg:flex-row flex-col lg:gap-x-[50px] xl:gap-x-[100px] gap-y-4', className)}>
<div className="lg:basis-2/5">
<h2 className="text-md font-bold mb-1">{title}</h2>
<p className="text-sm text-gray-500">{description}</p>
</div>
<div className="lg:mt-0 lg:basis-3/5">
{children}
</div>
</section>
)
}
@@ -0,0 +1,10 @@
export function FieldWithLabel({ id, label, children }: { id: string, label: React.ReactNode, children: React.ReactNode }) {
return (
<div className="flex flex-col gap-1">
<label htmlFor={id} className="text-sm font-medium">
{label}
</label>
{children}
</div>
)
}
@@ -0,0 +1,22 @@
import Container from '@/components/container'
import { Separator } from '@/components/ui/separator'
import { SidebarTrigger } from '@/components/ui/sidebar'
export function PageLayout({ title, children, className, innerClassName }: { title: string, children: React.ReactNode, className?: string, innerClassName?: string }) {
return (
<div className={cn('w-full', className)}>
<div className="border-b">
<Container>
<header className="flex h-14 -ml-1.5 shrink-0 items-center gap-2">
<SidebarTrigger />
<Separator orientation="vertical" className="mr-1.5 h-4!" />
<h1>{title}</h1>
</header>
</Container>
</div>
<Container className={innerClassName}>
{children}
</Container>
</div>
)
}
@@ -0,0 +1,13 @@
import { Link } from 'react-router'
export function SetApiKeyWarning() {
return (
<div className="text-xs bg-warning px-2 rounded-md flex items-center gap-1 border border-warning-border">
{i18n.t('options.setAPIKeyWarning.please')}
{' '}
<Link to="/api-keys" className="text-blue-500 hover:underline">{i18n.t('options.apiKeys.title')}</Link>
{' '}
{i18n.t('options.setAPIKeyWarning.page')}
</div>
)
}
+23
View File
@@ -0,0 +1,23 @@
<!doctype html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Options | Read Frog</title>
<!-- Customize the manifest options -->
<meta name="manifest.open_in_tab" content="true" />
<!-- <meta name="manifest.browser_style" content="true" /> -->
<!-- Set include/exclude if the page should be removed from some builds -->
<!-- <meta
name="manifest.include"
content="['chrome', 'firefox', 'edge', 'safari']"
/> -->
<!-- <meta name="manifest.exclude" content="['chrome']" /> -->
</head>
<body>
<div id="root"></div>
<script type="module" src="./main.tsx"></script>
</body>
</html>
+57
View File
@@ -0,0 +1,57 @@
import type { Config } from '@/types/config/config'
import { Provider as JotaiProvider } from 'jotai'
import { useHydrateAtoms } from 'jotai/utils'
import React from 'react'
import ReactDOM from 'react-dom/client'
import { HashRouter } from 'react-router'
import { SidebarProvider } from '@/components/ui/sidebar'
import { configAtom } from '@/utils/atoms/config'
import { DEFAULT_CONFIG } from '@/utils/constants/config'
import App from './app'
import { AppSidebar } from './app-sidebar'
import '@/assets/tailwind/theme.css'
import './style.css'
document.documentElement.classList.toggle(
'dark',
localStorage.theme === 'dark'
|| (!('theme' in localStorage)
&& window.matchMedia('(prefers-color-scheme: dark)').matches),
)
function HydrateAtoms({
initialValues,
children,
}: {
initialValues: [[typeof configAtom, Config]]
children: React.ReactNode
}) {
useHydrateAtoms(initialValues)
return children
}
async function initApp() {
const root = document.getElementById('root')!
root.className = 'antialiased bg-background'
const config = await storage.getItem<Config>('local:config')
ReactDOM.createRoot(root).render(
<React.StrictMode>
<JotaiProvider>
<HydrateAtoms initialValues={[[configAtom, config ?? DEFAULT_CONFIG]]}>
<HashRouter>
<SidebarProvider>
<AppSidebar />
<App />
</SidebarProvider>
</HashRouter>
</HydrateAtoms>
</JotaiProvider>
</React.StrictMode>,
)
}
initApp()
+109
View File
@@ -0,0 +1,109 @@
import type { APIProviderNames } from '@/types/config/provider'
import { useAtom } from 'jotai'
import { useState } from 'react'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { apiProviderNames } from '@/types/config/provider'
import { configFields } from '@/utils/atoms/config'
import { API_PROVIDER_ITEMS } from '@/utils/constants/config'
import { ConfigCard } from '../components/config-card'
import { FieldWithLabel } from '../components/field-with-label'
import { PageLayout } from '../components/page-layout'
export function ApiKeysPage() {
return (
<PageLayout title={i18n.t('options.apiKeys.title')} innerClassName="[&>*]:border-b [&>*:last-child]:border-b-0">
{apiProviderNames.map(provider => (
<ProviderConfigCard key={provider} provider={provider} />
))}
</PageLayout>
)
}
export function ProviderConfigCard({ provider }: { provider: APIProviderNames }) {
const [providersConfig, setProvidersConfig] = useAtom(configFields.providersConfig)
const [showAPIKey, setShowAPIKey] = useState(false)
return (
<ConfigCard
title={(
<div className="flex items-center gap-2">
<img
src={API_PROVIDER_ITEMS[provider].logo}
alt={API_PROVIDER_ITEMS[provider].name}
className="border-border size-5 p-[3px] rounded-full border bg-white"
/>
{API_PROVIDER_ITEMS[provider].name}
</div>
)}
description={i18n.t(`options.apiKeys.description.${provider}`)}
>
<div className="flex flex-col gap-y-4">
<FieldWithLabel id={`${provider}-apiKey`} label="API Key">
<Input
className="mt-1 mb-2"
value={providersConfig[provider].apiKey}
type={showAPIKey ? 'text' : 'password'}
onChange={e =>
setProvidersConfig({
...providersConfig,
[provider]: {
...providersConfig[provider],
apiKey: e.target.value,
},
})}
/>
<div className="flex items-center space-x-2">
<Checkbox
id={`apiKey-${provider}`}
checked={showAPIKey}
onCheckedChange={checked => setShowAPIKey(checked === true)}
/>
<label
htmlFor={`apiKey-${provider}`}
className="text-sm leading-none font-medium peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
{i18n.t('options.apiKeys.apiKey.showAPIKey')}
</label>
</div>
</FieldWithLabel>
<AdvancedProviderConfig provider={provider} />
</div>
</ConfigCard>
)
}
function AdvancedProviderConfig({ provider }: { provider: APIProviderNames }) {
const [providersConfig, setProvidersConfig] = useAtom(configFields.providersConfig)
const [showAdvanced, setShowAdvanced] = useState(false)
return (
<div>
<button
type="button"
className={cn('text-sm font-medium text-blue-600 hover:underline', showAdvanced && 'mb-2')}
onClick={() => setShowAdvanced(!showAdvanced)}
>
{showAdvanced ? i18n.t('options.apiKeys.advancedConfig.hide') : i18n.t('options.apiKeys.advancedConfig.show')}
</button>
{showAdvanced && (
<FieldWithLabel id={`${provider}-baseURL`} label="Base URL">
<Input
className="mt-1 mb-2"
value={providersConfig[provider].baseURL}
onChange={e =>
setProvidersConfig({
...providersConfig,
[provider]: {
...providersConfig[provider],
baseURL: e.target.value,
},
})}
/>
</FieldWithLabel>
)}
</div>
)
}
@@ -0,0 +1,12 @@
import { PageLayout } from '../../components/page-layout'
import { ReadConfig } from './read-config'
import TranslationConfig from './translation-config'
export function GeneralPage() {
return (
<PageLayout title={i18n.t('options.general.title')} innerClassName="[&>*]:border-b [&>*:last-child]:border-b-0">
<ReadConfig />
<TranslationConfig />
</PageLayout>
)
}
@@ -0,0 +1,145 @@
import type { ReadProviderNames } from '@/types/config/provider'
import deepmerge from 'deepmerge'
import { useAtom, useAtomValue } from 'jotai'
import ProviderIcon from '@/components/provider-icon'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { readProviderModels } from '@/types/config/provider'
import { configFields } from '@/utils/atoms/config'
import { READ_PROVIDER_ITEMS } from '@/utils/constants/config'
import { ConfigCard } from '../../components/config-card'
import { FieldWithLabel } from '../../components/field-with-label'
import { SetApiKeyWarning } from '../../components/set-api-key-warning'
export function ReadConfig() {
return (
<ConfigCard title={i18n.t('options.general.readConfig.title')} description={i18n.t('options.general.readConfig.description')}>
<div className="flex flex-col gap-4">
<ReadProviderSelector />
<ReadModelSelector />
</div>
</ConfigCard>
)
}
function ReadProviderSelector() {
const [readConfig, setReadConfig] = useAtom(configFields.read)
const providersConfig = useAtomValue(configFields.providersConfig)
const providerConfig = providersConfig[readConfig.provider]
return (
<FieldWithLabel
id="readProvider"
label={(
<div className="flex gap-2">
{i18n.t('options.general.readConfig.provider')}
{!providerConfig.apiKey && <SetApiKeyWarning />}
</div>
)}
>
<Select
value={readConfig.provider}
onValueChange={(value: ReadProviderNames) =>
setReadConfig(
{ ...readConfig, provider: value },
)}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{Object.entries(READ_PROVIDER_ITEMS).map(([value, { logo, name }]) => (
<SelectItem key={value} value={value}>
<ProviderIcon logo={logo} name={name} />
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</FieldWithLabel>
)
}
function ReadModelSelector() {
const [readConfig, setReadConfig] = useAtom(configFields.read)
const modelConfig = readConfig.models[readConfig.provider]
return (
<FieldWithLabel id="readModel" label={i18n.t('options.general.readConfig.model.title')}>
{modelConfig.isCustomModel
? (
<Input
value={modelConfig.customModel}
onChange={e =>
setReadConfig(deepmerge(readConfig, {
models: {
[readConfig.provider]: {
customModel: e.target.value,
},
},
}))}
/>
)
: (
<Select
value={modelConfig.model}
onValueChange={value =>
setReadConfig(deepmerge(readConfig, {
models: {
[readConfig.provider]: {
model: value,
},
},
}))}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select a model" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{readProviderModels[readConfig.provider].map(model => (
<SelectItem key={model} value={model}>
{model}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
)}
<div className="mt-0.5 flex items-center space-x-2">
<Checkbox
id={`isCustomModel-${readConfig.provider}`}
checked={modelConfig.isCustomModel}
onCheckedChange={(checked) => {
if (checked === false) {
setReadConfig(deepmerge(readConfig, {
models: {
[readConfig.provider]: {
customModel: '',
isCustomModel: false,
},
},
}))
}
else {
setReadConfig(deepmerge(readConfig, {
models: {
[readConfig.provider]: {
customModel: readConfig.models[readConfig.provider].model,
isCustomModel: true,
},
},
}))
}
}}
/>
<label
htmlFor={`isCustomModel-${readConfig.provider}`}
className="text-sm leading-none font-medium peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
{i18n.t('options.general.readConfig.model.enterCustomModel')}
</label>
</div>
</FieldWithLabel>
)
}
@@ -0,0 +1,212 @@
import type { PageTranslateRange, TranslateProviderNames } from '@/types/config/provider'
import deepmerge from 'deepmerge'
import { useAtom, useAtomValue } from 'jotai'
import ProviderIcon from '@/components/provider-icon'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { isLLMTranslateProvider, pageTranslateRangeSchema, translateProviderModels } from '@/types/config/provider'
import { configFields } from '@/utils/atoms/config'
import { LLM_TRANSLATE_PROVIDER_ITEMS, PURE_TRANSLATE_PROVIDER_ITEMS } from '@/utils/constants/config'
import { ConfigCard } from '../../components/config-card'
import { FieldWithLabel } from '../../components/field-with-label'
import { SetApiKeyWarning } from '../../components/set-api-key-warning'
export default function TranslationConfig() {
return (
<ConfigCard title={i18n.t('options.general.translationConfig.title')} description={i18n.t('options.general.translationConfig.description')}>
<div className="space-y-4">
<TranslateProviderSelector />
<TranslateModelSelector />
<RangeSelector />
</div>
</ConfigCard>
)
}
function RangeSelector() {
const [translateConfig, setTranslateConfig] = useAtom(configFields.translate)
return (
<FieldWithLabel id="translateRange" label={i18n.t('options.general.translationConfig.translateRange.title')}>
<Select
value={translateConfig.page.range}
onValueChange={(value: PageTranslateRange) =>
setTranslateConfig(
deepmerge(translateConfig, { page: { range: value } }),
)}
>
<SelectTrigger className="w-full">
<SelectValue asChild>
<span>
{i18n.t(
`options.general.translationConfig.translateRange.range.${translateConfig.page.range}`,
)}
</span>
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectGroup>
{pageTranslateRangeSchema.options.map(range => (
<SelectItem key={range} value={range}>
{i18n.t(
`options.general.translationConfig.translateRange.range.${range}`,
)}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</FieldWithLabel>
)
}
function TranslateProviderSelector() {
const [translateConfig, setTranslateConfig] = useAtom(configFields.translate)
const providersConfig = useAtomValue(configFields.providersConfig)
const providerConfig = isLLMTranslateProvider(translateConfig.provider)
? providersConfig[translateConfig.provider]
: null
return (
<FieldWithLabel
id="translateProvider"
label={(
<div className="flex gap-2">
{i18n.t('options.general.translationConfig.provider')}
{providerConfig && !providerConfig.apiKey && <SetApiKeyWarning />}
</div>
)}
>
<Select
value={translateConfig.provider}
onValueChange={(value: TranslateProviderNames) =>
setTranslateConfig(
{ ...translateConfig, provider: value },
)}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>{i18n.t('translateService.aiTranslator')}</SelectLabel>
{Object.entries(LLM_TRANSLATE_PROVIDER_ITEMS).map(([value, { logo, name }]) => (
<SelectItem key={value} value={value}>
<ProviderIcon logo={logo} name={name} />
</SelectItem>
))}
</SelectGroup>
<SelectGroup>
<SelectLabel>{i18n.t('translateService.normalTranslator')}</SelectLabel>
{Object.entries(PURE_TRANSLATE_PROVIDER_ITEMS).map(([value, { logo, name }]) => (
<SelectItem key={value} value={value}>
<ProviderIcon logo={logo} name={name} />
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</FieldWithLabel>
)
}
function TranslateModelSelector() {
const [translateConfig, setTranslateConfig] = useAtom(configFields.translate)
const provider = translateConfig.provider
if (!isLLMTranslateProvider(provider)) {
return null
}
const modelConfig = translateConfig.models[provider]
return (
<FieldWithLabel id="translateModel" label={i18n.t('options.general.translationConfig.model.title')}>
{modelConfig.isCustomModel
? (
<Input
value={modelConfig.customModel}
onChange={e =>
setTranslateConfig(deepmerge(translateConfig, {
models: {
[provider]: {
customModel: e.target.value,
},
},
}))}
/>
)
: (
<Select
value={modelConfig.model}
onValueChange={value =>
setTranslateConfig(deepmerge(translateConfig, {
models: {
[provider]: {
model: value,
},
},
}))}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select a model" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{translateProviderModels[provider].map(model => (
<SelectItem key={model} value={model}>
{model}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
)}
<div className="mt-0.5 flex items-center space-x-2">
<Checkbox
id={`isCustomModel-${translateConfig.provider}`}
checked={modelConfig.isCustomModel}
onCheckedChange={(checked) => {
if (checked === false) {
setTranslateConfig(deepmerge(translateConfig, {
models: {
[translateConfig.provider]: {
customModel: '',
isCustomModel: false,
},
},
}))
}
else {
setTranslateConfig(deepmerge(translateConfig, {
models: {
[translateConfig.provider]: {
customModel: translateConfig.models[provider].model,
isCustomModel: true,
},
},
}))
}
}}
/>
<label
htmlFor={`isCustomModel-${translateConfig.provider}`}
className="text-sm leading-none font-medium peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
{i18n.t('options.general.translationConfig.model.enterCustomModel')}
</label>
</div>
</FieldWithLabel>
)
}
@@ -0,0 +1,98 @@
import { useAtom } from 'jotai'
import { Plus, Trash2 } from 'lucide-react'
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { configFields } from '@/utils/atoms/config'
import { ConfigCard } from '../../components/config-card'
export function AlwaysTranslate() {
return (
<ConfigCard title="Always Translate" description="Always translate the webpage">
<PatternTable />
</ConfigCard>
)
}
function PatternTable() {
const [translateConfig, setTranslateConfig] = useAtom(configFields.translate)
const [inputValue, setInputValue] = useState('')
const { autoTranslatePatterns } = translateConfig.page
const addPattern = (pattern: string) => {
const cleanedPattern = pattern.trim()
if (!cleanedPattern)
return
setTranslateConfig({
page: {
...translateConfig.page,
autoTranslatePatterns: [...autoTranslatePatterns, cleanedPattern],
},
})
setInputValue('')
}
const removePattern = (pattern: string) => {
setTranslateConfig({
page: {
...translateConfig.page,
autoTranslatePatterns: autoTranslatePatterns.filter(p => p !== pattern),
},
})
}
const handleAddPattern = () => {
addPattern(inputValue)
}
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
addPattern(inputValue)
}
}
return (
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<Input
placeholder="Enter URL Pattern"
value={inputValue}
onChange={e => setInputValue(e.target.value)}
onKeyDown={handleKeyPress}
/>
<Button className="size-9" onClick={handleAddPattern}>
<Plus className="size-5" />
</Button>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[100px]">URL Pattern</TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{autoTranslatePatterns.map(pattern => (
<TableRow key={pattern}>
<TableCell>{pattern}</TableCell>
<TableCell className="text-right">
<Button variant="outline" size="icon" onClick={() => removePattern(pattern)}>
<Trash2 className="size-4" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)
}
@@ -0,0 +1,10 @@
import { PageLayout } from '../../components/page-layout'
import { AlwaysTranslate } from './always-translate'
export function TranslationPage() {
return (
<PageLayout title={i18n.t('options.translation.title')} innerClassName="[&>*]:border-b [&>*:last-child]:border-b-0">
<AlwaysTranslate />
</PageLayout>
)
}
+4
View File
@@ -0,0 +1,4 @@
body {
/* Chrome set the font to 75% of the default size */
font-size: initial; /* reset to 16px */
}
+68
View File
@@ -0,0 +1,68 @@
import { useSetAtom } from 'jotai'
import { Bolt, Star } from 'lucide-react'
import { Toaster } from 'sonner'
import { version } from '../../../../../package.json'
import { initIsIgnoreTabAtom } from './atom'
import { AlwaysTranslate } from './components/always-translate'
import FloatingButton from './components/floating-button'
import Hotkey from './components/hotkey-selector'
import LanguageLevelSelector from './components/language-level-selector'
import LanguageOptionsSelector from './components/language-options-selector'
import QuickLinks from './components/quick-links'
import ReadButton from './components/read-button'
import ReadProviderSelector from './components/read-provider-selector'
import TranslateButton from './components/translate-button'
import TranslateProviderSelector from './components/translate-provider-selector'
function App() {
const initIsIgnoreTab = useSetAtom(initIsIgnoreTabAtom)
useEffect(() => {
initIsIgnoreTab()
}, [initIsIgnoreTab])
return (
<>
<div className="bg-background flex flex-col gap-4 px-6 pt-5 pb-4">
<LanguageOptionsSelector />
<LanguageLevelSelector />
<ReadProviderSelector />
<TranslateProviderSelector />
<div className="grid w-full grid-cols-2 gap-2">
<ReadButton />
<TranslateButton />
</div>
<AlwaysTranslate />
<Hotkey />
<FloatingButton />
<QuickLinks />
</div>
<div className="flex items-center justify-between bg-neutral-200 px-2 py-1 dark:bg-neutral-800">
<button
type="button"
className="flex cursor-pointer items-center gap-1 rounded-md px-2 py-1 hover:bg-neutral-300 dark:hover:bg-neutral-700"
onClick={() => browser.runtime.openOptionsPage()}
>
<Bolt className="size-4" strokeWidth={1.6} />
<span className="text-[13px] font-medium">
{i18n.t('popup.options')}
</span>
</button>
<span className="text-sm text-neutral-500 dark:text-neutral-400">
{version}
</span>
<button
type="button"
className="flex cursor-pointer items-center gap-1 rounded-md px-2 py-1 hover:bg-neutral-300 dark:hover:bg-neutral-700"
onClick={() =>
window.open('https://github.com/mengxi-ream/read-frog', '_blank')}
>
<Star className="size-4" strokeWidth={1.6} />
<span className="text-[13px] font-medium">Github</span>
</button>
</div>
<Toaster richColors position="bottom-center" className="-translate-y-8" duration={10000} />
</>
)
}
export default App
+109
View File
@@ -0,0 +1,109 @@
import type { Config } from '@/types/config/config'
import { atom } from 'jotai'
import { configFields } from '@/utils/atoms/config'
import { getActiveTabUrl } from '@/utils/utils'
type TranslateConfig = Config['translate']
const EMPTY_TAB_URLS = [
'about:blank',
'chrome://newtab/',
'edge://newtab/',
'about:newtab',
]
const EXTENSION_URLS = [
'chrome://extensions/',
'chrome-extension://',
'edge-extension://',
'chrome://newtab/',
'edge://newtab/',
]
async function checkIgnoreTab() {
if (typeof window !== 'undefined' && browser.tabs) {
const tabs = await browser.tabs.query({
active: true,
currentWindow: true,
})
const currentTab = tabs[0]
const currentUrl = currentTab?.url || ''
return (
EMPTY_TAB_URLS.some(url => currentUrl.includes(url))
|| EXTENSION_URLS.some(url => currentUrl.includes(url))
)
}
return false
}
export const isIgnoreTabAtom = atom<boolean>(false)
// Create a derived atom for initialization
export const initIsIgnoreTabAtom = atom(null, async (get, set) => {
const isIgnore = await checkIgnoreTab()
set(isIgnoreTabAtom, isIgnore)
})
// Sync atom to store the checked state
export const isCurrentSiteInPatternsAtom = atom<boolean>(false)
export async function getIsInPatterns(translateConfig: TranslateConfig) {
const activeTabUrl = await getActiveTabUrl()
if (!activeTabUrl)
return false
return translateConfig.page.autoTranslatePatterns.some(pattern =>
activeTabUrl.includes(pattern),
)
}
// Async atom to initialize the checked state
export const initIsCurrentSiteInPatternsAtom = atom(
null,
async (get, set) => {
const translateConfig = get(configFields.translate)
set(isCurrentSiteInPatternsAtom, await getIsInPatterns(translateConfig))
},
)
// Atom to toggle current site in auto-translate patterns
export const toggleCurrentSiteAtom = atom(
null,
async (get, set, checked: boolean) => {
const translateConfig = get(configFields.translate)
const activeTabUrl = await getActiveTabUrl()
if (!activeTabUrl)
return
const currentPatterns = translateConfig.page.autoTranslatePatterns
const hostname = new URL(activeTabUrl).hostname
if (checked) {
// Add hostname to patterns if not already present
if (!currentPatterns.some(pattern => hostname.includes(pattern))) {
set(configFields.translate, {
page: {
...translateConfig.page,
autoTranslatePatterns: [...currentPatterns, hostname],
},
})
}
}
else {
// Remove patterns that match the current hostname
const filteredPatterns = currentPatterns.filter(pattern =>
!hostname.includes(pattern),
)
set(configFields.translate, {
page: {
...translateConfig.page,
autoTranslatePatterns: filteredPatterns,
},
})
}
// Update the local state
set(isCurrentSiteInPatternsAtom, checked)
},
)
@@ -0,0 +1,22 @@
import { useAtom, useAtomValue } from 'jotai'
import { Switch } from '@/components/ui/switch'
import { isCurrentSiteInPatternsAtom, isIgnoreTabAtom, toggleCurrentSiteAtom } from '../atom'
export function AlwaysTranslate() {
const isCurrentSiteInPatterns = useAtomValue(isCurrentSiteInPatternsAtom)
const [, toggleCurrentSite] = useAtom(toggleCurrentSiteAtom)
const isIgnoreTab = useAtomValue(isIgnoreTabAtom)
return (
<div className="flex items-center justify-between gap-2">
<span className="text-[13px] font-medium">
{i18n.t('popup.alwaysTranslate')}
</span>
<Switch
checked={isCurrentSiteInPatterns}
onCheckedChange={toggleCurrentSite}
disabled={isIgnoreTab}
/>
</div>
)
}
@@ -0,0 +1,24 @@
import { useAtom } from 'jotai'
import { Switch } from '@/components/ui/switch'
import { configFields } from '@/utils/atoms/config'
export default function FloatingButton() {
const [floatingButton, setFloatingButton] = useAtom(
configFields.floatingButton,
)
return (
<div className="flex items-center justify-between gap-2">
<span className="text-[13px] font-medium">
{i18n.t('popup.enabledFloatingButton')}
</span>
<Switch
checked={floatingButton.enabled}
onCheckedChange={(checked) => {
setFloatingButton({ enabled: checked })
}}
/>
</div>
)
}
@@ -0,0 +1,61 @@
import deepmerge from 'deepmerge'
import { useAtom } from 'jotai'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
} from '@/components/ui/select'
import { Switch } from '@/components/ui/switch'
import { configFields } from '@/utils/atoms/config'
import { HOTKEY_ITEMS, HOTKEYS } from '@/utils/constants/hotkeys'
export default function HotkeySelector() {
const [translateConfig, setTranslateConfig] = useAtom(
configFields.translate,
)
return (
<div className="flex items-center justify-between gap-2">
<Select
value={translateConfig.node.hotkey}
onValueChange={(value: typeof HOTKEYS[number]) => setTranslateConfig(deepmerge(translateConfig, { node: { hotkey: value } }))}
>
<SelectTrigger
size="sm"
className="ring-none cursor-pointer truncate border-none bg-transparent pl-0 text-[13px] font-medium shadow-none hover:bg-transparent focus-visible:border-none focus-visible:ring-0 dark:bg-transparent dark:hover:bg-transparent"
>
<div className="truncate">
{i18n.t('popup.hover')}
{' '}
+
{' '}
{HOTKEY_ITEMS[translateConfig.node.hotkey].icon}
{' '}
{HOTKEY_ITEMS[translateConfig.node.hotkey].label}
{' '}
{i18n.t('popup.translateParagraph')}
</div>
</SelectTrigger>
<SelectContent>
{HOTKEYS.map(item => (
<SelectItem key={item} value={item}>
{i18n.t('popup.hover')}
{' '}
+
{HOTKEY_ITEMS[item].icon}
{' '}
{HOTKEY_ITEMS[item].label}
{' '}
{i18n.t('popup.translateParagraph')}
</SelectItem>
))}
</SelectContent>
</Select>
<Switch
checked={translateConfig.node.enabled}
onCheckedChange={checked => setTranslateConfig(deepmerge(translateConfig, { node: { enabled: checked } }))}
/>
</div>
)
}
@@ -0,0 +1,43 @@
import type { LangLevel } from '@/types/config/languages'
import { useAtom } from 'jotai'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { configFields } from '@/utils/atoms/config'
export default function LanguageLevelSelector() {
const [language, setLanguage] = useAtom(configFields.language)
return (
<div className="flex items-center justify-between gap-2">
<span className="text-[13px] font-medium">{i18n.t('languageLevel')}</span>
<Select
value={language.level}
onValueChange={(value: LangLevel) => setLanguage({ level: value })}
>
<SelectTrigger
size="sm"
className="bg-input/50 hover:bg-input !h-7 w-29 cursor-pointer pr-1.5 pl-2.5 outline-none"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="beginner">
{i18n.t('languageLevels.beginner')}
</SelectItem>
<SelectItem value="intermediate">
{i18n.t('languageLevels.intermediate')}
</SelectItem>
<SelectItem value="advanced">
{i18n.t('languageLevels.advanced')}
</SelectItem>
</SelectContent>
</Select>
</div>
)
}
@@ -0,0 +1,81 @@
import type {
LangCodeISO6393,
} from '@/types/config/languages'
import { useAtom } from 'jotai'
import { ArrowRight, ChevronDown } from 'lucide-react'
import {
LANG_CODE_TO_EN_NAME,
LANG_CODE_TO_LOCALE_NAME,
langCodeISO6393Schema,
} from '@/types/config/languages'
import { configFields } from '@/utils/atoms/config'
export default function LanguageOptionsSelector() {
const [language, setLanguage] = useAtom(configFields.language)
const handleSourceLangChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const newLangCode = e.target.value as LangCodeISO6393
setLanguage({ sourceCode: newLangCode })
}
const handleTargetLangChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const newLangCode = e.target.value as LangCodeISO6393
setLanguage({ targetCode: newLangCode })
}
logger.log('language', language)
return (
<div className="flex items-center gap-2">
<div className="bg-input/50 hover:bg-input border-input relative inline-flex h-13 w-32 items-center justify-between rounded-lg border shadow-xs">
<span className="pt-5 pl-4 text-sm text-neutral-500">
{language.sourceCode === 'auto'
? i18n.t('popup.autoLang')
: i18n.t('popup.sourceLang')}
</span>
<ChevronDown
className="absolute right-2 h-5 w-5 text-neutral-400 dark:text-neutral-600"
strokeWidth={1.5}
/>
<select
className="insect-0 absolute w-32 cursor-pointer appearance-none truncate bg-transparent pr-8 pb-4 pl-4 text-base font-medium outline-none"
value={language.sourceCode}
onChange={handleSourceLangChange}
>
<option value="auto">
{`${LANG_CODE_TO_EN_NAME[language.detectedCode]} (${LANG_CODE_TO_LOCALE_NAME[language.detectedCode]})`}
{' '}
(auto)
</option>
{langCodeISO6393Schema.options.map(key => (
<option key={key} value={key}>
{`${LANG_CODE_TO_EN_NAME[key]} (${LANG_CODE_TO_LOCALE_NAME[key]})`}
</option>
))}
</select>
</div>
<ArrowRight className="h-4 w-4 text-neutral-500" strokeWidth={2} />
<div className="bg-input/50 hover:bg-input border-input relative inline-flex h-13 w-32 items-center justify-between rounded-lg border shadow-xs">
<span className="pt-5 pl-4 text-sm text-neutral-500">
{i18n.t('popup.targetLang')}
</span>
<ChevronDown
className="absolute right-2 h-5 w-5 text-neutral-400 dark:text-neutral-600"
strokeWidth={1.5}
/>
<select
className="insect-0 absolute w-32 cursor-pointer appearance-none truncate bg-transparent pr-8 pb-4 pl-4 text-base font-medium outline-none"
value={language.targetCode}
onChange={handleTargetLangChange}
>
{langCodeISO6393Schema.options.map(key => (
<option key={key} value={key}>
{`${LANG_CODE_TO_EN_NAME[key]} (${LANG_CODE_TO_LOCALE_NAME[key]})`}
</option>
))}
</select>
</div>
</div>
)
}
@@ -0,0 +1,49 @@
import adviceIcon from '@/assets/icon/advice.svg'
import discordIcon from '@/assets/icon/discord.svg'
import helpIcon from '@/assets/icon/help.svg'
const LINKS: {
label: 'help' | 'advice' | 'discord'
icon: string
url: string
}[] = [
{
label: 'help',
icon: helpIcon,
url: 'https://readfrog.app/tutorial/',
},
{
label: 'advice',
icon: adviceIcon,
url: 'https://wj.qq.com/s2/22031975/aea0/',
},
{
label: 'discord',
icon: discordIcon,
url: 'https://discord.gg/ej45e3PezJ',
},
]
export default function QuickLinks() {
return (
<div className="flex justify-between">
{LINKS.map(link => (
<LinkCard key={link.url} link={link} />
))}
</div>
)
}
function LinkCard({ link }: { link: (typeof LINKS)[number] }) {
return (
<a
href={link.url}
target="_blank"
rel="noreferrer"
className="border-border bg-input/50 hover:bg-input flex w-20 flex-col items-center justify-center gap-1.5 rounded-md border py-3 text-sm"
>
<img src={link.icon} alt={link.label} className="size-5" />
{i18n.t(`popup.${link.label}`)}
</a>
)
}
@@ -0,0 +1,39 @@
import { useAtomValue } from 'jotai'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { configFields } from '@/utils/atoms/config'
import { isAnyAPIKey } from '@/utils/config/config'
import { isIgnoreTabAtom } from '../atom'
export default function ReadButton({ className }: { className?: string }) {
const isIgnoreTab = useAtomValue(isIgnoreTabAtom)
const providersConfig = useAtomValue(configFields.providersConfig)
const requestReadArticle = async () => {
if (!isAnyAPIKey(providersConfig)) {
toast.error(i18n.t('noConfig.warning'))
return
}
const [currentTab] = await browser.tabs.query({
active: true,
currentWindow: true,
})
if (currentTab.id) {
sendMessage('popupRequestReadArticle', {
tabId: currentTab.id,
})
}
}
return (
<Button
onClick={requestReadArticle}
className={cn('border-primary', className)}
disabled={isIgnoreTab}
>
{i18n.t('popup.read')}
</Button>
)
}
@@ -0,0 +1,57 @@
import type { ReadProviderNames } from '@/types/config/provider'
import { useAtom } from 'jotai'
import { CircleHelp } from 'lucide-react'
import ProviderIcon from '@/components/provider-icon'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { configFields } from '@/utils/atoms/config'
import { READ_PROVIDER_ITEMS } from '@/utils/constants/config'
export default function ReadProviderSelector() {
const [readConfig, setReadConfig] = useAtom(configFields.read)
return (
<div className="flex items-center justify-between gap-2">
<span className="text-[13px] font-medium flex items-center gap-1.5">
{i18n.t('readService.title')}
<Tooltip>
<TooltipTrigger asChild>
<CircleHelp className="size-3 text-blue-300 dark:text-blue-700/70" />
</TooltipTrigger>
<TooltipContent>
<p>
{i18n.t('readService.description')}
</p>
</TooltipContent>
</Tooltip>
</span>
<Select
value={readConfig.provider}
onValueChange={(value: ReadProviderNames) => {
setReadConfig({
...readConfig,
provider: value,
})
}}
>
<SelectTrigger className="bg-input/50 hover:bg-input !h-7 w-31 cursor-pointer pr-1.5 pl-2.5 outline-none">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(READ_PROVIDER_ITEMS).map(([value, { logo, name }]) => (
<SelectItem key={value} value={value}>
<ProviderIcon logo={logo} name={name} />
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)
}
@@ -0,0 +1,59 @@
import type { translateProviderModels } from '@/types/config/provider'
import { useAtom, useAtomValue } from 'jotai'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { pureTranslateProvider } from '@/types/config/provider'
import { configFields } from '@/utils/atoms/config'
import { isPageTranslatedAtom } from '@/utils/atoms/translation'
import { hasSetAPIKey } from '@/utils/config/config'
import { isIgnoreTabAtom } from '../atom'
export default function TranslateButton({ className }: { className?: string }) {
const [isPageTranslated, setIsPageTranslated] = useAtom(isPageTranslatedAtom)
const isIgnoreTab = useAtomValue(isIgnoreTabAtom)
const providersConfig = useAtomValue(configFields.providersConfig)
const translateConfig = useAtomValue(configFields.translate)
const toggleTranslation = async () => {
const [currentTab] = await browser.tabs.query({
active: true,
currentWindow: true,
})
if (currentTab.id) {
if (!isPageTranslated) {
const provider = translateConfig.provider
const isPure = pureTranslateProvider.includes(
provider as typeof pureTranslateProvider[number],
)
if (!isPure && !hasSetAPIKey(provider as keyof typeof translateProviderModels, providersConfig)) {
toast.error(i18n.t('noConfig.warning'))
return
}
}
sendMessage('setEnablePageTranslation', {
tabId: currentTab.id,
enabled: !isPageTranslated,
})
setIsPageTranslated(prev => !prev)
}
}
return (
<Button
onClick={toggleTranslation}
disabled={isIgnoreTab}
variant="outline"
className={cn('border-primary', className)}
>
{isPageTranslated
? i18n.t('popup.showOriginal')
: i18n.t('popup.translate')}
</Button>
)
}
@@ -0,0 +1,70 @@
import type { TranslateProviderNames } from '@/types/config/provider'
import { useAtom } from 'jotai'
import { CircleHelp } from 'lucide-react'
import ProviderIcon from '@/components/provider-icon'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { configFields } from '@/utils/atoms/config'
import { LLM_TRANSLATE_PROVIDER_ITEMS, PURE_TRANSLATE_PROVIDER_ITEMS } from '@/utils/constants/config'
export default function TranslateProviderSelector() {
const [translateConfig, setTranslateConfig] = useAtom(configFields.translate)
return (
<div className="flex items-center justify-between gap-2">
<span className="text-[13px] font-medium flex items-center gap-1.5">
{i18n.t('translateService.title')}
<Tooltip>
<TooltipTrigger asChild>
<CircleHelp className="size-3 text-blue-300 dark:text-blue-700/70" />
</TooltipTrigger>
<TooltipContent>
<p>
{i18n.t('translateService.description')}
</p>
</TooltipContent>
</Tooltip>
</span>
<Select
value={translateConfig.provider}
onValueChange={(value: TranslateProviderNames) => {
setTranslateConfig({
...translateConfig,
provider: value,
})
}}
>
<SelectTrigger className="bg-input/50 hover:bg-input !h-7 w-31 cursor-pointer pr-1.5 pl-2.5 outline-none">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>{i18n.t('translateService.aiTranslator')}</SelectLabel>
{Object.entries(LLM_TRANSLATE_PROVIDER_ITEMS).map(([value, { logo, name }]) => (
<SelectItem key={value} value={value}>
<ProviderIcon logo={logo} name={name} />
</SelectItem>
))}
</SelectGroup>
<SelectGroup>
<SelectLabel>{i18n.t('translateService.normalTranslator')}</SelectLabel>
{Object.entries(PURE_TRANSLATE_PROVIDER_ITEMS).map(([value, { logo, name }]) => (
<SelectItem key={value} value={value}>
<ProviderIcon logo={logo} name={name} />
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
)
}
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Popup | Read Frog</title>
<meta name="manifest.type" content="browser_action" />
</head>
<body>
<div id="root"></div>
<script type="module" src="./main.tsx"></script>
</body>
</html>
+85
View File
@@ -0,0 +1,85 @@
import type { Config } from '@/types/config/config.ts'
import { Provider as JotaiProvider } from 'jotai'
import { useHydrateAtoms } from 'jotai/utils'
import React from 'react'
import ReactDOM from 'react-dom/client'
import { TooltipProvider } from '@/components/ui/tooltip.tsx'
import { configAtom } from '@/utils/atoms/config.ts'
import { isPageTranslatedAtom } from '@/utils/atoms/translation.ts'
import { globalConfig, loadGlobalConfigPromise } from '@/utils/config/config.ts'
import { DEFAULT_CONFIG } from '@/utils/constants/config.ts'
import App from './app.tsx'
import { getIsInPatterns, isCurrentSiteInPatternsAtom } from './atom.ts'
import '@/assets/tailwind/text-small.css'
import '@/assets/tailwind/theme.css'
document.documentElement.classList.toggle(
'dark',
localStorage.theme === 'dark'
|| (!('theme' in localStorage)
&& window.matchMedia('(prefers-color-scheme: dark)').matches),
)
function HydrateAtoms({
initialValues,
children,
}: {
initialValues: [
[typeof configAtom, Config],
[typeof isPageTranslatedAtom, boolean],
[typeof isCurrentSiteInPatternsAtom, boolean],
]
children: React.ReactNode
}) {
useHydrateAtoms(initialValues)
return children
}
async function initApp() {
await loadGlobalConfigPromise
const root = document.getElementById('root')!
root.className = 'text-base antialiased w-[320px] bg-background'
const config = globalConfig ?? DEFAULT_CONFIG
const activeTab = await browser.tabs.query({
active: true,
currentWindow: true,
})
const tabId = activeTab[0].id
let isPageTranslated: boolean = false
if (tabId) {
isPageTranslated
= (await sendMessage('getEnablePageTranslation', {
tabId,
})) ?? false
}
const isInPatterns = tabId
? await getIsInPatterns(config.translate)
: false
ReactDOM.createRoot(root).render(
<React.StrictMode>
<JotaiProvider>
<HydrateAtoms
initialValues={[
[configAtom, config],
[isPageTranslatedAtom, isPageTranslated],
[isCurrentSiteInPatternsAtom, isInPatterns],
]}
>
<TooltipProvider>
<App />
</TooltipProvider>
</HydrateAtoms>
</JotaiProvider>
</React.StrictMode>,
)
}
initApp()
+11
View File
@@ -0,0 +1,11 @@
import FloatingButton from './components/floating-button'
import SideContent from './components/side-content'
export default function App() {
return (
<div className="text-black dark:text-white">
<FloatingButton />
<SideContent />
</div>
)
}
+23
View File
@@ -0,0 +1,23 @@
import { atom, createStore } from 'jotai'
export const store = createStore()
export const isSideOpenAtom = atom(false)
export const progressAtom = atom({
completed: 0,
total: 0,
})
// Translation port atom for browser.runtime.connect
export const translationPortAtom = atom<Browser.runtime.Port | null>(null)
export const enablePageTranslationAtom = atom(false)
// export const explainAtom = atomWithMutation(() => ({
// mutationKey: ["explainArticle"],
// mutationFn: mutationFn,
// }));
export const readStateAtom = atom<
'extracting' | 'analyzing' | 'continue?' | 'explaining' | undefined
>(undefined)
@@ -0,0 +1,22 @@
import type { LucideIcon } from 'lucide-react'
export default function HiddenButton({
Icon,
onClick,
children,
}: {
Icon: LucideIcon
onClick: () => void
children?: React.ReactNode
}) {
return (
<button
type="button"
className="border-border mr-2 translate-x-12 cursor-pointer rounded-full border bg-white p-1.5 text-neutral-600 dark:text-neutral-400 transition-transform duration-300 group-hover:translate-x-0 hover:bg-neutral-100 dark:bg-neutral-900 dark:hover:bg-neutral-800"
onClick={onClick}
>
<Icon className="h-5 w-5" strokeWidth={1.8} />
{children}
</button>
)
}
@@ -0,0 +1,42 @@
import { useAtomValue, useSetAtom } from 'jotai'
import { BookOpenText } from 'lucide-react'
import { toast } from 'sonner'
import { useExtractContent } from '@/hooks/read/extract'
import { useReadArticle } from '@/hooks/read/read'
import { configFields } from '@/utils/atoms/config'
import { isAnyAPIKey } from '@/utils/config/config'
import { isSideOpenAtom } from '../../atoms'
import HiddenButton from './components/hidden-button'
export default function FloatingReadButton() {
const providersConfig = useAtomValue(configFields.providersConfig)
const setIsSideOpen = useSetAtom(isSideOpenAtom)
const {
mutate: readArticle,
analyzeContent,
explainArticle,
} = useReadArticle()
const { data: extractedContent } = useExtractContent()
const startReadArticle = () => {
if (!isAnyAPIKey(providersConfig)) {
toast.error(i18n.t('noConfig.warning'))
return
}
setIsSideOpen(true)
if (!extractedContent) {
toast.warning('Waiting content to be extracted...')
return
}
if (analyzeContent.isPending || explainArticle.isPending) {
toast.warning('Reading in progress...')
return
}
readArticle()
}
return <HiddenButton Icon={BookOpenText} onClick={startReadArticle} />
}
@@ -0,0 +1,149 @@
import { useAtom, useAtomValue } from 'jotai'
import { Bolt, X } from 'lucide-react'
import { Toaster } from 'sonner'
import readFrogLogo from '@/assets/icon/read-frog.png'
import { configFields } from '@/utils/atoms/config'
import { APP_NAME } from '@/utils/constants/app'
import { isSideOpenAtom } from '../../atoms'
import HiddenButton from './components/hidden-button'
import FloatingReadButton from './floating-read-button'
import TranslateButton from './translate-button'
export default function FloatingButton() {
const [floatingButton, setFloatingButton] = useAtom(
configFields.floatingButton,
)
const sideContent = useAtomValue(configFields.sideContent)
const [isSideOpen, setIsSideOpen] = useAtom(isSideOpenAtom)
const [isDraggingButton, setIsDraggingButton] = useState(false)
// clientY is the top of the icon button
const [initialClientY, setInitialClientY] = useState<number | null>(null)
// 按钮拖动处理
useEffect(() => {
if (!isDraggingButton || !initialClientY || !floatingButton)
return
const handleMouseMove = (e: MouseEvent) => {
const initialY = floatingButton.position * window.innerHeight
const newY = Math.max(
30,
Math.min(
window.innerHeight - 100,
initialY + e.clientY - initialClientY,
),
)
const newPosition = newY / window.innerHeight
setFloatingButton({ position: newPosition })
}
const handleMouseUp = () => {
setIsDraggingButton(false)
}
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
document.body.style.userSelect = 'none'
return () => {
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
document.body.style.userSelect = ''
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isDraggingButton, initialClientY])
const handleButtonDragStart = (e: React.MouseEvent) => {
// 记录初始位置,用于后续判断是点击还是拖动
setInitialClientY(e.clientY)
let hasMoved = false // 标记是否发生了移动
e.preventDefault()
setIsDraggingButton(true)
// 创建一个监听器检测移动
const handleMouseMove = (moveEvent: MouseEvent) => {
const moveDistance = Math.abs(moveEvent.clientY - e.clientY)
// 如果移动距离大于阈值,标记为已移动
if (moveDistance > 5) {
hasMoved = true
}
}
document.addEventListener('mousemove', handleMouseMove)
// 在鼠标释放时,只有未移动才触发点击事件
const handleMouseUp = () => {
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
// 只有未移动过才触发点击
if (!hasMoved) {
setIsSideOpen(o => !o)
}
}
document.addEventListener('mouseup', handleMouseUp, { once: true })
return () => {
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
}
}
return (
floatingButton.enabled && (
<div
className="group fixed z-[2147483647] flex flex-col items-end gap-2"
style={{
right: isSideOpen
? `calc(${sideContent.width}px + var(--removed-body-scroll-bar-size, 0px))`
: 'var(--removed-body-scroll-bar-size, 0px)',
top: `${floatingButton.position * 100}vh`,
}}
>
<FloatingReadButton />
<TranslateButton />
<div
className={cn(
'border-border flex h-10 w-15 items-center rounded-l-full border border-r-0 bg-white opacity-60 shadow-lg group-hover:opacity-100 dark:bg-neutral-900',
'translate-x-5 transition-transform duration-300 group-hover:translate-x-0',
isSideOpen && 'opacity-100',
isDraggingButton ? 'cursor-move' : 'cursor-pointer',
)}
onMouseDown={handleButtonDragStart}
>
<div
title="Close floating button"
className={cn(
'border-border absolute -top-1 -left-1 hidden cursor-pointer rounded-full border bg-neutral-100 dark:bg-neutral-900',
'group-hover:block',
)}
onMouseDown={e => e.stopPropagation()} // 父级不会收到 mousedown
onClick={(e) => {
e.stopPropagation() // 父级不会收到 click
setFloatingButton({ enabled: false })
}}
>
<X className="h-3 w-3 text-neutral-400 dark:text-neutral-600" />
</div>
<img
src={readFrogLogo}
alt={APP_NAME}
className="ml-[5px] h-8 w-8 rounded-full"
/>
</div>
<HiddenButton
Icon={Bolt}
onClick={() => {
sendMessage('openOptionsPage', undefined)
}}
/>
<Toaster richColors className="z-[2147483647]" duration={10000} />
</div>
)
)
}
@@ -0,0 +1,52 @@
import type { translateProviderModels } from '@/types/config/provider'
import { useAtomValue } from 'jotai'
import { Check, Languages } from 'lucide-react'
import { toast } from 'sonner'
import { pureTranslateProvider } from '@/types/config/provider'
import { configFields } from '@/utils/atoms/config'
import { hasSetAPIKey } from '@/utils/config/config'
import { removeAllTranslatedWrapperNodes } from '@/utils/host/translate/node-manipulation'
import { enablePageTranslationAtom } from '../../atoms'
import HiddenButton from './components/hidden-button'
export default function TranslateButton() {
const enablePageTranslation = useAtomValue(enablePageTranslationAtom)
const providersConfig = useAtomValue(configFields.providersConfig)
const translateConfig = useAtomValue(configFields.translate)
return (
<HiddenButton
Icon={Languages}
onClick={() => {
const provider = translateConfig.provider
const isPure = pureTranslateProvider.includes(
provider as typeof pureTranslateProvider[number],
)
if (!isPure && !hasSetAPIKey(provider as keyof typeof translateProviderModels, providersConfig)) {
toast.error(i18n.t('noConfig.warning'))
return
}
if (!enablePageTranslation) {
sendMessage('setEnablePageTranslationOnContentScript', {
enabled: true,
})
}
else {
removeAllTranslatedWrapperNodes()
sendMessage('setEnablePageTranslationOnContentScript', {
enabled: false,
})
}
}}
>
<Check
className={cn(
'absolute -right-0.5 -bottom-0.5 h-3 w-3 rounded-full bg-green-500 text-white',
enablePageTranslation ? 'block' : 'hidden',
)}
/>
</HiddenButton>
)
}
@@ -0,0 +1,132 @@
import type { ArticleAnalysis, ArticleExplanation } from '@/types/content'
import { useMutationState } from '@tanstack/react-query'
import { useAtom, useAtomValue } from 'jotai'
import { toast } from 'sonner'
import LoadingDots from '@/components/loading-dots'
import { Button } from '@/components/ui/button'
import { Progress } from '@/components/ui/progress'
import { ScrollArea } from '@/components/ui/scroll-area'
import { useExtractContent } from '@/hooks/read/extract'
import { useExplainArticle } from '@/hooks/read/read'
import { progressAtom, readStateAtom } from '../../atoms'
import Explanation from './explanation'
export default function Content() {
const progress = useAtomValue(progressAtom)
const [readState, setReadState] = useAtom(readStateAtom)
const { isPending: isExtractingContent, data: extractedContent }
= useExtractContent()
const { mutate: explainArticle } = useExplainArticle()
const explainDataList = useMutationState({
filters: {
mutationKey: ['explainArticle'],
},
select: mutation => mutation.state.data,
})
const analyzeContentDataList = useMutationState({
filters: {
mutationKey: ['analyzeContent'],
},
select: mutation => mutation.state.data,
})
// const handleReadForMe = () => {
// if (!extractedContent?.paragraphs.join("\n").trim()) {
// toast.error("Cannot read the content: content is not available");
// return;
// }
// readArticle(extractedContent);
// };
const handleContinue = () => {
const analyzeContentData
= analyzeContentDataList[analyzeContentDataList.length - 1]
if (extractedContent && analyzeContentData) {
explainArticle({
extractedContent,
articleAnalysis: analyzeContentData as ArticleAnalysis,
})
setReadState('explaining')
}
else {
toast.error('Cannot generate the explanation: content is not available')
}
}
if (isExtractingContent) {
return (
<div className="flex h-full w-full flex-1 items-center justify-center gap-x-2 p-4">
<LoadingDots />
Extracting content...
</div>
)
}
if (readState === 'analyzing') {
return (
<div className="flex h-full w-full flex-1 items-center justify-center gap-x-2 p-4">
<LoadingDots />
Analyzing...
</div>
)
}
if (readState === 'continue?') {
return (
<div className="flex h-full w-full flex-1 items-center justify-center gap-x-2 p-4">
<div className="flex flex-col gap-6">
<p>
The content does not appear to be an article or book. Are you sure
you want to proceed?
</p>
<Button className="mx-auto" onClick={handleContinue}>
Continue
</Button>
</div>
</div>
)
}
if (readState === 'explaining') {
return (
<div className="flex h-full w-full flex-1 items-center justify-center gap-x-2 p-4">
<div className="flex flex-col gap-6">
<div className="flex items-center gap-2">
<LoadingDots />
Generating...
</div>
<Progress
value={
progress.total !== 0
? (progress.completed / progress.total) * 100
: 0
}
/>
</div>
</div>
)
}
return (
<>
{explainDataList.length > 0
&& explainDataList[explainDataList.length - 1]
? (
<ScrollArea className="h-full flex-1">
<Explanation
articleExplanation={
explainDataList[
explainDataList.length - 1
] as ArticleExplanation['paragraphs']
}
/>
</ScrollArea>
)
: null}
</>
)
}
@@ -0,0 +1,93 @@
/* eslint-disable react/no-array-index-key */
import type { ArticleExplanation } from '../../../../types/content'
function createCumulativeIndexMap(articleExplanation: ArticleExplanation['paragraphs']): number[] {
const cumulativeIndexes: number[] = []
let total = 0
for (const paragraph of articleExplanation) {
cumulativeIndexes.push(total)
total += paragraph.length
}
return cumulativeIndexes
}
function getGlobalIndex(pIndex: number, sIndex: number, cumulativeIndexes: number[]): number {
return cumulativeIndexes[pIndex] + sIndex
}
export default function Explanation({
articleExplanation,
}: {
articleExplanation: ArticleExplanation['paragraphs']
}) {
const cumulativeIndexes = createCumulativeIndexMap(articleExplanation)
return (
<>
{articleExplanation.map((paragraph, pIndex) => (
<div key={pIndex}>
{paragraph.map((sentence, sIndex) => {
const globalIndex = getGlobalIndex(
pIndex,
sIndex,
cumulativeIndexes,
)
return (
<div
key={`${pIndex}-${sIndex}`}
className={cn(
'px-3 py-2',
globalIndex % 2 === 0
? 'bg-background'
: 'bg-neutral-100 dark:bg-neutral-800', // TODO: move this to variable
)}
>
<div className="mb-2 inline-block bg-amber-400 px-1 py-0.5 text-sm dark:bg-amber-600">
Sentence
{' '}
{globalIndex + 1}
</div>
<div className="mb-1 text-base font-semibold">
{sentence.originalSentence}
</div>
<div className="text-muted-foreground mb-2 text-base">
{sentence.translatedSentence}
</div>
<div className="mb-2 flex items-center text-sm font-medium text-blue-500">
<div className="h-4 w-1 bg-blue-500"></div>
<span className="ml-2">Key Words</span>
</div>
{sentence.words.length > 0 && (
<ul className="mb-2 space-y-2">
{sentence.words.map((word, wIndex) => (
<li key={wIndex} className="text-sm">
<div className="mb-1 flex flex-wrap items-center gap-x-2">
<span className="font-semibold">
{wIndex + 1}
.
{word.word}
</span>
<span className="text-muted-foreground">
{word.syntacticCategory}
</span>
</div>
{word.explanation}
</li>
))}
</ul>
)}
<div className="mb-2 flex items-center text-sm font-medium text-blue-500">
<div className="h-4 w-1 bg-blue-500"></div>
<span className="ml-2">Explanation</span>
</div>
<div className="text-sm">{sentence.explanation}</div>
</div>
)
})}
</div>
))}
</>
)
}
@@ -0,0 +1,128 @@
import { kebabCase } from 'case-anything'
import { useAtom, useAtomValue } from 'jotai'
import { Toaster } from 'sonner'
import { APIConfigWarning } from '@/components/api-config-warning'
import { configFields } from '@/utils/atoms/config'
import { isAnyAPIKey } from '@/utils/config/config'
import { APP_NAME } from '@/utils/constants/app'
import { MIN_SIDE_CONTENT_WIDTH } from '../../../../utils/constants/side'
import { isSideOpenAtom } from '../../atoms'
import Content from './content'
import { Metadata } from './metadata'
import { TopBar } from './top-bar'
export default function SideContent() {
const isSideOpen = useAtomValue(isSideOpenAtom)
const [sideContent, setSideContent] = useAtom(configFields.sideContent)
const [isResizing, setIsResizing] = useState(false)
const providersConfig = useAtomValue(configFields.providersConfig)
// Setup resize handlers
useEffect(() => {
if (!isResizing)
return
const handleMouseMove = (e: MouseEvent) => {
if (!isResizing)
return
const windowWidth = window.innerWidth
const newWidth = windowWidth - e.clientX
const clampedWidth = Math.max(MIN_SIDE_CONTENT_WIDTH, newWidth)
setSideContent({ width: clampedWidth })
}
const handleMouseUp = () => {
setIsResizing(false)
}
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
document.body.style.userSelect = 'none'
return () => {
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
document.body.style.userSelect = ''
}
}, [isResizing, setSideContent])
// HTML width adjustment
useEffect(() => {
const styleId = `shrink-origin-for-${kebabCase(APP_NAME)}-side-content`
let styleTag = document.getElementById(styleId)
if (isSideOpen) {
if (!styleTag) {
styleTag = document.createElement('style')
styleTag.id = styleId
document.head.appendChild(styleTag)
}
styleTag.textContent = `
html {
width: calc(100% - ${sideContent.width}px) !important;
position: relative !important;
min-height: 100vh !important;
}
`
}
else {
if (styleTag) {
document.head.removeChild(styleTag)
}
}
return () => {
if (styleTag && document.head.contains(styleTag)) {
document.head.removeChild(styleTag)
}
}
}, [isSideOpen, sideContent.width])
const handleResizeStart = (e: React.MouseEvent) => {
e.preventDefault()
setIsResizing(true)
}
return (
<>
<div
className={cn(
'bg-background fixed top-0 right-0 z-[2147483647] h-full pr-[var(--removed-body-scroll-bar-size,0px)]',
isSideOpen
? 'border-border translate-x-0 border-l'
: 'translate-x-full',
)}
style={{
width: `calc(${sideContent.width}px + var(--removed-body-scroll-bar-size, 0px))`,
}}
>
{/* Resize handle */}
<div
className="absolute top-0 left-0 z-10 h-full w-2 cursor-ew-resize justify-center bg-transparent"
onMouseDown={handleResizeStart}
>
</div>
<div className="flex h-full flex-col gap-y-2 py-3">
<TopBar className="mx-3" />
{!isAnyAPIKey(providersConfig) && (
<APIConfigWarning className="mx-3" />
)}
<Metadata className="mx-3" />
<Content />
</div>
<Toaster richColors className="z-[2147483647]" duration={10000} />
</div>
{/* Transparent overlay to prevent other events during resizing */}
{isResizing && (
<div className="fixed inset-0 z-[2147483647] cursor-ew-resize bg-transparent" />
)}
</>
)
}
@@ -0,0 +1,95 @@
import { useMutationState } from '@tanstack/react-query'
import { useSetAtom } from 'jotai'
import { BookOpenText, RotateCcw } from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { useExtractContent } from '@/hooks/read/extract'
import { useReadArticle } from '@/hooks/read/read'
import { cn } from '@/utils/tailwind'
import { isSideOpenAtom } from '../../atoms'
export function Metadata({ className }: { className?: string }) {
const title = document.title ?? 'Untitled'
const favicon = getFaviconUrl()
const {
mutate: readArticle,
analyzeContent,
explainArticle,
} = useReadArticle()
const { isPending: isExtractingContent, data: extractedContent }
= useExtractContent()
const explainData = useMutationState({
filters: {
mutationKey: ['explainArticle'],
},
select: mutation => mutation.state.data,
})
// TODO: show regenerate button at certain conditions and implement the logic
const setIsSideOpen = useSetAtom(isSideOpenAtom)
useEffect(() => {
const removeListener = onMessage('readArticle', () => {
setIsSideOpen(true)
if (!extractedContent) {
toast.warning('Waiting content to be extracted...')
return
}
if (analyzeContent.isPending || explainArticle.isPending) {
toast.warning('Reading in progress...')
return
}
readArticle()
})
return () => {
removeListener()
}
}, [extractedContent, analyzeContent, explainArticle, setIsSideOpen, readArticle])
return (
<div
className={cn(
'relative flex items-center justify-between gap-x-2 rounded-md bg-neutral-100 px-2 py-2 hover:bg-neutral-200 dark:bg-neutral-900 dark:hover:bg-neutral-800',
className,
)}
>
<div className="flex min-w-0 flex-1 items-center gap-x-2">
{favicon && (
<img
src={favicon}
alt="Favicon"
className="h-4 w-4 flex-shrink-0 rounded-xs"
/>
)}
<h1 className="truncate text-base font-semibold">{title}</h1>
</div>
<Button
size="sm"
onClick={() => extractedContent && readArticle()}
disabled={
isExtractingContent
|| analyzeContent.isPending
|| explainArticle.isPending
}
>
{explainData.length > 0 && explainData[explainData.length - 1]
? (
<>
<RotateCcw className="size-3" />
Regenerate
</>
)
: (
<>
<BookOpenText className="size-3" />
Read
</>
)}
</Button>
</div>
)
}
@@ -0,0 +1,210 @@
import type {
LangCodeISO6393,
LangLevel,
} from '@/types/config/languages'
import type { ReadProviderNames } from '@/types/config/provider'
import { SelectGroup } from '@radix-ui/react-select'
// import { onMessage } from "@/utils/message";
import { useAtom, useSetAtom } from 'jotai'
import { ArrowRight, X } from 'lucide-react'
import ProviderIcon from '@/components/provider-icon'
import {
Select,
SelectContent,
SelectItem,
SelectLabel,
SelectTrigger,
} from '@/components/ui/select'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import {
LANG_CODE_TO_EN_NAME,
LANG_CODE_TO_LOCALE_NAME,
langCodeISO6393Schema,
langLevel,
} from '@/types/config/languages'
import { configFields } from '@/utils/atoms/config'
import { READ_PROVIDER_ITEMS } from '@/utils/constants/config'
import { cn } from '@/utils/tailwind'
import { shadowWrapper } from '../..'
import { isSideOpenAtom } from '../../atoms'
export function TopBar({ className }: { className?: string }) {
const setIsSideOpen = useSetAtom(isSideOpenAtom)
return (
<div className={cn('flex items-start justify-between', className)}>
<div className="flex items-center gap-x-2 text-sm">
<SourceLangSelect />
<ArrowRight size={12} strokeWidth={1} className="-mx-1" />
<TargetLangSelect />
{/* <div className="flex items-center h-7 gap-2 px-2 border rounded-md border-border hover:bg-muted">
<div className="w-1 h-1 rounded-full bg-orange-500"></div>
<span className="">{i18n.t(`languageLevels.${langLevel}`)}</span>
</div> */}
<LangLevelSelect />
<ProviderSelect />
</div>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="flex h-4 w-4 cursor-pointer items-center justify-center rounded-full bg-neutral-200 p-0.5 dark:bg-neutral-800"
onClick={() => setIsSideOpen(false)}
>
<X strokeWidth={1.2} className="text-neutral-500" />
</button>
</TooltipTrigger>
<TooltipContent container={shadowWrapper} side="left">
<p>Close</p>
</TooltipContent>
</Tooltip>
</div>
)
}
function ProviderSelect() {
const [readConfig, setReadConfig] = useAtom(configFields.read)
return (
<Select
value={readConfig.provider}
onValueChange={(value: ReadProviderNames) => {
setReadConfig({
...readConfig,
provider: value,
})
}}
>
<SelectTrigger
hideChevron
className="flex !size-7 items-center justify-center p-0"
>
<img
src={READ_PROVIDER_ITEMS[readConfig.provider].logo}
alt={readConfig.provider}
className="size-4 p-0.5 bg-white rounded-full"
/>
</SelectTrigger>
<SelectContent container={shadowWrapper}>
<SelectGroup>
<SelectLabel>{i18n.t('readService.title')}</SelectLabel>
{Object.entries(READ_PROVIDER_ITEMS).map(([provider, { logo, name }]) => (
<SelectItem key={provider} value={provider}>
<ProviderIcon logo={logo} name={name} />
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
)
}
function LangLevelSelect() {
const [language, setLanguage] = useAtom(configFields.language)
return (
<Select
value={language.level}
onValueChange={(newLevel: LangLevel) => setLanguage({ level: newLevel })}
>
<SelectTrigger
hideChevron
className="border-border flex !h-7 w-auto items-center gap-2 rounded-md border px-2"
>
<div className="h-1 w-1 shrink-0 rounded-full bg-orange-500"></div>
<div className="max-w-16 min-w-0 truncate">
{i18n.t(`languageLevels.${language.level}`)}
</div>
</SelectTrigger>
<SelectContent container={shadowWrapper}>
<SelectGroup>
<SelectLabel>{i18n.t('languageLevel')}</SelectLabel>
{langLevel.options.map(level => (
<SelectItem key={level} value={level}>
{i18n.t(`languageLevels.${level}`)}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
)
}
function TargetLangSelect() {
const [language, setLanguage] = useAtom(configFields.language)
return (
<Select
value={language.targetCode}
onValueChange={(newTargetCode: LangCodeISO6393) =>
setLanguage({ targetCode: newTargetCode })}
>
<SelectTrigger
hideChevron
className="border-border flex !h-7 w-auto items-center gap-2 rounded-md border px-2"
>
<div className="h-1 w-1 shrink-0 rounded-full bg-blue-500"></div>
<div className="max-w-16 min-w-0 truncate">
{`${LANG_CODE_TO_EN_NAME[language.targetCode]} (${LANG_CODE_TO_LOCALE_NAME[language.targetCode]})`}
</div>
</SelectTrigger>
<SelectContent container={shadowWrapper}>
<SelectGroup>
<SelectLabel>{i18n.t('side.targetLang')}</SelectLabel>
{langCodeISO6393Schema.options.map(key => (
<SelectItem key={key} value={key}>
{`${LANG_CODE_TO_EN_NAME[key]} (${LANG_CODE_TO_LOCALE_NAME[key]})`}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
)
}
function SourceLangSelect() {
const [language, setLanguage] = useAtom(configFields.language)
return (
<Select
value={language.sourceCode}
onValueChange={(newSourceCode: LangCodeISO6393 | 'auto') =>
setLanguage({ sourceCode: newSourceCode })}
>
<SelectTrigger
hideChevron
className="border-border flex !h-7 w-auto items-center gap-2 rounded-md border px-2"
>
<div className="h-1 w-1 shrink-0 rounded-full bg-blue-500"></div>
<div className="max-w-16 min-w-0 truncate">
{language.sourceCode === 'auto'
? LANG_CODE_TO_EN_NAME[language.detectedCode]
: LANG_CODE_TO_EN_NAME[language.sourceCode]}
</div>
</SelectTrigger>
<SelectContent container={shadowWrapper}>
<SelectGroup>
<SelectLabel>{i18n.t('side.sourceLang')}</SelectLabel>
<SelectItem value="auto">
{`${LANG_CODE_TO_EN_NAME[language.detectedCode]} (${LANG_CODE_TO_LOCALE_NAME[language.detectedCode]})`}
<span className="rounded-full bg-neutral-200 px-1 text-xs dark:bg-neutral-800">
auto
</span>
</SelectItem>
{langCodeISO6393Schema.options.map(key => (
<SelectItem key={key} value={key}>
{`${LANG_CODE_TO_EN_NAME[key]} (${LANG_CODE_TO_LOCALE_NAME[key]})`}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
)
}

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