Spaces:
Sleeping
Sleeping
Commit
·
88b6846
0
Parent(s):
TTS Dataset Collector for HF Spaces
Browse files- .dockerignore +12 -0
- .gitignore +44 -0
- Dockerfile +65 -0
- README.md +47 -0
- docker-compose.yml +13 -0
- eslint.config.mjs +18 -0
- next.config.ts +18 -0
- package-lock.json +0 -0
- package.json +37 -0
- postcss.config.js +6 -0
- public/file.svg +1 -0
- public/globe.svg +1 -0
- public/next.svg +1 -0
- public/vercel.svg +1 -0
- public/window.svg +1 -0
- src/app/api/bookmarks/route.ts +103 -0
- src/app/api/dataset-stats/route.ts +60 -0
- src/app/api/export-dataset/route.ts +76 -0
- src/app/api/fonts/[filename]/route.ts +48 -0
- src/app/api/fonts/route.ts +46 -0
- src/app/api/save-recording/route.ts +104 -0
- src/app/api/skip-recording/route.ts +66 -0
- src/app/api/upload-font/route.ts +45 -0
- src/app/favicon.ico +0 -0
- src/app/globals.css +85 -0
- src/app/layout.tsx +43 -0
- src/app/page.module.css +141 -0
- src/app/page.tsx +429 -0
- src/components/AudioRecorder.tsx +406 -0
- src/components/DatasetStats.tsx +121 -0
- src/components/FontSelector.tsx +163 -0
- src/components/HelpModal.tsx +121 -0
- src/components/Providers.tsx +12 -0
- src/components/SettingsModal.tsx +124 -0
- src/components/TextInput.tsx +111 -0
- src/components/ui/badge.tsx +39 -0
- src/components/ui/card.tsx +78 -0
- src/components/ui/slider.tsx +32 -0
- src/components/ui/switch.tsx +33 -0
- src/hooks/useKeyboardShortcuts.ts +41 -0
- src/hooks/useLocalStorage.ts +31 -0
- src/instrumentation.ts +26 -0
- src/lib/cleanup.ts +195 -0
- src/lib/dataPath.ts +126 -0
- src/lib/language.ts +30 -0
- src/lib/utils.ts +6 -0
- start_app.bat +9 -0
- tailwind.config.ts +61 -0
- tsconfig.json +34 -0
.dockerignore
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
node_modules
|
| 2 |
+
.next
|
| 3 |
+
.git
|
| 4 |
+
.vs
|
| 5 |
+
dataset/audio
|
| 6 |
+
dataset/transcriptions
|
| 7 |
+
dataset/metadata/dataset_info.json
|
| 8 |
+
*.log
|
| 9 |
+
npm-debug.log*
|
| 10 |
+
yarn-debug.log*
|
| 11 |
+
yarn-error.log*
|
| 12 |
+
.pnpm-debug.log*
|
.gitignore
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
| 2 |
+
|
| 3 |
+
# dependencies
|
| 4 |
+
/node_modules
|
| 5 |
+
/.pnp
|
| 6 |
+
.pnp.*
|
| 7 |
+
.yarn/*
|
| 8 |
+
!.yarn/patches
|
| 9 |
+
!.yarn/plugins
|
| 10 |
+
!.yarn/releases
|
| 11 |
+
!.yarn/versions
|
| 12 |
+
|
| 13 |
+
# testing
|
| 14 |
+
/coverage
|
| 15 |
+
|
| 16 |
+
# next.js
|
| 17 |
+
/.next/
|
| 18 |
+
/out/
|
| 19 |
+
|
| 20 |
+
# production
|
| 21 |
+
/build
|
| 22 |
+
|
| 23 |
+
# misc
|
| 24 |
+
.DS_Store
|
| 25 |
+
*.pem
|
| 26 |
+
|
| 27 |
+
# debug
|
| 28 |
+
npm-debug.log*
|
| 29 |
+
yarn-debug.log*
|
| 30 |
+
yarn-error.log*
|
| 31 |
+
.pnpm-debug.log*
|
| 32 |
+
|
| 33 |
+
# env files (can opt-in for committing if needed)
|
| 34 |
+
.env*
|
| 35 |
+
|
| 36 |
+
# vercel
|
| 37 |
+
.vercel
|
| 38 |
+
|
| 39 |
+
# typescript
|
| 40 |
+
*.tsbuildinfo
|
| 41 |
+
next-env.d.ts
|
| 42 |
+
|
| 43 |
+
# Dataset folder (user data - created at runtime)
|
| 44 |
+
/dataset/
|
Dockerfile
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM node:18-alpine AS base
|
| 2 |
+
|
| 3 |
+
# Install dependencies only when needed
|
| 4 |
+
FROM base AS deps
|
| 5 |
+
WORKDIR /app
|
| 6 |
+
|
| 7 |
+
# Copy package.json and package-lock.json
|
| 8 |
+
COPY package.json package-lock.json* ./
|
| 9 |
+
|
| 10 |
+
# Install dependencies
|
| 11 |
+
RUN npm ci
|
| 12 |
+
|
| 13 |
+
# Rebuild the source code only when needed
|
| 14 |
+
FROM base AS builder
|
| 15 |
+
WORKDIR /app
|
| 16 |
+
COPY --from=deps /app/node_modules ./node_modules
|
| 17 |
+
COPY . .
|
| 18 |
+
|
| 19 |
+
# Set environment variable for build
|
| 20 |
+
ENV NEXT_TELEMETRY_DISABLED=1
|
| 21 |
+
|
| 22 |
+
# Build the application
|
| 23 |
+
RUN npm run build
|
| 24 |
+
|
| 25 |
+
# Production image, copy all the files and run next
|
| 26 |
+
FROM base AS runner
|
| 27 |
+
WORKDIR /app
|
| 28 |
+
|
| 29 |
+
ENV NODE_ENV=production
|
| 30 |
+
ENV NEXT_TELEMETRY_DISABLED=1
|
| 31 |
+
|
| 32 |
+
# Create a non-root user with UID 1000 (required for HF Spaces)
|
| 33 |
+
RUN addgroup --system --gid 1001 nodejs
|
| 34 |
+
RUN adduser --system --uid 1000 nextjs
|
| 35 |
+
|
| 36 |
+
# Copy public folder
|
| 37 |
+
COPY --from=builder /app/public ./public
|
| 38 |
+
|
| 39 |
+
# Copy built application
|
| 40 |
+
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
| 41 |
+
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
| 42 |
+
|
| 43 |
+
# Create /data directory for HF Spaces persistent storage
|
| 44 |
+
# This directory is mounted at runtime on HF Spaces
|
| 45 |
+
RUN mkdir -p /data && chmod 777 /data
|
| 46 |
+
|
| 47 |
+
# Create local dataset directory as fallback (for non-HF environments)
|
| 48 |
+
RUN mkdir -p /app/dataset && chown nextjs:nodejs /app/dataset
|
| 49 |
+
|
| 50 |
+
# Set default data directory (overridden by HF Spaces when /data is available)
|
| 51 |
+
ENV DATA_DIR=/data
|
| 52 |
+
|
| 53 |
+
USER nextjs
|
| 54 |
+
|
| 55 |
+
# HF Spaces uses port 7860 by default
|
| 56 |
+
EXPOSE 7860
|
| 57 |
+
|
| 58 |
+
ENV PORT=7860
|
| 59 |
+
ENV HOSTNAME="0.0.0.0"
|
| 60 |
+
|
| 61 |
+
# Health check for container monitoring
|
| 62 |
+
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
| 63 |
+
CMD wget --no-verbose --tries=1 --spider http://localhost:7860/ || exit 1
|
| 64 |
+
|
| 65 |
+
CMD ["node", "server.js"]
|
README.md
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: TTS Dataset Collector
|
| 3 |
+
emoji: 🎙️
|
| 4 |
+
colorFrom: purple
|
| 5 |
+
colorTo: blue
|
| 6 |
+
sdk: docker
|
| 7 |
+
app_file: Dockerfile
|
| 8 |
+
app_port: 7860
|
| 9 |
+
pinned: false
|
| 10 |
+
---
|
| 11 |
+
|
| 12 |
+
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
| 13 |
+
|
| 14 |
+
## Getting Started
|
| 15 |
+
|
| 16 |
+
First, run the development server:
|
| 17 |
+
|
| 18 |
+
```bash
|
| 19 |
+
npm run dev
|
| 20 |
+
# or
|
| 21 |
+
yarn dev
|
| 22 |
+
# or
|
| 23 |
+
pnpm dev
|
| 24 |
+
# or
|
| 25 |
+
bun dev
|
| 26 |
+
```
|
| 27 |
+
|
| 28 |
+
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
| 29 |
+
|
| 30 |
+
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
| 31 |
+
|
| 32 |
+
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
| 33 |
+
|
| 34 |
+
## Learn More
|
| 35 |
+
|
| 36 |
+
To learn more about Next.js, take a look at the following resources:
|
| 37 |
+
|
| 38 |
+
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
| 39 |
+
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
| 40 |
+
|
| 41 |
+
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
| 42 |
+
|
| 43 |
+
## Deploy on Vercel
|
| 44 |
+
|
| 45 |
+
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
| 46 |
+
|
| 47 |
+
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
docker-compose.yml
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version: '3.8'
|
| 2 |
+
|
| 3 |
+
services:
|
| 4 |
+
tts-collector:
|
| 5 |
+
container_name: tts-dataset-collector
|
| 6 |
+
build: .
|
| 7 |
+
ports:
|
| 8 |
+
- "3000:3000"
|
| 9 |
+
volumes:
|
| 10 |
+
- ./dataset:/app/dataset
|
| 11 |
+
restart: unless-stopped
|
| 12 |
+
environment:
|
| 13 |
+
- NODE_ENV=production
|
eslint.config.mjs
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { defineConfig, globalIgnores } from "eslint/config";
|
| 2 |
+
import nextVitals from "eslint-config-next/core-web-vitals";
|
| 3 |
+
import nextTs from "eslint-config-next/typescript";
|
| 4 |
+
|
| 5 |
+
const eslintConfig = defineConfig([
|
| 6 |
+
...nextVitals,
|
| 7 |
+
...nextTs,
|
| 8 |
+
// Override default ignores of eslint-config-next.
|
| 9 |
+
globalIgnores([
|
| 10 |
+
// Default ignores of eslint-config-next:
|
| 11 |
+
".next/**",
|
| 12 |
+
"out/**",
|
| 13 |
+
"build/**",
|
| 14 |
+
"next-env.d.ts",
|
| 15 |
+
]),
|
| 16 |
+
]);
|
| 17 |
+
|
| 18 |
+
export default eslintConfig;
|
next.config.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { NextConfig } from "next";
|
| 2 |
+
|
| 3 |
+
const nextConfig: NextConfig = {
|
| 4 |
+
output: 'standalone',
|
| 5 |
+
env: {
|
| 6 |
+
DATA_DIR: process.env.DATA_DIR || '',
|
| 7 |
+
},
|
| 8 |
+
// Ensure server-side code can access environment variables
|
| 9 |
+
serverRuntimeConfig: {
|
| 10 |
+
DATA_DIR: process.env.DATA_DIR || '',
|
| 11 |
+
},
|
| 12 |
+
// Enable instrumentation for cleanup scheduler
|
| 13 |
+
experimental: {
|
| 14 |
+
instrumentationHook: true,
|
| 15 |
+
},
|
| 16 |
+
};
|
| 17 |
+
|
| 18 |
+
export default nextConfig;
|
package-lock.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "polar-interstellar",
|
| 3 |
+
"version": "0.1.0",
|
| 4 |
+
"private": true,
|
| 5 |
+
"scripts": {
|
| 6 |
+
"dev": "next dev",
|
| 7 |
+
"build": "next build",
|
| 8 |
+
"start": "next start",
|
| 9 |
+
"lint": "eslint"
|
| 10 |
+
},
|
| 11 |
+
"dependencies": {
|
| 12 |
+
"@types/archiver": "^7.0.0",
|
| 13 |
+
"archiver": "^7.0.1",
|
| 14 |
+
"class-variance-authority": "^0.7.1",
|
| 15 |
+
"clsx": "^2.1.1",
|
| 16 |
+
"framer-motion": "^12.23.24",
|
| 17 |
+
"franc": "^6.2.0",
|
| 18 |
+
"jszip": "^3.10.1",
|
| 19 |
+
"lucide-react": "^0.555.0",
|
| 20 |
+
"next": "16.0.5",
|
| 21 |
+
"react": "19.2.0",
|
| 22 |
+
"react-dom": "19.2.0",
|
| 23 |
+
"sonner": "^2.0.7",
|
| 24 |
+
"tailwind-merge": "^3.4.0",
|
| 25 |
+
"tailwindcss": "^3.4.18"
|
| 26 |
+
},
|
| 27 |
+
"devDependencies": {
|
| 28 |
+
"@types/node": "^20",
|
| 29 |
+
"@types/react": "^19",
|
| 30 |
+
"@types/react-dom": "^19",
|
| 31 |
+
"autoprefixer": "^10.4.22",
|
| 32 |
+
"eslint": "^9",
|
| 33 |
+
"eslint-config-next": "16.0.5",
|
| 34 |
+
"postcss": "^8.5.6",
|
| 35 |
+
"typescript": "^5"
|
| 36 |
+
}
|
| 37 |
+
}
|
postcss.config.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
module.exports = {
|
| 2 |
+
plugins: {
|
| 3 |
+
tailwindcss: {},
|
| 4 |
+
autoprefixer: {},
|
| 5 |
+
},
|
| 6 |
+
}
|
public/file.svg
ADDED
|
|
public/globe.svg
ADDED
|
|
public/next.svg
ADDED
|
|
public/vercel.svg
ADDED
|
|
public/window.svg
ADDED
|
|
src/app/api/bookmarks/route.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextResponse } from 'next/server';
|
| 2 |
+
import { promises as fs } from 'fs';
|
| 3 |
+
import path from 'path';
|
| 4 |
+
import { getMetadataPath, ensureDir, sanitizePath } from '@/lib/dataPath';
|
| 5 |
+
|
| 6 |
+
export async function POST(request: Request) {
|
| 7 |
+
try {
|
| 8 |
+
const body = await request.json();
|
| 9 |
+
const { speaker_id, dataset_name, index } = body;
|
| 10 |
+
|
| 11 |
+
if (!speaker_id || !dataset_name || index === undefined) {
|
| 12 |
+
return NextResponse.json({ error: 'Missing parameters: speaker_id, dataset_name, and index are required' }, { status: 400 });
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
// Validate index is a number
|
| 16 |
+
if (typeof index !== 'number' || index < 0) {
|
| 17 |
+
return NextResponse.json({ error: 'Invalid index: must be a non-negative number' }, { status: 400 });
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
// Sanitize inputs
|
| 21 |
+
const safeSpeakerId = sanitizePath(speaker_id);
|
| 22 |
+
const safeDatasetName = sanitizePath(dataset_name);
|
| 23 |
+
|
| 24 |
+
const metadataDir = getMetadataPath();
|
| 25 |
+
await ensureDir(metadataDir);
|
| 26 |
+
const metadataPath = path.join(metadataDir, 'dataset_info.json');
|
| 27 |
+
|
| 28 |
+
let datasetInfo: Record<string, unknown> = { speakers: {} };
|
| 29 |
+
try {
|
| 30 |
+
const content = await fs.readFile(metadataPath, 'utf-8');
|
| 31 |
+
datasetInfo = JSON.parse(content);
|
| 32 |
+
} catch {
|
| 33 |
+
// File might not exist
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
// Ensure nested structure exists
|
| 37 |
+
const speakers = (datasetInfo.speakers || {}) as Record<string, { datasets?: Record<string, { bookmarks?: number[] }> }>;
|
| 38 |
+
if (!speakers[safeSpeakerId]) {
|
| 39 |
+
speakers[safeSpeakerId] = { datasets: {} };
|
| 40 |
+
}
|
| 41 |
+
if (!speakers[safeSpeakerId].datasets) {
|
| 42 |
+
speakers[safeSpeakerId].datasets = {};
|
| 43 |
+
}
|
| 44 |
+
if (!speakers[safeSpeakerId].datasets![safeDatasetName]) {
|
| 45 |
+
speakers[safeSpeakerId].datasets![safeDatasetName] = { bookmarks: [] };
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
const ds = speakers[safeSpeakerId].datasets![safeDatasetName];
|
| 49 |
+
if (!ds.bookmarks) ds.bookmarks = [];
|
| 50 |
+
|
| 51 |
+
// Toggle bookmark
|
| 52 |
+
if (ds.bookmarks.includes(index)) {
|
| 53 |
+
ds.bookmarks = ds.bookmarks.filter((i: number) => i !== index);
|
| 54 |
+
} else {
|
| 55 |
+
ds.bookmarks.push(index);
|
| 56 |
+
// Sort bookmarks for consistency
|
| 57 |
+
ds.bookmarks.sort((a, b) => a - b);
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
datasetInfo.speakers = speakers;
|
| 61 |
+
await fs.writeFile(metadataPath, JSON.stringify(datasetInfo, null, 2));
|
| 62 |
+
|
| 63 |
+
return NextResponse.json({ success: true, bookmarks: ds.bookmarks });
|
| 64 |
+
|
| 65 |
+
} catch (error) {
|
| 66 |
+
console.error('Error toggling bookmark:', error);
|
| 67 |
+
return NextResponse.json({
|
| 68 |
+
error: 'Internal Server Error',
|
| 69 |
+
details: error instanceof Error ? error.message : 'Unknown error'
|
| 70 |
+
}, { status: 500 });
|
| 71 |
+
}
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
export async function GET(request: Request) {
|
| 75 |
+
try {
|
| 76 |
+
const { searchParams } = new URL(request.url);
|
| 77 |
+
const speaker_id = searchParams.get('speaker_id');
|
| 78 |
+
const dataset_name = searchParams.get('dataset_name');
|
| 79 |
+
|
| 80 |
+
if (!speaker_id || !dataset_name) {
|
| 81 |
+
return NextResponse.json({ bookmarks: [] });
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
// Sanitize inputs
|
| 85 |
+
const safeSpeakerId = sanitizePath(speaker_id);
|
| 86 |
+
const safeDatasetName = sanitizePath(dataset_name);
|
| 87 |
+
|
| 88 |
+
const metadataPath = path.join(getMetadataPath(), 'dataset_info.json');
|
| 89 |
+
|
| 90 |
+
try {
|
| 91 |
+
const content = await fs.readFile(metadataPath, 'utf-8');
|
| 92 |
+
const datasetInfo = JSON.parse(content);
|
| 93 |
+
const speakers = datasetInfo.speakers as Record<string, { datasets?: Record<string, { bookmarks?: number[] }> }>;
|
| 94 |
+
const bookmarks = speakers[safeSpeakerId]?.datasets?.[safeDatasetName]?.bookmarks || [];
|
| 95 |
+
return NextResponse.json({ bookmarks });
|
| 96 |
+
} catch {
|
| 97 |
+
return NextResponse.json({ bookmarks: [] });
|
| 98 |
+
}
|
| 99 |
+
} catch (error) {
|
| 100 |
+
console.error('Error getting bookmarks:', error);
|
| 101 |
+
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
|
| 102 |
+
}
|
| 103 |
+
}
|
src/app/api/dataset-stats/route.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextResponse } from 'next/server';
|
| 2 |
+
import { promises as fs } from 'fs';
|
| 3 |
+
import path from 'path';
|
| 4 |
+
import { getMetadataPath, ensureDir, getDataDir } from '@/lib/dataPath';
|
| 5 |
+
import { getCleanupConfig } from '@/lib/cleanup';
|
| 6 |
+
|
| 7 |
+
export async function GET() {
|
| 8 |
+
try {
|
| 9 |
+
const metadataDir = getMetadataPath();
|
| 10 |
+
await ensureDir(metadataDir);
|
| 11 |
+
const metadataPath = path.join(metadataDir, 'dataset_info.json');
|
| 12 |
+
|
| 13 |
+
let metadata: Record<string, unknown> = { speakers: {}, last_updated: null };
|
| 14 |
+
try {
|
| 15 |
+
const content = await fs.readFile(metadataPath, 'utf-8');
|
| 16 |
+
metadata = JSON.parse(content);
|
| 17 |
+
} catch {
|
| 18 |
+
// File might not exist yet
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
// Flatten statistics
|
| 22 |
+
let recordedSentences = 0;
|
| 23 |
+
const speakers = metadata.speakers as Record<string, { datasets?: Record<string, { recordings?: number }> }>;
|
| 24 |
+
|
| 25 |
+
for (const speakerId in speakers) {
|
| 26 |
+
const speaker = speakers[speakerId];
|
| 27 |
+
if (speaker?.datasets) {
|
| 28 |
+
for (const datasetName in speaker.datasets) {
|
| 29 |
+
const dataset = speaker.datasets[datasetName];
|
| 30 |
+
recordedSentences += dataset?.recordings || 0;
|
| 31 |
+
}
|
| 32 |
+
}
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
// Get cleanup configuration for display
|
| 36 |
+
const cleanupConfig = getCleanupConfig();
|
| 37 |
+
|
| 38 |
+
return NextResponse.json({
|
| 39 |
+
lastUpdated: metadata.last_updated || null,
|
| 40 |
+
total_recordings: Number(metadata.total_recordings) || 0,
|
| 41 |
+
total_duration: Number(metadata.total_duration) || 0,
|
| 42 |
+
recent_recordings: Array.isArray(metadata.recent_recordings) ? metadata.recent_recordings : [],
|
| 43 |
+
speakers: Object.keys(speakers).length,
|
| 44 |
+
recordedSentences,
|
| 45 |
+
// Include storage info for transparency
|
| 46 |
+
storage: {
|
| 47 |
+
dataDirectory: getDataDir(),
|
| 48 |
+
autoCleanupHours: cleanupConfig.maxFileAgeHours,
|
| 49 |
+
isHuggingFaceSpaces: getDataDir() === '/data'
|
| 50 |
+
},
|
| 51 |
+
details: metadata
|
| 52 |
+
});
|
| 53 |
+
} catch (error) {
|
| 54 |
+
console.error('Error getting stats:', error);
|
| 55 |
+
return NextResponse.json({
|
| 56 |
+
error: 'Internal Server Error',
|
| 57 |
+
details: error instanceof Error ? error.message : 'Unknown error'
|
| 58 |
+
}, { status: 500 });
|
| 59 |
+
}
|
| 60 |
+
}
|
src/app/api/export-dataset/route.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextResponse } from 'next/server';
|
| 2 |
+
import { promises as fs } from 'fs';
|
| 3 |
+
import path from 'path';
|
| 4 |
+
import archiver from 'archiver';
|
| 5 |
+
import { getDataPath, sanitizePath } from '@/lib/dataPath';
|
| 6 |
+
|
| 7 |
+
export async function GET(request: Request) {
|
| 8 |
+
const { searchParams } = new URL(request.url);
|
| 9 |
+
const datasetName = searchParams.get('dataset_name');
|
| 10 |
+
|
| 11 |
+
if (!datasetName) {
|
| 12 |
+
return NextResponse.json({ error: 'Dataset name required' }, { status: 400 });
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
// Sanitize dataset name to prevent path traversal
|
| 16 |
+
const safeDatasetName = sanitizePath(datasetName);
|
| 17 |
+
const datasetDir = getDataPath(safeDatasetName);
|
| 18 |
+
|
| 19 |
+
try {
|
| 20 |
+
await fs.access(datasetDir);
|
| 21 |
+
} catch {
|
| 22 |
+
return NextResponse.json({ error: 'Dataset not found' }, { status: 404 });
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
const archive = archiver('zip', {
|
| 26 |
+
zlib: { level: 9 } // Sets the compression level.
|
| 27 |
+
});
|
| 28 |
+
|
| 29 |
+
const stream = new ReadableStream({
|
| 30 |
+
async start(controller) {
|
| 31 |
+
archive.on('data', (chunk) => {
|
| 32 |
+
controller.enqueue(chunk);
|
| 33 |
+
});
|
| 34 |
+
|
| 35 |
+
archive.on('end', () => {
|
| 36 |
+
controller.close();
|
| 37 |
+
});
|
| 38 |
+
|
| 39 |
+
archive.on('error', (err) => {
|
| 40 |
+
console.error('Archive error:', err);
|
| 41 |
+
controller.error(err);
|
| 42 |
+
});
|
| 43 |
+
|
| 44 |
+
// Generate metadata.csv for LJSpeech compatibility
|
| 45 |
+
try {
|
| 46 |
+
const metadataPath = path.join(datasetDir, 'dataset_info.json');
|
| 47 |
+
const metadataContent = await fs.readFile(metadataPath, 'utf-8');
|
| 48 |
+
const metadata = JSON.parse(metadataContent);
|
| 49 |
+
|
| 50 |
+
if (metadata.recordings && Array.isArray(metadata.recordings)) {
|
| 51 |
+
const csvContent = metadata.recordings.map((r: { filename?: string; text?: string }) =>
|
| 52 |
+
`${r.filename || ''}|${r.text || ''}`
|
| 53 |
+
).join('\n');
|
| 54 |
+
archive.append(csvContent, { name: 'metadata.csv' });
|
| 55 |
+
}
|
| 56 |
+
} catch (e) {
|
| 57 |
+
console.warn('Could not generate metadata.csv', e);
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
try {
|
| 61 |
+
archive.directory(datasetDir, false);
|
| 62 |
+
archive.finalize();
|
| 63 |
+
} catch (error) {
|
| 64 |
+
console.error('Error finalizing archive:', error);
|
| 65 |
+
controller.error(error);
|
| 66 |
+
}
|
| 67 |
+
}
|
| 68 |
+
});
|
| 69 |
+
|
| 70 |
+
return new NextResponse(stream, {
|
| 71 |
+
headers: {
|
| 72 |
+
'Content-Type': 'application/zip',
|
| 73 |
+
'Content-Disposition': `attachment; filename="${safeDatasetName}.zip"`
|
| 74 |
+
}
|
| 75 |
+
});
|
| 76 |
+
}
|
src/app/api/fonts/[filename]/route.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextResponse } from 'next/server';
|
| 2 |
+
import { promises as fs } from 'fs';
|
| 3 |
+
import path from 'path';
|
| 4 |
+
import { getFontsPath, sanitizePath } from '@/lib/dataPath';
|
| 5 |
+
|
| 6 |
+
export async function GET(
|
| 7 |
+
request: Request,
|
| 8 |
+
{ params }: { params: Promise<{ filename: string }> }
|
| 9 |
+
) {
|
| 10 |
+
try {
|
| 11 |
+
const { filename } = await params;
|
| 12 |
+
|
| 13 |
+
// Security check: prevent directory traversal
|
| 14 |
+
if (!filename || filename.includes('..') || filename.includes('/') || filename.includes('\\')) {
|
| 15 |
+
return new NextResponse('Invalid filename', { status: 400 });
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
// Validate file extension
|
| 19 |
+
const ext = path.extname(filename).toLowerCase();
|
| 20 |
+
if (ext !== '.ttf' && ext !== '.otf') {
|
| 21 |
+
return new NextResponse('Invalid font file type', { status: 400 });
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
// Sanitize filename (but preserve extension)
|
| 25 |
+
const baseName = sanitizePath(path.basename(filename, ext), 100);
|
| 26 |
+
const safeFilename = `${baseName}${ext}`;
|
| 27 |
+
|
| 28 |
+
const fontsDir = getFontsPath();
|
| 29 |
+
const filePath = path.join(fontsDir, filename); // Use original filename for lookup
|
| 30 |
+
|
| 31 |
+
try {
|
| 32 |
+
const fileBuffer = await fs.readFile(filePath);
|
| 33 |
+
const contentType = ext === '.otf' ? 'font/otf' : 'font/ttf';
|
| 34 |
+
|
| 35 |
+
return new NextResponse(fileBuffer, {
|
| 36 |
+
headers: {
|
| 37 |
+
'Content-Type': contentType,
|
| 38 |
+
'Cache-Control': 'public, max-age=31536000, immutable',
|
| 39 |
+
},
|
| 40 |
+
});
|
| 41 |
+
} catch {
|
| 42 |
+
return new NextResponse('Font not found', { status: 404 });
|
| 43 |
+
}
|
| 44 |
+
} catch (error) {
|
| 45 |
+
console.error('Error serving font:', error);
|
| 46 |
+
return new NextResponse('Internal Server Error', { status: 500 });
|
| 47 |
+
}
|
| 48 |
+
}
|
src/app/api/fonts/route.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextResponse } from 'next/server';
|
| 2 |
+
import { promises as fs } from 'fs';
|
| 3 |
+
import path from 'path';
|
| 4 |
+
import { getFontsPath, ensureDir } from '@/lib/dataPath';
|
| 5 |
+
|
| 6 |
+
const DEFAULT_FONTS = [
|
| 7 |
+
{ name: "Times New Roman", family: "Times New Roman", css: "font-family: 'Times New Roman', serif;" },
|
| 8 |
+
{ name: "Arial", family: "Arial", css: "font-family: Arial, sans-serif;" },
|
| 9 |
+
{ name: "Nastaliq", family: "Noto Nastaliq Urdu", css: "font-family: 'Noto Nastaliq Urdu', serif;" },
|
| 10 |
+
{ name: "Naskh", family: "Scheherazade New", css: "font-family: 'Scheherazade New', serif;" }
|
| 11 |
+
];
|
| 12 |
+
|
| 13 |
+
export async function GET() {
|
| 14 |
+
try {
|
| 15 |
+
const fontsDir = getFontsPath();
|
| 16 |
+
await ensureDir(fontsDir);
|
| 17 |
+
|
| 18 |
+
let files: string[] = [];
|
| 19 |
+
try {
|
| 20 |
+
files = await fs.readdir(fontsDir);
|
| 21 |
+
} catch {
|
| 22 |
+
// Directory might not exist yet
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
const customFonts = files
|
| 26 |
+
.filter(file => file.endsWith('.ttf') || file.endsWith('.otf'))
|
| 27 |
+
.map(file => {
|
| 28 |
+
const family = path.basename(file, path.extname(file));
|
| 29 |
+
return {
|
| 30 |
+
name: file,
|
| 31 |
+
family: family,
|
| 32 |
+
css: `font-family: '${family}', serif;`,
|
| 33 |
+
url: `/api/fonts/${encodeURIComponent(file)}`,
|
| 34 |
+
isCustom: true
|
| 35 |
+
};
|
| 36 |
+
});
|
| 37 |
+
|
| 38 |
+
return NextResponse.json({ fonts: [...DEFAULT_FONTS, ...customFonts] });
|
| 39 |
+
} catch (error) {
|
| 40 |
+
console.error('Error listing fonts:', error);
|
| 41 |
+
return NextResponse.json({
|
| 42 |
+
error: 'Internal Server Error',
|
| 43 |
+
details: error instanceof Error ? error.message : 'Unknown error'
|
| 44 |
+
}, { status: 500 });
|
| 45 |
+
}
|
| 46 |
+
}
|
src/app/api/save-recording/route.ts
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextResponse } from 'next/server';
|
| 2 |
+
import { promises as fs } from 'fs';
|
| 3 |
+
import path from 'path';
|
| 4 |
+
import { getAudioPath, getTranscriptionsPath, getMetadataPath, ensureDir, sanitizePath } from '@/lib/dataPath';
|
| 5 |
+
|
| 6 |
+
export async function POST(request: Request) {
|
| 7 |
+
try {
|
| 8 |
+
const formData = await request.formData();
|
| 9 |
+
const audioFile = formData.get('audio') as File;
|
| 10 |
+
const metadataStr = formData.get('metadata') as string;
|
| 11 |
+
|
| 12 |
+
if (!audioFile || !metadataStr) {
|
| 13 |
+
return NextResponse.json({ error: 'Missing audio or metadata' }, { status: 400 });
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
let metadata;
|
| 17 |
+
try {
|
| 18 |
+
metadata = JSON.parse(metadataStr);
|
| 19 |
+
} catch {
|
| 20 |
+
return NextResponse.json({ error: 'Invalid metadata JSON' }, { status: 400 });
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
const { speaker_id, dataset_name, text, font_style, timestamp, emotion, rating, duration } = metadata;
|
| 24 |
+
|
| 25 |
+
// Validate required fields
|
| 26 |
+
if (!speaker_id || !dataset_name || !text) {
|
| 27 |
+
return NextResponse.json({ error: 'Missing required fields: speaker_id, dataset_name, or text' }, { status: 400 });
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
// Sanitize inputs to prevent path traversal
|
| 31 |
+
const safeSpeakerId = sanitizePath(speaker_id);
|
| 32 |
+
const safeDataset = sanitizePath(dataset_name);
|
| 33 |
+
const safeText = sanitizePath(text.substring(0, 20));
|
| 34 |
+
|
| 35 |
+
// Get paths using centralized data path utility
|
| 36 |
+
const audioDir = getAudioPath(safeSpeakerId);
|
| 37 |
+
const textDir = getTranscriptionsPath(safeSpeakerId);
|
| 38 |
+
|
| 39 |
+
// Ensure directories exist
|
| 40 |
+
await ensureDir(audioDir);
|
| 41 |
+
await ensureDir(textDir);
|
| 42 |
+
|
| 43 |
+
// Generate filename with timestamp for uniqueness
|
| 44 |
+
const timeStr = new Date().toISOString().replace(/-/g, '').replace(/:/g, '').replace(/\./g, '').substring(0, 14);
|
| 45 |
+
const index = metadata.index !== undefined ? `line${metadata.index + 1}` : `t${timeStr}`;
|
| 46 |
+
|
| 47 |
+
const baseName = `${safeDataset}_${safeSpeakerId}_${index}_${safeText}_${timeStr}`;
|
| 48 |
+
const audioName = `${baseName}.wav`;
|
| 49 |
+
const textName = `${baseName}.txt`;
|
| 50 |
+
|
| 51 |
+
// Save Audio
|
| 52 |
+
const audioBuffer = Buffer.from(await audioFile.arrayBuffer());
|
| 53 |
+
await fs.writeFile(path.join(audioDir, audioName), audioBuffer);
|
| 54 |
+
|
| 55 |
+
// Save Transcription
|
| 56 |
+
const transcriptionContent = `[METADATA]
|
| 57 |
+
Recording_ID: ${audioName}
|
| 58 |
+
Speaker_ID: ${speaker_id}
|
| 59 |
+
Dataset_Name: ${dataset_name}
|
| 60 |
+
Timestamp: ${timestamp || new Date().toISOString()}
|
| 61 |
+
Font_Style: ${font_style || 'default'}
|
| 62 |
+
Emotion: ${emotion || 'neutral'}
|
| 63 |
+
Rating: ${rating || 3}
|
| 64 |
+
Duration: ${duration || 0}
|
| 65 |
+
[TEXT]
|
| 66 |
+
${text}
|
| 67 |
+
`;
|
| 68 |
+
await fs.writeFile(path.join(textDir, textName), transcriptionContent);
|
| 69 |
+
|
| 70 |
+
// Update Metadata
|
| 71 |
+
const metadataDir = getMetadataPath();
|
| 72 |
+
await ensureDir(metadataDir);
|
| 73 |
+
const metadataPath = path.join(metadataDir, 'dataset_info.json');
|
| 74 |
+
|
| 75 |
+
let datasetInfo: Record<string, unknown> = {
|
| 76 |
+
speakers: {},
|
| 77 |
+
last_updated: null,
|
| 78 |
+
total_duration: 0,
|
| 79 |
+
total_recordings: 0
|
| 80 |
+
};
|
| 81 |
+
|
| 82 |
+
try {
|
| 83 |
+
const content = await fs.readFile(metadataPath, 'utf-8');
|
| 84 |
+
datasetInfo = JSON.parse(content);
|
| 85 |
+
} catch {
|
| 86 |
+
// File doesn't exist yet, use defaults
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
datasetInfo.last_updated = new Date().toISOString();
|
| 90 |
+
datasetInfo.total_recordings = (Number(datasetInfo.total_recordings) || 0) + 1;
|
| 91 |
+
datasetInfo.total_duration = (Number(datasetInfo.total_duration) || 0) + (Number(duration) || 0);
|
| 92 |
+
|
| 93 |
+
await fs.writeFile(metadataPath, JSON.stringify(datasetInfo, null, 2));
|
| 94 |
+
|
| 95 |
+
return NextResponse.json({ success: true, filename: audioName });
|
| 96 |
+
|
| 97 |
+
} catch (error) {
|
| 98 |
+
console.error('Error saving recording:', error);
|
| 99 |
+
return NextResponse.json({
|
| 100 |
+
error: 'Internal Server Error',
|
| 101 |
+
details: error instanceof Error ? error.message : 'Unknown error'
|
| 102 |
+
}, { status: 500 });
|
| 103 |
+
}
|
| 104 |
+
}
|
src/app/api/skip-recording/route.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextResponse } from 'next/server';
|
| 2 |
+
import { promises as fs } from 'fs';
|
| 3 |
+
import path from 'path';
|
| 4 |
+
import { getDataPath, ensureDir, sanitizePath } from '@/lib/dataPath';
|
| 5 |
+
|
| 6 |
+
export async function POST(request: Request) {
|
| 7 |
+
try {
|
| 8 |
+
const data = await request.json();
|
| 9 |
+
const { speaker_id, dataset_name, index, text, reason } = data;
|
| 10 |
+
|
| 11 |
+
if (!speaker_id || !dataset_name) {
|
| 12 |
+
return NextResponse.json({ success: false, error: 'Missing required fields: speaker_id and dataset_name' }, { status: 400 });
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
// Sanitize inputs
|
| 16 |
+
const safeSpeakerId = sanitizePath(speaker_id);
|
| 17 |
+
const safeDatasetName = sanitizePath(dataset_name);
|
| 18 |
+
|
| 19 |
+
const datasetDir = getDataPath(safeDatasetName);
|
| 20 |
+
const skippedFile = path.join(datasetDir, 'skipped.json');
|
| 21 |
+
|
| 22 |
+
// Ensure dataset directory exists
|
| 23 |
+
await ensureDir(datasetDir);
|
| 24 |
+
|
| 25 |
+
let skippedData: Array<{
|
| 26 |
+
speaker_id: string;
|
| 27 |
+
index: number;
|
| 28 |
+
text: string;
|
| 29 |
+
reason: string;
|
| 30 |
+
timestamp: string;
|
| 31 |
+
}> = [];
|
| 32 |
+
|
| 33 |
+
try {
|
| 34 |
+
const fileContent = await fs.readFile(skippedFile, 'utf-8');
|
| 35 |
+
skippedData = JSON.parse(fileContent);
|
| 36 |
+
|
| 37 |
+
// Ensure it's an array
|
| 38 |
+
if (!Array.isArray(skippedData)) {
|
| 39 |
+
skippedData = [];
|
| 40 |
+
}
|
| 41 |
+
} catch {
|
| 42 |
+
// File doesn't exist or is empty, start new
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
const newSkip = {
|
| 46 |
+
speaker_id: safeSpeakerId,
|
| 47 |
+
index: typeof index === 'number' ? index : -1,
|
| 48 |
+
text: typeof text === 'string' ? text.substring(0, 500) : '', // Limit text length
|
| 49 |
+
reason: typeof reason === 'string' ? reason : 'Skipped by user',
|
| 50 |
+
timestamp: new Date().toISOString()
|
| 51 |
+
};
|
| 52 |
+
|
| 53 |
+
skippedData.push(newSkip);
|
| 54 |
+
|
| 55 |
+
await fs.writeFile(skippedFile, JSON.stringify(skippedData, null, 2));
|
| 56 |
+
|
| 57 |
+
return NextResponse.json({ success: true });
|
| 58 |
+
} catch (error) {
|
| 59 |
+
console.error('Error skipping recording:', error);
|
| 60 |
+
return NextResponse.json({
|
| 61 |
+
success: false,
|
| 62 |
+
error: 'Internal server error',
|
| 63 |
+
details: error instanceof Error ? error.message : 'Unknown error'
|
| 64 |
+
}, { status: 500 });
|
| 65 |
+
}
|
| 66 |
+
}
|
src/app/api/upload-font/route.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextResponse } from 'next/server';
|
| 2 |
+
import { promises as fs } from 'fs';
|
| 3 |
+
import path from 'path';
|
| 4 |
+
import { getFontsPath, ensureDir, sanitizePath } from '@/lib/dataPath';
|
| 5 |
+
|
| 6 |
+
export async function POST(request: Request) {
|
| 7 |
+
try {
|
| 8 |
+
const formData = await request.formData();
|
| 9 |
+
const fontFile = formData.get('font') as File;
|
| 10 |
+
|
| 11 |
+
if (!fontFile) {
|
| 12 |
+
return NextResponse.json({ error: 'No font file provided' }, { status: 400 });
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
if (!fontFile.name.endsWith('.ttf') && !fontFile.name.endsWith('.otf')) {
|
| 16 |
+
return NextResponse.json({ error: 'Only .ttf and .otf files are supported' }, { status: 400 });
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
// Validate file size (max 10MB)
|
| 20 |
+
const MAX_FONT_SIZE = 10 * 1024 * 1024; // 10MB
|
| 21 |
+
if (fontFile.size > MAX_FONT_SIZE) {
|
| 22 |
+
return NextResponse.json({ error: 'Font file too large (max 10MB)' }, { status: 400 });
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
const fontsDir = getFontsPath();
|
| 26 |
+
await ensureDir(fontsDir);
|
| 27 |
+
|
| 28 |
+
// Sanitize original name and add timestamp for uniqueness
|
| 29 |
+
const timestamp = Date.now();
|
| 30 |
+
const safeName = sanitizePath(fontFile.name.replace(/\.(ttf|otf)$/i, ''), 40);
|
| 31 |
+
const extension = fontFile.name.endsWith('.otf') ? '.otf' : '.ttf';
|
| 32 |
+
const fileName = `font_${timestamp}_${safeName}${extension}`;
|
| 33 |
+
|
| 34 |
+
const buffer = Buffer.from(await fontFile.arrayBuffer());
|
| 35 |
+
await fs.writeFile(path.join(fontsDir, fileName), buffer);
|
| 36 |
+
|
| 37 |
+
return NextResponse.json({ success: true, filename: fileName });
|
| 38 |
+
} catch (error) {
|
| 39 |
+
console.error('Error uploading font:', error);
|
| 40 |
+
return NextResponse.json({
|
| 41 |
+
error: 'Internal Server Error',
|
| 42 |
+
details: error instanceof Error ? error.message : 'Unknown error'
|
| 43 |
+
}, { status: 500 });
|
| 44 |
+
}
|
| 45 |
+
}
|
src/app/favicon.ico
ADDED
|
|
src/app/globals.css
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@tailwind base;
|
| 2 |
+
@tailwind components;
|
| 3 |
+
@tailwind utilities;
|
| 4 |
+
|
| 5 |
+
@layer base {
|
| 6 |
+
:root {
|
| 7 |
+
--background: 0 0% 100%;
|
| 8 |
+
--foreground: 222.2 84% 4.9%;
|
| 9 |
+
|
| 10 |
+
--card: 0 0% 100%;
|
| 11 |
+
--card-foreground: 222.2 84% 4.9%;
|
| 12 |
+
|
| 13 |
+
--popover: 0 0% 100%;
|
| 14 |
+
--popover-foreground: 222.2 84% 4.9%;
|
| 15 |
+
|
| 16 |
+
--primary: 221.2 83.2% 53.3%;
|
| 17 |
+
--primary-foreground: 210 40% 98%;
|
| 18 |
+
|
| 19 |
+
--secondary: 210 40% 96.1%;
|
| 20 |
+
--secondary-foreground: 222.2 47.4% 11.2%;
|
| 21 |
+
|
| 22 |
+
--muted: 210 40% 96.1%;
|
| 23 |
+
--muted-foreground: 215.4 16.3% 46.9%;
|
| 24 |
+
|
| 25 |
+
--accent: 210 40% 96.1%;
|
| 26 |
+
--accent-foreground: 222.2 47.4% 11.2%;
|
| 27 |
+
|
| 28 |
+
--destructive: 0 84.2% 60.2%;
|
| 29 |
+
--destructive-foreground: 210 40% 98%;
|
| 30 |
+
|
| 31 |
+
--border: 214.3 31.8% 91.4%;
|
| 32 |
+
--input: 214.3 31.8% 91.4%;
|
| 33 |
+
--ring: 221.2 83.2% 53.3%;
|
| 34 |
+
|
| 35 |
+
--radius: 0.5rem;
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
.dark {
|
| 39 |
+
--background: 222.2 84% 4.9%;
|
| 40 |
+
--foreground: 210 40% 98%;
|
| 41 |
+
|
| 42 |
+
--card: 222.2 84% 4.9%;
|
| 43 |
+
--card-foreground: 210 40% 98%;
|
| 44 |
+
|
| 45 |
+
--popover: 222.2 84% 4.9%;
|
| 46 |
+
--popover-foreground: 210 40% 98%;
|
| 47 |
+
|
| 48 |
+
--primary: 217.2 91.2% 59.8%;
|
| 49 |
+
--primary-foreground: 222.2 47.4% 11.2%;
|
| 50 |
+
|
| 51 |
+
--secondary: 217.2 32.6% 17.5%;
|
| 52 |
+
--secondary-foreground: 210 40% 98%;
|
| 53 |
+
|
| 54 |
+
--muted: 217.2 32.6% 17.5%;
|
| 55 |
+
--muted-foreground: 215 20.2% 65.1%;
|
| 56 |
+
|
| 57 |
+
--accent: 217.2 32.6% 17.5%;
|
| 58 |
+
--accent-foreground: 210 40% 98%;
|
| 59 |
+
|
| 60 |
+
--destructive: 0 62.8% 30.6%;
|
| 61 |
+
--destructive-foreground: 210 40% 98%;
|
| 62 |
+
|
| 63 |
+
--border: 217.2 32.6% 17.5%;
|
| 64 |
+
--input: 217.2 32.6% 17.5%;
|
| 65 |
+
--ring: 212.7 26.8% 83.9%;
|
| 66 |
+
}
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
@layer base {
|
| 70 |
+
* {
|
| 71 |
+
@apply border-border;
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
body {
|
| 75 |
+
@apply bg-background text-foreground;
|
| 76 |
+
}
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
.glass {
|
| 80 |
+
@apply bg-white/10 backdrop-blur-lg border border-white/20;
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
.glass-card {
|
| 84 |
+
@apply bg-card/50 backdrop-blur-md border border-border/50 shadow-xl;
|
| 85 |
+
}
|
src/app/layout.tsx
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { Metadata } from "next";
|
| 2 |
+
import { DM_Sans, Playfair_Display, JetBrains_Mono } from "next/font/google";
|
| 3 |
+
import "./globals.css";
|
| 4 |
+
import { Providers } from "@/components/Providers";
|
| 5 |
+
|
| 6 |
+
const dmSans = DM_Sans({
|
| 7 |
+
subsets: ["latin"],
|
| 8 |
+
variable: '--font-dm-sans',
|
| 9 |
+
display: 'swap',
|
| 10 |
+
});
|
| 11 |
+
|
| 12 |
+
const playfair = Playfair_Display({
|
| 13 |
+
subsets: ["latin"],
|
| 14 |
+
variable: '--font-playfair',
|
| 15 |
+
display: 'swap',
|
| 16 |
+
});
|
| 17 |
+
|
| 18 |
+
const jetbrains = JetBrains_Mono({
|
| 19 |
+
subsets: ["latin"],
|
| 20 |
+
variable: '--font-jetbrains',
|
| 21 |
+
display: 'swap',
|
| 22 |
+
});
|
| 23 |
+
|
| 24 |
+
export const metadata: Metadata = {
|
| 25 |
+
title: "TTS Dataset Collector - Build Speech Datasets",
|
| 26 |
+
description: "Advanced TTS Dataset Collection Tool",
|
| 27 |
+
};
|
| 28 |
+
|
| 29 |
+
export default function RootLayout({
|
| 30 |
+
children,
|
| 31 |
+
}: Readonly<{
|
| 32 |
+
children: React.ReactNode;
|
| 33 |
+
}>) {
|
| 34 |
+
return (
|
| 35 |
+
<html lang="en" className="dark">
|
| 36 |
+
<body className={`${dmSans.variable} ${playfair.variable} ${jetbrains.variable} font-sans antialiased bg-background text-foreground min-h-screen selection:bg-primary/30 selection:text-primary-foreground`}>
|
| 37 |
+
<Providers>
|
| 38 |
+
{children}
|
| 39 |
+
</Providers>
|
| 40 |
+
</body>
|
| 41 |
+
</html>
|
| 42 |
+
);
|
| 43 |
+
}
|
src/app/page.module.css
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.page {
|
| 2 |
+
--background: #fafafa;
|
| 3 |
+
--foreground: #fff;
|
| 4 |
+
|
| 5 |
+
--text-primary: #000;
|
| 6 |
+
--text-secondary: #666;
|
| 7 |
+
|
| 8 |
+
--button-primary-hover: #383838;
|
| 9 |
+
--button-secondary-hover: #f2f2f2;
|
| 10 |
+
--button-secondary-border: #ebebeb;
|
| 11 |
+
|
| 12 |
+
display: flex;
|
| 13 |
+
min-height: 100vh;
|
| 14 |
+
align-items: center;
|
| 15 |
+
justify-content: center;
|
| 16 |
+
font-family: var(--font-geist-sans);
|
| 17 |
+
background-color: var(--background);
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
.main {
|
| 21 |
+
display: flex;
|
| 22 |
+
min-height: 100vh;
|
| 23 |
+
width: 100%;
|
| 24 |
+
max-width: 800px;
|
| 25 |
+
flex-direction: column;
|
| 26 |
+
align-items: flex-start;
|
| 27 |
+
justify-content: space-between;
|
| 28 |
+
background-color: var(--foreground);
|
| 29 |
+
padding: 120px 60px;
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
.intro {
|
| 33 |
+
display: flex;
|
| 34 |
+
flex-direction: column;
|
| 35 |
+
align-items: flex-start;
|
| 36 |
+
text-align: left;
|
| 37 |
+
gap: 24px;
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
.intro h1 {
|
| 41 |
+
max-width: 320px;
|
| 42 |
+
font-size: 40px;
|
| 43 |
+
font-weight: 600;
|
| 44 |
+
line-height: 48px;
|
| 45 |
+
letter-spacing: -2.4px;
|
| 46 |
+
text-wrap: balance;
|
| 47 |
+
color: var(--text-primary);
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
.intro p {
|
| 51 |
+
max-width: 440px;
|
| 52 |
+
font-size: 18px;
|
| 53 |
+
line-height: 32px;
|
| 54 |
+
text-wrap: balance;
|
| 55 |
+
color: var(--text-secondary);
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
.intro a {
|
| 59 |
+
font-weight: 500;
|
| 60 |
+
color: var(--text-primary);
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
.ctas {
|
| 64 |
+
display: flex;
|
| 65 |
+
flex-direction: row;
|
| 66 |
+
width: 100%;
|
| 67 |
+
max-width: 440px;
|
| 68 |
+
gap: 16px;
|
| 69 |
+
font-size: 14px;
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
.ctas a {
|
| 73 |
+
display: flex;
|
| 74 |
+
justify-content: center;
|
| 75 |
+
align-items: center;
|
| 76 |
+
height: 40px;
|
| 77 |
+
padding: 0 16px;
|
| 78 |
+
border-radius: 128px;
|
| 79 |
+
border: 1px solid transparent;
|
| 80 |
+
transition: 0.2s;
|
| 81 |
+
cursor: pointer;
|
| 82 |
+
width: fit-content;
|
| 83 |
+
font-weight: 500;
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
a.primary {
|
| 87 |
+
background: var(--text-primary);
|
| 88 |
+
color: var(--background);
|
| 89 |
+
gap: 8px;
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
a.secondary {
|
| 93 |
+
border-color: var(--button-secondary-border);
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
/* Enable hover only on non-touch devices */
|
| 97 |
+
@media (hover: hover) and (pointer: fine) {
|
| 98 |
+
a.primary:hover {
|
| 99 |
+
background: var(--button-primary-hover);
|
| 100 |
+
border-color: transparent;
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
a.secondary:hover {
|
| 104 |
+
background: var(--button-secondary-hover);
|
| 105 |
+
border-color: transparent;
|
| 106 |
+
}
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
@media (max-width: 600px) {
|
| 110 |
+
.main {
|
| 111 |
+
padding: 48px 24px;
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
.intro {
|
| 115 |
+
gap: 16px;
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
.intro h1 {
|
| 119 |
+
font-size: 32px;
|
| 120 |
+
line-height: 40px;
|
| 121 |
+
letter-spacing: -1.92px;
|
| 122 |
+
}
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
@media (prefers-color-scheme: dark) {
|
| 126 |
+
.logo {
|
| 127 |
+
filter: invert();
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
.page {
|
| 131 |
+
--background: #000;
|
| 132 |
+
--foreground: #000;
|
| 133 |
+
|
| 134 |
+
--text-primary: #ededed;
|
| 135 |
+
--text-secondary: #999;
|
| 136 |
+
|
| 137 |
+
--button-primary-hover: #ccc;
|
| 138 |
+
--button-secondary-hover: #1a1a1a;
|
| 139 |
+
--button-secondary-border: #1a1a1a;
|
| 140 |
+
}
|
| 141 |
+
}
|
src/app/page.tsx
ADDED
|
@@ -0,0 +1,429 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import React, { useState, useEffect } from 'react';
|
| 4 |
+
import HelpModal from '@/components/HelpModal';
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
import TextInput from '@/components/TextInput';
|
| 8 |
+
import AudioRecorder from '@/components/AudioRecorder';
|
| 9 |
+
import FontSelector from '@/components/FontSelector';
|
| 10 |
+
import DatasetStats from '@/components/DatasetStats';
|
| 11 |
+
import SettingsModal from '@/components/SettingsModal';
|
| 12 |
+
import { Mic2, Moon, Sun, Settings, Search, SkipForward, SkipBack, Bookmark, Hash, HelpCircle } from 'lucide-react';
|
| 13 |
+
import { motion, AnimatePresence } from 'framer-motion';
|
| 14 |
+
import { useLocalStorage } from '@/hooks/useLocalStorage';
|
| 15 |
+
import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts';
|
| 16 |
+
import { toast } from 'sonner';
|
| 17 |
+
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
| 18 |
+
import { Badge } from '@/components/ui/badge';
|
| 19 |
+
import { cn } from '@/lib/utils';
|
| 20 |
+
import { detectLanguage, isRTL } from '@/lib/language';
|
| 21 |
+
|
| 22 |
+
export default function Home() {
|
| 23 |
+
const [sentences, setSentences] = useState<string[]>([]);
|
| 24 |
+
const [currentIndex, setCurrentIndex] = useLocalStorage('currentIndex', 0);
|
| 25 |
+
const [speakerId, setSpeakerId] = useLocalStorage('speakerId', '');
|
| 26 |
+
const [datasetName, setDatasetName] = useLocalStorage('datasetName', 'dataset1');
|
| 27 |
+
const [fontStyle, setFontStyle] = useLocalStorage('fontStyle', 'Times New Roman');
|
| 28 |
+
const [fontFamily, setFontFamily] = useState('Times New Roman'); // Actual CSS font family
|
| 29 |
+
const [darkMode, setDarkMode] = useLocalStorage('darkMode', true);
|
| 30 |
+
|
| 31 |
+
// Settings
|
| 32 |
+
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
| 33 |
+
const [isHelpOpen, setIsHelpOpen] = useState(false);
|
| 34 |
+
const [autoAdvance, setAutoAdvance] = useLocalStorage('autoAdvance', true);
|
| 35 |
+
const [autoSave, setAutoSave] = useLocalStorage('autoSave', false);
|
| 36 |
+
const [silenceThreshold, setSilenceThreshold] = useLocalStorage('silenceThreshold', 5);
|
| 37 |
+
|
| 38 |
+
// Navigation & Search
|
| 39 |
+
const [jumpIndex, setJumpIndex] = useState('');
|
| 40 |
+
const [bookmarks, setBookmarks] = useState<number[]>([]);
|
| 41 |
+
const [detectedLang, setDetectedLang] = useState('eng');
|
| 42 |
+
const [isRTLDir, setIsRTLDir] = useState(false);
|
| 43 |
+
|
| 44 |
+
useEffect(() => {
|
| 45 |
+
if (sentences.length > 0 && sentences[currentIndex]) {
|
| 46 |
+
const lang = detectLanguage(sentences[currentIndex]);
|
| 47 |
+
setDetectedLang(lang);
|
| 48 |
+
setIsRTLDir(isRTL(lang));
|
| 49 |
+
}
|
| 50 |
+
}, [currentIndex, sentences]);
|
| 51 |
+
|
| 52 |
+
const [searchQuery, setSearchQuery] = useState('');
|
| 53 |
+
|
| 54 |
+
useEffect(() => {
|
| 55 |
+
if (darkMode) {
|
| 56 |
+
document.documentElement.classList.add('dark');
|
| 57 |
+
} else {
|
| 58 |
+
document.documentElement.classList.remove('dark');
|
| 59 |
+
}
|
| 60 |
+
}, [darkMode]);
|
| 61 |
+
|
| 62 |
+
useEffect(() => {
|
| 63 |
+
if (speakerId && datasetName) {
|
| 64 |
+
fetch(`/api/bookmarks?speaker_id=${speakerId}&dataset_name=${datasetName}`)
|
| 65 |
+
.then(res => res.json())
|
| 66 |
+
.then(data => setBookmarks(data.bookmarks || []));
|
| 67 |
+
}
|
| 68 |
+
}, [speakerId, datasetName]);
|
| 69 |
+
|
| 70 |
+
// Keyboard Shortcuts
|
| 71 |
+
useKeyboardShortcuts({
|
| 72 |
+
'arrowright': () => handleNext(),
|
| 73 |
+
'arrowleft': () => handlePrev(),
|
| 74 |
+
'ctrl+s': () => document.getElementById('save-btn')?.click(),
|
| 75 |
+
' ': () => document.getElementById('record-btn')?.click(),
|
| 76 |
+
'ctrl+f': () => document.getElementById('search-input')?.focus(),
|
| 77 |
+
});
|
| 78 |
+
|
| 79 |
+
const handleSentencesLoaded = (loadedSentences: string[]) => {
|
| 80 |
+
setSentences(loadedSentences);
|
| 81 |
+
setCurrentIndex(0);
|
| 82 |
+
};
|
| 83 |
+
|
| 84 |
+
const handleNext = () => {
|
| 85 |
+
if (currentIndex < sentences.length - 1) {
|
| 86 |
+
setCurrentIndex(prev => prev + 1);
|
| 87 |
+
} else {
|
| 88 |
+
toast.info('Reached end of sentences');
|
| 89 |
+
}
|
| 90 |
+
};
|
| 91 |
+
|
| 92 |
+
const handlePrev = () => {
|
| 93 |
+
if (currentIndex > 0) {
|
| 94 |
+
setCurrentIndex(prev => prev - 1);
|
| 95 |
+
}
|
| 96 |
+
};
|
| 97 |
+
|
| 98 |
+
const handleJump = (e: React.FormEvent) => {
|
| 99 |
+
e.preventDefault();
|
| 100 |
+
const idx = parseInt(jumpIndex) - 1;
|
| 101 |
+
if (idx >= 0 && idx < sentences.length) {
|
| 102 |
+
setCurrentIndex(idx);
|
| 103 |
+
setJumpIndex('');
|
| 104 |
+
} else {
|
| 105 |
+
toast.error('Invalid sentence number');
|
| 106 |
+
}
|
| 107 |
+
};
|
| 108 |
+
|
| 109 |
+
const handleSearch = (e: React.FormEvent) => {
|
| 110 |
+
e.preventDefault();
|
| 111 |
+
if (!searchQuery) return;
|
| 112 |
+
|
| 113 |
+
// Find next occurrence after current index
|
| 114 |
+
let nextIndex = sentences.findIndex((s, i) => i > currentIndex && s.toLowerCase().includes(searchQuery.toLowerCase()));
|
| 115 |
+
|
| 116 |
+
// If not found, wrap around
|
| 117 |
+
if (nextIndex === -1) {
|
| 118 |
+
nextIndex = sentences.findIndex((s) => s.toLowerCase().includes(searchQuery.toLowerCase()));
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
if (nextIndex !== -1) {
|
| 122 |
+
setCurrentIndex(nextIndex);
|
| 123 |
+
toast.success(`Found match at #${nextIndex + 1}`);
|
| 124 |
+
} else {
|
| 125 |
+
toast.error('No matches found');
|
| 126 |
+
}
|
| 127 |
+
};
|
| 128 |
+
|
| 129 |
+
const handleSkip = async () => {
|
| 130 |
+
try {
|
| 131 |
+
const res = await fetch('/api/skip-recording', {
|
| 132 |
+
method: 'POST',
|
| 133 |
+
headers: { 'Content-Type': 'application/json' },
|
| 134 |
+
body: JSON.stringify({
|
| 135 |
+
speaker_id: speakerId,
|
| 136 |
+
dataset_name: datasetName,
|
| 137 |
+
index: currentIndex,
|
| 138 |
+
text: sentences[currentIndex],
|
| 139 |
+
reason: 'User skipped'
|
| 140 |
+
})
|
| 141 |
+
});
|
| 142 |
+
|
| 143 |
+
if (res.ok) {
|
| 144 |
+
toast.info('Sentence skipped');
|
| 145 |
+
handleNext();
|
| 146 |
+
} else {
|
| 147 |
+
toast.error('Failed to skip');
|
| 148 |
+
}
|
| 149 |
+
} catch (err) {
|
| 150 |
+
toast.error('Error skipping');
|
| 151 |
+
}
|
| 152 |
+
};
|
| 153 |
+
|
| 154 |
+
const handleFontChange = (font: string) => {
|
| 155 |
+
setFontStyle(font);
|
| 156 |
+
setFontFamily(font);
|
| 157 |
+
};
|
| 158 |
+
|
| 159 |
+
const toggleBookmark = async () => {
|
| 160 |
+
try {
|
| 161 |
+
const res = await fetch('/api/bookmarks', {
|
| 162 |
+
method: 'POST',
|
| 163 |
+
headers: { 'Content-Type': 'application/json' },
|
| 164 |
+
body: JSON.stringify({ speaker_id: speakerId, dataset_name: datasetName, index: currentIndex })
|
| 165 |
+
});
|
| 166 |
+
const data = await res.json();
|
| 167 |
+
if (data.success) {
|
| 168 |
+
setBookmarks(data.bookmarks);
|
| 169 |
+
toast.success(bookmarks.includes(currentIndex) ? 'Bookmark removed' : 'Bookmarked');
|
| 170 |
+
}
|
| 171 |
+
} catch (err) {
|
| 172 |
+
toast.error('Failed to toggle bookmark');
|
| 173 |
+
}
|
| 174 |
+
};
|
| 175 |
+
|
| 176 |
+
return (
|
| 177 |
+
<main className="min-h-screen pb-20 transition-colors duration-300 bg-background text-foreground">
|
| 178 |
+
<header className="sticky top-0 z-20 border-b border-border/40 bg-background/80 backdrop-blur-xl supports-[backdrop-filter]:bg-background/60">
|
| 179 |
+
<div className="container py-4 flex items-center justify-between">
|
| 180 |
+
<div className="flex items-center gap-3">
|
| 181 |
+
<div className="w-10 h-10 bg-primary rounded-xl flex items-center justify-center text-primary-foreground shadow-lg shadow-primary/20">
|
| 182 |
+
<Mic2 className="w-6 h-6" />
|
| 183 |
+
</div>
|
| 184 |
+
<h1 className="text-xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-primary to-purple-400">
|
| 185 |
+
TTS Dataset Collector
|
| 186 |
+
</h1>
|
| 187 |
+
</div>
|
| 188 |
+
|
| 189 |
+
<div className="flex items-center gap-3">
|
| 190 |
+
<div className="hidden md:flex items-center gap-2 px-3 py-1.5 rounded-full bg-secondary/50 border border-border/50 text-sm font-medium">
|
| 191 |
+
<span className="opacity-70">Sentence</span>
|
| 192 |
+
<span className="text-primary">{currentIndex + 1}</span>
|
| 193 |
+
<span className="opacity-40">/</span>
|
| 194 |
+
<span className="opacity-70">{sentences.length || 0}</span>
|
| 195 |
+
</div>
|
| 196 |
+
|
| 197 |
+
<button
|
| 198 |
+
onClick={() => setIsHelpOpen(true)}
|
| 199 |
+
className="btn btn-ghost rounded-full w-10 h-10 p-0"
|
| 200 |
+
title="Help"
|
| 201 |
+
>
|
| 202 |
+
<HelpCircle className="w-5 h-5" />
|
| 203 |
+
</button>
|
| 204 |
+
|
| 205 |
+
<button
|
| 206 |
+
onClick={() => setIsSettingsOpen(true)}
|
| 207 |
+
className="btn btn-ghost rounded-full w-10 h-10 p-0"
|
| 208 |
+
title="Settings"
|
| 209 |
+
>
|
| 210 |
+
<Settings className="w-5 h-5" />
|
| 211 |
+
</button>
|
| 212 |
+
|
| 213 |
+
<button
|
| 214 |
+
onClick={() => setDarkMode(!darkMode)}
|
| 215 |
+
className="btn btn-ghost rounded-full w-10 h-10 p-0"
|
| 216 |
+
title="Toggle Dark Mode"
|
| 217 |
+
>
|
| 218 |
+
{darkMode ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
|
| 219 |
+
</button>
|
| 220 |
+
</div>
|
| 221 |
+
</div>
|
| 222 |
+
</header>
|
| 223 |
+
|
| 224 |
+
<div className="container grid grid-cols-1 lg:grid-cols-12 gap-8 mt-8">
|
| 225 |
+
{/* Left Sidebar */}
|
| 226 |
+
<div className="lg:col-span-4 space-y-6">
|
| 227 |
+
<motion.div
|
| 228 |
+
initial={{ opacity: 0, x: -20 }}
|
| 229 |
+
animate={{ opacity: 1, x: 0 }}
|
| 230 |
+
transition={{ duration: 0.3 }}
|
| 231 |
+
>
|
| 232 |
+
<Card>
|
| 233 |
+
<CardHeader>
|
| 234 |
+
<CardTitle className="text-lg">Configuration</CardTitle>
|
| 235 |
+
</CardHeader>
|
| 236 |
+
<CardContent className="space-y-4">
|
| 237 |
+
<div>
|
| 238 |
+
<label className="label">Speaker ID</label>
|
| 239 |
+
<input
|
| 240 |
+
type="text"
|
| 241 |
+
className="input"
|
| 242 |
+
placeholder="e.g. spk_001"
|
| 243 |
+
value={speakerId}
|
| 244 |
+
onChange={(e) => setSpeakerId(e.target.value)}
|
| 245 |
+
/>
|
| 246 |
+
</div>
|
| 247 |
+
<div>
|
| 248 |
+
<label className="label">Dataset Name</label>
|
| 249 |
+
<input
|
| 250 |
+
type="text"
|
| 251 |
+
className="input"
|
| 252 |
+
placeholder="e.g. common_voice"
|
| 253 |
+
value={datasetName}
|
| 254 |
+
onChange={(e) => setDatasetName(e.target.value)}
|
| 255 |
+
/>
|
| 256 |
+
</div>
|
| 257 |
+
</CardContent>
|
| 258 |
+
</Card>
|
| 259 |
+
</motion.div>
|
| 260 |
+
|
| 261 |
+
<FontSelector currentFont={fontStyle} onFontChange={handleFontChange} />
|
| 262 |
+
|
| 263 |
+
<TextInput onSentencesLoaded={handleSentencesLoaded} />
|
| 264 |
+
|
| 265 |
+
<DatasetStats />
|
| 266 |
+
</div>
|
| 267 |
+
|
| 268 |
+
{/* Main Content */}
|
| 269 |
+
<div className="lg:col-span-8 space-y-6">
|
| 270 |
+
<AnimatePresence mode="wait">
|
| 271 |
+
{sentences.length > 0 ? (
|
| 272 |
+
<motion.div
|
| 273 |
+
key="content"
|
| 274 |
+
initial={{ opacity: 0, y: 20 }}
|
| 275 |
+
animate={{ opacity: 1, y: 0 }}
|
| 276 |
+
exit={{ opacity: 0, y: -20 }}
|
| 277 |
+
transition={{ duration: 0.3 }}
|
| 278 |
+
className="space-y-6"
|
| 279 |
+
>
|
| 280 |
+
{/* Navigation Bar */}
|
| 281 |
+
<div className="flex items-center gap-2 overflow-x-auto pb-2">
|
| 282 |
+
<form onSubmit={handleJump} className="flex items-center gap-2">
|
| 283 |
+
<div className="relative">
|
| 284 |
+
<Hash className="absolute left-2.5 top-2.5 w-4 h-4 text-muted-foreground" />
|
| 285 |
+
<input
|
| 286 |
+
type="number"
|
| 287 |
+
className="input w-24 pl-9"
|
| 288 |
+
placeholder="Jump"
|
| 289 |
+
value={jumpIndex}
|
| 290 |
+
onChange={(e) => setJumpIndex(e.target.value)}
|
| 291 |
+
/>
|
| 292 |
+
</div>
|
| 293 |
+
</form>
|
| 294 |
+
|
| 295 |
+
<form onSubmit={handleSearch} className="flex items-center gap-2">
|
| 296 |
+
<div className="relative">
|
| 297 |
+
<Search className="absolute left-2.5 top-2.5 w-4 h-4 text-muted-foreground" />
|
| 298 |
+
<input
|
| 299 |
+
id="search-input"
|
| 300 |
+
type="text"
|
| 301 |
+
className="input w-32 md:w-48 pl-9"
|
| 302 |
+
placeholder="Find text..."
|
| 303 |
+
value={searchQuery}
|
| 304 |
+
onChange={(e) => setSearchQuery(e.target.value)}
|
| 305 |
+
/>
|
| 306 |
+
</div>
|
| 307 |
+
</form>
|
| 308 |
+
|
| 309 |
+
<div className="flex-1" />
|
| 310 |
+
|
| 311 |
+
<button onClick={() => setCurrentIndex(0)} className="btn btn-secondary text-xs" title="First">
|
| 312 |
+
First
|
| 313 |
+
</button>
|
| 314 |
+
<button onClick={() => setCurrentIndex(sentences.length - 1)} className="btn btn-secondary text-xs" title="Last">
|
| 315 |
+
Last
|
| 316 |
+
</button>
|
| 317 |
+
</div>
|
| 318 |
+
|
| 319 |
+
<Card className="border-primary/20 shadow-lg shadow-primary/5">
|
| 320 |
+
<CardContent className="pt-6">
|
| 321 |
+
<div className="flex justify-between items-center mb-6">
|
| 322 |
+
<div className="flex gap-2">
|
| 323 |
+
<Badge variant="outline" className="opacity-70">
|
| 324 |
+
SENTENCE {currentIndex + 1}
|
| 325 |
+
</Badge>
|
| 326 |
+
<Badge variant="secondary" className="opacity-50 text-xs uppercase">
|
| 327 |
+
{detectedLang}
|
| 328 |
+
</Badge>
|
| 329 |
+
</div>
|
| 330 |
+
<div className="flex gap-2">
|
| 331 |
+
<button
|
| 332 |
+
className={cn(
|
| 333 |
+
"p-2 rounded-full transition-colors",
|
| 334 |
+
bookmarks.includes(currentIndex)
|
| 335 |
+
? "bg-primary text-primary-foreground shadow-lg shadow-primary/25"
|
| 336 |
+
: "hover:bg-secondary opacity-50 hover:opacity-100"
|
| 337 |
+
)}
|
| 338 |
+
onClick={toggleBookmark}
|
| 339 |
+
title="Bookmark"
|
| 340 |
+
>
|
| 341 |
+
<Bookmark className={cn("w-4 h-4", bookmarks.includes(currentIndex) && "fill-current")} />
|
| 342 |
+
</button>
|
| 343 |
+
</div>
|
| 344 |
+
</div>
|
| 345 |
+
|
| 346 |
+
<motion.div
|
| 347 |
+
key={currentIndex}
|
| 348 |
+
initial={{ opacity: 0, scale: 0.98 }}
|
| 349 |
+
animate={{ opacity: 1, scale: 1 }}
|
| 350 |
+
transition={{ duration: 0.2 }}
|
| 351 |
+
className={cn(
|
| 352 |
+
"sentence-display",
|
| 353 |
+
isRTLDir && "text-right"
|
| 354 |
+
)}
|
| 355 |
+
style={{ fontFamily: fontFamily, direction: isRTLDir ? 'rtl' : 'ltr' }}
|
| 356 |
+
>
|
| 357 |
+
{sentences[currentIndex]}
|
| 358 |
+
</motion.div>
|
| 359 |
+
</CardContent>
|
| 360 |
+
</Card>
|
| 361 |
+
|
| 362 |
+
{currentIndex < sentences.length - 1 && (
|
| 363 |
+
<motion.div
|
| 364 |
+
initial={{ opacity: 0 }}
|
| 365 |
+
animate={{ opacity: 0.6 }}
|
| 366 |
+
className="rounded-xl border border-dashed border-border p-4 bg-secondary/10"
|
| 367 |
+
>
|
| 368 |
+
<div className="text-xs font-bold opacity-50 uppercase tracking-wider mb-2">Next Up</div>
|
| 369 |
+
<div
|
| 370 |
+
className="text-lg text-center opacity-70 line-clamp-1"
|
| 371 |
+
style={{ fontFamily: fontFamily }}
|
| 372 |
+
>
|
| 373 |
+
{sentences[currentIndex + 1]}
|
| 374 |
+
</div>
|
| 375 |
+
</motion.div>
|
| 376 |
+
)}
|
| 377 |
+
|
| 378 |
+
<AudioRecorder
|
| 379 |
+
speakerId={speakerId}
|
| 380 |
+
datasetName={datasetName}
|
| 381 |
+
text={sentences[currentIndex]}
|
| 382 |
+
fontStyle={fontStyle}
|
| 383 |
+
index={currentIndex}
|
| 384 |
+
onSaved={() => { }}
|
| 385 |
+
onNext={handleNext}
|
| 386 |
+
onPrev={handlePrev}
|
| 387 |
+
onSkip={handleSkip}
|
| 388 |
+
hasPrev={currentIndex > 0}
|
| 389 |
+
hasNext={currentIndex < sentences.length - 1}
|
| 390 |
+
autoAdvance={autoAdvance}
|
| 391 |
+
autoSave={autoSave}
|
| 392 |
+
silenceThreshold={silenceThreshold}
|
| 393 |
+
/>
|
| 394 |
+
</motion.div>
|
| 395 |
+
) : (
|
| 396 |
+
<motion.div
|
| 397 |
+
key="empty"
|
| 398 |
+
initial={{ opacity: 0, scale: 0.9 }}
|
| 399 |
+
animate={{ opacity: 1, scale: 1 }}
|
| 400 |
+
className="flex flex-col items-center justify-center py-20 text-center opacity-70"
|
| 401 |
+
>
|
| 402 |
+
<div className="w-24 h-24 bg-secondary/50 rounded-full flex items-center justify-center mb-6">
|
| 403 |
+
<Mic2 className="w-10 h-10 text-primary/50" />
|
| 404 |
+
</div>
|
| 405 |
+
<h3 className="text-2xl font-bold mb-3">No Sentences Loaded</h3>
|
| 406 |
+
<p className="text-lg max-w-md mx-auto text-muted-foreground">
|
| 407 |
+
Import a text file or paste content to begin your recording session.
|
| 408 |
+
</p>
|
| 409 |
+
</motion.div>
|
| 410 |
+
)}
|
| 411 |
+
</AnimatePresence>
|
| 412 |
+
</div>
|
| 413 |
+
</div>
|
| 414 |
+
|
| 415 |
+
<SettingsModal
|
| 416 |
+
isOpen={isSettingsOpen}
|
| 417 |
+
onClose={() => setIsSettingsOpen(false)}
|
| 418 |
+
autoAdvance={autoAdvance}
|
| 419 |
+
setAutoAdvance={setAutoAdvance}
|
| 420 |
+
autoSave={autoSave}
|
| 421 |
+
setAutoSave={setAutoSave}
|
| 422 |
+
silenceThreshold={silenceThreshold}
|
| 423 |
+
setSilenceThreshold={setSilenceThreshold}
|
| 424 |
+
datasetName={datasetName}
|
| 425 |
+
/>
|
| 426 |
+
<HelpModal isOpen={isHelpOpen} onClose={() => setIsHelpOpen(false)} />
|
| 427 |
+
</main>
|
| 428 |
+
);
|
| 429 |
+
}
|
src/components/AudioRecorder.tsx
ADDED
|
@@ -0,0 +1,406 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import React, { useState, useRef, useEffect } from 'react';
|
| 4 |
+
import { Mic, Square, Save, Play, SkipForward, SkipBack, Star, Volume2 } from 'lucide-react';
|
| 5 |
+
import { motion, AnimatePresence } from 'framer-motion';
|
| 6 |
+
import { toast } from 'sonner';
|
| 7 |
+
import { Card, CardContent } from '@/components/ui/card';
|
| 8 |
+
import { Badge } from '@/components/ui/badge';
|
| 9 |
+
import { cn } from '@/lib/utils';
|
| 10 |
+
|
| 11 |
+
interface AudioRecorderProps {
|
| 12 |
+
speakerId: string;
|
| 13 |
+
datasetName: string;
|
| 14 |
+
text: string;
|
| 15 |
+
fontStyle: string;
|
| 16 |
+
index: number;
|
| 17 |
+
onSaved: () => void;
|
| 18 |
+
onNext: () => void;
|
| 19 |
+
onPrev: () => void;
|
| 20 |
+
onSkip: () => void;
|
| 21 |
+
hasPrev: boolean;
|
| 22 |
+
hasNext: boolean;
|
| 23 |
+
autoAdvance: boolean;
|
| 24 |
+
autoSave: boolean;
|
| 25 |
+
silenceThreshold: number;
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
const EMOTIONS = [
|
| 29 |
+
{ id: 'neutral', label: 'Neutral', color: 'bg-gray-500' },
|
| 30 |
+
{ id: 'happy', label: 'Happy', color: 'bg-yellow-500' },
|
| 31 |
+
{ id: 'sad', label: 'Sad', color: 'bg-blue-500' },
|
| 32 |
+
{ id: 'angry', label: 'Angry', color: 'bg-red-500' },
|
| 33 |
+
{ id: 'surprised', label: 'Surprised', color: 'bg-purple-500' },
|
| 34 |
+
{ id: 'whisper', label: 'Whisper', color: 'bg-indigo-500' },
|
| 35 |
+
{ id: 'excited', label: 'Excited', color: 'bg-orange-500' },
|
| 36 |
+
];
|
| 37 |
+
|
| 38 |
+
export default function AudioRecorder({
|
| 39 |
+
speakerId,
|
| 40 |
+
datasetName,
|
| 41 |
+
text,
|
| 42 |
+
fontStyle,
|
| 43 |
+
index,
|
| 44 |
+
onSaved,
|
| 45 |
+
onNext,
|
| 46 |
+
onPrev,
|
| 47 |
+
onSkip,
|
| 48 |
+
hasPrev,
|
| 49 |
+
hasNext,
|
| 50 |
+
autoAdvance,
|
| 51 |
+
autoSave,
|
| 52 |
+
silenceThreshold
|
| 53 |
+
}: AudioRecorderProps) {
|
| 54 |
+
const [isRecording, setIsRecording] = useState(false);
|
| 55 |
+
const [audioBlob, setAudioBlob] = useState<Blob | null>(null);
|
| 56 |
+
const [audioUrl, setAudioUrl] = useState<string | null>(null);
|
| 57 |
+
const [isSaving, setIsSaving] = useState(false);
|
| 58 |
+
const [isSilent, setIsSilent] = useState(false);
|
| 59 |
+
const [emotion, setEmotion] = useState('neutral');
|
| 60 |
+
const [rating, setRating] = useState(3);
|
| 61 |
+
const [duration, setDuration] = useState(0);
|
| 62 |
+
const [audioLevel, setAudioLevel] = useState(0);
|
| 63 |
+
|
| 64 |
+
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
| 65 |
+
const chunksRef = useRef<Blob[]>([]);
|
| 66 |
+
const canvasRef = useRef<HTMLCanvasElement>(null);
|
| 67 |
+
const audioContextRef = useRef<AudioContext | null>(null);
|
| 68 |
+
const analyserRef = useRef<AnalyserNode | null>(null);
|
| 69 |
+
const sourceRef = useRef<MediaStreamAudioSourceNode | null>(null);
|
| 70 |
+
const animationFrameRef = useRef<number | null>(null);
|
| 71 |
+
const startTimeRef = useRef<number>(0);
|
| 72 |
+
|
| 73 |
+
useEffect(() => {
|
| 74 |
+
return () => {
|
| 75 |
+
if (audioUrl) URL.revokeObjectURL(audioUrl);
|
| 76 |
+
if (animationFrameRef.current) cancelAnimationFrame(animationFrameRef.current);
|
| 77 |
+
if (audioContextRef.current) audioContextRef.current.close();
|
| 78 |
+
};
|
| 79 |
+
}, [audioUrl]);
|
| 80 |
+
|
| 81 |
+
const startRecording = async () => {
|
| 82 |
+
try {
|
| 83 |
+
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
| 84 |
+
mediaRecorderRef.current = new MediaRecorder(stream);
|
| 85 |
+
chunksRef.current = [];
|
| 86 |
+
startTimeRef.current = Date.now();
|
| 87 |
+
|
| 88 |
+
mediaRecorderRef.current.ondataavailable = (e) => {
|
| 89 |
+
if (e.data.size > 0) chunksRef.current.push(e.data);
|
| 90 |
+
};
|
| 91 |
+
|
| 92 |
+
mediaRecorderRef.current.onstop = () => {
|
| 93 |
+
const blob = new Blob(chunksRef.current, { type: 'audio/wav' });
|
| 94 |
+
setAudioBlob(blob);
|
| 95 |
+
setAudioUrl(URL.createObjectURL(blob));
|
| 96 |
+
setDuration((Date.now() - startTimeRef.current) / 1000);
|
| 97 |
+
stopVisualizer();
|
| 98 |
+
|
| 99 |
+
if (autoSave) {
|
| 100 |
+
saveRecording(blob);
|
| 101 |
+
}
|
| 102 |
+
};
|
| 103 |
+
|
| 104 |
+
mediaRecorderRef.current.start();
|
| 105 |
+
setIsRecording(true);
|
| 106 |
+
startVisualizer(stream);
|
| 107 |
+
toast.info('Recording started');
|
| 108 |
+
} catch (err) {
|
| 109 |
+
console.error('Error accessing microphone:', err);
|
| 110 |
+
toast.error('Could not access microphone');
|
| 111 |
+
}
|
| 112 |
+
};
|
| 113 |
+
|
| 114 |
+
const stopRecording = () => {
|
| 115 |
+
if (mediaRecorderRef.current && isRecording) {
|
| 116 |
+
mediaRecorderRef.current.stop();
|
| 117 |
+
setIsRecording(false);
|
| 118 |
+
mediaRecorderRef.current.stream.getTracks().forEach(track => track.stop());
|
| 119 |
+
toast.info('Recording stopped');
|
| 120 |
+
}
|
| 121 |
+
};
|
| 122 |
+
|
| 123 |
+
const startVisualizer = (stream: MediaStream) => {
|
| 124 |
+
if (!canvasRef.current) return;
|
| 125 |
+
|
| 126 |
+
// @ts-ignore
|
| 127 |
+
audioContextRef.current = new (window.AudioContext || (window as any).webkitAudioContext)();
|
| 128 |
+
analyserRef.current = audioContextRef.current.createAnalyser();
|
| 129 |
+
sourceRef.current = audioContextRef.current.createMediaStreamSource(stream);
|
| 130 |
+
|
| 131 |
+
sourceRef.current.connect(analyserRef.current);
|
| 132 |
+
analyserRef.current.fftSize = 256;
|
| 133 |
+
|
| 134 |
+
const bufferLength = analyserRef.current.frequencyBinCount;
|
| 135 |
+
const dataArray = new Uint8Array(bufferLength);
|
| 136 |
+
const canvas = canvasRef.current;
|
| 137 |
+
const ctx = canvas.getContext('2d');
|
| 138 |
+
if (!ctx) return;
|
| 139 |
+
|
| 140 |
+
const draw = () => {
|
| 141 |
+
if (!analyserRef.current) return;
|
| 142 |
+
|
| 143 |
+
animationFrameRef.current = requestAnimationFrame(draw);
|
| 144 |
+
analyserRef.current.getByteTimeDomainData(dataArray);
|
| 145 |
+
|
| 146 |
+
// Calculate RMS for level meter
|
| 147 |
+
let sum = 0;
|
| 148 |
+
for (let i = 0; i < bufferLength; i++) {
|
| 149 |
+
const x = (dataArray[i] - 128) / 128.0;
|
| 150 |
+
sum += x * x;
|
| 151 |
+
}
|
| 152 |
+
const rms = Math.sqrt(sum / bufferLength);
|
| 153 |
+
const db = 20 * Math.log10(rms);
|
| 154 |
+
// Normalize db to 0-1 range (approx -60db to 0db)
|
| 155 |
+
const level = Math.max(0, (db + 60) / 60);
|
| 156 |
+
setAudioLevel(level);
|
| 157 |
+
|
| 158 |
+
// Silence detection
|
| 159 |
+
const isCurrentlySilent = level < (silenceThreshold / 100);
|
| 160 |
+
setIsSilent(isCurrentlySilent);
|
| 161 |
+
|
| 162 |
+
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
| 163 |
+
|
| 164 |
+
ctx.lineWidth = 2;
|
| 165 |
+
ctx.strokeStyle = isCurrentlySilent ? 'rgb(239, 68, 68)' : 'rgb(139, 92, 246)';
|
| 166 |
+
ctx.beginPath();
|
| 167 |
+
|
| 168 |
+
const sliceWidth = canvas.width * 1.0 / bufferLength;
|
| 169 |
+
let x = 0;
|
| 170 |
+
|
| 171 |
+
for (let i = 0; i < bufferLength; i++) {
|
| 172 |
+
const v = dataArray[i] / 128.0;
|
| 173 |
+
const y = v * canvas.height / 2;
|
| 174 |
+
|
| 175 |
+
if (i === 0) {
|
| 176 |
+
ctx.moveTo(x, y);
|
| 177 |
+
} else {
|
| 178 |
+
ctx.lineTo(x, y);
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
x += sliceWidth;
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
+
ctx.lineTo(canvas.width, canvas.height / 2);
|
| 185 |
+
ctx.stroke();
|
| 186 |
+
};
|
| 187 |
+
|
| 188 |
+
draw();
|
| 189 |
+
};
|
| 190 |
+
|
| 191 |
+
const stopVisualizer = () => {
|
| 192 |
+
if (animationFrameRef.current) cancelAnimationFrame(animationFrameRef.current);
|
| 193 |
+
setAudioLevel(0);
|
| 194 |
+
};
|
| 195 |
+
|
| 196 |
+
const saveRecording = async (blobToSave: Blob | null = audioBlob) => {
|
| 197 |
+
if (!blobToSave || !speakerId || !datasetName) {
|
| 198 |
+
toast.error('Missing recording or metadata');
|
| 199 |
+
return;
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
setIsSaving(true);
|
| 203 |
+
const formData = new FormData();
|
| 204 |
+
formData.append('audio', blobToSave);
|
| 205 |
+
formData.append('metadata', JSON.stringify({
|
| 206 |
+
speaker_id: speakerId,
|
| 207 |
+
dataset_name: datasetName,
|
| 208 |
+
text: text,
|
| 209 |
+
font_style: fontStyle,
|
| 210 |
+
index: index,
|
| 211 |
+
emotion: emotion,
|
| 212 |
+
rating: rating,
|
| 213 |
+
duration: duration,
|
| 214 |
+
timestamp: new Date().toISOString()
|
| 215 |
+
}));
|
| 216 |
+
|
| 217 |
+
try {
|
| 218 |
+
const res = await fetch('/api/save-recording', {
|
| 219 |
+
method: 'POST',
|
| 220 |
+
body: formData
|
| 221 |
+
});
|
| 222 |
+
|
| 223 |
+
if (res.ok) {
|
| 224 |
+
onSaved();
|
| 225 |
+
setAudioBlob(null);
|
| 226 |
+
setAudioUrl(null);
|
| 227 |
+
toast.success('Recording saved successfully');
|
| 228 |
+
if (autoAdvance) {
|
| 229 |
+
onNext();
|
| 230 |
+
}
|
| 231 |
+
} else {
|
| 232 |
+
toast.error('Failed to save recording');
|
| 233 |
+
}
|
| 234 |
+
} catch (err) {
|
| 235 |
+
console.error('Error saving:', err);
|
| 236 |
+
toast.error('Error saving recording');
|
| 237 |
+
} finally {
|
| 238 |
+
setIsSaving(false);
|
| 239 |
+
}
|
| 240 |
+
};
|
| 241 |
+
|
| 242 |
+
return (
|
| 243 |
+
<Card className="border-primary/10">
|
| 244 |
+
<CardContent className="p-6 space-y-6">
|
| 245 |
+
{/* Meter Bar */}
|
| 246 |
+
<div className="w-full h-2 bg-secondary rounded-full overflow-hidden">
|
| 247 |
+
<div
|
| 248 |
+
className="h-full bg-primary transition-all duration-75 ease-out"
|
| 249 |
+
style={{ width: `${Math.min(100, audioLevel * 100)}%` }}
|
| 250 |
+
/>
|
| 251 |
+
</div>
|
| 252 |
+
|
| 253 |
+
<div className="flex justify-center relative">
|
| 254 |
+
<canvas
|
| 255 |
+
ref={canvasRef}
|
| 256 |
+
width={600}
|
| 257 |
+
height={100}
|
| 258 |
+
className="w-full h-24 bg-secondary/30 rounded-xl border border-border"
|
| 259 |
+
/>
|
| 260 |
+
{isRecording && isSilent && (
|
| 261 |
+
<Badge variant="destructive" className="absolute top-2 right-2 animate-pulse">
|
| 262 |
+
SILENCE DETECTED
|
| 263 |
+
</Badge>
|
| 264 |
+
)}
|
| 265 |
+
</div>
|
| 266 |
+
|
| 267 |
+
<div className="flex justify-center gap-6 items-center">
|
| 268 |
+
<AnimatePresence mode="wait">
|
| 269 |
+
{!isRecording ? (
|
| 270 |
+
<motion.button
|
| 271 |
+
key="record"
|
| 272 |
+
initial={{ scale: 0 }}
|
| 273 |
+
animate={{ scale: 1 }}
|
| 274 |
+
exit={{ scale: 0 }}
|
| 275 |
+
whileHover={{ scale: 1.1 }}
|
| 276 |
+
whileTap={{ scale: 0.9 }}
|
| 277 |
+
className="w-20 h-20 rounded-full bg-gradient-to-br from-red-500 to-pink-600 flex items-center justify-center shadow-lg shadow-red-500/30 hover:shadow-red-500/50 transition-shadow"
|
| 278 |
+
onClick={startRecording}
|
| 279 |
+
id="record-btn"
|
| 280 |
+
>
|
| 281 |
+
<Mic className="w-8 h-8 text-white" />
|
| 282 |
+
</motion.button>
|
| 283 |
+
) : (
|
| 284 |
+
<motion.button
|
| 285 |
+
key="stop"
|
| 286 |
+
initial={{ scale: 0 }}
|
| 287 |
+
animate={{ scale: 1 }}
|
| 288 |
+
exit={{ scale: 0 }}
|
| 289 |
+
whileHover={{ scale: 1.1 }}
|
| 290 |
+
whileTap={{ scale: 0.9 }}
|
| 291 |
+
className="w-20 h-20 rounded-full bg-secondary flex items-center justify-center shadow-lg hover:bg-secondary/80"
|
| 292 |
+
onClick={stopRecording}
|
| 293 |
+
>
|
| 294 |
+
<Square className="w-8 h-8 fill-current" />
|
| 295 |
+
</motion.button>
|
| 296 |
+
)}
|
| 297 |
+
</AnimatePresence>
|
| 298 |
+
</div>
|
| 299 |
+
|
| 300 |
+
<AnimatePresence>
|
| 301 |
+
{audioUrl && (
|
| 302 |
+
<motion.div
|
| 303 |
+
initial={{ opacity: 0, height: 0 }}
|
| 304 |
+
animate={{ opacity: 1, height: 'auto' }}
|
| 305 |
+
exit={{ opacity: 0, height: 0 }}
|
| 306 |
+
className="space-y-6 overflow-hidden"
|
| 307 |
+
>
|
| 308 |
+
<div className="flex items-center justify-between gap-4 p-4 bg-secondary/20 rounded-xl border border-border/50">
|
| 309 |
+
<audio src={audioUrl} controls className="flex-1 h-8" />
|
| 310 |
+
<div className="flex items-center gap-2 text-sm font-medium">
|
| 311 |
+
<Volume2 className="w-4 h-4 text-muted-foreground" />
|
| 312 |
+
{duration.toFixed(1)}s
|
| 313 |
+
</div>
|
| 314 |
+
</div>
|
| 315 |
+
|
| 316 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
| 317 |
+
<div className="space-y-2">
|
| 318 |
+
<label className="label">Emotion</label>
|
| 319 |
+
<div className="flex flex-wrap gap-2">
|
| 320 |
+
{EMOTIONS.map((e) => (
|
| 321 |
+
<button
|
| 322 |
+
key={e.id}
|
| 323 |
+
onClick={() => setEmotion(e.id)}
|
| 324 |
+
className={cn(
|
| 325 |
+
"px-3 py-1.5 rounded-full text-xs font-medium transition-all border",
|
| 326 |
+
emotion === e.id
|
| 327 |
+
? "bg-primary text-primary-foreground border-primary"
|
| 328 |
+
: "bg-secondary/50 text-muted-foreground border-transparent hover:bg-secondary"
|
| 329 |
+
)}
|
| 330 |
+
>
|
| 331 |
+
{e.label}
|
| 332 |
+
</button>
|
| 333 |
+
))}
|
| 334 |
+
</div>
|
| 335 |
+
</div>
|
| 336 |
+
|
| 337 |
+
<div className="space-y-2">
|
| 338 |
+
<label className="label">Quality Rating</label>
|
| 339 |
+
<div className="flex gap-1">
|
| 340 |
+
{[1, 2, 3, 4, 5].map((star) => (
|
| 341 |
+
<button
|
| 342 |
+
key={star}
|
| 343 |
+
onClick={() => setRating(star)}
|
| 344 |
+
className="p-1 hover:scale-110 transition-transform"
|
| 345 |
+
>
|
| 346 |
+
<Star
|
| 347 |
+
className={cn(
|
| 348 |
+
"w-6 h-6 transition-colors",
|
| 349 |
+
star <= rating ? "fill-yellow-500 text-yellow-500" : "text-muted-foreground"
|
| 350 |
+
)}
|
| 351 |
+
/>
|
| 352 |
+
</button>
|
| 353 |
+
))}
|
| 354 |
+
</div>
|
| 355 |
+
</div>
|
| 356 |
+
</div>
|
| 357 |
+
|
| 358 |
+
<div className="flex gap-4 pt-4 border-t border-border/50">
|
| 359 |
+
<button
|
| 360 |
+
className="btn btn-secondary flex-1"
|
| 361 |
+
onClick={onPrev}
|
| 362 |
+
disabled={!hasPrev}
|
| 363 |
+
>
|
| 364 |
+
<SkipBack className="w-4 h-4 mr-2" />
|
| 365 |
+
Previous
|
| 366 |
+
</button>
|
| 367 |
+
|
| 368 |
+
<button
|
| 369 |
+
className="btn btn-secondary flex-1"
|
| 370 |
+
onClick={onSkip}
|
| 371 |
+
title="Skip this sentence"
|
| 372 |
+
>
|
| 373 |
+
<SkipForward className="w-4 h-4 mr-2" />
|
| 374 |
+
Skip
|
| 375 |
+
</button>
|
| 376 |
+
|
| 377 |
+
<button
|
| 378 |
+
className="btn btn-primary flex-[2]"
|
| 379 |
+
onClick={() => saveRecording()}
|
| 380 |
+
disabled={isSaving}
|
| 381 |
+
id="save-btn"
|
| 382 |
+
>
|
| 383 |
+
{isSaving ? 'Saving...' : (
|
| 384 |
+
<>
|
| 385 |
+
<Save className="w-4 h-4 mr-2" />
|
| 386 |
+
Save & Next
|
| 387 |
+
</>
|
| 388 |
+
)}
|
| 389 |
+
</button>
|
| 390 |
+
|
| 391 |
+
<button
|
| 392 |
+
className="btn btn-secondary flex-1"
|
| 393 |
+
onClick={onNext}
|
| 394 |
+
disabled={!hasNext}
|
| 395 |
+
>
|
| 396 |
+
Next
|
| 397 |
+
<SkipForward className="w-4 h-4 ml-2" />
|
| 398 |
+
</button>
|
| 399 |
+
</div>
|
| 400 |
+
</motion.div>
|
| 401 |
+
)}
|
| 402 |
+
</AnimatePresence>
|
| 403 |
+
</CardContent>
|
| 404 |
+
</Card>
|
| 405 |
+
);
|
| 406 |
+
}
|
src/components/DatasetStats.tsx
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import React, { useEffect, useState } from 'react';
|
| 4 |
+
import { BarChart3, Clock, Mic, Users, History, Play } from 'lucide-react';
|
| 5 |
+
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
| 6 |
+
import { Badge } from '@/components/ui/badge';
|
| 7 |
+
|
| 8 |
+
interface Recording {
|
| 9 |
+
filename: string;
|
| 10 |
+
text: string;
|
| 11 |
+
duration: number;
|
| 12 |
+
timestamp: string;
|
| 13 |
+
emotion: string;
|
| 14 |
+
rating: number;
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
interface Stats {
|
| 18 |
+
total_recordings: number;
|
| 19 |
+
total_duration: number;
|
| 20 |
+
speakers: number;
|
| 21 |
+
recent_recordings: Recording[];
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
export default function DatasetStats() {
|
| 25 |
+
const [stats, setStats] = useState<Stats>({
|
| 26 |
+
total_recordings: 0,
|
| 27 |
+
total_duration: 0,
|
| 28 |
+
speakers: 0,
|
| 29 |
+
recent_recordings: []
|
| 30 |
+
});
|
| 31 |
+
|
| 32 |
+
const fetchStats = () => {
|
| 33 |
+
fetch('/api/dataset-stats')
|
| 34 |
+
.then(res => res.json())
|
| 35 |
+
.then(data => setStats(data))
|
| 36 |
+
.catch(err => console.error('Error fetching stats:', err));
|
| 37 |
+
};
|
| 38 |
+
|
| 39 |
+
useEffect(() => {
|
| 40 |
+
fetchStats();
|
| 41 |
+
const interval = setInterval(fetchStats, 5000); // Refresh every 5s
|
| 42 |
+
return () => clearInterval(interval);
|
| 43 |
+
}, []);
|
| 44 |
+
|
| 45 |
+
return (
|
| 46 |
+
<div className="space-y-6">
|
| 47 |
+
<Card className="bg-gradient-to-br from-primary/5 to-purple-500/5 border-primary/10">
|
| 48 |
+
<CardHeader>
|
| 49 |
+
<CardTitle className="text-lg flex items-center gap-2">
|
| 50 |
+
<BarChart3 className="w-4 h-4" />
|
| 51 |
+
Session Stats
|
| 52 |
+
</CardTitle>
|
| 53 |
+
</CardHeader>
|
| 54 |
+
<CardContent className="space-y-3">
|
| 55 |
+
<div className="flex items-center justify-between p-3 bg-background/50 rounded-lg border border-border/50">
|
| 56 |
+
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
| 57 |
+
<Mic className="w-4 h-4" />
|
| 58 |
+
<span>Recordings</span>
|
| 59 |
+
</div>
|
| 60 |
+
<span className="font-mono font-bold">{stats.total_recordings}</span>
|
| 61 |
+
</div>
|
| 62 |
+
|
| 63 |
+
<div className="flex items-center justify-between p-3 bg-background/50 rounded-lg border border-border/50">
|
| 64 |
+
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
| 65 |
+
<Clock className="w-4 h-4" />
|
| 66 |
+
<span>Duration</span>
|
| 67 |
+
</div>
|
| 68 |
+
<span className="font-mono font-bold">{(stats.total_duration / 60).toFixed(1)}m</span>
|
| 69 |
+
</div>
|
| 70 |
+
|
| 71 |
+
<div className="flex items-center justify-between p-3 bg-background/50 rounded-lg border border-border/50">
|
| 72 |
+
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
| 73 |
+
<Users className="w-4 h-4" />
|
| 74 |
+
<span>Speakers</span>
|
| 75 |
+
</div>
|
| 76 |
+
<span className="font-mono font-bold">{stats.speakers}</span>
|
| 77 |
+
</div>
|
| 78 |
+
</CardContent>
|
| 79 |
+
</Card>
|
| 80 |
+
|
| 81 |
+
<Card className="max-h-[400px] flex flex-col">
|
| 82 |
+
<CardHeader>
|
| 83 |
+
<CardTitle className="text-lg flex items-center gap-2">
|
| 84 |
+
<History className="w-4 h-4" />
|
| 85 |
+
Recent History
|
| 86 |
+
</CardTitle>
|
| 87 |
+
</CardHeader>
|
| 88 |
+
<CardContent className="flex-1 overflow-y-auto space-y-2 pr-2 custom-scrollbar">
|
| 89 |
+
{stats.recent_recordings.length === 0 ? (
|
| 90 |
+
<p className="text-sm text-muted-foreground text-center py-4">No recordings yet</p>
|
| 91 |
+
) : (
|
| 92 |
+
stats.recent_recordings.map((rec, i) => (
|
| 93 |
+
<div key={i} className="p-3 rounded-lg bg-secondary/20 border border-border/50 hover:bg-secondary/40 transition-colors group">
|
| 94 |
+
<div className="flex justify-between items-start mb-1">
|
| 95 |
+
<span className="text-xs font-mono text-muted-foreground truncate max-w-[150px]" title={rec.filename}>
|
| 96 |
+
{rec.filename}
|
| 97 |
+
</span>
|
| 98 |
+
<Badge variant="outline" className="text-[10px] h-5 px-1.5">
|
| 99 |
+
{rec.emotion}
|
| 100 |
+
</Badge>
|
| 101 |
+
</div>
|
| 102 |
+
<p className="text-sm line-clamp-2 mb-2 text-foreground/90">{rec.text}</p>
|
| 103 |
+
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
| 104 |
+
<div className="flex items-center gap-1">
|
| 105 |
+
<Clock className="w-3 h-3" />
|
| 106 |
+
{rec.duration.toFixed(1)}s
|
| 107 |
+
</div>
|
| 108 |
+
<div className="flex gap-0.5">
|
| 109 |
+
{[...Array(rec.rating)].map((_, i) => (
|
| 110 |
+
<span key={i} className="text-yellow-500">★</span>
|
| 111 |
+
))}
|
| 112 |
+
</div>
|
| 113 |
+
</div>
|
| 114 |
+
</div>
|
| 115 |
+
))
|
| 116 |
+
)}
|
| 117 |
+
</CardContent>
|
| 118 |
+
</Card>
|
| 119 |
+
</div>
|
| 120 |
+
);
|
| 121 |
+
}
|
src/components/FontSelector.tsx
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import React, { useState, useEffect, useRef } from 'react';
|
| 4 |
+
import { Type, Upload, Check, Star } from 'lucide-react';
|
| 5 |
+
import { toast } from 'sonner';
|
| 6 |
+
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
| 7 |
+
import { Badge } from '@/components/ui/badge';
|
| 8 |
+
import { getSuggestedFont } from '@/lib/language';
|
| 9 |
+
|
| 10 |
+
interface Font {
|
| 11 |
+
name: string;
|
| 12 |
+
family: string;
|
| 13 |
+
css: string;
|
| 14 |
+
url?: string;
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
interface FontSelectorProps {
|
| 18 |
+
currentFont: string;
|
| 19 |
+
onFontChange: (font: string) => void;
|
| 20 |
+
detectedLang?: string;
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
export default function FontSelector({ currentFont, onFontChange, detectedLang }: FontSelectorProps) {
|
| 24 |
+
const [fonts, setFonts] = useState<Font[]>([]);
|
| 25 |
+
const [loading, setLoading] = useState(true);
|
| 26 |
+
const [suggestedFont, setSuggestedFont] = useState<string | null>(null);
|
| 27 |
+
const fileInputRef = useRef<HTMLInputElement>(null);
|
| 28 |
+
|
| 29 |
+
const fetchFonts = async () => {
|
| 30 |
+
try {
|
| 31 |
+
const res = await fetch('/api/fonts');
|
| 32 |
+
const data = await res.json();
|
| 33 |
+
if (data.fonts) {
|
| 34 |
+
setFonts(data.fonts);
|
| 35 |
+
|
| 36 |
+
// Inject custom fonts styles
|
| 37 |
+
data.fonts.forEach((font: Font) => {
|
| 38 |
+
if (font.url) {
|
| 39 |
+
const style = document.createElement('style');
|
| 40 |
+
style.textContent = `
|
| 41 |
+
@font-face {
|
| 42 |
+
font-family: '${font.family}';
|
| 43 |
+
src: url('${font.url}') format('truetype');
|
| 44 |
+
}
|
| 45 |
+
`;
|
| 46 |
+
document.head.appendChild(style);
|
| 47 |
+
}
|
| 48 |
+
});
|
| 49 |
+
}
|
| 50 |
+
} catch (error) {
|
| 51 |
+
console.error('Failed to fetch fonts', error);
|
| 52 |
+
toast.error('Failed to load fonts');
|
| 53 |
+
} finally {
|
| 54 |
+
setLoading(false);
|
| 55 |
+
}
|
| 56 |
+
};
|
| 57 |
+
|
| 58 |
+
useEffect(() => {
|
| 59 |
+
fetchFonts();
|
| 60 |
+
}, []);
|
| 61 |
+
|
| 62 |
+
useEffect(() => {
|
| 63 |
+
if (detectedLang) {
|
| 64 |
+
const suggested = getSuggestedFont(detectedLang);
|
| 65 |
+
setSuggestedFont(suggested);
|
| 66 |
+
}
|
| 67 |
+
}, [detectedLang]);
|
| 68 |
+
|
| 69 |
+
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
| 70 |
+
const file = e.target.files?.[0];
|
| 71 |
+
if (!file) return;
|
| 72 |
+
|
| 73 |
+
const formData = new FormData();
|
| 74 |
+
formData.append('font', file);
|
| 75 |
+
|
| 76 |
+
const promise = fetch('/api/upload-font', {
|
| 77 |
+
method: 'POST',
|
| 78 |
+
body: formData,
|
| 79 |
+
}).then(async (res) => {
|
| 80 |
+
if (!res.ok) throw new Error('Upload failed');
|
| 81 |
+
await fetchFonts();
|
| 82 |
+
return 'Font uploaded successfully';
|
| 83 |
+
});
|
| 84 |
+
|
| 85 |
+
toast.promise(promise, {
|
| 86 |
+
loading: 'Uploading font...',
|
| 87 |
+
success: (data) => data,
|
| 88 |
+
error: 'Failed to upload font',
|
| 89 |
+
});
|
| 90 |
+
};
|
| 91 |
+
|
| 92 |
+
return (
|
| 93 |
+
<Card>
|
| 94 |
+
<CardHeader>
|
| 95 |
+
<CardTitle className="text-lg flex items-center gap-2">
|
| 96 |
+
<Type className="w-4 h-4" />
|
| 97 |
+
Typography
|
| 98 |
+
</CardTitle>
|
| 99 |
+
</CardHeader>
|
| 100 |
+
<CardContent className="space-y-4">
|
| 101 |
+
<div className="space-y-2">
|
| 102 |
+
<label className="label">Display Font</label>
|
| 103 |
+
<div className="space-y-2 max-h-[200px] overflow-y-auto pr-2 custom-scrollbar">
|
| 104 |
+
{fonts.map((font) => (
|
| 105 |
+
<div
|
| 106 |
+
key={font.family}
|
| 107 |
+
className={`
|
| 108 |
+
p-3 rounded-lg border cursor-pointer transition-all flex items-center justify-between group
|
| 109 |
+
${currentFont === font.family
|
| 110 |
+
? 'bg-primary/10 border-primary shadow-sm'
|
| 111 |
+
: 'bg-background hover:bg-secondary/50 border-border/50 hover:border-border'
|
| 112 |
+
}
|
| 113 |
+
`}
|
| 114 |
+
onClick={() => onFontChange(font.family)}
|
| 115 |
+
>
|
| 116 |
+
<div className="flex flex-col gap-1">
|
| 117 |
+
<span className="text-sm font-medium" style={{ fontFamily: font.family }}>
|
| 118 |
+
{font.name}
|
| 119 |
+
</span>
|
| 120 |
+
{suggestedFont === font.name && (
|
| 121 |
+
<Badge variant="secondary" className="text-[10px] w-fit px-1.5 h-4 gap-1">
|
| 122 |
+
<Star className="w-2 h-2 fill-current" /> Recommended
|
| 123 |
+
</Badge>
|
| 124 |
+
)}
|
| 125 |
+
</div>
|
| 126 |
+
{currentFont === font.family && (
|
| 127 |
+
<Check className="w-4 h-4 text-primary" />
|
| 128 |
+
)}
|
| 129 |
+
</div>
|
| 130 |
+
))}
|
| 131 |
+
</div>
|
| 132 |
+
<p className="text-xs text-muted-foreground mt-2">
|
| 133 |
+
Select a font optimized for the target language.
|
| 134 |
+
</p>
|
| 135 |
+
</div>
|
| 136 |
+
|
| 137 |
+
<div className="relative">
|
| 138 |
+
<div className="absolute inset-0 flex items-center">
|
| 139 |
+
<span className="w-full border-t border-border" />
|
| 140 |
+
</div>
|
| 141 |
+
<div className="relative flex justify-center text-xs uppercase">
|
| 142 |
+
<span className="bg-card px-2 text-muted-foreground">Custom Font</span>
|
| 143 |
+
</div>
|
| 144 |
+
</div>
|
| 145 |
+
|
| 146 |
+
<button
|
| 147 |
+
className="btn btn-secondary w-full"
|
| 148 |
+
onClick={() => fileInputRef.current?.click()}
|
| 149 |
+
>
|
| 150 |
+
<Upload className="w-4 h-4 mr-2" />
|
| 151 |
+
Upload .ttf File
|
| 152 |
+
</button>
|
| 153 |
+
<input
|
| 154 |
+
type="file"
|
| 155 |
+
accept=".ttf"
|
| 156 |
+
ref={fileInputRef}
|
| 157 |
+
className="hidden"
|
| 158 |
+
onChange={handleFileUpload}
|
| 159 |
+
/>
|
| 160 |
+
</CardContent>
|
| 161 |
+
</Card>
|
| 162 |
+
);
|
| 163 |
+
}
|
src/components/HelpModal.tsx
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import React from 'react';
|
| 4 |
+
import { X, Keyboard, Mic, Save, Search, SkipForward, Bookmark, HelpCircle } from 'lucide-react';
|
| 5 |
+
import { motion, AnimatePresence } from 'framer-motion';
|
| 6 |
+
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
| 7 |
+
|
| 8 |
+
interface HelpModalProps {
|
| 9 |
+
isOpen: boolean;
|
| 10 |
+
onClose: () => void;
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
export default function HelpModal({ isOpen, onClose }: HelpModalProps) {
|
| 14 |
+
return (
|
| 15 |
+
<AnimatePresence>
|
| 16 |
+
{isOpen && (
|
| 17 |
+
<>
|
| 18 |
+
<motion.div
|
| 19 |
+
initial={{ opacity: 0 }}
|
| 20 |
+
animate={{ opacity: 1 }}
|
| 21 |
+
exit={{ opacity: 0 }}
|
| 22 |
+
className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50"
|
| 23 |
+
onClick={onClose}
|
| 24 |
+
/>
|
| 25 |
+
<motion.div
|
| 26 |
+
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
| 27 |
+
animate={{ opacity: 1, scale: 1, y: 0 }}
|
| 28 |
+
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
| 29 |
+
className="fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 w-full max-w-2xl z-50 max-h-[85vh] overflow-y-auto"
|
| 30 |
+
>
|
| 31 |
+
<Card>
|
| 32 |
+
<CardHeader>
|
| 33 |
+
<CardTitle className="flex items-center justify-between">
|
| 34 |
+
<div className="flex items-center gap-2">
|
| 35 |
+
<HelpCircle className="w-5 h-5 text-primary" />
|
| 36 |
+
<span>Help & Documentation</span>
|
| 37 |
+
</div>
|
| 38 |
+
<button onClick={onClose} className="text-muted-foreground hover:text-foreground">
|
| 39 |
+
<X className="w-5 h-5" />
|
| 40 |
+
</button>
|
| 41 |
+
</CardTitle>
|
| 42 |
+
</CardHeader>
|
| 43 |
+
<CardContent className="space-y-6">
|
| 44 |
+
{/* Quick Start */}
|
| 45 |
+
<section className="space-y-3">
|
| 46 |
+
<h3 className="text-lg font-semibold flex items-center gap-2">
|
| 47 |
+
🚀 Quick Start
|
| 48 |
+
</h3>
|
| 49 |
+
<ol className="list-decimal list-inside space-y-2 text-sm text-muted-foreground ml-2">
|
| 50 |
+
<li>Enter a <strong>Speaker ID</strong> and <strong>Dataset Name</strong>.</li>
|
| 51 |
+
<li>Paste your text sentences into the text area and click <strong>Load Sentences</strong>.</li>
|
| 52 |
+
<li>Press <strong>Spacebar</strong> or click the microphone to start recording.</li>
|
| 53 |
+
<li>Rate the recording and select an emotion (optional).</li>
|
| 54 |
+
<li>Press <strong>Ctrl + S</strong> or click "Save & Next" to save and move to the next sentence.</li>
|
| 55 |
+
<li>When finished, go to <strong>Settings</strong> to export your dataset.</li>
|
| 56 |
+
</ol>
|
| 57 |
+
</section>
|
| 58 |
+
|
| 59 |
+
<div className="grid md:grid-cols-2 gap-6">
|
| 60 |
+
{/* Features */}
|
| 61 |
+
<section className="space-y-3">
|
| 62 |
+
<h3 className="text-lg font-semibold flex items-center gap-2">
|
| 63 |
+
✨ Features
|
| 64 |
+
</h3>
|
| 65 |
+
<ul className="space-y-2 text-sm">
|
| 66 |
+
<li className="flex items-start gap-2">
|
| 67 |
+
<Mic className="w-4 h-4 mt-0.5 text-primary" />
|
| 68 |
+
<span><strong>Emotion Tagging:</strong> Label recordings with emotions like Happy, Sad, or Whisper.</span>
|
| 69 |
+
</li>
|
| 70 |
+
<li className="flex items-start gap-2">
|
| 71 |
+
<Bookmark className="w-4 h-4 mt-0.5 text-primary" />
|
| 72 |
+
<span><strong>Bookmarks:</strong> Flag difficult sentences to review later.</span>
|
| 73 |
+
</li>
|
| 74 |
+
<li className="flex items-start gap-2">
|
| 75 |
+
<Search className="w-4 h-4 mt-0.5 text-primary" />
|
| 76 |
+
<span><strong>Search:</strong> Find specific sentences by keyword.</span>
|
| 77 |
+
</li>
|
| 78 |
+
<li className="flex items-start gap-2">
|
| 79 |
+
<SkipForward className="w-4 h-4 mt-0.5 text-primary" />
|
| 80 |
+
<span><strong>Skip:</strong> Skip irrelevant or problematic sentences.</span>
|
| 81 |
+
</li>
|
| 82 |
+
</ul>
|
| 83 |
+
</section>
|
| 84 |
+
|
| 85 |
+
{/* Keyboard Shortcuts */}
|
| 86 |
+
<section className="space-y-3">
|
| 87 |
+
<h3 className="text-lg font-semibold flex items-center gap-2">
|
| 88 |
+
⌨️ Shortcuts
|
| 89 |
+
</h3>
|
| 90 |
+
<div className="space-y-2 text-sm">
|
| 91 |
+
<div className="flex justify-between items-center bg-secondary/30 p-2 rounded">
|
| 92 |
+
<span>Start/Stop Recording</span>
|
| 93 |
+
<kbd className="bg-background border border-border px-1.5 rounded text-xs">Space</kbd>
|
| 94 |
+
</div>
|
| 95 |
+
<div className="flex justify-between items-center bg-secondary/30 p-2 rounded">
|
| 96 |
+
<span>Save & Next</span>
|
| 97 |
+
<kbd className="bg-background border border-border px-1.5 rounded text-xs">Ctrl + S</kbd>
|
| 98 |
+
</div>
|
| 99 |
+
<div className="flex justify-between items-center bg-secondary/30 p-2 rounded">
|
| 100 |
+
<span>Next Sentence</span>
|
| 101 |
+
<kbd className="bg-background border border-border px-1.5 rounded text-xs">→</kbd>
|
| 102 |
+
</div>
|
| 103 |
+
<div className="flex justify-between items-center bg-secondary/30 p-2 rounded">
|
| 104 |
+
<span>Previous Sentence</span>
|
| 105 |
+
<kbd className="bg-background border border-border px-1.5 rounded text-xs">←</kbd>
|
| 106 |
+
</div>
|
| 107 |
+
<div className="flex justify-between items-center bg-secondary/30 p-2 rounded">
|
| 108 |
+
<span>Focus Search</span>
|
| 109 |
+
<kbd className="bg-background border border-border px-1.5 rounded text-xs">Ctrl + F</kbd>
|
| 110 |
+
</div>
|
| 111 |
+
</div>
|
| 112 |
+
</section>
|
| 113 |
+
</div>
|
| 114 |
+
</CardContent>
|
| 115 |
+
</Card>
|
| 116 |
+
</motion.div>
|
| 117 |
+
</>
|
| 118 |
+
)}
|
| 119 |
+
</AnimatePresence>
|
| 120 |
+
);
|
| 121 |
+
}
|
src/components/Providers.tsx
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import { Toaster } from 'sonner';
|
| 4 |
+
|
| 5 |
+
export function Providers({ children }: { children: React.ReactNode }) {
|
| 6 |
+
return (
|
| 7 |
+
<>
|
| 8 |
+
{children}
|
| 9 |
+
<Toaster position="top-center" richColors />
|
| 10 |
+
</>
|
| 11 |
+
);
|
| 12 |
+
}
|
src/components/SettingsModal.tsx
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import React from 'react';
|
| 4 |
+
import { X, Download, Save, Trash2, Mic, Settings } from 'lucide-react';
|
| 5 |
+
import { motion, AnimatePresence } from 'framer-motion';
|
| 6 |
+
import { toast } from 'sonner';
|
| 7 |
+
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
| 8 |
+
import { Switch } from '@/components/ui/switch';
|
| 9 |
+
import { Slider } from '@/components/ui/slider';
|
| 10 |
+
|
| 11 |
+
interface SettingsModalProps {
|
| 12 |
+
isOpen: boolean;
|
| 13 |
+
onClose: () => void;
|
| 14 |
+
autoAdvance: boolean;
|
| 15 |
+
setAutoAdvance: (value: boolean) => void;
|
| 16 |
+
autoSave: boolean;
|
| 17 |
+
setAutoSave: (value: boolean) => void;
|
| 18 |
+
silenceThreshold: number;
|
| 19 |
+
setSilenceThreshold: (value: number) => void;
|
| 20 |
+
datasetName: string;
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
export default function SettingsModal({
|
| 24 |
+
isOpen,
|
| 25 |
+
onClose,
|
| 26 |
+
autoAdvance,
|
| 27 |
+
setAutoAdvance,
|
| 28 |
+
autoSave,
|
| 29 |
+
setAutoSave,
|
| 30 |
+
silenceThreshold,
|
| 31 |
+
setSilenceThreshold,
|
| 32 |
+
datasetName
|
| 33 |
+
}: SettingsModalProps) {
|
| 34 |
+
const handleExport = async () => {
|
| 35 |
+
try {
|
| 36 |
+
// Trigger download by navigating to the API endpoint
|
| 37 |
+
window.location.href = `/api/export-dataset?dataset_name=${datasetName}`;
|
| 38 |
+
toast.success('Export started');
|
| 39 |
+
} catch (error) {
|
| 40 |
+
console.error(error);
|
| 41 |
+
toast.error('Error exporting dataset');
|
| 42 |
+
}
|
| 43 |
+
};
|
| 44 |
+
|
| 45 |
+
return (
|
| 46 |
+
<AnimatePresence>
|
| 47 |
+
{isOpen && (
|
| 48 |
+
<>
|
| 49 |
+
<motion.div
|
| 50 |
+
initial={{ opacity: 0 }}
|
| 51 |
+
animate={{ opacity: 1 }}
|
| 52 |
+
exit={{ opacity: 0 }}
|
| 53 |
+
className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50"
|
| 54 |
+
onClick={onClose}
|
| 55 |
+
/>
|
| 56 |
+
<motion.div
|
| 57 |
+
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
| 58 |
+
animate={{ opacity: 1, scale: 1, y: 0 }}
|
| 59 |
+
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
| 60 |
+
className="fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 w-full max-w-md z-50"
|
| 61 |
+
>
|
| 62 |
+
<Card>
|
| 63 |
+
<CardHeader>
|
| 64 |
+
<CardTitle className="flex items-center justify-between">
|
| 65 |
+
<span>Settings</span>
|
| 66 |
+
<button onClick={onClose} className="text-muted-foreground hover:text-foreground">
|
| 67 |
+
<X className="w-5 h-5" />
|
| 68 |
+
</button>
|
| 69 |
+
</CardTitle>
|
| 70 |
+
</CardHeader>
|
| 71 |
+
<CardContent className="space-y-6">
|
| 72 |
+
<div className="space-y-4">
|
| 73 |
+
<div className="flex items-center justify-between">
|
| 74 |
+
<div className="space-y-0.5">
|
| 75 |
+
<label className="text-sm font-medium">Auto-advance</label>
|
| 76 |
+
<p className="text-xs text-muted-foreground">
|
| 77 |
+
Go to next sentence after saving
|
| 78 |
+
</p>
|
| 79 |
+
</div>
|
| 80 |
+
<Switch checked={autoAdvance} onCheckedChange={setAutoAdvance} />
|
| 81 |
+
</div>
|
| 82 |
+
|
| 83 |
+
<div className="flex items-center justify-between">
|
| 84 |
+
<div className="space-y-0.5">
|
| 85 |
+
<label className="text-sm font-medium">Auto-save</label>
|
| 86 |
+
<p className="text-xs text-muted-foreground">
|
| 87 |
+
Save automatically when recording stops
|
| 88 |
+
</p>
|
| 89 |
+
</div>
|
| 90 |
+
<Switch checked={autoSave} onCheckedChange={setAutoSave} />
|
| 91 |
+
</div>
|
| 92 |
+
|
| 93 |
+
<div className="space-y-2">
|
| 94 |
+
<div className="flex justify-between">
|
| 95 |
+
<label className="text-sm font-medium">Silence Threshold</label>
|
| 96 |
+
<span className="text-xs text-muted-foreground">{silenceThreshold}%</span>
|
| 97 |
+
</div>
|
| 98 |
+
<Slider
|
| 99 |
+
value={[silenceThreshold]}
|
| 100 |
+
onValueChange={(vals) => setSilenceThreshold(vals[0])}
|
| 101 |
+
max={100}
|
| 102 |
+
step={1}
|
| 103 |
+
/>
|
| 104 |
+
</div>
|
| 105 |
+
|
| 106 |
+
<div className="pt-4 border-t border-border">
|
| 107 |
+
<h4 className="text-sm font-medium mb-3">Data Management</h4>
|
| 108 |
+
<button
|
| 109 |
+
onClick={handleExport}
|
| 110 |
+
className="btn btn-secondary w-full flex items-center justify-center gap-2"
|
| 111 |
+
>
|
| 112 |
+
<Download className="w-4 h-4" />
|
| 113 |
+
Export Dataset (ZIP)
|
| 114 |
+
</button>
|
| 115 |
+
</div>
|
| 116 |
+
</div>
|
| 117 |
+
</CardContent>
|
| 118 |
+
</Card>
|
| 119 |
+
</motion.div>
|
| 120 |
+
</>
|
| 121 |
+
)}
|
| 122 |
+
</AnimatePresence>
|
| 123 |
+
);
|
| 124 |
+
}
|
src/components/TextInput.tsx
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import React, { useState, useRef } from 'react';
|
| 4 |
+
import { Upload, FileText } from 'lucide-react';
|
| 5 |
+
import { toast } from 'sonner';
|
| 6 |
+
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
| 7 |
+
|
| 8 |
+
interface TextInputProps {
|
| 9 |
+
onSentencesLoaded: (sentences: string[]) => void;
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
export default function TextInput({ onSentencesLoaded }: TextInputProps) {
|
| 13 |
+
const [text, setText] = useState('');
|
| 14 |
+
const fileInputRef = useRef<HTMLInputElement>(null);
|
| 15 |
+
|
| 16 |
+
const processText = (inputText: string) => {
|
| 17 |
+
if (!inputText.trim()) return;
|
| 18 |
+
|
| 19 |
+
// Simple sentence splitting (can be improved or use API)
|
| 20 |
+
// Split by . ! ? followed by space or newline
|
| 21 |
+
const sentences = inputText
|
| 22 |
+
.replace(/([.!?])\s+/g, '$1|')
|
| 23 |
+
.split('|')
|
| 24 |
+
.map(s => s.trim())
|
| 25 |
+
.filter(s => s.length > 0);
|
| 26 |
+
|
| 27 |
+
if (sentences.length > 0) {
|
| 28 |
+
onSentencesLoaded(sentences);
|
| 29 |
+
toast.success(`Loaded ${sentences.length} sentences`);
|
| 30 |
+
setText('');
|
| 31 |
+
} else {
|
| 32 |
+
toast.error('No valid sentences found');
|
| 33 |
+
}
|
| 34 |
+
};
|
| 35 |
+
|
| 36 |
+
const handleTextChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
| 37 |
+
setText(e.target.value);
|
| 38 |
+
};
|
| 39 |
+
|
| 40 |
+
const handlePaste = () => {
|
| 41 |
+
processText(text);
|
| 42 |
+
};
|
| 43 |
+
|
| 44 |
+
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
| 45 |
+
const file = e.target.files?.[0];
|
| 46 |
+
if (!file) return;
|
| 47 |
+
|
| 48 |
+
const reader = new FileReader();
|
| 49 |
+
reader.onload = (event) => {
|
| 50 |
+
const content = event.target?.result as string;
|
| 51 |
+
setText(content);
|
| 52 |
+
processText(content);
|
| 53 |
+
toast.success(`File loaded: ${file.name}`);
|
| 54 |
+
};
|
| 55 |
+
reader.onerror = () => toast.error('Failed to read file');
|
| 56 |
+
reader.readAsText(file);
|
| 57 |
+
};
|
| 58 |
+
|
| 59 |
+
return (
|
| 60 |
+
<Card>
|
| 61 |
+
<CardHeader>
|
| 62 |
+
<CardTitle className="text-lg flex items-center gap-2">
|
| 63 |
+
<FileText className="w-4 h-4" />
|
| 64 |
+
Input Data
|
| 65 |
+
</CardTitle>
|
| 66 |
+
</CardHeader>
|
| 67 |
+
<CardContent className="space-y-4">
|
| 68 |
+
<div
|
| 69 |
+
className="border-2 border-dashed border-border rounded-xl p-6 text-center hover:bg-secondary/50 transition-colors cursor-pointer relative group"
|
| 70 |
+
onClick={() => fileInputRef.current?.click()}
|
| 71 |
+
>
|
| 72 |
+
<input
|
| 73 |
+
type="file"
|
| 74 |
+
accept=".txt"
|
| 75 |
+
ref={fileInputRef}
|
| 76 |
+
className="hidden"
|
| 77 |
+
onChange={handleFileUpload}
|
| 78 |
+
/>
|
| 79 |
+
<Upload className="w-8 h-8 mx-auto mb-2 text-muted-foreground group-hover:text-primary transition-colors" />
|
| 80 |
+
<p className="text-sm font-medium">Drop text file or click to upload</p>
|
| 81 |
+
<p className="text-xs text-muted-foreground mt-1">.txt files supported</p>
|
| 82 |
+
</div>
|
| 83 |
+
|
| 84 |
+
<div className="relative">
|
| 85 |
+
<div className="absolute inset-0 flex items-center">
|
| 86 |
+
<span className="w-full border-t border-border" />
|
| 87 |
+
</div>
|
| 88 |
+
<div className="relative flex justify-center text-xs uppercase">
|
| 89 |
+
<span className="bg-card px-2 text-muted-foreground">Or paste text</span>
|
| 90 |
+
</div>
|
| 91 |
+
</div>
|
| 92 |
+
|
| 93 |
+
<div className="space-y-2">
|
| 94 |
+
<textarea
|
| 95 |
+
className="input min-h-[100px] resize-none"
|
| 96 |
+
placeholder="Paste your sentences here (one per line)..."
|
| 97 |
+
value={text}
|
| 98 |
+
onChange={handleTextChange}
|
| 99 |
+
/>
|
| 100 |
+
<button
|
| 101 |
+
onClick={handlePaste}
|
| 102 |
+
disabled={!text.trim()}
|
| 103 |
+
className="btn btn-secondary w-full"
|
| 104 |
+
>
|
| 105 |
+
Load Sentences
|
| 106 |
+
</button>
|
| 107 |
+
</div>
|
| 108 |
+
</CardContent>
|
| 109 |
+
</Card>
|
| 110 |
+
);
|
| 111 |
+
}
|
src/components/ui/badge.tsx
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as React from "react"
|
| 2 |
+
import { cva, type VariantProps } from "class-variance-authority"
|
| 3 |
+
import { cn } from "@/lib/utils"
|
| 4 |
+
|
| 5 |
+
const badgeVariants = cva(
|
| 6 |
+
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
| 7 |
+
{
|
| 8 |
+
variants: {
|
| 9 |
+
variant: {
|
| 10 |
+
default:
|
| 11 |
+
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
|
| 12 |
+
secondary:
|
| 13 |
+
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
| 14 |
+
destructive:
|
| 15 |
+
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
| 16 |
+
outline: "text-foreground",
|
| 17 |
+
success:
|
| 18 |
+
"border-transparent bg-green-500/15 text-green-600 dark:text-green-400 hover:bg-green-500/25",
|
| 19 |
+
warning:
|
| 20 |
+
"border-transparent bg-yellow-500/15 text-yellow-600 dark:text-yellow-400 hover:bg-yellow-500/25",
|
| 21 |
+
},
|
| 22 |
+
},
|
| 23 |
+
defaultVariants: {
|
| 24 |
+
variant: "default",
|
| 25 |
+
},
|
| 26 |
+
}
|
| 27 |
+
)
|
| 28 |
+
|
| 29 |
+
export interface BadgeProps
|
| 30 |
+
extends React.HTMLAttributes<HTMLDivElement>,
|
| 31 |
+
VariantProps<typeof badgeVariants> { }
|
| 32 |
+
|
| 33 |
+
function Badge({ className, variant, ...props }: BadgeProps) {
|
| 34 |
+
return (
|
| 35 |
+
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
| 36 |
+
)
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
export { Badge, badgeVariants }
|
src/components/ui/card.tsx
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as React from "react"
|
| 2 |
+
import { cn } from "@/lib/utils"
|
| 3 |
+
|
| 4 |
+
const Card = React.forwardRef<
|
| 5 |
+
HTMLDivElement,
|
| 6 |
+
React.HTMLAttributes<HTMLDivElement>
|
| 7 |
+
>(({ className, ...props }, ref) => (
|
| 8 |
+
<div
|
| 9 |
+
ref={ref}
|
| 10 |
+
className={cn(
|
| 11 |
+
"rounded-xl border bg-card text-card-foreground shadow-sm glass-card",
|
| 12 |
+
className
|
| 13 |
+
)}
|
| 14 |
+
{...props}
|
| 15 |
+
/>
|
| 16 |
+
))
|
| 17 |
+
Card.displayName = "Card"
|
| 18 |
+
|
| 19 |
+
const CardHeader = React.forwardRef<
|
| 20 |
+
HTMLDivElement,
|
| 21 |
+
React.HTMLAttributes<HTMLDivElement>
|
| 22 |
+
>(({ className, ...props }, ref) => (
|
| 23 |
+
<div
|
| 24 |
+
ref={ref}
|
| 25 |
+
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
| 26 |
+
{...props}
|
| 27 |
+
/>
|
| 28 |
+
))
|
| 29 |
+
CardHeader.displayName = "CardHeader"
|
| 30 |
+
|
| 31 |
+
const CardTitle = React.forwardRef<
|
| 32 |
+
HTMLParagraphElement,
|
| 33 |
+
React.HTMLAttributes<HTMLHeadingElement>
|
| 34 |
+
>(({ className, ...props }, ref) => (
|
| 35 |
+
<h3
|
| 36 |
+
ref={ref}
|
| 37 |
+
className={cn(
|
| 38 |
+
"text-2xl font-semibold leading-none tracking-tight",
|
| 39 |
+
className
|
| 40 |
+
)}
|
| 41 |
+
{...props}
|
| 42 |
+
/>
|
| 43 |
+
))
|
| 44 |
+
CardTitle.displayName = "CardTitle"
|
| 45 |
+
|
| 46 |
+
const CardDescription = React.forwardRef<
|
| 47 |
+
HTMLParagraphElement,
|
| 48 |
+
React.HTMLAttributes<HTMLParagraphElement>
|
| 49 |
+
>(({ className, ...props }, ref) => (
|
| 50 |
+
<p
|
| 51 |
+
ref={ref}
|
| 52 |
+
className={cn("text-sm text-muted-foreground", className)}
|
| 53 |
+
{...props}
|
| 54 |
+
/>
|
| 55 |
+
))
|
| 56 |
+
CardDescription.displayName = "CardDescription"
|
| 57 |
+
|
| 58 |
+
const CardContent = React.forwardRef<
|
| 59 |
+
HTMLDivElement,
|
| 60 |
+
React.HTMLAttributes<HTMLDivElement>
|
| 61 |
+
>(({ className, ...props }, ref) => (
|
| 62 |
+
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
| 63 |
+
))
|
| 64 |
+
CardContent.displayName = "CardContent"
|
| 65 |
+
|
| 66 |
+
const CardFooter = React.forwardRef<
|
| 67 |
+
HTMLDivElement,
|
| 68 |
+
React.HTMLAttributes<HTMLDivElement>
|
| 69 |
+
>(({ className, ...props }, ref) => (
|
| 70 |
+
<div
|
| 71 |
+
ref={ref}
|
| 72 |
+
className={cn("flex items-center p-6 pt-0", className)}
|
| 73 |
+
{...props}
|
| 74 |
+
/>
|
| 75 |
+
))
|
| 76 |
+
CardFooter.displayName = "CardFooter"
|
| 77 |
+
|
| 78 |
+
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
src/components/ui/slider.tsx
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import * as React from 'react';
|
| 4 |
+
import { cn } from '@/lib/utils';
|
| 5 |
+
|
| 6 |
+
interface SliderProps {
|
| 7 |
+
value: number[];
|
| 8 |
+
onValueChange: (value: number[]) => void;
|
| 9 |
+
max?: number;
|
| 10 |
+
step?: number;
|
| 11 |
+
className?: string;
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
export function Slider({ value, onValueChange, max = 100, step = 1, className }: SliderProps) {
|
| 15 |
+
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
| 16 |
+
onValueChange([parseFloat(e.target.value)]);
|
| 17 |
+
};
|
| 18 |
+
|
| 19 |
+
return (
|
| 20 |
+
<div className={cn("relative flex w-full touch-none select-none items-center", className)}>
|
| 21 |
+
<input
|
| 22 |
+
type="range"
|
| 23 |
+
min={0}
|
| 24 |
+
max={max}
|
| 25 |
+
step={step}
|
| 26 |
+
value={value[0]}
|
| 27 |
+
onChange={handleChange}
|
| 28 |
+
className="h-2 w-full cursor-pointer appearance-none rounded-full bg-secondary/50 accent-primary"
|
| 29 |
+
/>
|
| 30 |
+
</div>
|
| 31 |
+
);
|
| 32 |
+
}
|
src/components/ui/switch.tsx
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import * as React from 'react';
|
| 4 |
+
import { cn } from '@/lib/utils';
|
| 5 |
+
|
| 6 |
+
interface SwitchProps {
|
| 7 |
+
checked: boolean;
|
| 8 |
+
onCheckedChange: (checked: boolean) => void;
|
| 9 |
+
className?: string;
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
export function Switch({ checked, onCheckedChange, className }: SwitchProps) {
|
| 13 |
+
return (
|
| 14 |
+
<button
|
| 15 |
+
type="button"
|
| 16 |
+
role="switch"
|
| 17 |
+
aria-checked={checked}
|
| 18 |
+
onClick={() => onCheckedChange(!checked)}
|
| 19 |
+
className={cn(
|
| 20 |
+
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50",
|
| 21 |
+
checked ? "bg-primary" : "bg-input/50 bg-gray-200 dark:bg-gray-700",
|
| 22 |
+
className
|
| 23 |
+
)}
|
| 24 |
+
>
|
| 25 |
+
<span
|
| 26 |
+
className={cn(
|
| 27 |
+
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform",
|
| 28 |
+
checked ? "translate-x-5 bg-white" : "translate-x-0 bg-white"
|
| 29 |
+
)}
|
| 30 |
+
/>
|
| 31 |
+
</button>
|
| 32 |
+
);
|
| 33 |
+
}
|
src/hooks/useKeyboardShortcuts.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useEffect } from 'react';
|
| 2 |
+
|
| 3 |
+
interface Shortcuts {
|
| 4 |
+
[key: string]: () => void;
|
| 5 |
+
}
|
| 6 |
+
|
| 7 |
+
export function useKeyboardShortcuts(shortcuts: Shortcuts) {
|
| 8 |
+
useEffect(() => {
|
| 9 |
+
const handleKeyDown = (event: KeyboardEvent) => {
|
| 10 |
+
// Ignore if typing in an input
|
| 11 |
+
if (
|
| 12 |
+
document.activeElement?.tagName === 'INPUT' ||
|
| 13 |
+
document.activeElement?.tagName === 'TEXTAREA' ||
|
| 14 |
+
document.activeElement?.tagName === 'SELECT'
|
| 15 |
+
) {
|
| 16 |
+
return;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
const key = event.key.toLowerCase();
|
| 20 |
+
const ctrl = event.ctrlKey || event.metaKey;
|
| 21 |
+
const shift = event.shiftKey;
|
| 22 |
+
|
| 23 |
+
// Construct key string like "ctrl+s" or "shift+arrowright"
|
| 24 |
+
let keyString = key;
|
| 25 |
+
if (shift) keyString = `shift+${keyString}`;
|
| 26 |
+
if (ctrl) keyString = `ctrl+${keyString}`;
|
| 27 |
+
|
| 28 |
+
if (shortcuts[keyString]) {
|
| 29 |
+
event.preventDefault();
|
| 30 |
+
shortcuts[keyString]();
|
| 31 |
+
} else if (shortcuts[key]) {
|
| 32 |
+
// Fallback for single keys
|
| 33 |
+
event.preventDefault();
|
| 34 |
+
shortcuts[key]();
|
| 35 |
+
}
|
| 36 |
+
};
|
| 37 |
+
|
| 38 |
+
window.addEventListener('keydown', handleKeyDown);
|
| 39 |
+
return () => window.removeEventListener('keydown', handleKeyDown);
|
| 40 |
+
}, [shortcuts]);
|
| 41 |
+
}
|
src/hooks/useLocalStorage.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useEffect } from 'react';
|
| 2 |
+
|
| 3 |
+
export function useLocalStorage<T>(key: string, initialValue: T) {
|
| 4 |
+
const [storedValue, setStoredValue] = useState<T>(() => {
|
| 5 |
+
if (typeof window === 'undefined') {
|
| 6 |
+
return initialValue;
|
| 7 |
+
}
|
| 8 |
+
try {
|
| 9 |
+
const item = window.localStorage.getItem(key);
|
| 10 |
+
return item ? JSON.parse(item) : initialValue;
|
| 11 |
+
} catch (error) {
|
| 12 |
+
console.log(error);
|
| 13 |
+
return initialValue;
|
| 14 |
+
}
|
| 15 |
+
});
|
| 16 |
+
|
| 17 |
+
const setValue = (value: T | ((val: T) => T)) => {
|
| 18 |
+
try {
|
| 19 |
+
const valueToStore =
|
| 20 |
+
value instanceof Function ? value(storedValue) : value;
|
| 21 |
+
setStoredValue(valueToStore);
|
| 22 |
+
if (typeof window !== 'undefined') {
|
| 23 |
+
window.localStorage.setItem(key, JSON.stringify(valueToStore));
|
| 24 |
+
}
|
| 25 |
+
} catch (error) {
|
| 26 |
+
console.log(error);
|
| 27 |
+
}
|
| 28 |
+
};
|
| 29 |
+
|
| 30 |
+
return [storedValue, setValue] as const;
|
| 31 |
+
}
|
src/instrumentation.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Next.js Instrumentation
|
| 3 |
+
* This file is used to initialize code that runs when the server starts.
|
| 4 |
+
* Used here to start the cleanup scheduler on HF Spaces.
|
| 5 |
+
*/
|
| 6 |
+
|
| 7 |
+
export async function register() {
|
| 8 |
+
// Only run on server-side
|
| 9 |
+
if (process.env.NEXT_RUNTIME === 'nodejs') {
|
| 10 |
+
try {
|
| 11 |
+
// Dynamic import to avoid client-side bundling issues
|
| 12 |
+
const { initializeDataDirs } = await import('./lib/dataPath');
|
| 13 |
+
const { startCleanupScheduler } = await import('./lib/cleanup');
|
| 14 |
+
|
| 15 |
+
// Initialize data directories
|
| 16 |
+
await initializeDataDirs();
|
| 17 |
+
|
| 18 |
+
// Start the cleanup scheduler (only runs on HF Spaces)
|
| 19 |
+
startCleanupScheduler();
|
| 20 |
+
|
| 21 |
+
console.log('[Instrumentation] Server initialization complete');
|
| 22 |
+
} catch (error) {
|
| 23 |
+
console.error('[Instrumentation] Error during initialization:', error);
|
| 24 |
+
}
|
| 25 |
+
}
|
| 26 |
+
}
|
src/lib/cleanup.ts
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { promises as fs } from 'fs';
|
| 2 |
+
import path from 'path';
|
| 3 |
+
import { getDataDir, getAudioPath, getTranscriptionsPath, getMetadataPath, getFontsPath } from './dataPath';
|
| 4 |
+
|
| 5 |
+
// Cleanup interval in milliseconds (6 hours)
|
| 6 |
+
const CLEANUP_INTERVAL_MS = 6 * 60 * 60 * 1000;
|
| 7 |
+
|
| 8 |
+
// Maximum age for files in milliseconds (24 hours)
|
| 9 |
+
const MAX_FILE_AGE_MS = 24 * 60 * 60 * 1000;
|
| 10 |
+
|
| 11 |
+
// Flag to track if cleanup scheduler is running
|
| 12 |
+
let cleanupSchedulerRunning = false;
|
| 13 |
+
|
| 14 |
+
/**
|
| 15 |
+
* Delete files older than MAX_FILE_AGE_MS from a directory
|
| 16 |
+
* Recursively processes subdirectories
|
| 17 |
+
*/
|
| 18 |
+
async function cleanupDirectory(dirPath: string, dryRun: boolean = false): Promise<number> {
|
| 19 |
+
let deletedCount = 0;
|
| 20 |
+
const now = Date.now();
|
| 21 |
+
|
| 22 |
+
try {
|
| 23 |
+
const entries = await fs.readdir(dirPath, { withFileTypes: true });
|
| 24 |
+
|
| 25 |
+
for (const entry of entries) {
|
| 26 |
+
const fullPath = path.join(dirPath, entry.name);
|
| 27 |
+
|
| 28 |
+
if (entry.isDirectory()) {
|
| 29 |
+
// Recursively clean subdirectories
|
| 30 |
+
deletedCount += await cleanupDirectory(fullPath, dryRun);
|
| 31 |
+
|
| 32 |
+
// Try to remove empty directories
|
| 33 |
+
try {
|
| 34 |
+
const contents = await fs.readdir(fullPath);
|
| 35 |
+
if (contents.length === 0) {
|
| 36 |
+
if (!dryRun) {
|
| 37 |
+
await fs.rmdir(fullPath);
|
| 38 |
+
}
|
| 39 |
+
console.log(`[Cleanup] Removed empty directory: ${fullPath}`);
|
| 40 |
+
}
|
| 41 |
+
} catch {
|
| 42 |
+
// Directory might not be empty or already removed
|
| 43 |
+
}
|
| 44 |
+
} else if (entry.isFile()) {
|
| 45 |
+
try {
|
| 46 |
+
const stats = await fs.stat(fullPath);
|
| 47 |
+
const fileAge = now - stats.mtimeMs;
|
| 48 |
+
|
| 49 |
+
if (fileAge > MAX_FILE_AGE_MS) {
|
| 50 |
+
if (!dryRun) {
|
| 51 |
+
await fs.unlink(fullPath);
|
| 52 |
+
}
|
| 53 |
+
deletedCount++;
|
| 54 |
+
console.log(`[Cleanup] Deleted old file: ${entry.name} (age: ${Math.round(fileAge / 3600000)}h)`);
|
| 55 |
+
}
|
| 56 |
+
} catch (error) {
|
| 57 |
+
console.error(`[Cleanup] Error processing file ${fullPath}:`, error);
|
| 58 |
+
}
|
| 59 |
+
}
|
| 60 |
+
}
|
| 61 |
+
} catch (error) {
|
| 62 |
+
// Directory might not exist yet
|
| 63 |
+
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
| 64 |
+
console.error(`[Cleanup] Error reading directory ${dirPath}:`, error);
|
| 65 |
+
}
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
return deletedCount;
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
/**
|
| 72 |
+
* Run cleanup on all data directories
|
| 73 |
+
*/
|
| 74 |
+
export async function runCleanup(dryRun: boolean = false): Promise<{ totalDeleted: number; timestamp: string }> {
|
| 75 |
+
const startTime = Date.now();
|
| 76 |
+
console.log(`[Cleanup] Starting cleanup at ${new Date().toISOString()}...`);
|
| 77 |
+
|
| 78 |
+
let totalDeleted = 0;
|
| 79 |
+
|
| 80 |
+
// Directories to clean
|
| 81 |
+
const directoriesToClean = [
|
| 82 |
+
getAudioPath(),
|
| 83 |
+
getTranscriptionsPath(),
|
| 84 |
+
];
|
| 85 |
+
|
| 86 |
+
for (const dir of directoriesToClean) {
|
| 87 |
+
try {
|
| 88 |
+
const deleted = await cleanupDirectory(dir, dryRun);
|
| 89 |
+
totalDeleted += deleted;
|
| 90 |
+
} catch (error) {
|
| 91 |
+
console.error(`[Cleanup] Error cleaning ${dir}:`, error);
|
| 92 |
+
}
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
// Clean up old metadata entries
|
| 96 |
+
try {
|
| 97 |
+
await cleanupMetadata();
|
| 98 |
+
} catch (error) {
|
| 99 |
+
console.error('[Cleanup] Error cleaning metadata:', error);
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
const duration = Date.now() - startTime;
|
| 103 |
+
console.log(`[Cleanup] Completed in ${duration}ms. Deleted ${totalDeleted} files.`);
|
| 104 |
+
|
| 105 |
+
return {
|
| 106 |
+
totalDeleted,
|
| 107 |
+
timestamp: new Date().toISOString()
|
| 108 |
+
};
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
/**
|
| 112 |
+
* Clean up old entries from metadata file
|
| 113 |
+
*/
|
| 114 |
+
async function cleanupMetadata(): Promise<void> {
|
| 115 |
+
const metadataPath = path.join(getMetadataPath(), 'dataset_info.json');
|
| 116 |
+
|
| 117 |
+
try {
|
| 118 |
+
const content = await fs.readFile(metadataPath, 'utf-8');
|
| 119 |
+
const metadata = JSON.parse(content);
|
| 120 |
+
|
| 121 |
+
// Update last cleanup timestamp
|
| 122 |
+
metadata.last_cleanup = new Date().toISOString();
|
| 123 |
+
|
| 124 |
+
// Clear old recent_recordings if they exist
|
| 125 |
+
if (metadata.recent_recordings && Array.isArray(metadata.recent_recordings)) {
|
| 126 |
+
const now = Date.now();
|
| 127 |
+
metadata.recent_recordings = metadata.recent_recordings.filter((rec: { timestamp?: string }) => {
|
| 128 |
+
if (!rec.timestamp) return false;
|
| 129 |
+
const recTime = new Date(rec.timestamp).getTime();
|
| 130 |
+
return (now - recTime) < MAX_FILE_AGE_MS;
|
| 131 |
+
});
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
await fs.writeFile(metadataPath, JSON.stringify(metadata, null, 2));
|
| 135 |
+
} catch (error) {
|
| 136 |
+
// Metadata file might not exist
|
| 137 |
+
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
| 138 |
+
throw error;
|
| 139 |
+
}
|
| 140 |
+
}
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
/**
|
| 144 |
+
* Start the cleanup scheduler
|
| 145 |
+
* Runs cleanup on startup and then periodically
|
| 146 |
+
*/
|
| 147 |
+
export function startCleanupScheduler(): void {
|
| 148 |
+
// Only run on HF Spaces (when /data exists or SPACE_ID is set)
|
| 149 |
+
const isHFSpaces = !!process.env.SPACE_ID || getDataDir() === '/data';
|
| 150 |
+
|
| 151 |
+
if (!isHFSpaces) {
|
| 152 |
+
console.log('[Cleanup] Not running on HF Spaces, skipping cleanup scheduler');
|
| 153 |
+
return;
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
if (cleanupSchedulerRunning) {
|
| 157 |
+
console.log('[Cleanup] Scheduler already running');
|
| 158 |
+
return;
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
cleanupSchedulerRunning = true;
|
| 162 |
+
console.log('[Cleanup] Starting cleanup scheduler (24h max age, 6h interval)');
|
| 163 |
+
|
| 164 |
+
// Run cleanup on startup (with a small delay to let the app initialize)
|
| 165 |
+
setTimeout(async () => {
|
| 166 |
+
try {
|
| 167 |
+
await runCleanup();
|
| 168 |
+
} catch (error) {
|
| 169 |
+
console.error('[Cleanup] Error during startup cleanup:', error);
|
| 170 |
+
}
|
| 171 |
+
}, 5000);
|
| 172 |
+
|
| 173 |
+
// Schedule periodic cleanup
|
| 174 |
+
setInterval(async () => {
|
| 175 |
+
try {
|
| 176 |
+
await runCleanup();
|
| 177 |
+
} catch (error) {
|
| 178 |
+
console.error('[Cleanup] Error during scheduled cleanup:', error);
|
| 179 |
+
}
|
| 180 |
+
}, CLEANUP_INTERVAL_MS);
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
/**
|
| 184 |
+
* Get cleanup status information
|
| 185 |
+
*/
|
| 186 |
+
export function getCleanupConfig() {
|
| 187 |
+
return {
|
| 188 |
+
maxFileAgeMs: MAX_FILE_AGE_MS,
|
| 189 |
+
maxFileAgeHours: MAX_FILE_AGE_MS / 3600000,
|
| 190 |
+
cleanupIntervalMs: CLEANUP_INTERVAL_MS,
|
| 191 |
+
cleanupIntervalHours: CLEANUP_INTERVAL_MS / 3600000,
|
| 192 |
+
isSchedulerRunning: cleanupSchedulerRunning,
|
| 193 |
+
dataDir: getDataDir(),
|
| 194 |
+
};
|
| 195 |
+
}
|
src/lib/dataPath.ts
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { promises as fs } from 'fs';
|
| 2 |
+
import path from 'path';
|
| 3 |
+
|
| 4 |
+
/**
|
| 5 |
+
* Determines the base data directory path based on environment.
|
| 6 |
+
* On Hugging Face Spaces with persistent storage, uses /data
|
| 7 |
+
* Otherwise, uses the local dataset folder.
|
| 8 |
+
*/
|
| 9 |
+
export function getDataDir(): string {
|
| 10 |
+
// Check for explicit environment variable first
|
| 11 |
+
if (process.env.DATA_DIR) {
|
| 12 |
+
return process.env.DATA_DIR;
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
// On HF Spaces with persistent storage, /data is available
|
| 16 |
+
// We check this at runtime since /data only exists at runtime, not build time
|
| 17 |
+
if (process.env.SPACE_ID || isHuggingFaceSpaces()) {
|
| 18 |
+
return '/data';
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
// Default to local dataset directory
|
| 22 |
+
return path.join(process.cwd(), 'dataset');
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
/**
|
| 26 |
+
* Check if running on Hugging Face Spaces
|
| 27 |
+
*/
|
| 28 |
+
function isHuggingFaceSpaces(): boolean {
|
| 29 |
+
// HF Spaces sets SPACE_ID environment variable
|
| 30 |
+
return !!process.env.SPACE_ID;
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
/**
|
| 34 |
+
* Get the full path to a subdirectory within the data directory
|
| 35 |
+
*/
|
| 36 |
+
export function getDataPath(...subPaths: string[]): string {
|
| 37 |
+
return path.join(getDataDir(), ...subPaths);
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
/**
|
| 41 |
+
* Get audio directory path for a speaker
|
| 42 |
+
*/
|
| 43 |
+
export function getAudioPath(speakerId?: string): string {
|
| 44 |
+
if (speakerId) {
|
| 45 |
+
return getDataPath('audio', speakerId);
|
| 46 |
+
}
|
| 47 |
+
return getDataPath('audio');
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
/**
|
| 51 |
+
* Get transcriptions directory path for a speaker
|
| 52 |
+
*/
|
| 53 |
+
export function getTranscriptionsPath(speakerId?: string): string {
|
| 54 |
+
if (speakerId) {
|
| 55 |
+
return getDataPath('transcriptions', speakerId);
|
| 56 |
+
}
|
| 57 |
+
return getDataPath('transcriptions');
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
/**
|
| 61 |
+
* Get metadata directory path
|
| 62 |
+
*/
|
| 63 |
+
export function getMetadataPath(): string {
|
| 64 |
+
return getDataPath('metadata');
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
/**
|
| 68 |
+
* Get fonts directory path
|
| 69 |
+
*/
|
| 70 |
+
export function getFontsPath(): string {
|
| 71 |
+
return getDataPath('fonts');
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
/**
|
| 75 |
+
* Safely create a directory, handling errors gracefully
|
| 76 |
+
*/
|
| 77 |
+
export async function ensureDir(dirPath: string): Promise<void> {
|
| 78 |
+
try {
|
| 79 |
+
await fs.mkdir(dirPath, { recursive: true });
|
| 80 |
+
} catch (error: unknown) {
|
| 81 |
+
// Ignore EEXIST errors (directory already exists)
|
| 82 |
+
if (error instanceof Error && 'code' in error && (error as NodeJS.ErrnoException).code !== 'EEXIST') {
|
| 83 |
+
console.error(`Failed to create directory ${dirPath}:`, error);
|
| 84 |
+
throw error;
|
| 85 |
+
}
|
| 86 |
+
}
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
/**
|
| 90 |
+
* Sanitize a string for use in file paths
|
| 91 |
+
* Prevents path traversal attacks and invalid characters
|
| 92 |
+
*/
|
| 93 |
+
export function sanitizePath(input: string, maxLength: number = 50): string {
|
| 94 |
+
if (!input || typeof input !== 'string') {
|
| 95 |
+
return 'unknown';
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
// Remove any path traversal attempts and invalid characters
|
| 99 |
+
return input
|
| 100 |
+
.replace(/\.\./g, '') // Prevent path traversal
|
| 101 |
+
.replace(/[\/\\:*?"<>|]/g, '_') // Remove invalid path characters
|
| 102 |
+
.replace(/[^a-zA-Z0-9_-]/g, '_') // Keep only safe characters
|
| 103 |
+
.substring(0, maxLength)
|
| 104 |
+
.replace(/^_+|_+$/g, '') // Trim leading/trailing underscores
|
| 105 |
+
|| 'unknown';
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
/**
|
| 109 |
+
* Initialize the data directory structure
|
| 110 |
+
* Creates all necessary subdirectories
|
| 111 |
+
*/
|
| 112 |
+
export async function initializeDataDirs(): Promise<void> {
|
| 113 |
+
const dirs = [
|
| 114 |
+
getDataPath(),
|
| 115 |
+
getAudioPath(),
|
| 116 |
+
getTranscriptionsPath(),
|
| 117 |
+
getMetadataPath(),
|
| 118 |
+
getFontsPath(),
|
| 119 |
+
];
|
| 120 |
+
|
| 121 |
+
for (const dir of dirs) {
|
| 122 |
+
await ensureDir(dir);
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
console.log(`[DataPath] Initialized data directories at: ${getDataDir()}`);
|
| 126 |
+
}
|
src/lib/language.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { franc } from 'franc';
|
| 2 |
+
|
| 3 |
+
export const RTL_LANGUAGES = ['arb', 'heb', 'urd', 'per', 'ara', 'fas', 'urd'];
|
| 4 |
+
|
| 5 |
+
export function detectLanguage(text: string): string {
|
| 6 |
+
// franc returns 'und' if undetermined
|
| 7 |
+
// We can set a minimum length threshold to avoid noise
|
| 8 |
+
if (!text || text.length < 5) return 'eng';
|
| 9 |
+
return franc(text);
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
export function isRTL(langCode: string): boolean {
|
| 13 |
+
return RTL_LANGUAGES.includes(langCode);
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
export function getSuggestedFont(langCode: string): string {
|
| 17 |
+
switch (langCode) {
|
| 18 |
+
case 'arb':
|
| 19 |
+
case 'ara':
|
| 20 |
+
return 'Amiri'; // Assuming we have this or similar
|
| 21 |
+
case 'jpn':
|
| 22 |
+
return 'Noto Sans JP';
|
| 23 |
+
case 'kor':
|
| 24 |
+
return 'Noto Sans KR';
|
| 25 |
+
case 'cmn':
|
| 26 |
+
return 'Noto Sans SC';
|
| 27 |
+
default:
|
| 28 |
+
return 'DM Sans';
|
| 29 |
+
}
|
| 30 |
+
}
|
src/lib/utils.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { type ClassValue, clsx } from "clsx"
|
| 2 |
+
import { twMerge } from "tailwind-merge"
|
| 3 |
+
|
| 4 |
+
export function cn(...inputs: ClassValue[]) {
|
| 5 |
+
return twMerge(clsx(inputs))
|
| 6 |
+
}
|
start_app.bat
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@echo off
|
| 2 |
+
echo Starting TTS Dataset Collector...
|
| 3 |
+
docker-compose up -d
|
| 4 |
+
echo.
|
| 5 |
+
echo Application started!
|
| 6 |
+
echo Opening browser...
|
| 7 |
+
timeout /t 5
|
| 8 |
+
start http://localhost:3000
|
| 9 |
+
pause
|
tailwind.config.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { Config } from "tailwindcss";
|
| 2 |
+
|
| 3 |
+
const config: Config = {
|
| 4 |
+
darkMode: ["class"],
|
| 5 |
+
content: [
|
| 6 |
+
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
|
| 7 |
+
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
|
| 8 |
+
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
|
| 9 |
+
],
|
| 10 |
+
theme: {
|
| 11 |
+
extend: {
|
| 12 |
+
fontFamily: {
|
| 13 |
+
sans: ["var(--font-dm-sans)", "sans-serif"],
|
| 14 |
+
serif: ["var(--font-playfair)", "serif"],
|
| 15 |
+
mono: ["var(--font-jetbrains)", "monospace"],
|
| 16 |
+
},
|
| 17 |
+
colors: {
|
| 18 |
+
border: "hsl(var(--border))",
|
| 19 |
+
input: "hsl(var(--input))",
|
| 20 |
+
ring: "hsl(var(--ring))",
|
| 21 |
+
background: "hsl(var(--background))",
|
| 22 |
+
foreground: "hsl(var(--foreground))",
|
| 23 |
+
primary: {
|
| 24 |
+
DEFAULT: "hsl(var(--primary))",
|
| 25 |
+
foreground: "hsl(var(--primary-foreground))",
|
| 26 |
+
},
|
| 27 |
+
secondary: {
|
| 28 |
+
DEFAULT: "hsl(var(--secondary))",
|
| 29 |
+
foreground: "hsl(var(--secondary-foreground))",
|
| 30 |
+
},
|
| 31 |
+
destructive: {
|
| 32 |
+
DEFAULT: "hsl(var(--destructive))",
|
| 33 |
+
foreground: "hsl(var(--destructive-foreground))",
|
| 34 |
+
},
|
| 35 |
+
muted: {
|
| 36 |
+
DEFAULT: "hsl(var(--muted))",
|
| 37 |
+
foreground: "hsl(var(--muted-foreground))",
|
| 38 |
+
},
|
| 39 |
+
accent: {
|
| 40 |
+
DEFAULT: "hsl(var(--accent))",
|
| 41 |
+
foreground: "hsl(var(--accent-foreground))",
|
| 42 |
+
},
|
| 43 |
+
popover: {
|
| 44 |
+
DEFAULT: "hsl(var(--popover))",
|
| 45 |
+
foreground: "hsl(var(--popover-foreground))",
|
| 46 |
+
},
|
| 47 |
+
card: {
|
| 48 |
+
DEFAULT: "hsl(var(--card))",
|
| 49 |
+
foreground: "hsl(var(--card-foreground))",
|
| 50 |
+
},
|
| 51 |
+
},
|
| 52 |
+
borderRadius: {
|
| 53 |
+
lg: "var(--radius)",
|
| 54 |
+
md: "calc(var(--radius) - 2px)",
|
| 55 |
+
sm: "calc(var(--radius) - 4px)",
|
| 56 |
+
},
|
| 57 |
+
},
|
| 58 |
+
},
|
| 59 |
+
plugins: [],
|
| 60 |
+
};
|
| 61 |
+
export default config;
|
tsconfig.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"compilerOptions": {
|
| 3 |
+
"target": "ES2017",
|
| 4 |
+
"lib": ["dom", "dom.iterable", "esnext"],
|
| 5 |
+
"allowJs": true,
|
| 6 |
+
"skipLibCheck": true,
|
| 7 |
+
"strict": true,
|
| 8 |
+
"noEmit": true,
|
| 9 |
+
"esModuleInterop": true,
|
| 10 |
+
"module": "esnext",
|
| 11 |
+
"moduleResolution": "bundler",
|
| 12 |
+
"resolveJsonModule": true,
|
| 13 |
+
"isolatedModules": true,
|
| 14 |
+
"jsx": "react-jsx",
|
| 15 |
+
"incremental": true,
|
| 16 |
+
"plugins": [
|
| 17 |
+
{
|
| 18 |
+
"name": "next"
|
| 19 |
+
}
|
| 20 |
+
],
|
| 21 |
+
"paths": {
|
| 22 |
+
"@/*": ["./src/*"]
|
| 23 |
+
}
|
| 24 |
+
},
|
| 25 |
+
"include": [
|
| 26 |
+
"next-env.d.ts",
|
| 27 |
+
"**/*.ts",
|
| 28 |
+
"**/*.tsx",
|
| 29 |
+
".next/types/**/*.ts",
|
| 30 |
+
".next/dev/types/**/*.ts",
|
| 31 |
+
"**/*.mts"
|
| 32 |
+
],
|
| 33 |
+
"exclude": ["node_modules"]
|
| 34 |
+
}
|