DB, Collections, Search
This commit is contained in:
58
.dockerignore
Normal file
58
.dockerignore
Normal file
@@ -0,0 +1,58 @@
|
||||
# Dependencies
|
||||
node_modules
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# Next.js
|
||||
.next
|
||||
out
|
||||
dist
|
||||
|
||||
# Testing
|
||||
coverage
|
||||
.nyc_output
|
||||
|
||||
# Misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# Debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Local env files
|
||||
.env*.local
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# Vercel
|
||||
.vercel
|
||||
|
||||
# TypeScript
|
||||
*.tsbuildinfo
|
||||
|
||||
# Git
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
# IDE
|
||||
.vscode
|
||||
.idea
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Playwright
|
||||
test-results
|
||||
playwright-report
|
||||
|
||||
# Documentation
|
||||
README.md
|
||||
CLAUDE.md
|
||||
HANZI-LEARNING-APP-SPECIFICATION.md
|
||||
PROJECT-NAMING.md
|
||||
LICENSE
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -39,3 +39,9 @@ yarn-error.log*
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# SSL certificates (except README)
|
||||
docker/ssl/*.pem
|
||||
docker/ssl/*.crt
|
||||
docker/ssl/*.key
|
||||
.testcontainer-db-url
|
||||
|
||||
65
app/page.tsx
65
app/page.tsx
@@ -1,65 +0,0 @@
|
||||
import Image from "next/image";
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
||||
<main className="flex min-h-screen w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/next.svg"
|
||||
alt="Next.js logo"
|
||||
width={100}
|
||||
height={20}
|
||||
priority
|
||||
/>
|
||||
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
||||
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
||||
To get started, edit the page.tsx file.
|
||||
</h1>
|
||||
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
||||
Looking for a starting point or more instructions? Head over to{" "}
|
||||
<a
|
||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Templates
|
||||
</a>{" "}
|
||||
or the{" "}
|
||||
<a
|
||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Learning
|
||||
</a>{" "}
|
||||
center.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
|
||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/vercel.svg"
|
||||
alt="Vercel logomark"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Deploy Now
|
||||
</a>
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
|
||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Documentation
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
54
docker-compose.yml
Normal file
54
docker-compose.yml
Normal file
@@ -0,0 +1,54 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
container_name: memohanzi-nginx
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- ./docker/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
- ./docker/ssl:/etc/nginx/ssl:ro
|
||||
depends_on:
|
||||
- app
|
||||
restart: unless-stopped
|
||||
|
||||
app:
|
||||
container_name: memohanzi-app
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./docker/Dockerfile
|
||||
expose:
|
||||
- "3000"
|
||||
environment:
|
||||
- DATABASE_URL=postgresql://memohanzi_user:password@postgres:5432/memohanzi_db
|
||||
- NEXTAUTH_URL=https://localhost
|
||||
- NEXTAUTH_SECRET=${NEXTAUTH_SECRET}
|
||||
- AUTH_TRUST_HOST=true
|
||||
- NODE_ENV=production
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
|
||||
postgres:
|
||||
image: postgres:18-alpine
|
||||
container_name: memohanzi-postgres
|
||||
ports:
|
||||
- "5432:5432"
|
||||
environment:
|
||||
POSTGRES_USER: memohanzi_user
|
||||
POSTGRES_PASSWORD: password
|
||||
POSTGRES_DB: memohanzi_db
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U memohanzi_user"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
postgres-data:
|
||||
81
docker/Dockerfile
Normal file
81
docker/Dockerfile
Normal file
@@ -0,0 +1,81 @@
|
||||
# Stage 1: Dependencies
|
||||
FROM node:22-alpine AS deps
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies for building native modules
|
||||
RUN apk add --no-cache \
|
||||
libc6-compat \
|
||||
openssl \
|
||||
python3 \
|
||||
make \
|
||||
g++
|
||||
|
||||
# Copy package files
|
||||
COPY package.json package-lock.json ./
|
||||
COPY prisma ./prisma
|
||||
|
||||
# Install dependencies
|
||||
RUN npm ci
|
||||
|
||||
# Generate Prisma Client
|
||||
RUN npx prisma generate
|
||||
|
||||
# Stage 2: Builder
|
||||
FROM node:22-alpine AS builder
|
||||
WORKDIR /app
|
||||
|
||||
# Install openssl for Prisma
|
||||
RUN apk add --no-cache openssl
|
||||
|
||||
# Copy dependencies from deps stage
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Generate Prisma Client again for builder context
|
||||
RUN npx prisma generate
|
||||
|
||||
# Build Next.js application
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
ENV NODE_ENV=production
|
||||
RUN npm run build
|
||||
|
||||
# Stage 3: Runner
|
||||
FROM node:22-alpine AS runner
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
# Install runtime dependencies
|
||||
RUN apk add --no-cache openssl
|
||||
|
||||
# Create non-root user
|
||||
RUN addgroup --system --gid 1001 nodejs && \
|
||||
adduser --system --uid 1001 nextjs
|
||||
|
||||
# Copy necessary files with ownership set during copy (more efficient)
|
||||
COPY --chown=nextjs:nodejs --from=builder /app/public ./public
|
||||
COPY --chown=nextjs:nodejs --from=builder /app/.next/standalone ./
|
||||
COPY --chown=nextjs:nodejs --from=builder /app/.next/static ./.next/static
|
||||
|
||||
# Copy node_modules for Prisma CLI and seed script (Prisma CLI has 33+ dependencies)
|
||||
COPY --chown=nextjs:nodejs --from=builder /app/node_modules ./node_modules
|
||||
COPY --chown=nextjs:nodejs --from=builder /app/prisma ./prisma
|
||||
|
||||
# Copy entrypoint script
|
||||
COPY --chown=nextjs:nodejs --from=builder /app/docker/entrypoint.sh ./entrypoint.sh
|
||||
RUN chmod +x ./entrypoint.sh
|
||||
|
||||
# Switch to non-root user
|
||||
USER nextjs
|
||||
|
||||
# Expose port
|
||||
EXPOSE 3000
|
||||
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
|
||||
# Start the application with automatic migrations
|
||||
ENTRYPOINT ["./entrypoint.sh"]
|
||||
18
docker/entrypoint.sh
Normal file
18
docker/entrypoint.sh
Normal file
@@ -0,0 +1,18 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
echo "🔄 Running database migrations..."
|
||||
# Check if migrations directory exists and has files
|
||||
if [ -d "prisma/migrations" ] && [ "$(ls -A prisma/migrations 2>/dev/null)" ]; then
|
||||
echo "Found migration files, running migrate deploy..."
|
||||
node_modules/.bin/prisma migrate deploy
|
||||
else
|
||||
echo "No migration files found, running db push..."
|
||||
node_modules/.bin/prisma db push --accept-data-loss
|
||||
fi
|
||||
|
||||
echo "🌱 Seeding database (if needed)..."
|
||||
node_modules/.bin/tsx prisma/seed.ts || echo "⚠️ Seed failed or already completed, continuing..."
|
||||
|
||||
echo "🚀 Starting application..."
|
||||
exec node server.js
|
||||
101
docker/nginx.conf
Normal file
101
docker/nginx.conf
Normal file
@@ -0,0 +1,101 @@
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
# Rate limiting zones
|
||||
limit_req_zone $binary_remote_addr zone=general:10m rate=10r/s;
|
||||
limit_req_zone $binary_remote_addr zone=auth:10m rate=5r/m;
|
||||
limit_req_zone $binary_remote_addr zone=api:10m rate=30r/m;
|
||||
|
||||
# Upstream Next.js app
|
||||
upstream nextjs_app {
|
||||
server app:3000;
|
||||
}
|
||||
|
||||
# HTTP server - redirect to HTTPS
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
location / {
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
}
|
||||
|
||||
# HTTPS server
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name _;
|
||||
|
||||
# SSL configuration
|
||||
ssl_certificate /etc/nginx/ssl/cert.pem;
|
||||
ssl_certificate_key /etc/nginx/ssl/key.pem;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
ssl_prefer_server_ciphers on;
|
||||
|
||||
# Security headers
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
|
||||
|
||||
# Max body size for file uploads
|
||||
client_max_body_size 10M;
|
||||
|
||||
# Logging
|
||||
access_log /var/log/nginx/access.log;
|
||||
error_log /var/log/nginx/error.log;
|
||||
|
||||
# Rate limiting for auth endpoints
|
||||
location ~ ^/(api/auth|login|register) {
|
||||
limit_req zone=auth burst=10 nodelay;
|
||||
proxy_pass http://nextjs_app;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
|
||||
# Rate limiting for API endpoints
|
||||
location /api/ {
|
||||
limit_req zone=api burst=20 nodelay;
|
||||
proxy_pass http://nextjs_app;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
|
||||
# General rate limiting for all other requests
|
||||
location / {
|
||||
limit_req zone=general burst=20 nodelay;
|
||||
proxy_pass http://nextjs_app;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
|
||||
# Cache static assets
|
||||
location ~* \.(jpg|jpeg|png|gif|ico|css|js|svg|woff|woff2|ttf|eot)$ {
|
||||
proxy_pass http://nextjs_app;
|
||||
proxy_cache_valid 200 30d;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
35
docker/ssl/README.md
Normal file
35
docker/ssl/README.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# SSL Certificates
|
||||
|
||||
This directory should contain SSL certificates for HTTPS.
|
||||
|
||||
## Development
|
||||
|
||||
For local development, you can generate self-signed certificates:
|
||||
|
||||
```bash
|
||||
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
|
||||
-keyout key.pem -out cert.pem \
|
||||
-subj "/C=US/ST=State/L=City/O=Organization/CN=localhost"
|
||||
```
|
||||
|
||||
## Production
|
||||
|
||||
For production, use certificates from a trusted Certificate Authority like:
|
||||
- Let's Encrypt (recommended, free)
|
||||
- Your domain provider
|
||||
- Commercial CA
|
||||
|
||||
### Let's Encrypt with Certbot
|
||||
|
||||
```bash
|
||||
sudo certbot certonly --standalone -d yourdomain.com
|
||||
sudo cp /etc/letsencrypt/live/yourdomain.com/fullchain.pem ./cert.pem
|
||||
sudo cp /etc/letsencrypt/live/yourdomain.com/privkey.pem ./key.pem
|
||||
```
|
||||
|
||||
## Required Files
|
||||
|
||||
- `cert.pem` - SSL certificate
|
||||
- `key.pem` - Private key
|
||||
|
||||
**Important:** Never commit real SSL certificates to version control!
|
||||
188
e2e/auth.spec.ts
Normal file
188
e2e/auth.spec.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test.describe('Authentication Flows', () => {
|
||||
// Run tests serially since they depend on shared state (registered user)
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
// Generate unique email for each test run to avoid conflicts
|
||||
// Include random suffix to prevent collisions between parallel browser runs
|
||||
const timestamp = Date.now()
|
||||
const randomSuffix = Math.random().toString(36).substring(7)
|
||||
const testEmail = `test${timestamp}${randomSuffix}@example.com`
|
||||
const testPassword = 'password123'
|
||||
const testName = 'Test User'
|
||||
|
||||
test.describe('Registration', () => {
|
||||
test('should display registration form', async ({ page }) => {
|
||||
await page.goto('/register')
|
||||
|
||||
await expect(page.locator('h2')).toContainText('Create Account')
|
||||
await expect(page.getByLabel('Email')).toBeVisible()
|
||||
await expect(page.getByLabel('Password')).toBeVisible()
|
||||
await expect(page.getByLabel('Name')).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: 'Create Account' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('should successfully register a new user', async ({ page }) => {
|
||||
await page.goto('/register')
|
||||
|
||||
// Fill out registration form
|
||||
await page.getByLabel('Email').fill(testEmail)
|
||||
await page.getByLabel('Password').fill(testPassword)
|
||||
await page.getByLabel('Name').fill(testName)
|
||||
|
||||
// Submit form
|
||||
await page.getByRole('button', { name: 'Create Account' }).click()
|
||||
|
||||
// Should redirect to login page
|
||||
await page.waitForURL(/\/login/, { timeout: 10000 })
|
||||
})
|
||||
|
||||
test('should show error for duplicate email', async ({ page }) => {
|
||||
await page.goto('/register')
|
||||
|
||||
// Try to register with existing email
|
||||
await page.getByLabel('Email').fill(testEmail)
|
||||
await page.getByLabel('Password').fill(testPassword)
|
||||
await page.getByLabel('Name').fill(testName)
|
||||
|
||||
await page.getByRole('button', { name: 'Create Account' }).click()
|
||||
|
||||
// Should show error message (from server)
|
||||
await expect(page.getByText(/already exists|error/i)).toBeVisible({ timeout: 5000 })
|
||||
})
|
||||
|
||||
test('should have link to login page', async ({ page }) => {
|
||||
await page.goto('/register')
|
||||
|
||||
const loginLink = page.getByRole('link', { name: 'Sign in' })
|
||||
await expect(loginLink).toBeVisible()
|
||||
|
||||
await loginLink.click()
|
||||
await expect(page).toHaveURL('/login')
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Login', () => {
|
||||
test('should display login form', async ({ page }) => {
|
||||
await page.goto('/login')
|
||||
|
||||
await expect(page.locator('h2')).toContainText('Sign In')
|
||||
await expect(page.getByLabel('Email')).toBeVisible()
|
||||
await expect(page.getByLabel('Password')).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: 'Sign In' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('should successfully login with valid credentials', async ({ page }) => {
|
||||
await page.goto('/login')
|
||||
|
||||
// Fill out login form
|
||||
await page.getByLabel('Email').fill(testEmail)
|
||||
await page.getByLabel('Password').fill(testPassword)
|
||||
|
||||
// Submit form
|
||||
await page.getByRole('button', { name: 'Sign In' }).click()
|
||||
|
||||
// Should redirect to dashboard
|
||||
await page.waitForURL('/dashboard', { timeout: 10000 })
|
||||
})
|
||||
|
||||
test('should show error for invalid credentials', async ({ page }) => {
|
||||
await page.goto('/login')
|
||||
|
||||
await page.getByLabel('Email').fill(testEmail)
|
||||
await page.getByLabel('Password').fill('wrongpassword')
|
||||
|
||||
await page.getByRole('button', { name: 'Sign In' }).click()
|
||||
|
||||
// Should show error message
|
||||
await expect(page.getByText(/error/i)).toBeVisible({ timeout: 5000 })
|
||||
})
|
||||
|
||||
test('should have link to registration page', async ({ page }) => {
|
||||
await page.goto('/login')
|
||||
|
||||
const registerLink = page.getByRole('link', { name: 'Sign up' })
|
||||
await expect(registerLink).toBeVisible()
|
||||
|
||||
await registerLink.click()
|
||||
await expect(page).toHaveURL('/register')
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Logout', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Log in before each test
|
||||
await page.goto('/login')
|
||||
await page.getByLabel('Email').fill(testEmail)
|
||||
await page.getByLabel('Password').fill(testPassword)
|
||||
await page.getByRole('button', { name: 'Sign In' }).click()
|
||||
await page.waitForURL('/dashboard', { timeout: 10000 })
|
||||
})
|
||||
|
||||
test('should successfully logout', async ({ page }) => {
|
||||
// Find and click logout button
|
||||
const logoutButton = page.getByRole('button', { name: /logout|sign out/i })
|
||||
await logoutButton.click()
|
||||
|
||||
// Should redirect to login or home page
|
||||
await page.waitForURL(/\/(login|$)/, { timeout: 5000 })
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Protected Routes', () => {
|
||||
test('should redirect to login when accessing dashboard without authentication', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('/dashboard')
|
||||
|
||||
// Should redirect to login page
|
||||
await page.waitForURL('/login', { timeout: 5000 })
|
||||
})
|
||||
|
||||
test('should redirect to login when accessing settings without authentication', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('/settings')
|
||||
|
||||
// Should redirect to login page
|
||||
await page.waitForURL('/login', { timeout: 5000 })
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Session Persistence', () => {
|
||||
test('should maintain session across page reloads', async ({ page }) => {
|
||||
// Login
|
||||
await page.goto('/login')
|
||||
await page.getByLabel('Email').fill(testEmail)
|
||||
await page.getByLabel('Password').fill(testPassword)
|
||||
await page.getByRole('button', { name: 'Sign In' }).click()
|
||||
await page.waitForURL('/dashboard', { timeout: 10000 })
|
||||
|
||||
// Reload page
|
||||
await page.reload()
|
||||
|
||||
// Should still be on dashboard
|
||||
await expect(page).toHaveURL('/dashboard')
|
||||
})
|
||||
|
||||
test('should maintain session when navigating between pages', async ({
|
||||
page,
|
||||
}) => {
|
||||
// Login
|
||||
await page.goto('/login')
|
||||
await page.getByLabel('Email').fill(testEmail)
|
||||
await page.getByLabel('Password').fill(testPassword)
|
||||
await page.getByRole('button', { name: 'Sign In' }).click()
|
||||
await page.waitForURL('/dashboard', { timeout: 10000 })
|
||||
|
||||
// Navigate to settings
|
||||
await page.goto('/settings')
|
||||
await expect(page).toHaveURL('/settings')
|
||||
|
||||
// Navigate back to dashboard
|
||||
await page.goto('/dashboard')
|
||||
await expect(page).toHaveURL('/dashboard')
|
||||
})
|
||||
})
|
||||
})
|
||||
17
e2e/global-setup.ts
Normal file
17
e2e/global-setup.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { execSync } from 'child_process'
|
||||
|
||||
async function globalSetup() {
|
||||
console.log('Running database seed before E2E tests...')
|
||||
try {
|
||||
execSync('npx prisma db seed', {
|
||||
stdio: 'inherit',
|
||||
env: process.env,
|
||||
})
|
||||
console.log('Database seed completed successfully')
|
||||
} catch (error) {
|
||||
console.error('Warning: Database seed failed. Tests may fail if seed data is missing.')
|
||||
console.error('Make sure Docker Compose is running and the database is accessible.')
|
||||
}
|
||||
}
|
||||
|
||||
export default globalSetup
|
||||
92
e2e/settings.spec.ts
Normal file
92
e2e/settings.spec.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test.describe('Settings Page', () => {
|
||||
// Run tests serially since they depend on shared state
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
// Generate unique email for each test run
|
||||
// Include random suffix to prevent collisions between parallel browser runs
|
||||
const timestamp = Date.now()
|
||||
const randomSuffix = Math.random().toString(36).substring(7)
|
||||
const testEmail = `settings${timestamp}${randomSuffix}@example.com`
|
||||
const testPassword = 'password123'
|
||||
const testName = 'Settings Test User'
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Try to register a new user (may already exist from previous test)
|
||||
await page.goto('/register')
|
||||
await page.getByLabel('Email').fill(testEmail)
|
||||
await page.getByLabel('Password').fill(testPassword)
|
||||
await page.getByLabel('Name').fill(testName)
|
||||
await page.getByRole('button', { name: 'Create Account' }).click()
|
||||
|
||||
// Wait for either redirect to login OR error message (if user exists)
|
||||
await Promise.race([
|
||||
page.waitForURL(/\/login/, { timeout: 10000 }),
|
||||
page.waitForSelector('text=/already exists|error/i', { timeout: 10000 }),
|
||||
])
|
||||
|
||||
// Navigate to login and login
|
||||
await page.goto('/login')
|
||||
await page.getByLabel('Email').fill(testEmail)
|
||||
await page.getByLabel('Password').fill(testPassword)
|
||||
await page.getByRole('button', { name: 'Sign In' }).click()
|
||||
await page.waitForURL('/dashboard', { timeout: 10000 })
|
||||
|
||||
// Navigate to settings
|
||||
await page.goto('/settings')
|
||||
await page.waitForLoadState('networkidle')
|
||||
})
|
||||
|
||||
test.describe('Profile Settings', () => {
|
||||
test('should display profile settings form', async ({ page }) => {
|
||||
// Should have profile section with name and email fields
|
||||
await expect(page.getByRole('heading', { name: 'Profile' })).toBeVisible()
|
||||
await expect(page.getByText('Name')).toBeVisible()
|
||||
await expect(page.getByText('Email')).toBeVisible()
|
||||
// Check textboxes exist
|
||||
const textboxes = page.getByRole('textbox')
|
||||
await expect(textboxes.first()).toBeVisible()
|
||||
})
|
||||
|
||||
test('should display current user information', async ({ page }) => {
|
||||
// Should show current values in the form
|
||||
// Profile section is the first form, so first two textboxes are name and email
|
||||
const textboxes = page.getByRole('textbox')
|
||||
await expect(textboxes.first()).toHaveValue(testName)
|
||||
await expect(textboxes.nth(1)).toHaveValue(testEmail)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Password Change', () => {
|
||||
test('should display password change form', async ({ page }) => {
|
||||
// Should have password section
|
||||
await expect(page.getByRole('heading', { name: 'Change Password' })).toBeVisible()
|
||||
await expect(page.getByText('Current Password')).toBeVisible()
|
||||
await expect(page.getByText('New Password')).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('User Preferences', () => {
|
||||
test('should display preferences form', async ({ page }) => {
|
||||
// Should have preferences section
|
||||
await expect(page.getByRole('heading', { name: 'Learning Preferences' })).toBeVisible()
|
||||
// Check for slider labels (they include values)
|
||||
await expect(page.getByText(/Cards Per Session/)).toBeVisible()
|
||||
await expect(page.getByText(/Daily Goal/)).toBeVisible()
|
||||
})
|
||||
|
||||
test('should display default preferences', async ({ page }) => {
|
||||
// Should show default values displayed in labels
|
||||
await expect(page.getByText(/Cards Per Session:.*20/)).toBeVisible()
|
||||
await expect(page.getByText(/Daily Goal:.*50/)).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Navigation', () => {
|
||||
test('should have navigation options', async ({ page }) => {
|
||||
// Check that we're on settings page and can navigate
|
||||
await expect(page).toHaveURL('/settings')
|
||||
})
|
||||
})
|
||||
})
|
||||
25
middleware.ts
Normal file
25
middleware.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import type { NextRequest } from "next/server"
|
||||
|
||||
// NOTE: Middleware is temporarily disabled due to Edge Runtime compatibility issues
|
||||
// with bcrypt. Auth protection is still enforced at the Server Action level.
|
||||
// This will be re-enabled in a future update with proper edge-compatible auth.
|
||||
|
||||
export async function middleware(request: NextRequest) {
|
||||
// Temporarily allow all requests
|
||||
// Auth checks are still performed in Server Actions
|
||||
return NextResponse.next()
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: [
|
||||
/*
|
||||
* Match all request paths except for the ones starting with:
|
||||
* - api (API routes)
|
||||
* - _next/static (static files)
|
||||
* - _next/image (image optimization files)
|
||||
* - favicon.ico (favicon file)
|
||||
*/
|
||||
"/((?!api|_next/static|_next/image|favicon.ico).*)",
|
||||
],
|
||||
}
|
||||
@@ -1,7 +1,12 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
output: "standalone",
|
||||
experimental: {
|
||||
serverActions: {
|
||||
bodySizeLimit: "2mb",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
6293
package-lock.json
generated
6293
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
44
package.json
44
package.json
@@ -1,26 +1,62 @@
|
||||
{
|
||||
"name": "memohanzi-init",
|
||||
"name": "memohanzi",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "Self-hosted spaced repetition app for learning Chinese characters",
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
"lint": "next lint",
|
||||
"test": "vitest",
|
||||
"test:unit": "vitest run --coverage",
|
||||
"test:integration": "vitest run --config vitest.integration.config.ts",
|
||||
"test:watch": "vitest watch",
|
||||
"test:e2e": "playwright test",
|
||||
"test:ci": "npm run test:unit && npm run test:integration && npm run test:e2e",
|
||||
"db:migrate": "prisma migrate dev",
|
||||
"db:generate": "prisma generate",
|
||||
"db:seed": "prisma db seed",
|
||||
"db:studio": "prisma studio",
|
||||
"db:push": "prisma db push"
|
||||
},
|
||||
"dependencies": {
|
||||
"@auth/prisma-adapter": "^2.7.4",
|
||||
"@hookform/resolvers": "^3.10.0",
|
||||
"bcrypt": "^5.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"next": "16.0.3",
|
||||
"next-auth": "^5.0.0-beta.25",
|
||||
"react": "19.2.0",
|
||||
"react-dom": "19.2.0"
|
||||
"react-dom": "19.2.0",
|
||||
"react-hook-form": "^7.54.2",
|
||||
"recharts": "^2.15.0",
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.49.1",
|
||||
"@prisma/client": "^6.19.0",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@testcontainers/postgresql": "^11.8.1",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.1.0",
|
||||
"@types/bcrypt": "^5.0.2",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"@vitest/coverage-v8": "^2.1.8",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.0.3",
|
||||
"jsdom": "^25.0.1",
|
||||
"prisma": "^6.19.0",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
"testcontainers": "^11.8.1",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5",
|
||||
"vitest": "^2.1.8"
|
||||
},
|
||||
"prisma": {
|
||||
"seed": "tsx prisma/seed.ts"
|
||||
}
|
||||
}
|
||||
|
||||
85
playwright-report/index.html
Normal file
85
playwright-report/index.html
Normal file
File diff suppressed because one or more lines are too long
45
playwright.config.ts
Normal file
45
playwright.config.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { defineConfig, devices } from '@playwright/test'
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
globalSetup: './e2e/global-setup.ts',
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
reporter: 'html',
|
||||
use: {
|
||||
baseURL: 'http://localhost:3000',
|
||||
trace: 'on-first-retry',
|
||||
},
|
||||
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
{
|
||||
name: 'firefox',
|
||||
use: { ...devices['Desktop Firefox'] },
|
||||
},
|
||||
{
|
||||
name: 'webkit',
|
||||
use: { ...devices['Desktop Safari'] },
|
||||
},
|
||||
// Mobile viewports
|
||||
{
|
||||
name: 'Mobile Chrome',
|
||||
use: { ...devices['Pixel 5'] },
|
||||
},
|
||||
{
|
||||
name: 'Mobile Safari',
|
||||
use: { ...devices['iPhone 12'] },
|
||||
},
|
||||
],
|
||||
|
||||
webServer: {
|
||||
command: 'npm run dev',
|
||||
url: 'http://localhost:3000',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
},
|
||||
})
|
||||
349
prisma/schema.prisma
Normal file
349
prisma/schema.prisma
Normal file
@@ -0,0 +1,349 @@
|
||||
// This is your Prisma schema file,
|
||||
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ENUMS
|
||||
// ============================================================================
|
||||
|
||||
enum UserRole {
|
||||
USER
|
||||
ADMIN
|
||||
MODERATOR
|
||||
}
|
||||
|
||||
enum CharacterDisplay {
|
||||
SIMPLIFIED
|
||||
TRADITIONAL
|
||||
BOTH
|
||||
}
|
||||
|
||||
enum Difficulty {
|
||||
EASY
|
||||
MEDIUM
|
||||
HARD
|
||||
SUSPENDED
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// LANGUAGE & HANZI MODELS
|
||||
// ============================================================================
|
||||
|
||||
model Language {
|
||||
id String @id @default(cuid())
|
||||
code String @unique // ISO 639-1 code (e.g., "en", "zh", "es")
|
||||
name String // English name (e.g., "English", "Chinese")
|
||||
nativeName String // Native name (e.g., "English", "中文")
|
||||
isActive Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
meanings HanziMeaning[]
|
||||
userPreferences UserPreference[]
|
||||
|
||||
@@map("languages")
|
||||
}
|
||||
|
||||
model Hanzi {
|
||||
id String @id @default(cuid())
|
||||
simplified String @unique // The simplified Chinese character
|
||||
radical String? // Radical/base component
|
||||
frequency Int? // Frequency ranking (lower = more common)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
forms HanziForm[]
|
||||
hskLevels HanziHSKLevel[]
|
||||
partsOfSpeech HanziPOS[]
|
||||
userProgress UserHanziProgress[]
|
||||
collectionItems CollectionItem[]
|
||||
sessionReviews SessionReview[]
|
||||
|
||||
@@index([simplified])
|
||||
@@map("hanzi")
|
||||
}
|
||||
|
||||
model HanziForm {
|
||||
id String @id @default(cuid())
|
||||
hanziId String
|
||||
traditional String // Traditional variant
|
||||
isDefault Boolean @default(false) // Primary traditional form
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
hanzi Hanzi @relation(fields: [hanziId], references: [id], onDelete: Cascade)
|
||||
transcriptions HanziTranscription[]
|
||||
meanings HanziMeaning[]
|
||||
classifiers HanziClassifier[]
|
||||
|
||||
@@index([hanziId])
|
||||
@@index([traditional])
|
||||
@@map("hanzi_forms")
|
||||
}
|
||||
|
||||
model HanziTranscription {
|
||||
id String @id @default(cuid())
|
||||
formId String
|
||||
type String // "pinyin", "numeric", "wadegiles", etc.
|
||||
value String // The actual transcription
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
form HanziForm @relation(fields: [formId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([formId])
|
||||
@@index([type, value])
|
||||
@@map("hanzi_transcriptions")
|
||||
}
|
||||
|
||||
model HanziMeaning {
|
||||
id String @id @default(cuid())
|
||||
formId String
|
||||
languageId String
|
||||
meaning String @db.Text // Translation/definition
|
||||
orderIndex Int @default(0) // Order of meanings
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
form HanziForm @relation(fields: [formId], references: [id], onDelete: Cascade)
|
||||
language Language @relation(fields: [languageId], references: [id])
|
||||
|
||||
@@index([formId])
|
||||
@@index([languageId])
|
||||
@@map("hanzi_meanings")
|
||||
}
|
||||
|
||||
model HanziHSKLevel {
|
||||
id String @id @default(cuid())
|
||||
hanziId String
|
||||
level String // "new-1", "old-3", etc.
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
hanzi Hanzi @relation(fields: [hanziId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([hanziId])
|
||||
@@index([level])
|
||||
@@map("hanzi_hsk_levels")
|
||||
}
|
||||
|
||||
model HanziPOS {
|
||||
id String @id @default(cuid())
|
||||
hanziId String
|
||||
pos String // "n", "v", "adj", "adv", etc.
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
hanzi Hanzi @relation(fields: [hanziId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([hanziId])
|
||||
@@map("hanzi_pos")
|
||||
}
|
||||
|
||||
model HanziClassifier {
|
||||
id String @id @default(cuid())
|
||||
formId String
|
||||
classifier String // Measure word
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
form HanziForm @relation(fields: [formId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([formId])
|
||||
@@map("hanzi_classifiers")
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// USER & AUTH MODELS
|
||||
// ============================================================================
|
||||
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
email String @unique
|
||||
password String // Hashed with bcrypt
|
||||
name String?
|
||||
role UserRole @default(USER)
|
||||
isActive Boolean @default(true)
|
||||
emailVerified DateTime?
|
||||
image String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
collections Collection[]
|
||||
hanziProgress UserHanziProgress[]
|
||||
preference UserPreference?
|
||||
learningSessions LearningSession[]
|
||||
accounts Account[]
|
||||
sessions Session[]
|
||||
|
||||
@@index([email])
|
||||
@@map("users")
|
||||
}
|
||||
|
||||
model UserPreference {
|
||||
id String @id @default(cuid())
|
||||
userId String @unique
|
||||
preferredLanguageId String
|
||||
characterDisplay CharacterDisplay @default(SIMPLIFIED)
|
||||
transcriptionType String @default("pinyin") // "pinyin", "numeric", etc.
|
||||
cardsPerSession Int @default(20)
|
||||
dailyGoal Int @default(50) // Cards per day
|
||||
removalThreshold Int @default(10) // Consecutive correct before suggesting removal
|
||||
allowManualDifficulty Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
preferredLanguage Language @relation(fields: [preferredLanguageId], references: [id])
|
||||
|
||||
@@map("user_preferences")
|
||||
}
|
||||
|
||||
// NextAuth.js models
|
||||
model Account {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
type String
|
||||
provider String
|
||||
providerAccountId String
|
||||
refresh_token String? @db.Text
|
||||
access_token String? @db.Text
|
||||
expires_at Int?
|
||||
token_type String?
|
||||
scope String?
|
||||
id_token String? @db.Text
|
||||
session_state String?
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([provider, providerAccountId])
|
||||
@@map("accounts")
|
||||
}
|
||||
|
||||
model Session {
|
||||
id String @id @default(cuid())
|
||||
sessionToken String @unique
|
||||
userId String
|
||||
expires DateTime
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("sessions")
|
||||
}
|
||||
|
||||
model VerificationToken {
|
||||
identifier String
|
||||
token String @unique
|
||||
expires DateTime
|
||||
|
||||
@@unique([identifier, token])
|
||||
@@map("verification_tokens")
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// LEARNING MODELS
|
||||
// ============================================================================
|
||||
|
||||
model Collection {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
description String? @db.Text
|
||||
isGlobal Boolean @default(false) // Global collections (HSK levels) vs user collections
|
||||
createdBy String?
|
||||
isPublic Boolean @default(false) // User collections can be public
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
creator User? @relation(fields: [createdBy], references: [id], onDelete: SetNull)
|
||||
items CollectionItem[]
|
||||
learningSessions LearningSession[]
|
||||
|
||||
@@index([createdBy])
|
||||
@@index([isGlobal])
|
||||
@@map("collections")
|
||||
}
|
||||
|
||||
model CollectionItem {
|
||||
id String @id @default(cuid())
|
||||
collectionId String
|
||||
hanziId String
|
||||
orderIndex Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
collection Collection @relation(fields: [collectionId], references: [id], onDelete: Cascade)
|
||||
hanzi Hanzi @relation(fields: [hanziId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([collectionId, hanziId])
|
||||
@@index([collectionId])
|
||||
@@index([hanziId])
|
||||
@@map("collection_items")
|
||||
}
|
||||
|
||||
model UserHanziProgress {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
hanziId String
|
||||
correctCount Int @default(0)
|
||||
incorrectCount Int @default(0)
|
||||
consecutiveCorrect Int @default(0)
|
||||
easeFactor Float @default(2.5) // SM-2 algorithm
|
||||
interval Int @default(1) // Days between reviews
|
||||
nextReviewDate DateTime @default(now())
|
||||
manualDifficulty Difficulty? // Optional manual override
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
hanzi Hanzi @relation(fields: [hanziId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([userId, hanziId])
|
||||
@@index([userId, nextReviewDate])
|
||||
@@index([hanziId])
|
||||
@@map("user_hanzi_progress")
|
||||
}
|
||||
|
||||
model LearningSession {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
collectionId String?
|
||||
startedAt DateTime @default(now())
|
||||
endedAt DateTime?
|
||||
cardsReviewed Int @default(0)
|
||||
correctAnswers Int @default(0)
|
||||
incorrectAnswers Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
collection Collection? @relation(fields: [collectionId], references: [id], onDelete: SetNull)
|
||||
reviews SessionReview[]
|
||||
|
||||
@@index([userId])
|
||||
@@index([startedAt])
|
||||
@@map("learning_sessions")
|
||||
}
|
||||
|
||||
model SessionReview {
|
||||
id String @id @default(cuid())
|
||||
sessionId String
|
||||
hanziId String
|
||||
isCorrect Boolean
|
||||
responseTime Int? // Milliseconds
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
session LearningSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
|
||||
hanzi Hanzi @relation(fields: [hanziId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([sessionId])
|
||||
@@index([hanziId])
|
||||
@@map("session_reviews")
|
||||
}
|
||||
87
prisma/seed.ts
Normal file
87
prisma/seed.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
import bcrypt from 'bcrypt'
|
||||
|
||||
const prisma = new PrismaClient()
|
||||
|
||||
async function main() {
|
||||
console.log('Starting database seeding...')
|
||||
|
||||
// Create English language
|
||||
const english = await prisma.language.upsert({
|
||||
where: { code: 'en' },
|
||||
update: {},
|
||||
create: {
|
||||
code: 'en',
|
||||
name: 'English',
|
||||
nativeName: 'English',
|
||||
isActive: true,
|
||||
},
|
||||
})
|
||||
|
||||
console.log('Created language:', english.name)
|
||||
|
||||
// Create admin user
|
||||
const hashedPassword = await bcrypt.hash('admin123', 10)
|
||||
const adminUser = await prisma.user.upsert({
|
||||
where: { email: 'admin@memohanzi.local' },
|
||||
update: {},
|
||||
create: {
|
||||
email: 'admin@memohanzi.local',
|
||||
password: hashedPassword,
|
||||
name: 'Admin User',
|
||||
role: 'ADMIN',
|
||||
isActive: true,
|
||||
preference: {
|
||||
create: {
|
||||
preferredLanguageId: english.id,
|
||||
characterDisplay: 'SIMPLIFIED',
|
||||
transcriptionType: 'pinyin',
|
||||
cardsPerSession: 20,
|
||||
dailyGoal: 50,
|
||||
removalThreshold: 10,
|
||||
allowManualDifficulty: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
console.log('Created admin user:', adminUser.email)
|
||||
|
||||
// Create test user
|
||||
const testPassword = await bcrypt.hash('test123', 10)
|
||||
const testUser = await prisma.user.upsert({
|
||||
where: { email: 'user@memohanzi.local' },
|
||||
update: {},
|
||||
create: {
|
||||
email: 'user@memohanzi.local',
|
||||
password: testPassword,
|
||||
name: 'Test User',
|
||||
role: 'USER',
|
||||
isActive: true,
|
||||
preference: {
|
||||
create: {
|
||||
preferredLanguageId: english.id,
|
||||
characterDisplay: 'SIMPLIFIED',
|
||||
transcriptionType: 'pinyin',
|
||||
cardsPerSession: 20,
|
||||
dailyGoal: 50,
|
||||
removalThreshold: 10,
|
||||
allowManualDifficulty: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
console.log('Created test user:', testUser.email)
|
||||
|
||||
console.log('Database seeding completed!')
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error('Error during seeding:', e)
|
||||
process.exit(1)
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect()
|
||||
})
|
||||
335
src/actions/admin.integration.test.ts
Normal file
335
src/actions/admin.integration.test.ts
Normal file
@@ -0,0 +1,335 @@
|
||||
import { describe, it, expect, beforeEach, beforeAll } from "vitest"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
import { parseHSKJson } from "@/lib/import/hsk-json-parser"
|
||||
import { parseCSV } from "@/lib/import/csv-parser"
|
||||
|
||||
/**
|
||||
* Integration tests for admin import functionality
|
||||
* These tests focus on the database operations and data transformation
|
||||
*/
|
||||
|
||||
describe("Admin Import Integration Tests", () => {
|
||||
let englishLanguage: any
|
||||
|
||||
beforeAll(async () => {
|
||||
// Create English language
|
||||
englishLanguage = await prisma.language.upsert({
|
||||
where: { code: "en" },
|
||||
create: {
|
||||
code: "en",
|
||||
name: "English",
|
||||
nativeName: "English",
|
||||
isActive: true,
|
||||
},
|
||||
update: {},
|
||||
})
|
||||
})
|
||||
|
||||
beforeEach(async () => {
|
||||
// Clear hanzi-related tables
|
||||
await prisma.sessionReview.deleteMany()
|
||||
await prisma.learningSession.deleteMany()
|
||||
await prisma.collectionItem.deleteMany()
|
||||
await prisma.userHanziProgress.deleteMany()
|
||||
await prisma.hanziClassifier.deleteMany()
|
||||
await prisma.hanziMeaning.deleteMany()
|
||||
await prisma.hanziTranscription.deleteMany()
|
||||
await prisma.hanziForm.deleteMany()
|
||||
await prisma.hanziPOS.deleteMany()
|
||||
await prisma.hanziHSKLevel.deleteMany()
|
||||
await prisma.hanzi.deleteMany()
|
||||
})
|
||||
|
||||
// Helper function to import parsed hanzi data
|
||||
async function saveParsedHanzi(parsedData: any[]) {
|
||||
// Ensure English language exists
|
||||
const engLanguage = await prisma.language.upsert({
|
||||
where: { code: "en" },
|
||||
create: {
|
||||
code: "en",
|
||||
name: "English",
|
||||
nativeName: "English",
|
||||
isActive: true,
|
||||
},
|
||||
update: {},
|
||||
})
|
||||
|
||||
for (const hanzi of parsedData) {
|
||||
const hanziRecord = await prisma.hanzi.upsert({
|
||||
where: { simplified: hanzi.simplified },
|
||||
update: {
|
||||
radical: hanzi.radical,
|
||||
frequency: hanzi.frequency,
|
||||
},
|
||||
create: {
|
||||
simplified: hanzi.simplified,
|
||||
radical: hanzi.radical,
|
||||
frequency: hanzi.frequency,
|
||||
},
|
||||
})
|
||||
|
||||
// Delete existing related data
|
||||
await prisma.hanziForm.deleteMany({
|
||||
where: { hanziId: hanziRecord.id },
|
||||
})
|
||||
await prisma.hanziHSKLevel.deleteMany({
|
||||
where: { hanziId: hanziRecord.id },
|
||||
})
|
||||
await prisma.hanziPOS.deleteMany({
|
||||
where: { hanziId: hanziRecord.id },
|
||||
})
|
||||
|
||||
// Create HSK levels
|
||||
if (hanzi.hskLevels.length > 0) {
|
||||
await prisma.hanziHSKLevel.createMany({
|
||||
data: hanzi.hskLevels.map((level: string) => ({
|
||||
hanziId: hanziRecord.id,
|
||||
level,
|
||||
})),
|
||||
})
|
||||
}
|
||||
|
||||
// Create parts of speech
|
||||
if (hanzi.partsOfSpeech.length > 0) {
|
||||
await prisma.hanziPOS.createMany({
|
||||
data: hanzi.partsOfSpeech.map((pos: string) => ({
|
||||
hanziId: hanziRecord.id,
|
||||
pos,
|
||||
})),
|
||||
})
|
||||
}
|
||||
|
||||
// Create forms
|
||||
for (const form of hanzi.forms) {
|
||||
const formRecord = await prisma.hanziForm.create({
|
||||
data: {
|
||||
hanziId: hanziRecord.id,
|
||||
traditional: form.traditional,
|
||||
isDefault: form.isDefault,
|
||||
},
|
||||
})
|
||||
|
||||
// Create transcriptions
|
||||
if (form.transcriptions.length > 0) {
|
||||
await prisma.hanziTranscription.createMany({
|
||||
data: form.transcriptions.map((trans: any) => ({
|
||||
formId: formRecord.id,
|
||||
type: trans.type,
|
||||
value: trans.value,
|
||||
})),
|
||||
})
|
||||
}
|
||||
|
||||
// Create meanings
|
||||
if (form.meanings.length > 0) {
|
||||
await prisma.hanziMeaning.createMany({
|
||||
data: form.meanings.map((meaning: any) => ({
|
||||
formId: formRecord.id,
|
||||
languageId: engLanguage.id,
|
||||
meaning: meaning.meaning,
|
||||
orderIndex: meaning.orderIndex,
|
||||
})),
|
||||
})
|
||||
}
|
||||
|
||||
// Create classifiers
|
||||
if (form.classifiers.length > 0) {
|
||||
await prisma.hanziClassifier.createMany({
|
||||
data: form.classifiers.map((classifier: string) => ({
|
||||
formId: formRecord.id,
|
||||
classifier,
|
||||
})),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe("Import JSON to Database", () => {
|
||||
it("should parse and save JSON data to database", async () => {
|
||||
const jsonData = JSON.stringify([
|
||||
{
|
||||
simplified: "好",
|
||||
radical: "女",
|
||||
level: ["new-1"],
|
||||
frequency: 123,
|
||||
pos: ["adj"],
|
||||
forms: [
|
||||
{
|
||||
traditional: "好",
|
||||
transcriptions: {
|
||||
pinyin: "hǎo",
|
||||
numeric: "hao3",
|
||||
},
|
||||
meanings: ["good"],
|
||||
classifiers: ["个"],
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
const { result, data } = parseHSKJson(jsonData)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.imported).toBe(1)
|
||||
expect(result.failed).toBe(0)
|
||||
|
||||
// Save to database
|
||||
await saveParsedHanzi(data)
|
||||
|
||||
// Verify data was saved
|
||||
const hanzi = await prisma.hanzi.findUnique({
|
||||
where: { simplified: "好" },
|
||||
include: {
|
||||
forms: {
|
||||
include: {
|
||||
transcriptions: true,
|
||||
meanings: true,
|
||||
classifiers: true,
|
||||
},
|
||||
},
|
||||
hskLevels: true,
|
||||
partsOfSpeech: true,
|
||||
},
|
||||
})
|
||||
|
||||
expect(hanzi).toBeDefined()
|
||||
expect(hanzi?.radical).toBe("女")
|
||||
expect(hanzi?.frequency).toBe(123)
|
||||
expect(hanzi?.forms).toHaveLength(1)
|
||||
expect(hanzi?.forms[0].traditional).toBe("好")
|
||||
expect(hanzi?.forms[0].transcriptions).toHaveLength(2)
|
||||
expect(hanzi?.forms[0].meanings).toHaveLength(1)
|
||||
expect(hanzi?.forms[0].classifiers).toHaveLength(1)
|
||||
expect(hanzi?.hskLevels).toHaveLength(1)
|
||||
expect(hanzi?.partsOfSpeech).toHaveLength(1)
|
||||
})
|
||||
|
||||
it("should parse and save CSV data to database", async () => {
|
||||
const csvData = `simplified,traditional,pinyin,meaning,hsk_level,radical,frequency,pos,classifiers
|
||||
爱,愛,ài,love,new-1,爫,456,v,个`
|
||||
|
||||
const { result, data } = parseCSV(csvData)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.imported).toBe(1)
|
||||
|
||||
// Save to database
|
||||
await saveParsedHanzi(data)
|
||||
|
||||
const hanzi = await prisma.hanzi.findUnique({
|
||||
where: { simplified: "爱" },
|
||||
include: {
|
||||
forms: {
|
||||
include: {
|
||||
transcriptions: true,
|
||||
meanings: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(hanzi).toBeDefined()
|
||||
expect(hanzi?.forms[0].traditional).toBe("愛")
|
||||
expect(hanzi?.forms[0].transcriptions[0].value).toBe("ài")
|
||||
})
|
||||
|
||||
it("should upsert existing hanzi", async () => {
|
||||
// First import
|
||||
const jsonData1 = JSON.stringify({
|
||||
simplified: "好",
|
||||
forms: [
|
||||
{
|
||||
traditional: "好",
|
||||
transcriptions: { pinyin: "hǎo" },
|
||||
meanings: ["good"],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const { data: data1 } = parseHSKJson(jsonData1)
|
||||
await saveParsedHanzi(data1)
|
||||
|
||||
// Second import with updated data
|
||||
const jsonData2 = JSON.stringify({
|
||||
simplified: "好",
|
||||
radical: "女",
|
||||
frequency: 100,
|
||||
forms: [
|
||||
{
|
||||
traditional: "好",
|
||||
transcriptions: { pinyin: "hǎo" },
|
||||
meanings: ["good", "fine"],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const { result, data: data2 } = parseHSKJson(jsonData2)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
|
||||
await saveParsedHanzi(data2)
|
||||
|
||||
const hanzi = await prisma.hanzi.findUnique({
|
||||
where: { simplified: "好" },
|
||||
include: {
|
||||
forms: {
|
||||
include: {
|
||||
meanings: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(hanzi?.radical).toBe("女")
|
||||
expect(hanzi?.frequency).toBe(100)
|
||||
// Should have replaced meanings
|
||||
expect(hanzi?.forms[0].meanings).toHaveLength(2)
|
||||
})
|
||||
|
||||
it("should handle multiple hanzi import", async () => {
|
||||
const jsonData = JSON.stringify([
|
||||
{
|
||||
simplified: "好",
|
||||
forms: [
|
||||
{
|
||||
traditional: "好",
|
||||
transcriptions: { pinyin: "hǎo" },
|
||||
meanings: ["good"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
simplified: "爱",
|
||||
forms: [
|
||||
{
|
||||
traditional: "愛",
|
||||
transcriptions: { pinyin: "ài" },
|
||||
meanings: ["love"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
simplified: "你",
|
||||
forms: [
|
||||
{
|
||||
traditional: "你",
|
||||
transcriptions: { pinyin: "nǐ" },
|
||||
meanings: ["you"],
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
const { result, data } = parseHSKJson(jsonData)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(data).toHaveLength(3)
|
||||
|
||||
await saveParsedHanzi(data)
|
||||
|
||||
const count = await prisma.hanzi.count()
|
||||
expect(count).toBe(3)
|
||||
})
|
||||
})
|
||||
})
|
||||
519
src/actions/admin.ts
Normal file
519
src/actions/admin.ts
Normal file
@@ -0,0 +1,519 @@
|
||||
"use server"
|
||||
|
||||
import { revalidatePath } from "next/cache"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
import { requireAdmin, auth } from "@/lib/auth"
|
||||
import { parseHSKJson } from "@/lib/import/hsk-json-parser"
|
||||
import { parseCSV } from "@/lib/import/csv-parser"
|
||||
import type { ParsedHanzi, ImportResult } from "@/lib/import/types"
|
||||
import { UserRole } from "@prisma/client"
|
||||
import { z } from "zod"
|
||||
|
||||
/**
|
||||
* Standard action result type
|
||||
*/
|
||||
export type ActionResult<T = void> = {
|
||||
success: boolean
|
||||
data?: T
|
||||
message?: string
|
||||
errors?: Record<string, string[]>
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// VALIDATION SCHEMAS
|
||||
// ============================================================================
|
||||
|
||||
const createGlobalCollectionSchema = z.object({
|
||||
name: z.string().min(1, "Name is required").max(100),
|
||||
description: z.string().optional(),
|
||||
hskLevel: z.string().optional(),
|
||||
})
|
||||
|
||||
const importHanziSchema = z.object({
|
||||
fileData: z.string().min(1, "File data is required"),
|
||||
format: z.enum(["json", "csv"]),
|
||||
updateExisting: z.boolean().default(true),
|
||||
})
|
||||
|
||||
const updateUserRoleSchema = z.object({
|
||||
userId: z.string().min(1),
|
||||
role: z.nativeEnum(UserRole),
|
||||
})
|
||||
|
||||
const toggleUserStatusSchema = z.object({
|
||||
userId: z.string().min(1),
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// GLOBAL COLLECTIONS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Create a global HSK collection (admin only)
|
||||
*/
|
||||
export async function createGlobalCollection(
|
||||
name: string,
|
||||
description?: string,
|
||||
hskLevel?: string
|
||||
): Promise<ActionResult<{ id: string }>> {
|
||||
try {
|
||||
await requireAdmin()
|
||||
|
||||
const validation = createGlobalCollectionSchema.safeParse({
|
||||
name,
|
||||
description,
|
||||
hskLevel,
|
||||
})
|
||||
|
||||
if (!validation.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Validation failed",
|
||||
errors: validation.error.flatten().fieldErrors,
|
||||
}
|
||||
}
|
||||
|
||||
const collection = await prisma.collection.create({
|
||||
data: {
|
||||
name,
|
||||
description,
|
||||
isGlobal: true,
|
||||
isPublic: true,
|
||||
createdBy: null,
|
||||
},
|
||||
})
|
||||
|
||||
revalidatePath("/admin/collections")
|
||||
revalidatePath("/collections")
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: { id: collection.id },
|
||||
message: "Global collection created successfully",
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.startsWith("Unauthorized")) {
|
||||
return {
|
||||
success: false,
|
||||
message: error.message,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: "Failed to create global collection",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// HANZI IMPORT
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Import hanzi from JSON or CSV format (admin only)
|
||||
*/
|
||||
export async function importHanzi(
|
||||
fileData: string,
|
||||
format: "json" | "csv",
|
||||
updateExisting: boolean = true
|
||||
): Promise<ActionResult<ImportResult>> {
|
||||
try {
|
||||
await requireAdmin()
|
||||
|
||||
const validation = importHanziSchema.safeParse({ fileData, format, updateExisting })
|
||||
|
||||
if (!validation.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Validation failed",
|
||||
errors: validation.error.flatten().fieldErrors,
|
||||
}
|
||||
}
|
||||
|
||||
// Parse the file
|
||||
const { result: parseResult, data: parsedData } = format === "json"
|
||||
? parseHSKJson(fileData)
|
||||
: parseCSV(fileData)
|
||||
|
||||
if (!parseResult.success) {
|
||||
return {
|
||||
success: false,
|
||||
data: parseResult,
|
||||
message: `Import failed: ${parseResult.errors.length} errors found`,
|
||||
}
|
||||
}
|
||||
|
||||
// Save to database
|
||||
await saveParsedHanzi(parsedData, updateExisting)
|
||||
|
||||
revalidatePath("/admin/hanzi")
|
||||
revalidatePath("/hanzi")
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: parseResult,
|
||||
message: `Successfully imported ${parseResult.imported} hanzi`,
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.startsWith("Unauthorized")) {
|
||||
return {
|
||||
success: false,
|
||||
message: error.message,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: "Failed to import hanzi",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save parsed hanzi to database
|
||||
*/
|
||||
async function saveParsedHanzi(parsedHanzi: ParsedHanzi[], updateExisting: boolean = true): Promise<void> {
|
||||
// Get or create English language
|
||||
let englishLanguage = await prisma.language.findUnique({
|
||||
where: { code: "en" },
|
||||
})
|
||||
|
||||
if (!englishLanguage) {
|
||||
englishLanguage = await prisma.language.create({
|
||||
data: {
|
||||
code: "en",
|
||||
name: "English",
|
||||
nativeName: "English",
|
||||
isActive: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
for (const hanzi of parsedHanzi) {
|
||||
// Check if hanzi exists
|
||||
const existingHanzi = await prisma.hanzi.findUnique({
|
||||
where: { simplified: hanzi.simplified },
|
||||
})
|
||||
|
||||
// Skip if exists and updateExisting is false
|
||||
if (existingHanzi && !updateExisting) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Upsert hanzi
|
||||
const hanziRecord = await prisma.hanzi.upsert({
|
||||
where: { simplified: hanzi.simplified },
|
||||
update: {
|
||||
radical: hanzi.radical,
|
||||
frequency: hanzi.frequency,
|
||||
},
|
||||
create: {
|
||||
simplified: hanzi.simplified,
|
||||
radical: hanzi.radical,
|
||||
frequency: hanzi.frequency,
|
||||
},
|
||||
})
|
||||
|
||||
// Delete existing related data to replace it
|
||||
await prisma.hanziForm.deleteMany({
|
||||
where: { hanziId: hanziRecord.id },
|
||||
})
|
||||
await prisma.hanziHSKLevel.deleteMany({
|
||||
where: { hanziId: hanziRecord.id },
|
||||
})
|
||||
await prisma.hanziPOS.deleteMany({
|
||||
where: { hanziId: hanziRecord.id },
|
||||
})
|
||||
|
||||
// Create HSK levels
|
||||
if (hanzi.hskLevels.length > 0) {
|
||||
await prisma.hanziHSKLevel.createMany({
|
||||
data: hanzi.hskLevels.map(level => ({
|
||||
hanziId: hanziRecord.id,
|
||||
level,
|
||||
})),
|
||||
})
|
||||
}
|
||||
|
||||
// Create parts of speech
|
||||
if (hanzi.partsOfSpeech.length > 0) {
|
||||
await prisma.hanziPOS.createMany({
|
||||
data: hanzi.partsOfSpeech.map(pos => ({
|
||||
hanziId: hanziRecord.id,
|
||||
pos,
|
||||
})),
|
||||
})
|
||||
}
|
||||
|
||||
// Create forms with transcriptions, meanings, and classifiers
|
||||
for (const form of hanzi.forms) {
|
||||
const formRecord = await prisma.hanziForm.create({
|
||||
data: {
|
||||
hanziId: hanziRecord.id,
|
||||
traditional: form.traditional,
|
||||
isDefault: form.isDefault,
|
||||
},
|
||||
})
|
||||
|
||||
// Create transcriptions
|
||||
if (form.transcriptions.length > 0) {
|
||||
await prisma.hanziTranscription.createMany({
|
||||
data: form.transcriptions.map(trans => ({
|
||||
formId: formRecord.id,
|
||||
type: trans.type,
|
||||
value: trans.value,
|
||||
})),
|
||||
})
|
||||
}
|
||||
|
||||
// Create meanings
|
||||
if (form.meanings.length > 0) {
|
||||
await prisma.hanziMeaning.createMany({
|
||||
data: form.meanings.map(meaning => ({
|
||||
formId: formRecord.id,
|
||||
languageId: englishLanguage!.id,
|
||||
meaning: meaning.meaning,
|
||||
orderIndex: meaning.orderIndex,
|
||||
})),
|
||||
})
|
||||
}
|
||||
|
||||
// Create classifiers
|
||||
if (form.classifiers.length > 0) {
|
||||
await prisma.hanziClassifier.createMany({
|
||||
data: form.classifiers.map(classifier => ({
|
||||
formId: formRecord.id,
|
||||
classifier,
|
||||
})),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// IMPORT HISTORY
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get import history (admin only)
|
||||
* Note: This is a placeholder - we don't have an import history table yet
|
||||
*/
|
||||
export async function getImportHistory(): Promise<
|
||||
ActionResult<{ imports: any[] }>
|
||||
> {
|
||||
try {
|
||||
await requireAdmin()
|
||||
|
||||
// TODO: Implement import history tracking
|
||||
// For now, return empty array
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: { imports: [] },
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.startsWith("Unauthorized")) {
|
||||
return {
|
||||
success: false,
|
||||
message: error.message,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: "Failed to get import history",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// USER MANAGEMENT
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get users for management (admin only)
|
||||
*/
|
||||
export async function getUserManagement(
|
||||
page: number = 1,
|
||||
pageSize: number = 20
|
||||
): Promise<
|
||||
ActionResult<{
|
||||
users: Array<{
|
||||
id: string
|
||||
email: string
|
||||
name: string | null
|
||||
role: UserRole
|
||||
isActive: boolean
|
||||
createdAt: Date
|
||||
}>
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
}>
|
||||
> {
|
||||
try {
|
||||
await requireAdmin()
|
||||
|
||||
const skip = (page - 1) * pageSize
|
||||
|
||||
const [users, total] = await Promise.all([
|
||||
prisma.user.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
role: true,
|
||||
isActive: true,
|
||||
createdAt: true,
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
skip,
|
||||
take: pageSize,
|
||||
}),
|
||||
prisma.user.count(),
|
||||
])
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
users,
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
},
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.startsWith("Unauthorized")) {
|
||||
return {
|
||||
success: false,
|
||||
message: error.message,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: "Failed to get users",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update user role (admin only)
|
||||
*/
|
||||
export async function updateUserRole(
|
||||
userId: string,
|
||||
role: UserRole
|
||||
): Promise<ActionResult> {
|
||||
try {
|
||||
await requireAdmin()
|
||||
|
||||
const validation = updateUserRoleSchema.safeParse({ userId, role })
|
||||
|
||||
if (!validation.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Validation failed",
|
||||
errors: validation.error.flatten().fieldErrors,
|
||||
}
|
||||
}
|
||||
|
||||
// Prevent changing own role
|
||||
const session = await auth()
|
||||
if (session?.user && (session.user as any).id === userId) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Cannot change your own role",
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { role },
|
||||
})
|
||||
|
||||
revalidatePath("/admin/users")
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "User role updated successfully",
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.startsWith("Unauthorized")) {
|
||||
return {
|
||||
success: false,
|
||||
message: error.message,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: "Failed to update user role",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle user active status (admin only)
|
||||
*/
|
||||
export async function toggleUserStatus(userId: string): Promise<ActionResult> {
|
||||
try {
|
||||
await requireAdmin()
|
||||
|
||||
const validation = toggleUserStatusSchema.safeParse({ userId })
|
||||
|
||||
if (!validation.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Validation failed",
|
||||
errors: validation.error.flatten().fieldErrors,
|
||||
}
|
||||
}
|
||||
|
||||
// Prevent deactivating own account
|
||||
const session = await auth()
|
||||
if (session?.user && (session.user as any).id === userId) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Cannot deactivate your own account",
|
||||
}
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { isActive: true },
|
||||
})
|
||||
|
||||
if (!user) {
|
||||
return {
|
||||
success: false,
|
||||
message: "User not found",
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { isActive: !user.isActive },
|
||||
})
|
||||
|
||||
revalidatePath("/admin/users")
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `User ${user.isActive ? "deactivated" : "activated"} successfully`,
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.startsWith("Unauthorized")) {
|
||||
return {
|
||||
success: false,
|
||||
message: error.message,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: "Failed to toggle user status",
|
||||
}
|
||||
}
|
||||
}
|
||||
271
src/actions/auth.integration.test.ts
Normal file
271
src/actions/auth.integration.test.ts
Normal file
@@ -0,0 +1,271 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { register, login, updatePassword, updateProfile } from './auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import bcrypt from 'bcrypt'
|
||||
|
||||
// Mock the auth module
|
||||
vi.mock('@/lib/auth', () => ({
|
||||
signIn: vi.fn(),
|
||||
signOut: vi.fn(),
|
||||
auth: vi.fn(),
|
||||
}))
|
||||
|
||||
// Mock revalidatePath
|
||||
vi.mock('next/cache', () => ({
|
||||
revalidatePath: vi.fn(),
|
||||
}))
|
||||
|
||||
describe('Auth Server Actions - Integration Tests', () => {
|
||||
beforeEach(async () => {
|
||||
// Create English language for tests
|
||||
await prisma.language.create({
|
||||
data: {
|
||||
code: 'en',
|
||||
name: 'English',
|
||||
nativeName: 'English',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
describe('register', () => {
|
||||
it('should successfully register a new user', async () => {
|
||||
const result = await register('test@example.com', 'password123', 'Test User')
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.data?.userId).toBeDefined()
|
||||
expect(result.message).toBe('Account created successfully')
|
||||
|
||||
// Verify user was created in database
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email: 'test@example.com' },
|
||||
include: { preference: true },
|
||||
})
|
||||
|
||||
expect(user).toBeDefined()
|
||||
expect(user?.email).toBe('test@example.com')
|
||||
expect(user?.name).toBe('Test User')
|
||||
expect(user?.role).toBe('USER')
|
||||
expect(user?.isActive).toBe(true)
|
||||
|
||||
// Verify password was hashed
|
||||
const isPasswordValid = await bcrypt.compare('password123', user!.password)
|
||||
expect(isPasswordValid).toBe(true)
|
||||
|
||||
// Verify preferences were created
|
||||
expect(user?.preference).toBeDefined()
|
||||
expect(user?.preference?.characterDisplay).toBe('SIMPLIFIED')
|
||||
expect(user?.preference?.cardsPerSession).toBe(20)
|
||||
})
|
||||
|
||||
it('should reject duplicate email', async () => {
|
||||
// Create first user
|
||||
await register('test@example.com', 'password123', 'Test User')
|
||||
|
||||
// Try to create duplicate
|
||||
const result = await register('test@example.com', 'password456', 'Another User')
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.message).toBe('A user with this email already exists')
|
||||
})
|
||||
|
||||
it('should validate email format', async () => {
|
||||
const result = await register('invalid-email', 'password123', 'Test User')
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.message).toBe('Validation failed')
|
||||
expect(result.errors).toBeDefined()
|
||||
})
|
||||
|
||||
it('should validate password length', async () => {
|
||||
const result = await register('test@example.com', 'short', 'Test User')
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.message).toBe('Validation failed')
|
||||
expect(result.errors).toBeDefined()
|
||||
})
|
||||
|
||||
it('should handle missing default language', async () => {
|
||||
// Delete the English language
|
||||
await prisma.language.delete({ where: { code: 'en' } })
|
||||
|
||||
const result = await register('test@example.com', 'password123', 'Test User')
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.message).toBe('System configuration error: default language not found')
|
||||
})
|
||||
})
|
||||
|
||||
describe('login', () => {
|
||||
beforeEach(async () => {
|
||||
// Create a test user
|
||||
await register('test@example.com', 'password123', 'Test User')
|
||||
})
|
||||
|
||||
it('should successfully login with valid credentials', async () => {
|
||||
const { signIn } = await import('@/lib/auth')
|
||||
vi.mocked(signIn).mockResolvedValue(undefined)
|
||||
|
||||
const result = await login('test@example.com', 'password123')
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.message).toBe('Logged in successfully')
|
||||
expect(signIn).toHaveBeenCalledWith('credentials', {
|
||||
email: 'test@example.com',
|
||||
password: 'password123',
|
||||
redirect: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('should reject invalid credentials', async () => {
|
||||
const { signIn } = await import('@/lib/auth')
|
||||
vi.mocked(signIn).mockResolvedValue({ error: 'Invalid credentials' } as any)
|
||||
|
||||
const result = await login('test@example.com', 'wrongpassword')
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.message).toBe('Invalid email or password')
|
||||
})
|
||||
|
||||
it('should validate email format', async () => {
|
||||
const result = await login('invalid-email', 'password123')
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.message).toBe('Validation failed')
|
||||
})
|
||||
})
|
||||
|
||||
describe('updatePassword', () => {
|
||||
let testUserId: string
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create a test user
|
||||
const result = await register('test@example.com', 'oldpassword123', 'Test User')
|
||||
testUserId = result.data!.userId
|
||||
|
||||
// Mock auth to return test user session
|
||||
const { auth } = await import('@/lib/auth')
|
||||
vi.mocked(auth).mockResolvedValue({
|
||||
user: { id: testUserId, email: 'test@example.com' },
|
||||
} as any)
|
||||
})
|
||||
|
||||
it('should successfully update password with correct current password', async () => {
|
||||
const result = await updatePassword('oldpassword123', 'newpassword123')
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.message).toBe('Password updated successfully')
|
||||
|
||||
// Verify password was updated
|
||||
const user = await prisma.user.findUnique({ where: { id: testUserId } })
|
||||
const isNewPasswordValid = await bcrypt.compare('newpassword123', user!.password)
|
||||
expect(isNewPasswordValid).toBe(true)
|
||||
})
|
||||
|
||||
it('should reject incorrect current password', async () => {
|
||||
const result = await updatePassword('wrongpassword', 'newpassword123')
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.message).toBe('Current password is incorrect')
|
||||
})
|
||||
|
||||
it('should reject when not logged in', async () => {
|
||||
const { auth } = await import('@/lib/auth')
|
||||
vi.mocked(auth).mockResolvedValue(null)
|
||||
|
||||
const result = await updatePassword('oldpassword123', 'newpassword123')
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.message).toBe('You must be logged in to change your password')
|
||||
})
|
||||
|
||||
it('should validate new password length', async () => {
|
||||
const result = await updatePassword('oldpassword123', 'short')
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.message).toBe('Validation failed')
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateProfile', () => {
|
||||
let testUserId: string
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create a test user
|
||||
const result = await register('test@example.com', 'password123', 'Test User')
|
||||
testUserId = result.data!.userId
|
||||
|
||||
// Mock auth to return test user session
|
||||
const { auth } = await import('@/lib/auth')
|
||||
vi.mocked(auth).mockResolvedValue({
|
||||
user: { id: testUserId, email: 'test@example.com' },
|
||||
} as any)
|
||||
})
|
||||
|
||||
it('should successfully update name', async () => {
|
||||
const result = await updateProfile('New Name', undefined)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.message).toBe('Profile updated successfully')
|
||||
|
||||
// Verify name was updated
|
||||
const user = await prisma.user.findUnique({ where: { id: testUserId } })
|
||||
expect(user?.name).toBe('New Name')
|
||||
})
|
||||
|
||||
it('should successfully update email', async () => {
|
||||
const result = await updateProfile(undefined, 'newemail@example.com')
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.message).toBe('Profile updated successfully')
|
||||
|
||||
// Verify email was updated
|
||||
const user = await prisma.user.findUnique({ where: { id: testUserId } })
|
||||
expect(user?.email).toBe('newemail@example.com')
|
||||
})
|
||||
|
||||
it('should successfully update both name and email', async () => {
|
||||
const result = await updateProfile('New Name', 'newemail@example.com')
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
|
||||
// Verify both were updated
|
||||
const user = await prisma.user.findUnique({ where: { id: testUserId } })
|
||||
expect(user?.name).toBe('New Name')
|
||||
expect(user?.email).toBe('newemail@example.com')
|
||||
})
|
||||
|
||||
it('should reject duplicate email from another user', async () => {
|
||||
// Create another user
|
||||
await register('another@example.com', 'password123', 'Another User')
|
||||
|
||||
// Try to update to existing email
|
||||
const result = await updateProfile(undefined, 'another@example.com')
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.message).toBe('This email is already in use')
|
||||
})
|
||||
|
||||
it('should allow updating to same email', async () => {
|
||||
const result = await updateProfile('New Name', 'test@example.com')
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('should reject when not logged in', async () => {
|
||||
const { auth } = await import('@/lib/auth')
|
||||
vi.mocked(auth).mockResolvedValue(null)
|
||||
|
||||
const result = await updateProfile('New Name', undefined)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.message).toBe('You must be logged in to update your profile')
|
||||
})
|
||||
|
||||
it('should validate email format', async () => {
|
||||
const result = await updateProfile(undefined, 'invalid-email')
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.message).toBe('Validation failed')
|
||||
})
|
||||
})
|
||||
})
|
||||
317
src/actions/auth.ts
Normal file
317
src/actions/auth.ts
Normal file
@@ -0,0 +1,317 @@
|
||||
'use server'
|
||||
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import bcrypt from 'bcrypt'
|
||||
import { signIn, signOut } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import {
|
||||
loginSchema,
|
||||
registerSchema,
|
||||
updatePasswordSchema,
|
||||
updateProfileSchema
|
||||
} from '@/lib/validations/auth'
|
||||
import type { ActionResult } from '@/types'
|
||||
import { auth } from '@/lib/auth'
|
||||
|
||||
/**
|
||||
* Register a new user
|
||||
*/
|
||||
export async function register(
|
||||
email: string,
|
||||
password: string,
|
||||
name: string
|
||||
): Promise<ActionResult<{ userId: string }>> {
|
||||
try {
|
||||
// Validate input
|
||||
const validatedData = registerSchema.parse({ email, password, name })
|
||||
|
||||
// Check if user already exists
|
||||
const existingUser = await prisma.user.findUnique({
|
||||
where: { email: validatedData.email },
|
||||
})
|
||||
|
||||
if (existingUser) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'A user with this email already exists',
|
||||
}
|
||||
}
|
||||
|
||||
// Hash password
|
||||
const hashedPassword = await bcrypt.hash(validatedData.password, 10)
|
||||
|
||||
// Get default language (English)
|
||||
const defaultLanguage = await prisma.language.findUnique({
|
||||
where: { code: 'en' },
|
||||
})
|
||||
|
||||
if (!defaultLanguage) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'System configuration error: default language not found',
|
||||
}
|
||||
}
|
||||
|
||||
// Create user with preferences
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email: validatedData.email,
|
||||
password: hashedPassword,
|
||||
name: validatedData.name,
|
||||
role: 'USER',
|
||||
isActive: true,
|
||||
preference: {
|
||||
create: {
|
||||
preferredLanguageId: defaultLanguage.id,
|
||||
characterDisplay: 'SIMPLIFIED',
|
||||
transcriptionType: 'pinyin',
|
||||
cardsPerSession: 20,
|
||||
dailyGoal: 50,
|
||||
removalThreshold: 10,
|
||||
allowManualDifficulty: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
revalidatePath('/')
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: { userId: user.id },
|
||||
message: 'Account created successfully',
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error.name === 'ZodError') {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Validation failed',
|
||||
errors: error.flatten().fieldErrors,
|
||||
}
|
||||
}
|
||||
|
||||
console.error('Registration error:', error)
|
||||
return {
|
||||
success: false,
|
||||
message: 'An error occurred during registration',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Login with email and password
|
||||
*/
|
||||
export async function login(
|
||||
email: string,
|
||||
password: string
|
||||
): Promise<ActionResult> {
|
||||
try {
|
||||
// Validate input
|
||||
const validatedData = loginSchema.parse({ email, password })
|
||||
|
||||
// Attempt sign in with NextAuth
|
||||
const result = await signIn('credentials', {
|
||||
email: validatedData.email,
|
||||
password: validatedData.password,
|
||||
redirect: false,
|
||||
})
|
||||
|
||||
if (result?.error) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Invalid email or password',
|
||||
}
|
||||
}
|
||||
|
||||
revalidatePath('/')
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Logged in successfully',
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error.name === 'ZodError') {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Validation failed',
|
||||
errors: error.flatten().fieldErrors,
|
||||
}
|
||||
}
|
||||
|
||||
console.error('Login error:', error)
|
||||
return {
|
||||
success: false,
|
||||
message: 'An error occurred during login',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout current user
|
||||
*/
|
||||
export async function logout(): Promise<ActionResult> {
|
||||
try {
|
||||
await signOut({ redirect: false })
|
||||
revalidatePath('/')
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Logged out successfully',
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Logout error:', error)
|
||||
return {
|
||||
success: false,
|
||||
message: 'An error occurred during logout',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update user password
|
||||
*/
|
||||
export async function updatePassword(
|
||||
currentPassword: string,
|
||||
newPassword: string
|
||||
): Promise<ActionResult> {
|
||||
try {
|
||||
// Get current user session
|
||||
const session = await auth()
|
||||
if (!session?.user) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'You must be logged in to change your password',
|
||||
}
|
||||
}
|
||||
|
||||
// Validate input
|
||||
const validatedData = updatePasswordSchema.parse({
|
||||
currentPassword,
|
||||
newPassword,
|
||||
})
|
||||
|
||||
// Get user from database
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: (session.user as any).id },
|
||||
})
|
||||
|
||||
if (!user) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'User not found',
|
||||
}
|
||||
}
|
||||
|
||||
// Verify current password
|
||||
const isPasswordValid = await bcrypt.compare(
|
||||
validatedData.currentPassword,
|
||||
user.password
|
||||
)
|
||||
|
||||
if (!isPasswordValid) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Current password is incorrect',
|
||||
}
|
||||
}
|
||||
|
||||
// Hash new password
|
||||
const hashedPassword = await bcrypt.hash(validatedData.newPassword, 10)
|
||||
|
||||
// Update password
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { password: hashedPassword },
|
||||
})
|
||||
|
||||
revalidatePath('/settings')
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Password updated successfully',
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error.name === 'ZodError') {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Validation failed',
|
||||
errors: error.flatten().fieldErrors,
|
||||
}
|
||||
}
|
||||
|
||||
console.error('Update password error:', error)
|
||||
return {
|
||||
success: false,
|
||||
message: 'An error occurred while updating your password',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update user profile
|
||||
*/
|
||||
export async function updateProfile(
|
||||
name?: string,
|
||||
email?: string
|
||||
): Promise<ActionResult> {
|
||||
try {
|
||||
// Get current user session
|
||||
const session = await auth()
|
||||
if (!session?.user) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'You must be logged in to update your profile',
|
||||
}
|
||||
}
|
||||
|
||||
// Validate input
|
||||
const validatedData = updateProfileSchema.parse({ name, email })
|
||||
|
||||
// Check if email is already taken by another user
|
||||
if (validatedData.email) {
|
||||
const existingUser = await prisma.user.findFirst({
|
||||
where: {
|
||||
email: validatedData.email,
|
||||
NOT: { id: (session.user as any).id },
|
||||
},
|
||||
})
|
||||
|
||||
if (existingUser) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'This email is already in use',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update user
|
||||
await prisma.user.update({
|
||||
where: { id: (session.user as any).id },
|
||||
data: {
|
||||
...(validatedData.name && { name: validatedData.name }),
|
||||
...(validatedData.email && { email: validatedData.email }),
|
||||
},
|
||||
})
|
||||
|
||||
revalidatePath('/settings')
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Profile updated successfully',
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error.name === 'ZodError') {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Validation failed',
|
||||
errors: error.flatten().fieldErrors,
|
||||
}
|
||||
}
|
||||
|
||||
console.error('Update profile error:', error)
|
||||
return {
|
||||
success: false,
|
||||
message: 'An error occurred while updating your profile',
|
||||
}
|
||||
}
|
||||
}
|
||||
592
src/actions/collections.integration.test.ts
Normal file
592
src/actions/collections.integration.test.ts
Normal file
@@ -0,0 +1,592 @@
|
||||
import { describe, it, expect, beforeEach, beforeAll } from "vitest"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
|
||||
/**
|
||||
* Integration tests for collections functionality
|
||||
* These tests focus on database operations and data integrity
|
||||
*/
|
||||
|
||||
describe("Collections Database Operations", () => {
|
||||
let testUser: any
|
||||
let englishLanguage: any
|
||||
|
||||
beforeEach(async () => {
|
||||
// Note: Global beforeEach in vitest.integration.setup.ts clears all data
|
||||
// So we need to recreate user and language in each test
|
||||
|
||||
// Create English language
|
||||
englishLanguage = await prisma.language.create({
|
||||
data: {
|
||||
code: "en",
|
||||
name: "English",
|
||||
nativeName: "English",
|
||||
isActive: true,
|
||||
},
|
||||
})
|
||||
|
||||
// Create test user
|
||||
testUser = await prisma.user.create({
|
||||
data: {
|
||||
email: "testcollections@example.com",
|
||||
name: "Test User",
|
||||
password: "dummy",
|
||||
role: "USER",
|
||||
isActive: true,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
describe("Collection CRUD Operations", () => {
|
||||
it("should create a collection", async () => {
|
||||
const collection = await prisma.collection.create({
|
||||
data: {
|
||||
name: "Test Collection",
|
||||
description: "Test description",
|
||||
isPublic: false,
|
||||
isGlobal: false,
|
||||
createdBy: testUser.id,
|
||||
},
|
||||
})
|
||||
|
||||
expect(collection.id).toBeDefined()
|
||||
expect(collection.name).toBe("Test Collection")
|
||||
expect(collection.createdBy).toBe(testUser.id)
|
||||
})
|
||||
|
||||
it("should update a collection", async () => {
|
||||
const collection = await prisma.collection.create({
|
||||
data: {
|
||||
name: "Test Collection",
|
||||
isPublic: false,
|
||||
isGlobal: false,
|
||||
createdBy: testUser.id,
|
||||
},
|
||||
})
|
||||
|
||||
const updated = await prisma.collection.update({
|
||||
where: { id: collection.id },
|
||||
data: {
|
||||
name: "Updated Name",
|
||||
description: "New description",
|
||||
},
|
||||
})
|
||||
|
||||
expect(updated.name).toBe("Updated Name")
|
||||
expect(updated.description).toBe("New description")
|
||||
})
|
||||
|
||||
it("should delete a collection", async () => {
|
||||
const collection = await prisma.collection.create({
|
||||
data: {
|
||||
name: "Test Collection",
|
||||
isPublic: false,
|
||||
isGlobal: false,
|
||||
createdBy: testUser.id,
|
||||
},
|
||||
})
|
||||
|
||||
await prisma.collection.delete({
|
||||
where: { id: collection.id },
|
||||
})
|
||||
|
||||
const deleted = await prisma.collection.findUnique({
|
||||
where: { id: collection.id },
|
||||
})
|
||||
|
||||
expect(deleted).toBeNull()
|
||||
})
|
||||
|
||||
it("should get user collections", async () => {
|
||||
await prisma.collection.createMany({
|
||||
data: [
|
||||
{
|
||||
name: "Collection 1",
|
||||
isPublic: false,
|
||||
isGlobal: false,
|
||||
createdBy: testUser.id,
|
||||
},
|
||||
{
|
||||
name: "Collection 2",
|
||||
isPublic: true,
|
||||
isGlobal: false,
|
||||
createdBy: testUser.id,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const collections = await prisma.collection.findMany({
|
||||
where: { createdBy: testUser.id },
|
||||
})
|
||||
|
||||
expect(collections.length).toBe(2)
|
||||
})
|
||||
|
||||
it("should get global collections", async () => {
|
||||
await prisma.collection.create({
|
||||
data: {
|
||||
name: "HSK 1",
|
||||
isPublic: true,
|
||||
isGlobal: true,
|
||||
createdBy: null,
|
||||
},
|
||||
})
|
||||
|
||||
const globalCollections = await prisma.collection.findMany({
|
||||
where: { isGlobal: true },
|
||||
})
|
||||
|
||||
expect(globalCollections.length).toBe(1)
|
||||
expect(globalCollections[0].name).toBe("HSK 1")
|
||||
})
|
||||
})
|
||||
|
||||
describe("Collection Items and OrderIndex", () => {
|
||||
let collection: any
|
||||
let hanzi1: any
|
||||
let hanzi2: any
|
||||
let hanzi3: any
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create collection
|
||||
collection = await prisma.collection.create({
|
||||
data: {
|
||||
name: "Test Collection",
|
||||
isPublic: false,
|
||||
isGlobal: false,
|
||||
createdBy: testUser.id,
|
||||
},
|
||||
})
|
||||
|
||||
// Create hanzi
|
||||
hanzi1 = await prisma.hanzi.create({
|
||||
data: { simplified: "好", radical: "女", frequency: 100 },
|
||||
})
|
||||
hanzi2 = await prisma.hanzi.create({
|
||||
data: { simplified: "爱", radical: "爫", frequency: 200 },
|
||||
})
|
||||
hanzi3 = await prisma.hanzi.create({
|
||||
data: { simplified: "你", radical: "人", frequency: 50 },
|
||||
})
|
||||
})
|
||||
|
||||
it("should add hanzi to collection with correct orderIndex", async () => {
|
||||
await prisma.collectionItem.createMany({
|
||||
data: [
|
||||
{ collectionId: collection.id, hanziId: hanzi1.id, orderIndex: 0 },
|
||||
{ collectionId: collection.id, hanziId: hanzi2.id, orderIndex: 1 },
|
||||
{ collectionId: collection.id, hanziId: hanzi3.id, orderIndex: 2 },
|
||||
],
|
||||
})
|
||||
|
||||
const items = await prisma.collectionItem.findMany({
|
||||
where: { collectionId: collection.id },
|
||||
orderBy: { orderIndex: "asc" },
|
||||
include: { hanzi: true },
|
||||
})
|
||||
|
||||
expect(items.length).toBe(3)
|
||||
expect(items[0].hanzi.simplified).toBe("好")
|
||||
expect(items[0].orderIndex).toBe(0)
|
||||
expect(items[1].hanzi.simplified).toBe("爱")
|
||||
expect(items[1].orderIndex).toBe(1)
|
||||
expect(items[2].hanzi.simplified).toBe("你")
|
||||
expect(items[2].orderIndex).toBe(2)
|
||||
})
|
||||
|
||||
it("should preserve orderIndex after removing middle item", async () => {
|
||||
// Add three items
|
||||
await prisma.collectionItem.createMany({
|
||||
data: [
|
||||
{ collectionId: collection.id, hanziId: hanzi1.id, orderIndex: 0 },
|
||||
{ collectionId: collection.id, hanziId: hanzi2.id, orderIndex: 1 },
|
||||
{ collectionId: collection.id, hanziId: hanzi3.id, orderIndex: 2 },
|
||||
],
|
||||
})
|
||||
|
||||
// Remove middle item
|
||||
await prisma.collectionItem.deleteMany({
|
||||
where: {
|
||||
collectionId: collection.id,
|
||||
hanziId: hanzi2.id,
|
||||
},
|
||||
})
|
||||
|
||||
const items = await prisma.collectionItem.findMany({
|
||||
where: { collectionId: collection.id },
|
||||
orderBy: { orderIndex: "asc" },
|
||||
include: { hanzi: true },
|
||||
})
|
||||
|
||||
expect(items.length).toBe(2)
|
||||
expect(items[0].hanzi.simplified).toBe("好")
|
||||
expect(items[0].orderIndex).toBe(0)
|
||||
expect(items[1].hanzi.simplified).toBe("你")
|
||||
expect(items[1].orderIndex).toBe(2) // Should keep original orderIndex
|
||||
})
|
||||
|
||||
it("should prevent duplicate hanzi in collection (unique constraint)", async () => {
|
||||
await prisma.collectionItem.create({
|
||||
data: {
|
||||
collectionId: collection.id,
|
||||
hanziId: hanzi1.id,
|
||||
orderIndex: 0,
|
||||
},
|
||||
})
|
||||
|
||||
// Try to add same hanzi again
|
||||
await expect(
|
||||
prisma.collectionItem.create({
|
||||
data: {
|
||||
collectionId: collection.id,
|
||||
hanziId: hanzi1.id,
|
||||
orderIndex: 1,
|
||||
},
|
||||
})
|
||||
).rejects.toThrow()
|
||||
})
|
||||
|
||||
it("should allow same hanzi in different collections", async () => {
|
||||
const collection2 = await prisma.collection.create({
|
||||
data: {
|
||||
name: "Second Collection",
|
||||
isPublic: false,
|
||||
isGlobal: false,
|
||||
createdBy: testUser.id,
|
||||
},
|
||||
})
|
||||
|
||||
await prisma.collectionItem.createMany({
|
||||
data: [
|
||||
{ collectionId: collection.id, hanziId: hanzi1.id, orderIndex: 0 },
|
||||
{ collectionId: collection2.id, hanziId: hanzi1.id, orderIndex: 0 },
|
||||
],
|
||||
})
|
||||
|
||||
const items1 = await prisma.collectionItem.count({
|
||||
where: { collectionId: collection.id },
|
||||
})
|
||||
const items2 = await prisma.collectionItem.count({
|
||||
where: { collectionId: collection2.id },
|
||||
})
|
||||
|
||||
expect(items1).toBe(1)
|
||||
expect(items2).toBe(1)
|
||||
})
|
||||
|
||||
it("should delete collection items when collection is deleted (cascade)", async () => {
|
||||
await prisma.collectionItem.createMany({
|
||||
data: [
|
||||
{ collectionId: collection.id, hanziId: hanzi1.id, orderIndex: 0 },
|
||||
{ collectionId: collection.id, hanziId: hanzi2.id, orderIndex: 1 },
|
||||
],
|
||||
})
|
||||
|
||||
await prisma.collection.delete({
|
||||
where: { id: collection.id },
|
||||
})
|
||||
|
||||
const items = await prisma.collectionItem.findMany({
|
||||
where: { collectionId: collection.id },
|
||||
})
|
||||
|
||||
expect(items.length).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Hanzi Search Operations", () => {
|
||||
beforeEach(async () => {
|
||||
// Create hanzi with forms, transcriptions, and meanings
|
||||
const hanzi1 = await prisma.hanzi.create({
|
||||
data: { simplified: "好", radical: "女", frequency: 100 },
|
||||
})
|
||||
const form1 = await prisma.hanziForm.create({
|
||||
data: {
|
||||
hanziId: hanzi1.id,
|
||||
traditional: "好",
|
||||
isDefault: true,
|
||||
},
|
||||
})
|
||||
await prisma.hanziTranscription.create({
|
||||
data: {
|
||||
formId: form1.id,
|
||||
type: "pinyin",
|
||||
value: "hǎo",
|
||||
},
|
||||
})
|
||||
await prisma.hanziMeaning.create({
|
||||
data: {
|
||||
formId: form1.id,
|
||||
languageId: englishLanguage.id,
|
||||
meaning: "good",
|
||||
orderIndex: 0,
|
||||
},
|
||||
})
|
||||
|
||||
const hanzi2 = await prisma.hanzi.create({
|
||||
data: { simplified: "你", radical: "人", frequency: 50 },
|
||||
})
|
||||
const form2 = await prisma.hanziForm.create({
|
||||
data: {
|
||||
hanziId: hanzi2.id,
|
||||
traditional: "你",
|
||||
isDefault: true,
|
||||
},
|
||||
})
|
||||
await prisma.hanziTranscription.create({
|
||||
data: {
|
||||
formId: form2.id,
|
||||
type: "pinyin",
|
||||
value: "nǐ",
|
||||
},
|
||||
})
|
||||
await prisma.hanziMeaning.create({
|
||||
data: {
|
||||
formId: form2.id,
|
||||
languageId: englishLanguage.id,
|
||||
meaning: "you",
|
||||
orderIndex: 0,
|
||||
},
|
||||
})
|
||||
|
||||
const hanzi3 = await prisma.hanzi.create({
|
||||
data: { simplified: "中国", radical: null, frequency: 300 },
|
||||
})
|
||||
const form3 = await prisma.hanziForm.create({
|
||||
data: {
|
||||
hanziId: hanzi3.id,
|
||||
traditional: "中國",
|
||||
isDefault: true,
|
||||
},
|
||||
})
|
||||
await prisma.hanziTranscription.create({
|
||||
data: {
|
||||
formId: form3.id,
|
||||
type: "pinyin",
|
||||
value: "zhōng guó",
|
||||
},
|
||||
})
|
||||
await prisma.hanziMeaning.create({
|
||||
data: {
|
||||
formId: form3.id,
|
||||
languageId: englishLanguage.id,
|
||||
meaning: "China",
|
||||
orderIndex: 0,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it("should search hanzi by simplified character", async () => {
|
||||
const results = await prisma.hanzi.findMany({
|
||||
where: {
|
||||
simplified: { contains: "好" },
|
||||
},
|
||||
include: {
|
||||
forms: {
|
||||
where: { isDefault: true },
|
||||
include: {
|
||||
transcriptions: { where: { type: "pinyin" }, take: 1 },
|
||||
meanings: { orderBy: { orderIndex: "asc" }, take: 1 },
|
||||
},
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(results.length).toBeGreaterThan(0)
|
||||
expect(results.some((h) => h.simplified === "好")).toBe(true)
|
||||
})
|
||||
|
||||
it("should search hanzi by pinyin transcription", async () => {
|
||||
const results = await prisma.hanzi.findMany({
|
||||
where: {
|
||||
forms: {
|
||||
some: {
|
||||
transcriptions: {
|
||||
some: {
|
||||
value: { contains: "hǎo" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
forms: {
|
||||
where: { isDefault: true },
|
||||
include: {
|
||||
transcriptions: { where: { type: "pinyin" }, take: 1 },
|
||||
},
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(results.length).toBeGreaterThan(0)
|
||||
expect(results.some((h) => h.simplified === "好")).toBe(true)
|
||||
})
|
||||
|
||||
it("should search hanzi by meaning", async () => {
|
||||
const results = await prisma.hanzi.findMany({
|
||||
where: {
|
||||
forms: {
|
||||
some: {
|
||||
meanings: {
|
||||
some: {
|
||||
meaning: { contains: "good" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
forms: {
|
||||
where: { isDefault: true },
|
||||
include: {
|
||||
meanings: { orderBy: { orderIndex: "asc" }, take: 1 },
|
||||
},
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(results.length).toBeGreaterThan(0)
|
||||
expect(results.some((h) => h.simplified === "好")).toBe(true)
|
||||
})
|
||||
|
||||
it("should support multi-character hanzi in database", async () => {
|
||||
const multiChar = await prisma.hanzi.findUnique({
|
||||
where: { simplified: "中国" },
|
||||
include: {
|
||||
forms: {
|
||||
where: { isDefault: true },
|
||||
include: {
|
||||
transcriptions: true,
|
||||
meanings: true,
|
||||
},
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(multiChar).toBeDefined()
|
||||
expect(multiChar?.simplified).toBe("中国")
|
||||
expect(multiChar?.forms[0].traditional).toBe("中國")
|
||||
expect(multiChar?.forms[0].transcriptions[0].value).toBe("zhōng guó")
|
||||
expect(multiChar?.forms[0].meanings[0].meaning).toBe("China")
|
||||
})
|
||||
})
|
||||
|
||||
describe("Hanzi List Parsing Logic", () => {
|
||||
beforeEach(async () => {
|
||||
// Create test hanzi
|
||||
await prisma.hanzi.createMany({
|
||||
data: [
|
||||
{ simplified: "好", radical: "女", frequency: 100 },
|
||||
{ simplified: "爱", radical: "爫", frequency: 200 },
|
||||
{ simplified: "你", radical: "人", frequency: 50 },
|
||||
{ simplified: "中国", radical: null, frequency: 300 },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it("should parse newline-separated list", () => {
|
||||
const input = "好\n爱\n你"
|
||||
const parsed = input
|
||||
.split(/[\n,\s]+/)
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length > 0)
|
||||
|
||||
expect(parsed).toEqual(["好", "爱", "你"])
|
||||
})
|
||||
|
||||
it("should parse comma-separated list", () => {
|
||||
const input = "好, 爱, 你"
|
||||
const parsed = input
|
||||
.split(/[\n,\s]+/)
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length > 0)
|
||||
|
||||
expect(parsed).toEqual(["好", "爱", "你"])
|
||||
})
|
||||
|
||||
it("should parse space-separated list", () => {
|
||||
const input = "好 爱 你"
|
||||
const parsed = input
|
||||
.split(/[\n,\s]+/)
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length > 0)
|
||||
|
||||
expect(parsed).toEqual(["好", "爱", "你"])
|
||||
})
|
||||
|
||||
it("should handle multi-character words", () => {
|
||||
const input = "好 中国 你"
|
||||
const parsed = input
|
||||
.split(/[\n,\s]+/)
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length > 0)
|
||||
|
||||
expect(parsed).toEqual(["好", "中国", "你"])
|
||||
})
|
||||
|
||||
it("should detect duplicates", () => {
|
||||
const input = "好 爱 好 你"
|
||||
const chars = input
|
||||
.split(/[\n,\s]+/)
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length > 0)
|
||||
|
||||
const seen = new Set<string>()
|
||||
const duplicates: string[] = []
|
||||
const unique = chars.filter((char) => {
|
||||
if (seen.has(char)) {
|
||||
if (!duplicates.includes(char)) {
|
||||
duplicates.push(char)
|
||||
}
|
||||
return false
|
||||
}
|
||||
seen.add(char)
|
||||
return true
|
||||
})
|
||||
|
||||
expect(unique).toEqual(["好", "爱", "你"])
|
||||
expect(duplicates).toEqual(["好"])
|
||||
})
|
||||
|
||||
it("should validate all hanzi exist (strict mode)", async () => {
|
||||
const input = ["好", "不存在", "你"]
|
||||
|
||||
const foundHanzi = await prisma.hanzi.findMany({
|
||||
where: {
|
||||
simplified: { in: input },
|
||||
},
|
||||
})
|
||||
|
||||
const foundSet = new Set(foundHanzi.map((h) => h.simplified))
|
||||
const notFound = input.filter((char) => !foundSet.has(char))
|
||||
|
||||
expect(notFound).toEqual(["不存在"])
|
||||
})
|
||||
|
||||
it("should preserve order from input", async () => {
|
||||
const input = ["你", "爱", "好"]
|
||||
|
||||
const foundHanzi = await prisma.hanzi.findMany({
|
||||
where: {
|
||||
simplified: { in: input },
|
||||
},
|
||||
})
|
||||
|
||||
// Create a map for quick lookup
|
||||
const hanziMap = new Map(foundHanzi.map((h) => [h.simplified, h]))
|
||||
|
||||
// Preserve input order
|
||||
const ordered = input
|
||||
.map((char) => hanziMap.get(char))
|
||||
.filter((h): h is NonNullable<typeof h> => h !== undefined)
|
||||
|
||||
expect(ordered[0].simplified).toBe("你")
|
||||
expect(ordered[1].simplified).toBe("爱")
|
||||
expect(ordered[2].simplified).toBe("好")
|
||||
})
|
||||
})
|
||||
})
|
||||
934
src/actions/collections.ts
Normal file
934
src/actions/collections.ts
Normal file
@@ -0,0 +1,934 @@
|
||||
"use server"
|
||||
|
||||
import { revalidatePath } from "next/cache"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
import { auth } from "@/lib/auth"
|
||||
import { z } from "zod"
|
||||
|
||||
/**
|
||||
* Standard action result type
|
||||
*/
|
||||
export type ActionResult<T = void> = {
|
||||
success: boolean
|
||||
data?: T
|
||||
message?: string
|
||||
errors?: Record<string, string[]>
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// VALIDATION SCHEMAS
|
||||
// ============================================================================
|
||||
|
||||
const createCollectionSchema = z.object({
|
||||
name: z.string().min(1, "Name is required").max(100),
|
||||
description: z.string().optional(),
|
||||
isPublic: z.boolean(),
|
||||
})
|
||||
|
||||
const updateCollectionSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
name: z.string().min(1).max(100).optional(),
|
||||
description: z.string().optional(),
|
||||
isPublic: z.boolean().optional(),
|
||||
})
|
||||
|
||||
const deleteCollectionSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
})
|
||||
|
||||
const addHanziToCollectionSchema = z.object({
|
||||
collectionId: z.string().min(1),
|
||||
hanziIds: z.array(z.string().min(1)).min(1, "At least one hanzi ID is required"),
|
||||
})
|
||||
|
||||
const removeHanziFromCollectionSchema = z.object({
|
||||
collectionId: z.string().min(1),
|
||||
hanziId: z.string().min(1),
|
||||
})
|
||||
|
||||
const removeMultipleHanziSchema = z.object({
|
||||
collectionId: z.string().min(1),
|
||||
hanziIds: z.array(z.string().min(1)).min(1, "At least one hanzi ID is required"),
|
||||
})
|
||||
|
||||
const searchHanziSchema = z.object({
|
||||
query: z.string().min(1),
|
||||
excludeCollectionId: z.string().optional(),
|
||||
limit: z.number().int().positive().max(100).default(20),
|
||||
offset: z.number().int().min(0).default(0),
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// HELPER FUNCTIONS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Check if user owns a collection or is admin
|
||||
*/
|
||||
async function canModifyCollection(collectionId: string): Promise<boolean> {
|
||||
const session = await auth()
|
||||
if (!session?.user) return false
|
||||
|
||||
const user = session.user as any
|
||||
const collection = await prisma.collection.findUnique({
|
||||
where: { id: collectionId },
|
||||
select: { createdBy: true },
|
||||
})
|
||||
|
||||
if (!collection) return false
|
||||
|
||||
// Global collections can only be modified by admins
|
||||
if (collection.createdBy === null) {
|
||||
return user.role === "ADMIN"
|
||||
}
|
||||
|
||||
// User can modify their own collections or admins can modify any
|
||||
return collection.createdBy === user.id || user.role === "ADMIN"
|
||||
}
|
||||
|
||||
/**
|
||||
* Get next orderIndex for a collection
|
||||
*/
|
||||
async function getNextOrderIndex(collectionId: string): Promise<number> {
|
||||
const maxItem = await prisma.collectionItem.findFirst({
|
||||
where: { collectionId },
|
||||
orderBy: { orderIndex: "desc" },
|
||||
select: { orderIndex: true },
|
||||
})
|
||||
|
||||
return maxItem ? maxItem.orderIndex + 1 : 0
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// COLLECTION CRUD
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Create a new empty collection
|
||||
*/
|
||||
export async function createCollection(
|
||||
name: string,
|
||||
description?: string,
|
||||
isPublic: boolean = false
|
||||
): Promise<ActionResult<{ id: string }>> {
|
||||
try {
|
||||
const session = await auth()
|
||||
if (!session?.user) {
|
||||
return { success: false, message: "Unauthorized" }
|
||||
}
|
||||
|
||||
const validation = createCollectionSchema.safeParse({ name, description, isPublic })
|
||||
if (!validation.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Validation failed",
|
||||
errors: validation.error.flatten().fieldErrors,
|
||||
}
|
||||
}
|
||||
|
||||
const user = session.user as any
|
||||
const collection = await prisma.collection.create({
|
||||
data: {
|
||||
name,
|
||||
description,
|
||||
isPublic,
|
||||
isGlobal: false,
|
||||
createdBy: user.id,
|
||||
},
|
||||
})
|
||||
|
||||
revalidatePath("/collections")
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: { id: collection.id },
|
||||
message: "Collection created successfully",
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to create collection:", error)
|
||||
return {
|
||||
success: false,
|
||||
message: "Failed to create collection",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a collection with a list of hanzi (paste method)
|
||||
*/
|
||||
export async function createCollectionWithHanzi(
|
||||
name: string,
|
||||
description: string | undefined,
|
||||
isPublic: boolean,
|
||||
hanziList: string
|
||||
): Promise<ActionResult<{ id: string; added: number; notFound: string[] }>> {
|
||||
try {
|
||||
const session = await auth()
|
||||
if (!session?.user) {
|
||||
return { success: false, message: "Unauthorized" }
|
||||
}
|
||||
|
||||
const validation = createCollectionSchema.safeParse({ name, description, isPublic })
|
||||
if (!validation.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Validation failed",
|
||||
errors: validation.error.flatten().fieldErrors,
|
||||
}
|
||||
}
|
||||
|
||||
// Parse and validate the hanzi list
|
||||
const parseResult = await parseHanziList(hanziList)
|
||||
if (!parseResult.success || !parseResult.data) {
|
||||
return {
|
||||
success: false,
|
||||
message: parseResult.message || "Failed to parse hanzi list",
|
||||
data: { id: "", added: 0, notFound: parseResult.data?.notFound || [] },
|
||||
}
|
||||
}
|
||||
|
||||
if (!parseResult.data.valid) {
|
||||
return {
|
||||
success: false,
|
||||
message: `${parseResult.data.notFound.length} hanzi not found in database`,
|
||||
data: { id: "", added: 0, notFound: parseResult.data.notFound },
|
||||
}
|
||||
}
|
||||
|
||||
// Create collection and add hanzi in a transaction
|
||||
const user = session.user as any
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
const collection = await tx.collection.create({
|
||||
data: {
|
||||
name,
|
||||
description,
|
||||
isPublic,
|
||||
isGlobal: false,
|
||||
createdBy: user.id,
|
||||
},
|
||||
})
|
||||
|
||||
// Add hanzi with preserved order
|
||||
const items = parseResult.data!.found.map((hanzi, index) => ({
|
||||
collectionId: collection.id,
|
||||
hanziId: hanzi.id,
|
||||
orderIndex: index,
|
||||
}))
|
||||
|
||||
await tx.collectionItem.createMany({
|
||||
data: items,
|
||||
})
|
||||
|
||||
return {
|
||||
id: collection.id,
|
||||
added: items.length,
|
||||
}
|
||||
})
|
||||
|
||||
revalidatePath("/collections")
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: { ...result, notFound: [] },
|
||||
message: `Collection created with ${result.added} hanzi`,
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to create collection with hanzi:", error)
|
||||
return {
|
||||
success: false,
|
||||
message: "Failed to create collection",
|
||||
data: { id: "", added: 0, notFound: [] },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update collection details
|
||||
*/
|
||||
export async function updateCollection(
|
||||
id: string,
|
||||
data: { name?: string; description?: string; isPublic?: boolean }
|
||||
): Promise<ActionResult> {
|
||||
try {
|
||||
const validation = updateCollectionSchema.safeParse({ id, ...data })
|
||||
if (!validation.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Validation failed",
|
||||
errors: validation.error.flatten().fieldErrors,
|
||||
}
|
||||
}
|
||||
|
||||
const canModify = await canModifyCollection(id)
|
||||
if (!canModify) {
|
||||
return { success: false, message: "Unauthorized" }
|
||||
}
|
||||
|
||||
await prisma.collection.update({
|
||||
where: { id },
|
||||
data,
|
||||
})
|
||||
|
||||
revalidatePath("/collections")
|
||||
revalidatePath(`/collections/${id}`)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Collection updated successfully",
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to update collection:", error)
|
||||
return {
|
||||
success: false,
|
||||
message: "Failed to update collection",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a collection
|
||||
*/
|
||||
export async function deleteCollection(id: string): Promise<ActionResult> {
|
||||
try {
|
||||
const validation = deleteCollectionSchema.safeParse({ id })
|
||||
if (!validation.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Validation failed",
|
||||
errors: validation.error.flatten().fieldErrors,
|
||||
}
|
||||
}
|
||||
|
||||
const canModify = await canModifyCollection(id)
|
||||
if (!canModify) {
|
||||
return { success: false, message: "Unauthorized" }
|
||||
}
|
||||
|
||||
// Delete collection (cascade will handle items)
|
||||
await prisma.collection.delete({
|
||||
where: { id },
|
||||
})
|
||||
|
||||
revalidatePath("/collections")
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Collection deleted successfully",
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to delete collection:", error)
|
||||
return {
|
||||
success: false,
|
||||
message: "Failed to delete collection",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get collection details with hanzi (ordered by orderIndex)
|
||||
*/
|
||||
export async function getCollection(id: string): Promise<
|
||||
ActionResult<{
|
||||
id: string
|
||||
name: string
|
||||
description: string | null
|
||||
isPublic: boolean
|
||||
isGlobal: boolean
|
||||
createdBy: string | null
|
||||
hanziCount: number
|
||||
hanzi: Array<{
|
||||
id: string
|
||||
simplified: string
|
||||
pinyin: string | null
|
||||
meaning: string | null
|
||||
orderIndex: number
|
||||
}>
|
||||
}>
|
||||
> {
|
||||
try {
|
||||
const session = await auth()
|
||||
if (!session?.user) {
|
||||
return { success: false, message: "Unauthorized" }
|
||||
}
|
||||
|
||||
const user = session.user as any
|
||||
const collection = await prisma.collection.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
items: {
|
||||
orderBy: { orderIndex: "asc" },
|
||||
include: {
|
||||
hanzi: {
|
||||
include: {
|
||||
forms: {
|
||||
where: { isDefault: true },
|
||||
include: {
|
||||
transcriptions: {
|
||||
where: { type: "pinyin" },
|
||||
take: 1,
|
||||
},
|
||||
meanings: {
|
||||
orderBy: { orderIndex: "asc" },
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (!collection) {
|
||||
return { success: false, message: "Collection not found" }
|
||||
}
|
||||
|
||||
// Check access: global collections or own collections or public collections
|
||||
if (!collection.isGlobal && !collection.isPublic && collection.createdBy !== user.id) {
|
||||
return { success: false, message: "Unauthorized" }
|
||||
}
|
||||
|
||||
const hanzi = collection.items.map((item) => ({
|
||||
id: item.hanzi.id,
|
||||
simplified: item.hanzi.simplified,
|
||||
pinyin: item.hanzi.forms[0]?.transcriptions[0]?.value || null,
|
||||
meaning: item.hanzi.forms[0]?.meanings[0]?.meaning || null,
|
||||
orderIndex: item.orderIndex,
|
||||
}))
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
id: collection.id,
|
||||
name: collection.name,
|
||||
description: collection.description,
|
||||
isPublic: collection.isPublic,
|
||||
isGlobal: collection.isGlobal,
|
||||
createdBy: collection.createdBy,
|
||||
hanziCount: hanzi.length,
|
||||
hanzi,
|
||||
},
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to get collection:", error)
|
||||
return {
|
||||
success: false,
|
||||
message: "Failed to get collection",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user's collections
|
||||
*/
|
||||
export async function getUserCollections(): Promise<
|
||||
ActionResult<
|
||||
Array<{
|
||||
id: string
|
||||
name: string
|
||||
description: string | null
|
||||
isPublic: boolean
|
||||
hanziCount: number
|
||||
createdAt: Date
|
||||
}>
|
||||
>
|
||||
> {
|
||||
try {
|
||||
const session = await auth()
|
||||
if (!session?.user) {
|
||||
return { success: false, message: "Unauthorized" }
|
||||
}
|
||||
|
||||
const user = session.user as any
|
||||
const collections = await prisma.collection.findMany({
|
||||
where: {
|
||||
createdBy: user.id,
|
||||
},
|
||||
include: {
|
||||
_count: {
|
||||
select: { items: true },
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
})
|
||||
|
||||
const data = collections.map((col) => ({
|
||||
id: col.id,
|
||||
name: col.name,
|
||||
description: col.description,
|
||||
isPublic: col.isPublic,
|
||||
hanziCount: col._count.items,
|
||||
createdAt: col.createdAt,
|
||||
}))
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data,
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to get user collections:", error)
|
||||
return {
|
||||
success: false,
|
||||
message: "Failed to get collections",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get global (HSK) collections
|
||||
*/
|
||||
export async function getGlobalCollections(): Promise<
|
||||
ActionResult<
|
||||
Array<{
|
||||
id: string
|
||||
name: string
|
||||
description: string | null
|
||||
hanziCount: number
|
||||
}>
|
||||
>
|
||||
> {
|
||||
try {
|
||||
const collections = await prisma.collection.findMany({
|
||||
where: {
|
||||
isGlobal: true,
|
||||
},
|
||||
include: {
|
||||
_count: {
|
||||
select: { items: true },
|
||||
},
|
||||
},
|
||||
orderBy: { name: "asc" },
|
||||
})
|
||||
|
||||
const data = collections.map((col) => ({
|
||||
id: col.id,
|
||||
name: col.name,
|
||||
description: col.description,
|
||||
hanziCount: col._count.items,
|
||||
}))
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data,
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to get global collections:", error)
|
||||
return {
|
||||
success: false,
|
||||
message: "Failed to get global collections",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ADD/REMOVE HANZI
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Add hanzi to collection (bulk operation)
|
||||
* Skips duplicates and preserves order
|
||||
*/
|
||||
export async function addHanziToCollection(
|
||||
collectionId: string,
|
||||
hanziIds: string[]
|
||||
): Promise<ActionResult<{ added: number; skipped: string[] }>> {
|
||||
try {
|
||||
const validation = addHanziToCollectionSchema.safeParse({ collectionId, hanziIds })
|
||||
if (!validation.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Validation failed",
|
||||
errors: validation.error.flatten().fieldErrors,
|
||||
}
|
||||
}
|
||||
|
||||
const canModify = await canModifyCollection(collectionId)
|
||||
if (!canModify) {
|
||||
return { success: false, message: "Unauthorized" }
|
||||
}
|
||||
|
||||
// Check for existing items
|
||||
const existing = await prisma.collectionItem.findMany({
|
||||
where: {
|
||||
collectionId,
|
||||
hanziId: { in: hanziIds },
|
||||
},
|
||||
select: { hanziId: true },
|
||||
})
|
||||
|
||||
const existingIds = new Set(existing.map((item) => item.hanziId))
|
||||
const toAdd = hanziIds.filter((id) => !existingIds.has(id))
|
||||
const skipped = hanziIds.filter((id) => existingIds.has(id))
|
||||
|
||||
if (toAdd.length === 0) {
|
||||
return {
|
||||
success: true,
|
||||
data: { added: 0, skipped },
|
||||
message: "All hanzi already in collection",
|
||||
}
|
||||
}
|
||||
|
||||
// Get next orderIndex and add items
|
||||
const nextOrderIndex = await getNextOrderIndex(collectionId)
|
||||
const items = toAdd.map((hanziId, index) => ({
|
||||
collectionId,
|
||||
hanziId,
|
||||
orderIndex: nextOrderIndex + index,
|
||||
}))
|
||||
|
||||
await prisma.collectionItem.createMany({
|
||||
data: items,
|
||||
})
|
||||
|
||||
revalidatePath(`/collections/${collectionId}`)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: { added: toAdd.length, skipped },
|
||||
message:
|
||||
skipped.length > 0
|
||||
? `Added ${toAdd.length} hanzi, skipped ${skipped.length} duplicates`
|
||||
: `Added ${toAdd.length} hanzi`,
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to add hanzi to collection:", error)
|
||||
return {
|
||||
success: false,
|
||||
message: "Failed to add hanzi",
|
||||
data: { added: 0, skipped: [] },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a single hanzi from collection
|
||||
*/
|
||||
export async function removeHanziFromCollection(
|
||||
collectionId: string,
|
||||
hanziId: string
|
||||
): Promise<ActionResult> {
|
||||
try {
|
||||
const validation = removeHanziFromCollectionSchema.safeParse({ collectionId, hanziId })
|
||||
if (!validation.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Validation failed",
|
||||
errors: validation.error.flatten().fieldErrors,
|
||||
}
|
||||
}
|
||||
|
||||
const canModify = await canModifyCollection(collectionId)
|
||||
if (!canModify) {
|
||||
return { success: false, message: "Unauthorized" }
|
||||
}
|
||||
|
||||
await prisma.collectionItem.deleteMany({
|
||||
where: {
|
||||
collectionId,
|
||||
hanziId,
|
||||
},
|
||||
})
|
||||
|
||||
revalidatePath(`/collections/${collectionId}`)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Hanzi removed from collection",
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to remove hanzi from collection:", error)
|
||||
return {
|
||||
success: false,
|
||||
message: "Failed to remove hanzi",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove multiple hanzi from collection (bulk operation)
|
||||
*/
|
||||
export async function removeMultipleHanziFromCollection(
|
||||
collectionId: string,
|
||||
hanziIds: string[]
|
||||
): Promise<ActionResult<{ removed: number }>> {
|
||||
try {
|
||||
const validation = removeMultipleHanziSchema.safeParse({ collectionId, hanziIds })
|
||||
if (!validation.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Validation failed",
|
||||
errors: validation.error.flatten().fieldErrors,
|
||||
}
|
||||
}
|
||||
|
||||
const canModify = await canModifyCollection(collectionId)
|
||||
if (!canModify) {
|
||||
return { success: false, message: "Unauthorized" }
|
||||
}
|
||||
|
||||
const result = await prisma.collectionItem.deleteMany({
|
||||
where: {
|
||||
collectionId,
|
||||
hanziId: { in: hanziIds },
|
||||
},
|
||||
})
|
||||
|
||||
revalidatePath(`/collections/${collectionId}`)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: { removed: result.count },
|
||||
message: `Removed ${result.count} hanzi from collection`,
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to remove hanzi from collection:", error)
|
||||
return {
|
||||
success: false,
|
||||
message: "Failed to remove hanzi",
|
||||
data: { removed: 0 },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SEARCH AND PARSE
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Search hanzi for adding to collection
|
||||
* Optionally excludes hanzi already in a specific collection
|
||||
*/
|
||||
export async function searchHanziForCollection(
|
||||
query: string,
|
||||
excludeCollectionId?: string,
|
||||
limit: number = 20,
|
||||
offset: number = 0
|
||||
): Promise<
|
||||
ActionResult<{
|
||||
hanzi: Array<{
|
||||
id: string
|
||||
simplified: string
|
||||
pinyin: string | null
|
||||
meaning: string | null
|
||||
inCollection: boolean
|
||||
}>
|
||||
total: number
|
||||
hasMore: boolean
|
||||
}>
|
||||
> {
|
||||
try {
|
||||
const session = await auth()
|
||||
if (!session?.user) {
|
||||
return { success: false, message: "Unauthorized" }
|
||||
}
|
||||
|
||||
const validation = searchHanziSchema.safeParse({ query, excludeCollectionId, limit, offset })
|
||||
if (!validation.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Validation failed",
|
||||
errors: validation.error.flatten().fieldErrors,
|
||||
}
|
||||
}
|
||||
|
||||
// Search hanzi by simplified, pinyin, or meaning
|
||||
const searchResults = await prisma.hanzi.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ simplified: { contains: query } },
|
||||
{
|
||||
forms: {
|
||||
some: {
|
||||
OR: [
|
||||
{ transcriptions: { some: { value: { contains: query } } } },
|
||||
{ meanings: { some: { meaning: { contains: query } } } },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
include: {
|
||||
forms: {
|
||||
where: { isDefault: true },
|
||||
include: {
|
||||
transcriptions: {
|
||||
where: { type: "pinyin" },
|
||||
take: 1,
|
||||
},
|
||||
meanings: {
|
||||
orderBy: { orderIndex: "asc" },
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
skip: offset,
|
||||
take: limit + 1, // Take one extra to check if there are more
|
||||
})
|
||||
|
||||
const hasMore = searchResults.length > limit
|
||||
const results = searchResults.slice(0, limit)
|
||||
|
||||
// Check which hanzi are already in the collection
|
||||
let inCollectionIds = new Set<string>()
|
||||
if (excludeCollectionId) {
|
||||
const collectionItems = await prisma.collectionItem.findMany({
|
||||
where: {
|
||||
collectionId: excludeCollectionId,
|
||||
hanziId: { in: results.map((h) => h.id) },
|
||||
},
|
||||
select: { hanziId: true },
|
||||
})
|
||||
inCollectionIds = new Set(collectionItems.map((item) => item.hanziId))
|
||||
}
|
||||
|
||||
const hanzi = results.map((h) => ({
|
||||
id: h.id,
|
||||
simplified: h.simplified,
|
||||
pinyin: h.forms[0]?.transcriptions[0]?.value || null,
|
||||
meaning: h.forms[0]?.meanings[0]?.meaning || null,
|
||||
inCollection: inCollectionIds.has(h.id),
|
||||
}))
|
||||
|
||||
// Get total count for pagination
|
||||
const total = await prisma.hanzi.count({
|
||||
where: {
|
||||
OR: [
|
||||
{ simplified: { contains: query } },
|
||||
{
|
||||
forms: {
|
||||
some: {
|
||||
OR: [
|
||||
{ transcriptions: { some: { value: { contains: query } } } },
|
||||
{ meanings: { some: { meaning: { contains: query } } } },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
hanzi,
|
||||
total,
|
||||
hasMore,
|
||||
},
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to search hanzi:", error)
|
||||
return {
|
||||
success: false,
|
||||
message: "Failed to search hanzi",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse pasted hanzi list and validate all exist (strict mode)
|
||||
* Supports newline, comma, space separated lists
|
||||
*/
|
||||
export async function parseHanziList(input: string): Promise<
|
||||
ActionResult<{
|
||||
valid: boolean
|
||||
found: Array<{ id: string; simplified: string; pinyin: string | null }>
|
||||
notFound: string[]
|
||||
duplicates: string[]
|
||||
}>
|
||||
> {
|
||||
try {
|
||||
const session = await auth()
|
||||
if (!session?.user) {
|
||||
return { success: false, message: "Unauthorized" }
|
||||
}
|
||||
|
||||
// Split by newline, comma, or space, then trim and filter empty
|
||||
const chars = input
|
||||
.split(/[\n,\s]+/)
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length > 0)
|
||||
|
||||
if (chars.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
message: "No hanzi found in input",
|
||||
data: { valid: false, found: [], notFound: [], duplicates: [] },
|
||||
}
|
||||
}
|
||||
|
||||
// Check for duplicates in input
|
||||
const seen = new Set<string>()
|
||||
const duplicates: string[] = []
|
||||
const uniqueChars = chars.filter((char) => {
|
||||
if (seen.has(char)) {
|
||||
if (!duplicates.includes(char)) {
|
||||
duplicates.push(char)
|
||||
}
|
||||
return false
|
||||
}
|
||||
seen.add(char)
|
||||
return true
|
||||
})
|
||||
|
||||
// Query database for all hanzi
|
||||
const foundHanzi = await prisma.hanzi.findMany({
|
||||
where: {
|
||||
simplified: { in: uniqueChars },
|
||||
},
|
||||
include: {
|
||||
forms: {
|
||||
where: { isDefault: true },
|
||||
include: {
|
||||
transcriptions: {
|
||||
where: { type: "pinyin" },
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const foundSet = new Set(foundHanzi.map((h) => h.simplified))
|
||||
const notFound = uniqueChars.filter((char) => !foundSet.has(char))
|
||||
|
||||
// Preserve order from input
|
||||
const found = uniqueChars
|
||||
.map((char) => {
|
||||
const hanzi = foundHanzi.find((h) => h.simplified === char)
|
||||
if (!hanzi) return null
|
||||
return {
|
||||
id: hanzi.id,
|
||||
simplified: hanzi.simplified,
|
||||
pinyin: hanzi.forms[0]?.transcriptions[0]?.value || null,
|
||||
}
|
||||
})
|
||||
.filter((h): h is { id: string; simplified: string; pinyin: string | null } => h !== null)
|
||||
|
||||
const valid = notFound.length === 0
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
valid,
|
||||
found,
|
||||
notFound,
|
||||
duplicates,
|
||||
},
|
||||
message: valid ? `Found ${found.length} hanzi` : `${notFound.length} hanzi not found`,
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to parse hanzi list:", error)
|
||||
return {
|
||||
success: false,
|
||||
message: "Failed to parse hanzi list",
|
||||
data: { valid: false, found: [], notFound: [], duplicates: [] },
|
||||
}
|
||||
}
|
||||
}
|
||||
319
src/actions/hanzi.integration.test.ts
Normal file
319
src/actions/hanzi.integration.test.ts
Normal file
@@ -0,0 +1,319 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
import { searchHanzi, getHanzi, getHanziBySimplified } from "./hanzi"
|
||||
|
||||
/**
|
||||
* Integration tests for hanzi actions
|
||||
*/
|
||||
|
||||
describe("Hanzi Actions Integration Tests", () => {
|
||||
let englishLanguage: any
|
||||
let hanzi1: any
|
||||
let hanzi2: any
|
||||
let hanzi3: any
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create English language
|
||||
englishLanguage = await prisma.language.create({
|
||||
data: {
|
||||
code: "en",
|
||||
name: "English",
|
||||
nativeName: "English",
|
||||
isActive: true,
|
||||
},
|
||||
})
|
||||
|
||||
// Create test hanzi with full data
|
||||
hanzi1 = await prisma.hanzi.create({
|
||||
data: { simplified: "好", radical: "女", frequency: 100 },
|
||||
})
|
||||
const form1 = await prisma.hanziForm.create({
|
||||
data: {
|
||||
hanziId: hanzi1.id,
|
||||
traditional: "好",
|
||||
isDefault: true,
|
||||
},
|
||||
})
|
||||
await prisma.hanziTranscription.createMany({
|
||||
data: [
|
||||
{ formId: form1.id, type: "pinyin", value: "hǎo" },
|
||||
{ formId: form1.id, type: "numeric", value: "hao3" },
|
||||
],
|
||||
})
|
||||
await prisma.hanziMeaning.create({
|
||||
data: {
|
||||
formId: form1.id,
|
||||
languageId: englishLanguage.id,
|
||||
meaning: "good, well",
|
||||
orderIndex: 0,
|
||||
},
|
||||
})
|
||||
await prisma.hanziHSKLevel.create({
|
||||
data: { hanziId: hanzi1.id, level: "new-1" },
|
||||
})
|
||||
await prisma.hanziPOS.create({
|
||||
data: { hanziId: hanzi1.id, pos: "adj" },
|
||||
})
|
||||
await prisma.hanziClassifier.create({
|
||||
data: { formId: form1.id, classifier: "个" },
|
||||
})
|
||||
|
||||
hanzi2 = await prisma.hanzi.create({
|
||||
data: { simplified: "你", radical: "人", frequency: 50 },
|
||||
})
|
||||
const form2 = await prisma.hanziForm.create({
|
||||
data: {
|
||||
hanziId: hanzi2.id,
|
||||
traditional: "你",
|
||||
isDefault: true,
|
||||
},
|
||||
})
|
||||
await prisma.hanziTranscription.create({
|
||||
data: { formId: form2.id, type: "pinyin", value: "nǐ" },
|
||||
})
|
||||
await prisma.hanziMeaning.create({
|
||||
data: {
|
||||
formId: form2.id,
|
||||
languageId: englishLanguage.id,
|
||||
meaning: "you",
|
||||
orderIndex: 0,
|
||||
},
|
||||
})
|
||||
await prisma.hanziHSKLevel.create({
|
||||
data: { hanziId: hanzi2.id, level: "new-1" },
|
||||
})
|
||||
|
||||
hanzi3 = await prisma.hanzi.create({
|
||||
data: { simplified: "中国", radical: null, frequency: 300 },
|
||||
})
|
||||
const form3 = await prisma.hanziForm.create({
|
||||
data: {
|
||||
hanziId: hanzi3.id,
|
||||
traditional: "中國",
|
||||
isDefault: true,
|
||||
},
|
||||
})
|
||||
await prisma.hanziTranscription.create({
|
||||
data: { formId: form3.id, type: "pinyin", value: "zhōng guó" },
|
||||
})
|
||||
await prisma.hanziMeaning.create({
|
||||
data: {
|
||||
formId: form3.id,
|
||||
languageId: englishLanguage.id,
|
||||
meaning: "China",
|
||||
orderIndex: 0,
|
||||
},
|
||||
})
|
||||
await prisma.hanziHSKLevel.create({
|
||||
data: { hanziId: hanzi3.id, level: "new-2" },
|
||||
})
|
||||
})
|
||||
|
||||
describe("searchHanzi", () => {
|
||||
it("should search by simplified character", async () => {
|
||||
const result = await searchHanzi("好", undefined, 20, 0)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.data?.hanzi.length).toBeGreaterThan(0)
|
||||
expect(result.data?.hanzi.some((h) => h.simplified === "好")).toBe(true)
|
||||
expect(result.data?.total).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it("should search by pinyin", async () => {
|
||||
const result = await searchHanzi("hǎo", undefined, 20, 0)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.data?.hanzi.length).toBeGreaterThan(0)
|
||||
expect(result.data?.hanzi.some((h) => h.pinyin?.includes("hǎo"))).toBe(true)
|
||||
})
|
||||
|
||||
it("should search by meaning", async () => {
|
||||
const result = await searchHanzi("good", undefined, 20, 0)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.data?.hanzi.length).toBeGreaterThan(0)
|
||||
expect(result.data?.hanzi.some((h) => h.meaning?.includes("good"))).toBe(true)
|
||||
})
|
||||
|
||||
it("should filter by HSK level", async () => {
|
||||
const result = await searchHanzi("好", "new-1", 20, 0)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.data?.hanzi.length).toBeGreaterThan(0)
|
||||
const foundHanzi = result.data?.hanzi.find((h) => h.simplified === "好")
|
||||
expect(foundHanzi?.hskLevels).toContain("new-1")
|
||||
})
|
||||
|
||||
it("should support multi-character hanzi", async () => {
|
||||
const result = await searchHanzi("中国", undefined, 20, 0)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.data?.hanzi.length).toBeGreaterThan(0)
|
||||
const foundHanzi = result.data?.hanzi.find((h) => h.simplified === "中国")
|
||||
expect(foundHanzi).toBeDefined()
|
||||
expect(foundHanzi?.traditional).toBe("中國")
|
||||
expect(foundHanzi?.pinyin).toBe("zhōng guó")
|
||||
expect(foundHanzi?.meaning).toBe("China")
|
||||
})
|
||||
|
||||
it("should return empty for non-existent hanzi", async () => {
|
||||
const result = await searchHanzi("不存在的字", undefined, 20, 0)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.data?.hanzi.length).toBe(0)
|
||||
expect(result.data?.total).toBe(0)
|
||||
})
|
||||
|
||||
it("should support pagination", async () => {
|
||||
// Create multiple hanzi to test pagination
|
||||
for (let i = 0; i < 25; i++) {
|
||||
const h = await prisma.hanzi.create({
|
||||
data: { simplified: `测${i}`, radical: null, frequency: i },
|
||||
})
|
||||
const form = await prisma.hanziForm.create({
|
||||
data: {
|
||||
hanziId: h.id,
|
||||
traditional: `測${i}`,
|
||||
isDefault: true,
|
||||
},
|
||||
})
|
||||
await prisma.hanziTranscription.create({
|
||||
data: { formId: form.id, type: "pinyin", value: `cè ${i}` },
|
||||
})
|
||||
await prisma.hanziMeaning.create({
|
||||
data: {
|
||||
formId: form.id,
|
||||
languageId: englishLanguage.id,
|
||||
meaning: `test ${i}`,
|
||||
orderIndex: 0,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const page1 = await searchHanzi("测", undefined, 10, 0)
|
||||
const page2 = await searchHanzi("测", undefined, 10, 10)
|
||||
|
||||
expect(page1.success).toBe(true)
|
||||
expect(page1.data?.hanzi.length).toBe(10)
|
||||
expect(page1.data?.hasMore).toBe(true)
|
||||
|
||||
expect(page2.success).toBe(true)
|
||||
expect(page2.data?.hanzi.length).toBeGreaterThan(0)
|
||||
|
||||
// Check that results are different
|
||||
const page1Ids = page1.data?.hanzi.map((h) => h.id) || []
|
||||
const page2Ids = page2.data?.hanzi.map((h) => h.id) || []
|
||||
expect(page1Ids).not.toEqual(page2Ids)
|
||||
})
|
||||
|
||||
it("should validate required query parameter", async () => {
|
||||
const result = await searchHanzi("", undefined, 20, 0)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.message).toBe("Validation failed")
|
||||
expect(result.errors).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("getHanzi", () => {
|
||||
it("should get complete hanzi details", async () => {
|
||||
const result = await getHanzi(hanzi1.id)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.data).toBeDefined()
|
||||
expect(result.data?.simplified).toBe("好")
|
||||
expect(result.data?.radical).toBe("女")
|
||||
expect(result.data?.frequency).toBe(100)
|
||||
|
||||
// Check forms
|
||||
expect(result.data?.forms.length).toBeGreaterThan(0)
|
||||
const defaultForm = result.data?.forms.find((f) => f.isDefault)
|
||||
expect(defaultForm).toBeDefined()
|
||||
expect(defaultForm?.traditional).toBe("好")
|
||||
|
||||
// Check transcriptions
|
||||
expect(defaultForm?.transcriptions.length).toBe(2)
|
||||
const pinyinTrans = defaultForm?.transcriptions.find((t) => t.type === "pinyin")
|
||||
expect(pinyinTrans?.value).toBe("hǎo")
|
||||
|
||||
// Check meanings
|
||||
expect(defaultForm?.meanings.length).toBeGreaterThan(0)
|
||||
expect(defaultForm?.meanings[0].meaning).toBe("good, well")
|
||||
expect(defaultForm?.meanings[0].language).toBe("en")
|
||||
|
||||
// Check classifiers
|
||||
expect(defaultForm?.classifiers.length).toBe(1)
|
||||
expect(defaultForm?.classifiers[0].classifier).toBe("个")
|
||||
|
||||
// Check HSK levels
|
||||
expect(result.data?.hskLevels.length).toBe(1)
|
||||
expect(result.data?.hskLevels[0].level).toBe("new-1")
|
||||
|
||||
// Check parts of speech
|
||||
expect(result.data?.partsOfSpeech.length).toBe(1)
|
||||
expect(result.data?.partsOfSpeech[0].pos).toBe("adj")
|
||||
})
|
||||
|
||||
it("should handle multi-character hanzi", async () => {
|
||||
const result = await getHanzi(hanzi3.id)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.data?.simplified).toBe("中国")
|
||||
expect(result.data?.forms[0].traditional).toBe("中國")
|
||||
expect(result.data?.forms[0].transcriptions[0].value).toBe("zhōng guó")
|
||||
expect(result.data?.forms[0].meanings[0].meaning).toBe("China")
|
||||
})
|
||||
|
||||
it("should return error for non-existent hanzi", async () => {
|
||||
const result = await getHanzi("non-existent-id")
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.message).toBe("Hanzi not found")
|
||||
})
|
||||
|
||||
it("should validate required id parameter", async () => {
|
||||
const result = await getHanzi("")
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.message).toBe("Validation failed")
|
||||
})
|
||||
})
|
||||
|
||||
describe("getHanziBySimplified", () => {
|
||||
it("should get hanzi by simplified character", async () => {
|
||||
const result = await getHanziBySimplified("好")
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.data).toBeDefined()
|
||||
expect(result.data?.simplified).toBe("好")
|
||||
expect(result.data?.traditional).toBe("好")
|
||||
expect(result.data?.pinyin).toBe("hǎo")
|
||||
expect(result.data?.meaning).toBe("good, well")
|
||||
expect(result.data?.hskLevels).toContain("new-1")
|
||||
})
|
||||
|
||||
it("should work with multi-character words", async () => {
|
||||
const result = await getHanziBySimplified("中国")
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.data?.simplified).toBe("中国")
|
||||
expect(result.data?.traditional).toBe("中國")
|
||||
expect(result.data?.pinyin).toBe("zhōng guó")
|
||||
expect(result.data?.meaning).toBe("China")
|
||||
})
|
||||
|
||||
it("should return error for non-existent character", async () => {
|
||||
const result = await getHanziBySimplified("不存在")
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.message).toBe("Hanzi not found")
|
||||
})
|
||||
|
||||
it("should validate required char parameter", async () => {
|
||||
const result = await getHanziBySimplified("")
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.message).toBe("Validation failed")
|
||||
})
|
||||
})
|
||||
})
|
||||
366
src/actions/hanzi.ts
Normal file
366
src/actions/hanzi.ts
Normal file
@@ -0,0 +1,366 @@
|
||||
"use server"
|
||||
|
||||
import { prisma } from "@/lib/prisma"
|
||||
import { z } from "zod"
|
||||
|
||||
/**
|
||||
* Standard action result type
|
||||
*/
|
||||
export type ActionResult<T = void> = {
|
||||
success: boolean
|
||||
data?: T
|
||||
message?: string
|
||||
errors?: Record<string, string[]>
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// VALIDATION SCHEMAS
|
||||
// ============================================================================
|
||||
|
||||
const searchHanziSchema = z.object({
|
||||
query: z.string().min(1, "Search query is required"),
|
||||
hskLevel: z.string().optional(),
|
||||
limit: z.number().int().positive().max(100).default(20),
|
||||
offset: z.number().int().min(0).default(0),
|
||||
})
|
||||
|
||||
const getHanziSchema = z.object({
|
||||
id: z.string().min(1, "Hanzi ID is required"),
|
||||
})
|
||||
|
||||
const getHanziBySimplifiedSchema = z.object({
|
||||
char: z.string().min(1, "Character is required"),
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// HANZI SEARCH
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Search hanzi database (public - no authentication required)
|
||||
* Searches by simplified character, pinyin, or meaning
|
||||
*/
|
||||
export async function searchHanzi(
|
||||
query: string,
|
||||
hskLevel?: string,
|
||||
limit: number = 20,
|
||||
offset: number = 0
|
||||
): Promise<
|
||||
ActionResult<{
|
||||
hanzi: Array<{
|
||||
id: string
|
||||
simplified: string
|
||||
traditional: string | null
|
||||
pinyin: string | null
|
||||
meaning: string | null
|
||||
hskLevels: string[]
|
||||
radical: string | null
|
||||
frequency: number | null
|
||||
}>
|
||||
total: number
|
||||
hasMore: boolean
|
||||
}>
|
||||
> {
|
||||
try {
|
||||
const validation = searchHanziSchema.safeParse({ query, hskLevel, limit, offset })
|
||||
if (!validation.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Validation failed",
|
||||
errors: validation.error.flatten().fieldErrors,
|
||||
}
|
||||
}
|
||||
|
||||
// Build where clause
|
||||
const whereClause: any = {
|
||||
OR: [
|
||||
{ simplified: { contains: query, mode: "insensitive" } },
|
||||
{
|
||||
forms: {
|
||||
some: {
|
||||
OR: [
|
||||
{ traditional: { contains: query, mode: "insensitive" } },
|
||||
{
|
||||
transcriptions: {
|
||||
some: {
|
||||
value: { contains: query, mode: "insensitive" },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
meanings: {
|
||||
some: {
|
||||
meaning: { contains: query, mode: "insensitive" },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
// Add HSK level filter if provided
|
||||
if (hskLevel) {
|
||||
whereClause.hskLevels = {
|
||||
some: {
|
||||
level: hskLevel,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Get total count for pagination
|
||||
const total = await prisma.hanzi.count({ where: whereClause })
|
||||
|
||||
// Get hanzi with extra for hasMore check
|
||||
const results = await prisma.hanzi.findMany({
|
||||
where: whereClause,
|
||||
include: {
|
||||
forms: {
|
||||
where: { isDefault: true },
|
||||
include: {
|
||||
transcriptions: {
|
||||
where: { type: "pinyin" },
|
||||
take: 1,
|
||||
},
|
||||
meanings: {
|
||||
orderBy: { orderIndex: "asc" },
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
take: 1,
|
||||
},
|
||||
hskLevels: {
|
||||
select: { level: true },
|
||||
},
|
||||
},
|
||||
skip: offset,
|
||||
take: limit + 1, // Take one extra to check hasMore
|
||||
orderBy: [{ frequency: "asc" }, { simplified: "asc" }],
|
||||
})
|
||||
|
||||
const hasMore = results.length > limit
|
||||
const hanziList = results.slice(0, limit)
|
||||
|
||||
const hanzi = hanziList.map((h) => ({
|
||||
id: h.id,
|
||||
simplified: h.simplified,
|
||||
traditional: h.forms[0]?.traditional || null,
|
||||
pinyin: h.forms[0]?.transcriptions[0]?.value || null,
|
||||
meaning: h.forms[0]?.meanings[0]?.meaning || null,
|
||||
hskLevels: h.hskLevels.map((l) => l.level),
|
||||
radical: h.radical,
|
||||
frequency: h.frequency,
|
||||
}))
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
hanzi,
|
||||
total,
|
||||
hasMore,
|
||||
},
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to search hanzi:", error)
|
||||
return {
|
||||
success: false,
|
||||
message: "Failed to search hanzi",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get detailed hanzi information (public - no authentication required)
|
||||
* Returns all forms, transcriptions, meanings, HSK levels, etc.
|
||||
*/
|
||||
export async function getHanzi(id: string): Promise<
|
||||
ActionResult<{
|
||||
id: string
|
||||
simplified: string
|
||||
radical: string | null
|
||||
frequency: number | null
|
||||
forms: Array<{
|
||||
id: string
|
||||
traditional: string
|
||||
isDefault: boolean
|
||||
transcriptions: Array<{
|
||||
type: string
|
||||
value: string
|
||||
}>
|
||||
meanings: Array<{
|
||||
language: string
|
||||
meaning: string
|
||||
orderIndex: number
|
||||
}>
|
||||
classifiers: Array<{
|
||||
classifier: string
|
||||
}>
|
||||
}>
|
||||
hskLevels: Array<{
|
||||
level: string
|
||||
}>
|
||||
partsOfSpeech: Array<{
|
||||
pos: string
|
||||
}>
|
||||
}>
|
||||
> {
|
||||
try {
|
||||
const validation = getHanziSchema.safeParse({ id })
|
||||
if (!validation.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Validation failed",
|
||||
errors: validation.error.flatten().fieldErrors,
|
||||
}
|
||||
}
|
||||
|
||||
const hanzi = await prisma.hanzi.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
forms: {
|
||||
include: {
|
||||
transcriptions: {
|
||||
orderBy: { type: "asc" },
|
||||
},
|
||||
meanings: {
|
||||
include: {
|
||||
language: true,
|
||||
},
|
||||
orderBy: { orderIndex: "asc" },
|
||||
},
|
||||
classifiers: true,
|
||||
},
|
||||
orderBy: { isDefault: "desc" },
|
||||
},
|
||||
hskLevels: true,
|
||||
partsOfSpeech: true,
|
||||
},
|
||||
})
|
||||
|
||||
if (!hanzi) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Hanzi not found",
|
||||
}
|
||||
}
|
||||
|
||||
const data = {
|
||||
id: hanzi.id,
|
||||
simplified: hanzi.simplified,
|
||||
radical: hanzi.radical,
|
||||
frequency: hanzi.frequency,
|
||||
forms: hanzi.forms.map((form) => ({
|
||||
id: form.id,
|
||||
traditional: form.traditional,
|
||||
isDefault: form.isDefault,
|
||||
transcriptions: form.transcriptions.map((t) => ({
|
||||
type: t.type,
|
||||
value: t.value,
|
||||
})),
|
||||
meanings: form.meanings.map((m) => ({
|
||||
language: m.language.code,
|
||||
meaning: m.meaning,
|
||||
orderIndex: m.orderIndex,
|
||||
})),
|
||||
classifiers: form.classifiers.map((c) => ({
|
||||
classifier: c.classifier,
|
||||
})),
|
||||
})),
|
||||
hskLevels: hanzi.hskLevels.map((l) => ({
|
||||
level: l.level,
|
||||
})),
|
||||
partsOfSpeech: hanzi.partsOfSpeech.map((p) => ({
|
||||
pos: p.pos,
|
||||
})),
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data,
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to get hanzi:", error)
|
||||
return {
|
||||
success: false,
|
||||
message: "Failed to get hanzi details",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get hanzi by simplified character (public - no authentication required)
|
||||
* Quick lookup by simplified character
|
||||
*/
|
||||
export async function getHanziBySimplified(char: string): Promise<
|
||||
ActionResult<{
|
||||
id: string
|
||||
simplified: string
|
||||
traditional: string | null
|
||||
pinyin: string | null
|
||||
meaning: string | null
|
||||
hskLevels: string[]
|
||||
}>
|
||||
> {
|
||||
try {
|
||||
const validation = getHanziBySimplifiedSchema.safeParse({ char })
|
||||
if (!validation.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Validation failed",
|
||||
errors: validation.error.flatten().fieldErrors,
|
||||
}
|
||||
}
|
||||
|
||||
const hanzi = await prisma.hanzi.findUnique({
|
||||
where: { simplified: char },
|
||||
include: {
|
||||
forms: {
|
||||
where: { isDefault: true },
|
||||
include: {
|
||||
transcriptions: {
|
||||
where: { type: "pinyin" },
|
||||
take: 1,
|
||||
},
|
||||
meanings: {
|
||||
orderBy: { orderIndex: "asc" },
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
take: 1,
|
||||
},
|
||||
hskLevels: {
|
||||
select: { level: true },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (!hanzi) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Hanzi not found",
|
||||
}
|
||||
}
|
||||
|
||||
const data = {
|
||||
id: hanzi.id,
|
||||
simplified: hanzi.simplified,
|
||||
traditional: hanzi.forms[0]?.traditional || null,
|
||||
pinyin: hanzi.forms[0]?.transcriptions[0]?.value || null,
|
||||
meaning: hanzi.forms[0]?.meanings[0]?.meaning || null,
|
||||
hskLevels: hanzi.hskLevels.map((l) => l.level),
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data,
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to get hanzi by simplified:", error)
|
||||
return {
|
||||
success: false,
|
||||
message: "Failed to get hanzi",
|
||||
}
|
||||
}
|
||||
}
|
||||
290
src/actions/preferences.integration.test.ts
Normal file
290
src/actions/preferences.integration.test.ts
Normal file
@@ -0,0 +1,290 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { getPreferences, updatePreferences, getAvailableLanguages } from './preferences'
|
||||
import { register } from './auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
// Mock the auth module
|
||||
vi.mock('@/lib/auth', () => ({
|
||||
auth: vi.fn(),
|
||||
}))
|
||||
|
||||
// Mock revalidatePath
|
||||
vi.mock('next/cache', () => ({
|
||||
revalidatePath: vi.fn(),
|
||||
}))
|
||||
|
||||
describe('Preferences Server Actions - Integration Tests', () => {
|
||||
let testUserId: string
|
||||
let testLanguageId: string
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create English language for tests
|
||||
const language = await prisma.language.create({
|
||||
data: {
|
||||
code: 'en',
|
||||
name: 'English',
|
||||
nativeName: 'English',
|
||||
isActive: true,
|
||||
},
|
||||
})
|
||||
testLanguageId = language.id
|
||||
|
||||
// Create a test user
|
||||
const result = await register('test@example.com', 'password123', 'Test User')
|
||||
testUserId = result.data!.userId
|
||||
|
||||
// Mock auth to return test user session
|
||||
const { auth } = await import('@/lib/auth')
|
||||
vi.mocked(auth).mockResolvedValue({
|
||||
user: { id: testUserId, email: 'test@example.com' },
|
||||
} as any)
|
||||
})
|
||||
|
||||
describe('getPreferences', () => {
|
||||
it('should successfully get user preferences', async () => {
|
||||
const result = await getPreferences()
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.data).toBeDefined()
|
||||
expect(result.data?.preferredLanguageId).toBe(testLanguageId)
|
||||
expect(result.data?.characterDisplay).toBe('SIMPLIFIED')
|
||||
expect(result.data?.transcriptionType).toBe('pinyin')
|
||||
expect(result.data?.cardsPerSession).toBe(20)
|
||||
expect(result.data?.dailyGoal).toBe(50)
|
||||
expect(result.data?.removalThreshold).toBe(10)
|
||||
expect(result.data?.allowManualDifficulty).toBe(true)
|
||||
})
|
||||
|
||||
it('should reject when not logged in', async () => {
|
||||
const { auth } = await import('@/lib/auth')
|
||||
vi.mocked(auth).mockResolvedValue(null)
|
||||
|
||||
const result = await getPreferences()
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.message).toBe('You must be logged in to view preferences')
|
||||
})
|
||||
|
||||
it('should handle missing preferences', async () => {
|
||||
// Delete the preferences
|
||||
await prisma.userPreference.delete({ where: { userId: testUserId } })
|
||||
|
||||
const result = await getPreferences()
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.message).toBe('Preferences not found')
|
||||
})
|
||||
})
|
||||
|
||||
describe('updatePreferences', () => {
|
||||
it('should successfully update character display', async () => {
|
||||
const result = await updatePreferences({
|
||||
characterDisplay: 'TRADITIONAL',
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.message).toBe('Preferences updated successfully')
|
||||
|
||||
// Verify preference was updated
|
||||
const preference = await prisma.userPreference.findUnique({
|
||||
where: { userId: testUserId },
|
||||
})
|
||||
expect(preference?.characterDisplay).toBe('TRADITIONAL')
|
||||
})
|
||||
|
||||
it('should successfully update cards per session', async () => {
|
||||
const result = await updatePreferences({
|
||||
cardsPerSession: 30,
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
|
||||
// Verify preference was updated
|
||||
const preference = await prisma.userPreference.findUnique({
|
||||
where: { userId: testUserId },
|
||||
})
|
||||
expect(preference?.cardsPerSession).toBe(30)
|
||||
})
|
||||
|
||||
it('should successfully update daily goal', async () => {
|
||||
const result = await updatePreferences({
|
||||
dailyGoal: 100,
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
|
||||
// Verify preference was updated
|
||||
const preference = await prisma.userPreference.findUnique({
|
||||
where: { userId: testUserId },
|
||||
})
|
||||
expect(preference?.dailyGoal).toBe(100)
|
||||
})
|
||||
|
||||
it('should successfully update multiple preferences', async () => {
|
||||
const result = await updatePreferences({
|
||||
characterDisplay: 'BOTH',
|
||||
cardsPerSession: 25,
|
||||
dailyGoal: 75,
|
||||
transcriptionType: 'zhuyin',
|
||||
removalThreshold: 15,
|
||||
allowManualDifficulty: false,
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
|
||||
// Verify all preferences were updated
|
||||
const preference = await prisma.userPreference.findUnique({
|
||||
where: { userId: testUserId },
|
||||
})
|
||||
expect(preference?.characterDisplay).toBe('BOTH')
|
||||
expect(preference?.cardsPerSession).toBe(25)
|
||||
expect(preference?.dailyGoal).toBe(75)
|
||||
expect(preference?.transcriptionType).toBe('zhuyin')
|
||||
expect(preference?.removalThreshold).toBe(15)
|
||||
expect(preference?.allowManualDifficulty).toBe(false)
|
||||
})
|
||||
|
||||
it('should successfully update preferred language', async () => {
|
||||
// Create another language
|
||||
const spanish = await prisma.language.create({
|
||||
data: {
|
||||
code: 'es',
|
||||
name: 'Spanish',
|
||||
nativeName: 'Español',
|
||||
isActive: true,
|
||||
},
|
||||
})
|
||||
|
||||
const result = await updatePreferences({
|
||||
preferredLanguageId: spanish.id,
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
|
||||
// Verify preference was updated
|
||||
const preference = await prisma.userPreference.findUnique({
|
||||
where: { userId: testUserId },
|
||||
})
|
||||
expect(preference?.preferredLanguageId).toBe(spanish.id)
|
||||
})
|
||||
|
||||
it('should reject when not logged in', async () => {
|
||||
const { auth } = await import('@/lib/auth')
|
||||
vi.mocked(auth).mockResolvedValue(null)
|
||||
|
||||
const result = await updatePreferences({
|
||||
cardsPerSession: 30,
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.message).toBe('You must be logged in to update preferences')
|
||||
})
|
||||
|
||||
it('should validate cards per session range', async () => {
|
||||
const result = await updatePreferences({
|
||||
cardsPerSession: 0, // Below minimum (1)
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.message).toBe('Validation failed')
|
||||
})
|
||||
|
||||
it('should validate daily goal range', async () => {
|
||||
const result = await updatePreferences({
|
||||
dailyGoal: 0, // Below minimum (1)
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.message).toBe('Validation failed')
|
||||
})
|
||||
|
||||
it('should validate character display enum', async () => {
|
||||
const result = await updatePreferences({
|
||||
characterDisplay: 'INVALID' as any,
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.message).toBe('Validation failed')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getAvailableLanguages', () => {
|
||||
it('should return all active languages', async () => {
|
||||
// Create additional languages
|
||||
await prisma.language.createMany({
|
||||
data: [
|
||||
{
|
||||
code: 'es',
|
||||
name: 'Spanish',
|
||||
nativeName: 'Español',
|
||||
isActive: true,
|
||||
},
|
||||
{
|
||||
code: 'fr',
|
||||
name: 'French',
|
||||
nativeName: 'Français',
|
||||
isActive: true,
|
||||
},
|
||||
{
|
||||
code: 'de',
|
||||
name: 'German',
|
||||
nativeName: 'Deutsch',
|
||||
isActive: false, // Inactive
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const result = await getAvailableLanguages()
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.data).toBeDefined()
|
||||
expect(result.data?.length).toBe(3) // English, Spanish, French (not German)
|
||||
|
||||
// Should be sorted by name
|
||||
const names = result.data!.map((lang) => lang.name)
|
||||
expect(names).toEqual(['English', 'French', 'Spanish'])
|
||||
})
|
||||
|
||||
it('should return only active languages', async () => {
|
||||
await prisma.language.create({
|
||||
data: {
|
||||
code: 'de',
|
||||
name: 'German',
|
||||
nativeName: 'Deutsch',
|
||||
isActive: false,
|
||||
},
|
||||
})
|
||||
|
||||
const result = await getAvailableLanguages()
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const codes = result.data!.map((lang) => lang.code)
|
||||
expect(codes).not.toContain('de')
|
||||
})
|
||||
|
||||
it('should return language with all required fields', async () => {
|
||||
const result = await getAvailableLanguages()
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.data).toBeDefined()
|
||||
|
||||
const language = result.data![0]
|
||||
expect(language).toHaveProperty('id')
|
||||
expect(language).toHaveProperty('code')
|
||||
expect(language).toHaveProperty('name')
|
||||
expect(language).toHaveProperty('nativeName')
|
||||
})
|
||||
|
||||
it('should handle empty language list', async () => {
|
||||
// Delete user preferences first (they reference languages)
|
||||
await prisma.userPreference.deleteMany()
|
||||
// Then delete all languages
|
||||
await prisma.language.deleteMany()
|
||||
|
||||
const result = await getAvailableLanguages()
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.data).toEqual([])
|
||||
})
|
||||
})
|
||||
})
|
||||
145
src/actions/preferences.ts
Normal file
145
src/actions/preferences.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
'use server'
|
||||
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { updatePreferencesSchema } from '@/lib/validations/preferences'
|
||||
import type { ActionResult } from '@/types'
|
||||
|
||||
export type UserPreferences = {
|
||||
preferredLanguageId: string
|
||||
characterDisplay: 'SIMPLIFIED' | 'TRADITIONAL' | 'BOTH'
|
||||
transcriptionType: string
|
||||
cardsPerSession: number
|
||||
dailyGoal: number
|
||||
removalThreshold: number
|
||||
allowManualDifficulty: boolean
|
||||
}
|
||||
|
||||
export type Language = {
|
||||
id: string
|
||||
code: string
|
||||
name: string
|
||||
nativeName: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user preferences
|
||||
*/
|
||||
export async function getPreferences(): Promise<ActionResult<UserPreferences>> {
|
||||
try {
|
||||
const session = await auth()
|
||||
if (!session?.user) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'You must be logged in to view preferences',
|
||||
}
|
||||
}
|
||||
|
||||
const preference = await prisma.userPreference.findUnique({
|
||||
where: { userId: (session.user as any).id },
|
||||
})
|
||||
|
||||
if (!preference) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Preferences not found',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
preferredLanguageId: preference.preferredLanguageId,
|
||||
characterDisplay: preference.characterDisplay,
|
||||
transcriptionType: preference.transcriptionType,
|
||||
cardsPerSession: preference.cardsPerSession,
|
||||
dailyGoal: preference.dailyGoal,
|
||||
removalThreshold: preference.removalThreshold,
|
||||
allowManualDifficulty: preference.allowManualDifficulty,
|
||||
},
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Get preferences error:', error)
|
||||
return {
|
||||
success: false,
|
||||
message: 'An error occurred while fetching preferences',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update user preferences
|
||||
*/
|
||||
export async function updatePreferences(
|
||||
data: Partial<UserPreferences>
|
||||
): Promise<ActionResult> {
|
||||
try {
|
||||
const session = await auth()
|
||||
if (!session?.user) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'You must be logged in to update preferences',
|
||||
}
|
||||
}
|
||||
|
||||
// Validate input
|
||||
const validatedData = updatePreferencesSchema.parse(data)
|
||||
|
||||
// Update preferences
|
||||
await prisma.userPreference.update({
|
||||
where: { userId: (session.user as any).id },
|
||||
data: validatedData,
|
||||
})
|
||||
|
||||
revalidatePath('/settings')
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Preferences updated successfully',
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error.name === 'ZodError') {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Validation failed',
|
||||
errors: error.flatten().fieldErrors,
|
||||
}
|
||||
}
|
||||
|
||||
console.error('Update preferences error:', error)
|
||||
return {
|
||||
success: false,
|
||||
message: 'An error occurred while updating preferences',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available languages
|
||||
*/
|
||||
export async function getAvailableLanguages(): Promise<ActionResult<Language[]>> {
|
||||
try {
|
||||
const languages = await prisma.language.findMany({
|
||||
where: { isActive: true },
|
||||
select: {
|
||||
id: true,
|
||||
code: true,
|
||||
name: true,
|
||||
nativeName: true,
|
||||
},
|
||||
orderBy: { name: 'asc' },
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: languages,
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Get available languages error:', error)
|
||||
return {
|
||||
success: false,
|
||||
message: 'An error occurred while fetching languages',
|
||||
}
|
||||
}
|
||||
}
|
||||
332
src/app/(admin)/admin/import/page.tsx
Normal file
332
src/app/(admin)/admin/import/page.tsx
Normal file
@@ -0,0 +1,332 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { importHanzi } from "@/actions/admin"
|
||||
import type { ImportResult } from "@/lib/import/types"
|
||||
|
||||
export default function AdminImportPage() {
|
||||
const [inputMethod, setInputMethod] = useState<"file" | "paste">("file")
|
||||
const [file, setFile] = useState<File | null>(null)
|
||||
const [pastedData, setPastedData] = useState("")
|
||||
const [format, setFormat] = useState<"json" | "csv">("json")
|
||||
const [updateExisting, setUpdateExisting] = useState(true)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [result, setResult] = useState<ImportResult | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const selectedFile = e.target.files?.[0]
|
||||
if (selectedFile) {
|
||||
setFile(selectedFile)
|
||||
setResult(null)
|
||||
setError(null)
|
||||
|
||||
// Auto-detect format from file extension
|
||||
if (selectedFile.name.endsWith(".json")) {
|
||||
setFormat("json")
|
||||
} else if (selectedFile.name.endsWith(".csv")) {
|
||||
setFormat("csv")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
let dataToImport: string
|
||||
|
||||
if (inputMethod === "file") {
|
||||
if (!file) {
|
||||
setError("Please select a file")
|
||||
return
|
||||
}
|
||||
dataToImport = await file.text()
|
||||
} else {
|
||||
if (!pastedData.trim()) {
|
||||
setError("Please paste some data")
|
||||
return
|
||||
}
|
||||
dataToImport = pastedData
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
setResult(null)
|
||||
|
||||
try {
|
||||
const response = await importHanzi(dataToImport, format, updateExisting)
|
||||
|
||||
if (response.success && response.data) {
|
||||
setResult(response.data)
|
||||
// Clear input after successful import
|
||||
if (inputMethod === "paste") {
|
||||
setPastedData("")
|
||||
} else {
|
||||
setFile(null)
|
||||
}
|
||||
} else {
|
||||
setError(response.message || "Import failed")
|
||||
if (response.data) {
|
||||
setResult(response.data)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "An error occurred")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8 max-w-4xl">
|
||||
<h1 className="text-3xl font-bold mb-6">Import Hanzi Data</h1>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6 mb-6">
|
||||
<h2 className="text-xl font-semibold mb-4">Import Data</h2>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">
|
||||
Input Method
|
||||
</label>
|
||||
<div className="flex gap-4">
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="radio"
|
||||
value="file"
|
||||
checked={inputMethod === "file"}
|
||||
onChange={(e) => setInputMethod(e.target.value as "file" | "paste")}
|
||||
className="mr-2"
|
||||
disabled={loading}
|
||||
/>
|
||||
Upload File
|
||||
</label>
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="radio"
|
||||
value="paste"
|
||||
checked={inputMethod === "paste"}
|
||||
onChange={(e) => setInputMethod(e.target.value as "file" | "paste")}
|
||||
className="mr-2"
|
||||
disabled={loading}
|
||||
/>
|
||||
Paste Data
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{inputMethod === "file" ? (
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">
|
||||
Select File
|
||||
</label>
|
||||
<input
|
||||
type="file"
|
||||
accept=".json,.csv"
|
||||
onChange={handleFileChange}
|
||||
className="block w-full text-sm text-gray-900 border border-gray-300 rounded-lg cursor-pointer bg-gray-50 dark:text-gray-400 focus:outline-none dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">
|
||||
Paste Data
|
||||
</label>
|
||||
<textarea
|
||||
value={pastedData}
|
||||
onChange={(e) => setPastedData(e.target.value)}
|
||||
placeholder={`Paste your ${format.toUpperCase()} data here...`}
|
||||
className="block w-full h-64 text-sm text-gray-900 border border-gray-300 rounded-lg bg-gray-50 dark:text-gray-400 focus:outline-none dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 font-mono"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">Format</label>
|
||||
<div className="flex gap-4">
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="radio"
|
||||
value="json"
|
||||
checked={format === "json"}
|
||||
onChange={(e) => setFormat(e.target.value as "json" | "csv")}
|
||||
className="mr-2"
|
||||
disabled={loading}
|
||||
/>
|
||||
JSON (HSK Vocabulary)
|
||||
</label>
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="radio"
|
||||
value="csv"
|
||||
checked={format === "csv"}
|
||||
onChange={(e) => setFormat(e.target.value as "json" | "csv")}
|
||||
className="mr-2"
|
||||
disabled={loading}
|
||||
/>
|
||||
CSV
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={updateExisting}
|
||||
onChange={(e) => setUpdateExisting(e.target.checked)}
|
||||
className="mr-2"
|
||||
disabled={loading}
|
||||
/>
|
||||
<span className="text-sm font-medium">
|
||||
Update existing entries
|
||||
</span>
|
||||
</label>
|
||||
<p className="text-xs text-gray-600 dark:text-gray-400 mt-1 ml-6">
|
||||
{updateExisting
|
||||
? "Existing hanzi will be updated with new data"
|
||||
: "Existing hanzi will be skipped (only new ones will be added)"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || (inputMethod === "file" && !file) || (inputMethod === "paste" && !pastedData.trim())}
|
||||
className="bg-blue-600 text-white px-6 py-2 rounded-lg hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? "Importing..." : "Import"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-6">
|
||||
<p className="font-bold">Error</p>
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result && (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<h2 className="text-xl font-semibold mb-4">Import Results</h2>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4 mb-6">
|
||||
<div className="bg-green-100 dark:bg-green-900 p-4 rounded">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||
Imported
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-green-700 dark:text-green-300">
|
||||
{result.imported}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-red-100 dark:bg-red-900 p-4 rounded">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||
Failed
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-red-700 dark:text-red-300">
|
||||
{result.failed}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-blue-100 dark:bg-blue-900 p-4 rounded">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||
Success Rate
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-blue-700 dark:text-blue-300">
|
||||
{result.imported + result.failed > 0
|
||||
? Math.round(
|
||||
(result.imported / (result.imported + result.failed)) *
|
||||
100
|
||||
)
|
||||
: 0}
|
||||
%
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{result.errors.length > 0 && (
|
||||
<div>
|
||||
<h3 className="font-semibold mb-2">
|
||||
Errors ({result.errors.length})
|
||||
</h3>
|
||||
<div className="bg-gray-100 dark:bg-gray-700 rounded p-4 max-h-96 overflow-y-auto">
|
||||
{result.errors.map((err, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="mb-2 pb-2 border-b border-gray-300 dark:border-gray-600 last:border-0"
|
||||
>
|
||||
<p className="text-sm">
|
||||
{err.line && (
|
||||
<span className="font-semibold">Line {err.line}: </span>
|
||||
)}
|
||||
{err.character && (
|
||||
<span className="font-mono bg-gray-200 dark:bg-gray-600 px-1 rounded">
|
||||
{err.character}
|
||||
</span>
|
||||
)}{" "}
|
||||
- {err.error}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-6 mt-6">
|
||||
<h2 className="text-xl font-semibold mb-4">Format Information</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="font-semibold mb-2">JSON Format (HSK Vocabulary)</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300 mb-2">
|
||||
Source:{" "}
|
||||
<a
|
||||
href="https://github.com/drkameleon/complete-hsk-vocabulary"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline"
|
||||
>
|
||||
github.com/drkameleon/complete-hsk-vocabulary
|
||||
</a>
|
||||
</p>
|
||||
<pre className="bg-gray-100 dark:bg-gray-700 p-4 rounded text-sm overflow-x-auto">
|
||||
{JSON.stringify(
|
||||
{
|
||||
simplified: "爱好",
|
||||
radical: "爫",
|
||||
level: ["new-1", "old-3"],
|
||||
frequency: 4902,
|
||||
pos: ["n", "v"],
|
||||
forms: [
|
||||
{
|
||||
traditional: "愛好",
|
||||
transcriptions: {
|
||||
pinyin: "ài hào",
|
||||
numeric: "ai4 hao4",
|
||||
},
|
||||
meanings: ["to like; hobby"],
|
||||
classifiers: ["个"],
|
||||
},
|
||||
],
|
||||
},
|
||||
null,
|
||||
2
|
||||
)}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="font-semibold mb-2">CSV Format</h3>
|
||||
<pre className="bg-gray-100 dark:bg-gray-700 p-4 rounded text-sm overflow-x-auto">
|
||||
{`simplified,traditional,pinyin,meaning,hsk_level,radical,frequency,pos,classifiers
|
||||
爱好,愛好,ài hào,"to like; hobby","new-1,old-3",爫,4902,"n,v",个`}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
701
src/app/(app)/collections/[id]/page.tsx
Normal file
701
src/app/(app)/collections/[id]/page.tsx
Normal file
@@ -0,0 +1,701 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { useRouter, useParams } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import {
|
||||
getCollection,
|
||||
addHanziToCollection,
|
||||
removeHanziFromCollection,
|
||||
removeMultipleHanziFromCollection,
|
||||
searchHanziForCollection,
|
||||
parseHanziList,
|
||||
deleteCollection,
|
||||
} from "@/actions/collections"
|
||||
|
||||
type Hanzi = {
|
||||
id: string
|
||||
simplified: string
|
||||
pinyin: string | null
|
||||
meaning: string | null
|
||||
orderIndex: number
|
||||
}
|
||||
|
||||
type Collection = {
|
||||
id: string
|
||||
name: string
|
||||
description: string | null
|
||||
isPublic: boolean
|
||||
isGlobal: boolean
|
||||
createdBy: string | null
|
||||
hanziCount: number
|
||||
hanzi: Hanzi[]
|
||||
}
|
||||
|
||||
export default function CollectionDetailPage() {
|
||||
const router = useRouter()
|
||||
const params = useParams()
|
||||
const collectionId = params.id as string
|
||||
const [collection, setCollection] = useState<Collection | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Selection mode
|
||||
const [selectionMode, setSelectionMode] = useState(false)
|
||||
const [selectedHanziIds, setSelectedHanziIds] = useState<Set<string>>(new Set())
|
||||
|
||||
// Add hanzi modal
|
||||
const [showAddModal, setShowAddModal] = useState(false)
|
||||
const [addTab, setAddTab] = useState<"search" | "paste">("search")
|
||||
|
||||
// Search state
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
const [searchResults, setSearchResults] = useState<
|
||||
Array<{
|
||||
id: string
|
||||
simplified: string
|
||||
pinyin: string | null
|
||||
meaning: string | null
|
||||
inCollection: boolean
|
||||
}>
|
||||
>([])
|
||||
const [searchLoading, setSearchLoading] = useState(false)
|
||||
const [searchSelectedIds, setSearchSelectedIds] = useState<Set<string>>(new Set())
|
||||
|
||||
// Paste state
|
||||
const [pasteInput, setPasteInput] = useState("")
|
||||
const [parseResult, setParseResult] = useState<{
|
||||
valid: boolean
|
||||
found: Array<{ id: string; simplified: string; pinyin: string | null }>
|
||||
notFound: string[]
|
||||
duplicates: string[]
|
||||
} | null>(null)
|
||||
|
||||
const [actionLoading, setActionLoading] = useState(false)
|
||||
const [actionError, setActionError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
loadCollection()
|
||||
}, [collectionId])
|
||||
|
||||
const loadCollection = async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const result = await getCollection(collectionId)
|
||||
if (result.success && result.data) {
|
||||
setCollection(result.data)
|
||||
} else {
|
||||
setError(result.message || "Failed to load collection")
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "An error occurred")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = async () => {
|
||||
if (!searchQuery.trim()) return
|
||||
|
||||
setSearchLoading(true)
|
||||
try {
|
||||
const result = await searchHanziForCollection(searchQuery, collectionId, 50, 0)
|
||||
if (result.success && result.data) {
|
||||
setSearchResults(result.data.hanzi)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Search failed:", err)
|
||||
} finally {
|
||||
setSearchLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleParseList = async () => {
|
||||
if (!pasteInput.trim()) {
|
||||
setActionError("Please enter hanzi list")
|
||||
return
|
||||
}
|
||||
|
||||
setActionLoading(true)
|
||||
setActionError(null)
|
||||
|
||||
try {
|
||||
const result = await parseHanziList(pasteInput)
|
||||
if (result.success && result.data) {
|
||||
setParseResult(result.data)
|
||||
if (!result.data.valid) {
|
||||
setActionError(`${result.data.notFound.length} hanzi not found in database`)
|
||||
}
|
||||
} else {
|
||||
setActionError(result.message || "Failed to parse hanzi list")
|
||||
}
|
||||
} catch (err) {
|
||||
setActionError(err instanceof Error ? err.message : "An error occurred")
|
||||
} finally {
|
||||
setActionLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddSelectedFromSearch = async () => {
|
||||
if (searchSelectedIds.size === 0) return
|
||||
|
||||
setActionLoading(true)
|
||||
setActionError(null)
|
||||
|
||||
try {
|
||||
const result = await addHanziToCollection(collectionId, Array.from(searchSelectedIds))
|
||||
if (result.success) {
|
||||
setShowAddModal(false)
|
||||
setSearchQuery("")
|
||||
setSearchResults([])
|
||||
setSearchSelectedIds(new Set())
|
||||
await loadCollection()
|
||||
} else {
|
||||
setActionError(result.message || "Failed to add hanzi")
|
||||
}
|
||||
} catch (err) {
|
||||
setActionError(err instanceof Error ? err.message : "An error occurred")
|
||||
} finally {
|
||||
setActionLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddFromPaste = async () => {
|
||||
if (!parseResult || !parseResult.valid) {
|
||||
setActionError("Please preview and fix any errors first")
|
||||
return
|
||||
}
|
||||
|
||||
setActionLoading(true)
|
||||
setActionError(null)
|
||||
|
||||
try {
|
||||
const hanziIds = parseResult.found.map((h) => h.id)
|
||||
const result = await addHanziToCollection(collectionId, hanziIds)
|
||||
if (result.success) {
|
||||
setShowAddModal(false)
|
||||
setPasteInput("")
|
||||
setParseResult(null)
|
||||
await loadCollection()
|
||||
} else {
|
||||
setActionError(result.message || "Failed to add hanzi")
|
||||
}
|
||||
} catch (err) {
|
||||
setActionError(err instanceof Error ? err.message : "An error occurred")
|
||||
} finally {
|
||||
setActionLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemoveSingle = async (hanziId: string) => {
|
||||
if (!confirm("Remove this hanzi from the collection?")) return
|
||||
|
||||
setActionLoading(true)
|
||||
try {
|
||||
const result = await removeHanziFromCollection(collectionId, hanziId)
|
||||
if (result.success) {
|
||||
await loadCollection()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Remove failed:", err)
|
||||
} finally {
|
||||
setActionLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemoveSelected = async () => {
|
||||
if (selectedHanziIds.size === 0) return
|
||||
if (!confirm(`Remove ${selectedHanziIds.size} hanzi from the collection?`)) return
|
||||
|
||||
setActionLoading(true)
|
||||
try {
|
||||
const result = await removeMultipleHanziFromCollection(collectionId, Array.from(selectedHanziIds))
|
||||
if (result.success) {
|
||||
setSelectionMode(false)
|
||||
setSelectedHanziIds(new Set())
|
||||
await loadCollection()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Remove failed:", err)
|
||||
} finally {
|
||||
setActionLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteCollection = async () => {
|
||||
if (!confirm("Delete this collection? This action cannot be undone.")) return
|
||||
|
||||
setActionLoading(true)
|
||||
try {
|
||||
const result = await deleteCollection(collectionId)
|
||||
if (result.success) {
|
||||
router.push("/collections")
|
||||
} else {
|
||||
setActionError(result.message || "Failed to delete collection")
|
||||
}
|
||||
} catch (err) {
|
||||
setActionError(err instanceof Error ? err.message : "An error occurred")
|
||||
} finally {
|
||||
setActionLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center">
|
||||
<p className="text-gray-600 dark:text-gray-400">Loading...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error || !collection) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<p className="text-red-600 dark:text-red-400 mb-4">{error || "Collection not found"}</p>
|
||||
<Link href="/collections" className="text-blue-600 hover:underline">
|
||||
Back to Collections
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const canModify = !collection.isGlobal
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||
<nav className="bg-white dark:bg-gray-800 shadow">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between h-16 items-center">
|
||||
<div className="flex items-center space-x-8">
|
||||
<Link href="/dashboard">
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white cursor-pointer">
|
||||
MemoHanzi <span className="text-sm font-normal text-gray-500">记汉字</span>
|
||||
</h1>
|
||||
</Link>
|
||||
<Link
|
||||
href="/collections"
|
||||
className="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200"
|
||||
>
|
||||
Collections
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="flex justify-between items-start mb-8">
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h2 className="text-3xl font-bold text-gray-900 dark:text-white">
|
||||
{collection.name}
|
||||
</h2>
|
||||
{collection.isGlobal && (
|
||||
<span className="text-xs bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200 px-2 py-1 rounded">
|
||||
HSK
|
||||
</span>
|
||||
)}
|
||||
{collection.isPublic && !collection.isGlobal && (
|
||||
<span className="text-xs bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200 px-2 py-1 rounded">
|
||||
Public
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{collection.description && (
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-2">{collection.description}</p>
|
||||
)}
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{collection.hanziCount} hanzi
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{canModify && (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setShowAddModal(true)}
|
||||
disabled={actionLoading}
|
||||
className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 disabled:bg-gray-400"
|
||||
>
|
||||
Add Hanzi
|
||||
</button>
|
||||
{collection.hanziCount > 0 && (
|
||||
<button
|
||||
onClick={() => setSelectionMode(!selectionMode)}
|
||||
className="bg-gray-600 text-white px-4 py-2 rounded-lg hover:bg-gray-700"
|
||||
>
|
||||
{selectionMode ? "Cancel" : "Select"}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={handleDeleteCollection}
|
||||
disabled={actionLoading}
|
||||
className="bg-red-600 text-white px-4 py-2 rounded-lg hover:bg-red-700 disabled:bg-gray-400"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectionMode && selectedHanziIds.size > 0 && (
|
||||
<div className="bg-blue-100 dark:bg-blue-900 p-4 rounded-lg mb-6 flex justify-between items-center">
|
||||
<span className="text-blue-900 dark:text-blue-200 font-medium">
|
||||
{selectedHanziIds.size} selected
|
||||
</span>
|
||||
<button
|
||||
onClick={handleRemoveSelected}
|
||||
disabled={actionLoading}
|
||||
className="bg-red-600 text-white px-4 py-2 rounded-lg hover:bg-red-700 disabled:bg-gray-400"
|
||||
>
|
||||
Remove Selected
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{actionError && (
|
||||
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-6">
|
||||
{actionError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hanzi Grid */}
|
||||
{collection.hanzi.length === 0 ? (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-8 text-center">
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-4">
|
||||
This collection is empty. Add some hanzi to get started.
|
||||
</p>
|
||||
{canModify && (
|
||||
<button
|
||||
onClick={() => setShowAddModal(true)}
|
||||
className="bg-blue-600 text-white px-6 py-2 rounded-lg hover:bg-blue-700"
|
||||
>
|
||||
Add Hanzi
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
|
||||
{collection.hanzi.map((hanzi) => (
|
||||
<div
|
||||
key={hanzi.id}
|
||||
className={`bg-white dark:bg-gray-800 rounded-lg shadow p-4 relative ${
|
||||
selectedHanziIds.has(hanzi.id) ? "ring-2 ring-blue-600" : ""
|
||||
}`}
|
||||
>
|
||||
{selectionMode && (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedHanziIds.has(hanzi.id)}
|
||||
onChange={(e) => {
|
||||
const newSet = new Set(selectedHanziIds)
|
||||
if (e.target.checked) {
|
||||
newSet.add(hanzi.id)
|
||||
} else {
|
||||
newSet.delete(hanzi.id)
|
||||
}
|
||||
setSelectedHanziIds(newSet)
|
||||
}}
|
||||
className="absolute top-2 left-2"
|
||||
/>
|
||||
)}
|
||||
{!selectionMode && canModify && (
|
||||
<button
|
||||
onClick={() => handleRemoveSingle(hanzi.id)}
|
||||
disabled={actionLoading}
|
||||
className="absolute top-2 right-2 text-red-600 hover:text-red-800 disabled:text-gray-400"
|
||||
title="Remove"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
<div className="text-center">
|
||||
<div className="text-4xl mb-2">{hanzi.simplified}</div>
|
||||
{hanzi.pinyin && (
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400 mb-1">
|
||||
{hanzi.pinyin}
|
||||
</div>
|
||||
)}
|
||||
{hanzi.meaning && (
|
||||
<div className="text-xs text-gray-500 dark:text-gray-500 line-clamp-2">
|
||||
{hanzi.meaning}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{/* Add Hanzi Modal */}
|
||||
{showAddModal && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-3xl w-full max-h-[90vh] overflow-hidden">
|
||||
<div className="border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex justify-between items-center p-4">
|
||||
<h3 className="text-xl font-bold text-gray-900 dark:text-white">Add Hanzi</h3>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowAddModal(false)
|
||||
setSearchQuery("")
|
||||
setSearchResults([])
|
||||
setSearchSelectedIds(new Set())
|
||||
setPasteInput("")
|
||||
setParseResult(null)
|
||||
setActionError(null)
|
||||
}}
|
||||
className="text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex border-t border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
onClick={() => setAddTab("search")}
|
||||
className={`flex-1 py-3 px-6 text-center font-medium ${
|
||||
addTab === "search"
|
||||
? "border-b-2 border-blue-600 text-blue-600 dark:text-blue-400"
|
||||
: "text-gray-600 dark:text-gray-400"
|
||||
}`}
|
||||
>
|
||||
Search
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setAddTab("paste")}
|
||||
className={`flex-1 py-3 px-6 text-center font-medium ${
|
||||
addTab === "paste"
|
||||
? "border-b-2 border-blue-600 text-blue-600 dark:text-blue-400"
|
||||
: "text-gray-600 dark:text-gray-400"
|
||||
}`}
|
||||
>
|
||||
Paste
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 overflow-y-auto max-h-[calc(90vh-200px)]">
|
||||
{actionError && (
|
||||
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
|
||||
{actionError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{addTab === "search" ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyPress={(e) => e.key === "Enter" && handleSearch()}
|
||||
placeholder="Search by character, pinyin, or meaning..."
|
||||
className="flex-1 px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
/>
|
||||
<button
|
||||
onClick={handleSearch}
|
||||
disabled={searchLoading}
|
||||
className="bg-blue-600 text-white px-6 py-2 rounded-lg hover:bg-blue-700 disabled:bg-gray-400"
|
||||
>
|
||||
{searchLoading ? "..." : "Search"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{searchSelectedIds.size > 0 && (
|
||||
<div className="flex justify-between items-center bg-blue-50 dark:bg-blue-900/20 p-3 rounded">
|
||||
<span className="text-sm text-blue-900 dark:text-blue-200">
|
||||
{searchSelectedIds.size} selected
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setSearchSelectedIds(new Set())}
|
||||
className="text-sm text-blue-600 hover:underline"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{searchResults.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{searchResults.map((hanzi) => (
|
||||
<label
|
||||
key={hanzi.id}
|
||||
className={`flex items-center gap-3 p-3 border rounded-lg cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700 ${
|
||||
hanzi.inCollection
|
||||
? "opacity-50 cursor-not-allowed"
|
||||
: "border-gray-300 dark:border-gray-600"
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={searchSelectedIds.has(hanzi.id)}
|
||||
onChange={(e) => {
|
||||
const newSet = new Set(searchSelectedIds)
|
||||
if (e.target.checked) {
|
||||
newSet.add(hanzi.id)
|
||||
} else {
|
||||
newSet.delete(hanzi.id)
|
||||
}
|
||||
setSearchSelectedIds(newSet)
|
||||
}}
|
||||
disabled={hanzi.inCollection}
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-2xl">{hanzi.simplified}</span>
|
||||
{hanzi.pinyin && (
|
||||
<span className="text-gray-600 dark:text-gray-400">
|
||||
{hanzi.pinyin}
|
||||
</span>
|
||||
)}
|
||||
{hanzi.inCollection && (
|
||||
<span className="text-xs bg-gray-200 dark:bg-gray-700 px-2 py-1 rounded">
|
||||
Already in collection
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{hanzi.meaning && (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{hanzi.meaning}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<textarea
|
||||
value={pasteInput}
|
||||
onChange={(e) => {
|
||||
setPasteInput(e.target.value)
|
||||
setParseResult(null)
|
||||
setActionError(null)
|
||||
}}
|
||||
placeholder="Paste hanzi here (newline, comma, or space separated) Example: 好 爱 你 or: 好, 爱, 你"
|
||||
rows={6}
|
||||
className="block w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white font-mono text-sm"
|
||||
/>
|
||||
<button
|
||||
onClick={handleParseList}
|
||||
disabled={actionLoading || !pasteInput.trim()}
|
||||
className="mt-2 bg-gray-600 text-white px-4 py-2 rounded-lg hover:bg-gray-700 disabled:bg-gray-400"
|
||||
>
|
||||
{actionLoading ? "Parsing..." : "Preview"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{parseResult && (
|
||||
<div className="bg-gray-100 dark:bg-gray-700 rounded-lg p-4">
|
||||
<h4 className="font-semibold mb-3 text-gray-900 dark:text-white">
|
||||
Preview Results
|
||||
</h4>
|
||||
|
||||
{parseResult.found.length > 0 && (
|
||||
<div className="mb-3">
|
||||
<p className="text-sm font-medium text-green-700 dark:text-green-400 mb-2">
|
||||
✓ Found: {parseResult.found.length} hanzi
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{parseResult.found.slice(0, 30).map((h) => (
|
||||
<span
|
||||
key={h.id}
|
||||
className="bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200 px-2 py-1 rounded text-sm"
|
||||
>
|
||||
{h.simplified} {h.pinyin ? `(${h.pinyin})` : ""}
|
||||
</span>
|
||||
))}
|
||||
{parseResult.found.length > 30 && (
|
||||
<span className="text-gray-600 dark:text-gray-400 text-sm">
|
||||
... and {parseResult.found.length - 30} more
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{parseResult.notFound.length > 0 && (
|
||||
<div className="mb-3">
|
||||
<p className="text-sm font-medium text-red-700 dark:text-red-400 mb-2">
|
||||
✗ Not found: {parseResult.notFound.length} hanzi
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{parseResult.notFound.map((char, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200 px-2 py-1 rounded text-sm"
|
||||
>
|
||||
{char}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{parseResult.duplicates.length > 0 && (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-yellow-700 dark:text-yellow-400 mb-2">
|
||||
⚠ Duplicates in input: {parseResult.duplicates.length}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{parseResult.duplicates.map((char, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="bg-yellow-100 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200 px-2 py-1 rounded text-sm"
|
||||
>
|
||||
{char}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 p-4 flex justify-end gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowAddModal(false)
|
||||
setSearchQuery("")
|
||||
setSearchResults([])
|
||||
setSearchSelectedIds(new Set())
|
||||
setPasteInput("")
|
||||
setParseResult(null)
|
||||
setActionError(null)
|
||||
}}
|
||||
className="px-4 py-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={addTab === "search" ? handleAddSelectedFromSearch : handleAddFromPaste}
|
||||
disabled={
|
||||
actionLoading ||
|
||||
(addTab === "search"
|
||||
? searchSelectedIds.size === 0
|
||||
: !parseResult || !parseResult.valid)
|
||||
}
|
||||
className="bg-blue-600 text-white px-6 py-2 rounded-lg hover:bg-blue-700 disabled:bg-gray-400"
|
||||
>
|
||||
{actionLoading
|
||||
? "Adding..."
|
||||
: addTab === "search"
|
||||
? `Add Selected (${searchSelectedIds.size})`
|
||||
: `Add ${parseResult?.found.length || 0} Hanzi`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
439
src/app/(app)/collections/new/page.tsx
Normal file
439
src/app/(app)/collections/new/page.tsx
Normal file
@@ -0,0 +1,439 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { createCollection, createCollectionWithHanzi, parseHanziList } from "@/actions/collections"
|
||||
|
||||
type Tab = "empty" | "fromList"
|
||||
|
||||
export default function NewCollectionPage() {
|
||||
const router = useRouter()
|
||||
const [activeTab, setActiveTab] = useState<Tab>("empty")
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Form state
|
||||
const [name, setName] = useState("")
|
||||
const [description, setDescription] = useState("")
|
||||
const [isPublic, setIsPublic] = useState(false)
|
||||
const [hanziList, setHanziList] = useState("")
|
||||
|
||||
// Parse state
|
||||
const [parseResult, setParseResult] = useState<{
|
||||
valid: boolean
|
||||
found: Array<{ id: string; simplified: string; pinyin: string | null }>
|
||||
notFound: string[]
|
||||
duplicates: string[]
|
||||
} | null>(null)
|
||||
|
||||
const handleParseList = async () => {
|
||||
if (!hanziList.trim()) {
|
||||
setError("Please enter hanzi list")
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const result = await parseHanziList(hanziList)
|
||||
if (result.success && result.data) {
|
||||
setParseResult(result.data)
|
||||
if (!result.data.valid) {
|
||||
setError(`${result.data.notFound.length} hanzi not found in database`)
|
||||
}
|
||||
} else {
|
||||
setError(result.message || "Failed to parse hanzi list")
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "An error occurred")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreateEmpty = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (!name.trim()) {
|
||||
setError("Name is required")
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const result = await createCollection(name, description || undefined, isPublic)
|
||||
|
||||
if (result.success && result.data) {
|
||||
router.push(`/collections/${result.data.id}`)
|
||||
} else {
|
||||
setError(result.message || "Failed to create collection")
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "An error occurred")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreateWithHanzi = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (!name.trim()) {
|
||||
setError("Name is required")
|
||||
return
|
||||
}
|
||||
|
||||
if (!hanziList.trim()) {
|
||||
setError("Hanzi list is required")
|
||||
return
|
||||
}
|
||||
|
||||
if (!parseResult || !parseResult.valid) {
|
||||
setError("Please preview and fix any errors in the hanzi list first")
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const result = await createCollectionWithHanzi(
|
||||
name,
|
||||
description || undefined,
|
||||
isPublic,
|
||||
hanziList
|
||||
)
|
||||
|
||||
if (result.success && result.data) {
|
||||
router.push(`/collections/${result.data.id}`)
|
||||
} else {
|
||||
setError(result.message || "Failed to create collection")
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "An error occurred")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||
<nav className="bg-white dark:bg-gray-800 shadow">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between h-16 items-center">
|
||||
<div className="flex items-center space-x-8">
|
||||
<Link href="/dashboard">
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white cursor-pointer">
|
||||
MemoHanzi <span className="text-sm font-normal text-gray-500">记汉字</span>
|
||||
</h1>
|
||||
</Link>
|
||||
<Link
|
||||
href="/collections"
|
||||
className="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200"
|
||||
>
|
||||
Collections
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="mb-8">
|
||||
<h2 className="text-3xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
Create Collection
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Create a new collection to organize your hanzi
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow">
|
||||
<div className="border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex">
|
||||
<button
|
||||
onClick={() => setActiveTab("empty")}
|
||||
className={`flex-1 py-4 px-6 text-center font-medium ${
|
||||
activeTab === "empty"
|
||||
? "border-b-2 border-blue-600 text-blue-600 dark:text-blue-400"
|
||||
: "text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200"
|
||||
}`}
|
||||
>
|
||||
Empty Collection
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("fromList")}
|
||||
className={`flex-1 py-4 px-6 text-center font-medium ${
|
||||
activeTab === "fromList"
|
||||
? "border-b-2 border-blue-600 text-blue-600 dark:text-blue-400"
|
||||
: "text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200"
|
||||
}`}
|
||||
>
|
||||
From List
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
{error && (
|
||||
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-6">
|
||||
<p className="font-bold">Error</p>
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "empty" ? (
|
||||
<form onSubmit={handleCreateEmpty} className="space-y-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2 text-gray-900 dark:text-white">
|
||||
Collection Name *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g., My First 100 Characters"
|
||||
className="block w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
disabled={loading}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2 text-gray-900 dark:text-white">
|
||||
Description (optional)
|
||||
</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Brief description of this collection..."
|
||||
rows={3}
|
||||
className="block w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isPublic}
|
||||
onChange={(e) => setIsPublic(e.target.checked)}
|
||||
className="mr-2"
|
||||
disabled={loading}
|
||||
/>
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
Make this collection public
|
||||
</span>
|
||||
</label>
|
||||
<p className="text-xs text-gray-600 dark:text-gray-400 mt-1 ml-6">
|
||||
Public collections can be viewed by other users
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="bg-blue-600 text-white px-6 py-2 rounded-lg hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? "Creating..." : "Create Collection"}
|
||||
</button>
|
||||
<Link
|
||||
href="/collections"
|
||||
className="bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-white px-6 py-2 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600"
|
||||
>
|
||||
Cancel
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<form onSubmit={handleCreateWithHanzi} className="space-y-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2 text-gray-900 dark:text-white">
|
||||
Collection Name *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g., HSK 1 Vocabulary"
|
||||
className="block w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
disabled={loading}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2 text-gray-900 dark:text-white">
|
||||
Description (optional)
|
||||
</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Brief description of this collection..."
|
||||
rows={2}
|
||||
className="block w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2 text-gray-900 dark:text-white">
|
||||
Hanzi List *
|
||||
</label>
|
||||
<textarea
|
||||
value={hanziList}
|
||||
onChange={(e) => {
|
||||
setHanziList(e.target.value)
|
||||
setParseResult(null)
|
||||
setError(null)
|
||||
}}
|
||||
placeholder="Paste hanzi here (newline, comma, or space separated) Example: 好 爱 你 or: 好, 爱, 你 or: 好 爱 你"
|
||||
rows={6}
|
||||
className="block w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white font-mono text-sm"
|
||||
disabled={loading}
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleParseList}
|
||||
disabled={loading || !hanziList.trim()}
|
||||
className="mt-2 bg-gray-600 text-white px-4 py-2 rounded-lg hover:bg-gray-700 disabled:bg-gray-400 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? "Parsing..." : "Preview"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{parseResult && (
|
||||
<div className="bg-gray-100 dark:bg-gray-700 rounded-lg p-4">
|
||||
<h4 className="font-semibold mb-3 text-gray-900 dark:text-white">
|
||||
Preview Results
|
||||
</h4>
|
||||
|
||||
{parseResult.found.length > 0 && (
|
||||
<div className="mb-3">
|
||||
<p className="text-sm font-medium text-green-700 dark:text-green-400 mb-2">
|
||||
✓ Found: {parseResult.found.length} hanzi
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{parseResult.found.slice(0, 20).map((h) => (
|
||||
<span
|
||||
key={h.id}
|
||||
className="bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200 px-2 py-1 rounded text-sm"
|
||||
>
|
||||
{h.simplified} {h.pinyin ? `(${h.pinyin})` : ""}
|
||||
</span>
|
||||
))}
|
||||
{parseResult.found.length > 20 && (
|
||||
<span className="text-gray-600 dark:text-gray-400 text-sm">
|
||||
... and {parseResult.found.length - 20} more
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{parseResult.notFound.length > 0 && (
|
||||
<div className="mb-3">
|
||||
<p className="text-sm font-medium text-red-700 dark:text-red-400 mb-2">
|
||||
✗ Not found: {parseResult.notFound.length} hanzi
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{parseResult.notFound.map((char, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200 px-2 py-1 rounded text-sm"
|
||||
>
|
||||
{char}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-red-600 dark:text-red-400 mt-2">
|
||||
All hanzi must exist in the database. Please remove or correct these.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{parseResult.duplicates.length > 0 && (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-yellow-700 dark:text-yellow-400 mb-2">
|
||||
⚠ Duplicates in input: {parseResult.duplicates.length}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{parseResult.duplicates.map((char, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="bg-yellow-100 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200 px-2 py-1 rounded text-sm"
|
||||
>
|
||||
{char}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isPublic}
|
||||
onChange={(e) => setIsPublic(e.target.checked)}
|
||||
className="mr-2"
|
||||
disabled={loading}
|
||||
/>
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
Make this collection public
|
||||
</span>
|
||||
</label>
|
||||
<p className="text-xs text-gray-600 dark:text-gray-400 mt-1 ml-6">
|
||||
Public collections can be viewed by other users
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !parseResult || !parseResult.valid}
|
||||
className="bg-blue-600 text-white px-6 py-2 rounded-lg hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? "Creating..." : `Create with ${parseResult?.found.length || 0} Hanzi`}
|
||||
</button>
|
||||
<Link
|
||||
href="/collections"
|
||||
className="bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-white px-6 py-2 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600"
|
||||
>
|
||||
Cancel
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Help Section */}
|
||||
<div className="mt-6 bg-blue-50 dark:bg-blue-900/20 rounded-lg p-6">
|
||||
<h3 className="font-semibold text-blue-900 dark:text-blue-300 mb-2">Tips</h3>
|
||||
<ul className="text-sm text-blue-800 dark:text-blue-400 space-y-1 list-disc list-inside">
|
||||
<li>
|
||||
<strong>Empty Collection:</strong> Create an empty collection and add hanzi later
|
||||
</li>
|
||||
<li>
|
||||
<strong>From List:</strong> Create a collection with hanzi from a pasted list
|
||||
</li>
|
||||
<li>Supports both single characters (好) and multi-character words (中国)</li>
|
||||
<li>List can be newline, comma, or space separated</li>
|
||||
<li>All hanzi must exist in the database (use import to add new hanzi first)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
159
src/app/(app)/collections/page.tsx
Normal file
159
src/app/(app)/collections/page.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
import { auth } from "@/lib/auth"
|
||||
import { redirect } from "next/navigation"
|
||||
import { getUserCollections, getGlobalCollections } from "@/actions/collections"
|
||||
import Link from "next/link"
|
||||
|
||||
export default async function CollectionsPage() {
|
||||
const session = await auth()
|
||||
|
||||
if (!session?.user) {
|
||||
redirect("/login")
|
||||
}
|
||||
|
||||
const [userCollectionsResult, globalCollectionsResult] = await Promise.all([
|
||||
getUserCollections(),
|
||||
getGlobalCollections(),
|
||||
])
|
||||
|
||||
const userCollections = userCollectionsResult.success ? userCollectionsResult.data || [] : []
|
||||
const globalCollections = globalCollectionsResult.success
|
||||
? globalCollectionsResult.data || []
|
||||
: []
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||
<nav className="bg-white dark:bg-gray-800 shadow">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between h-16 items-center">
|
||||
<div className="flex items-center space-x-8">
|
||||
<Link href="/dashboard">
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white cursor-pointer">
|
||||
MemoHanzi <span className="text-sm font-normal text-gray-500">记汉字</span>
|
||||
</h1>
|
||||
</Link>
|
||||
<Link
|
||||
href="/collections"
|
||||
className="text-sm text-gray-900 dark:text-gray-200 font-medium"
|
||||
>
|
||||
Collections
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold text-gray-900 dark:text-white mb-2">Collections</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Organize your hanzi into collections for learning
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/collections/new"
|
||||
className="bg-blue-600 text-white px-6 py-2 rounded-lg hover:bg-blue-700"
|
||||
>
|
||||
Create Collection
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* User Collections */}
|
||||
<section className="mb-12">
|
||||
<h3 className="text-2xl font-semibold text-gray-900 dark:text-white mb-4">
|
||||
My Collections
|
||||
</h3>
|
||||
|
||||
{userCollections.length === 0 ? (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-8 text-center">
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-4">
|
||||
You don't have any collections yet.
|
||||
</p>
|
||||
<Link
|
||||
href="/collections/new"
|
||||
className="inline-block bg-blue-600 text-white px-6 py-2 rounded-lg hover:bg-blue-700"
|
||||
>
|
||||
Create Your First Collection
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{userCollections.map((collection) => (
|
||||
<Link
|
||||
key={collection.id}
|
||||
href={`/collections/${collection.id}`}
|
||||
className="bg-white dark:bg-gray-800 rounded-lg shadow p-6 hover:shadow-lg transition-shadow"
|
||||
>
|
||||
<h4 className="text-xl font-semibold text-gray-900 dark:text-white mb-2">
|
||||
{collection.name}
|
||||
</h4>
|
||||
{collection.description && (
|
||||
<p className="text-gray-600 dark:text-gray-400 text-sm mb-4 line-clamp-2">
|
||||
{collection.description}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-2xl font-bold text-blue-600 dark:text-blue-400">
|
||||
{collection.hanziCount}
|
||||
</span>
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{collection.isPublic ? "Public" : "Private"}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">
|
||||
Created {new Date(collection.createdAt).toLocaleDateString()}
|
||||
</p>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Global Collections (HSK) */}
|
||||
<section>
|
||||
<h3 className="text-2xl font-semibold text-gray-900 dark:text-white mb-4">
|
||||
HSK Collections
|
||||
</h3>
|
||||
|
||||
{globalCollections.length === 0 ? (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-8 text-center">
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
No HSK collections available yet. Ask an admin to create them.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{globalCollections.map((collection) => (
|
||||
<Link
|
||||
key={collection.id}
|
||||
href={`/collections/${collection.id}`}
|
||||
className="bg-white dark:bg-gray-800 rounded-lg shadow p-6 hover:shadow-lg transition-shadow border-2 border-green-200 dark:border-green-700"
|
||||
>
|
||||
<div className="flex items-center mb-2">
|
||||
<h4 className="text-xl font-semibold text-gray-900 dark:text-white">
|
||||
{collection.name}
|
||||
</h4>
|
||||
<span className="ml-2 text-xs bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200 px-2 py-1 rounded">
|
||||
HSK
|
||||
</span>
|
||||
</div>
|
||||
{collection.description && (
|
||||
<p className="text-gray-600 dark:text-gray-400 text-sm mb-4 line-clamp-2">
|
||||
{collection.description}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-2xl font-bold text-green-600 dark:text-green-400">
|
||||
{collection.hanziCount}
|
||||
</span>
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">hanzi</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
168
src/app/(app)/dashboard/page.tsx
Normal file
168
src/app/(app)/dashboard/page.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
import { auth } from '@/lib/auth'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { logout } from '@/actions/auth'
|
||||
import Link from 'next/link'
|
||||
|
||||
async function logoutAction() {
|
||||
'use server'
|
||||
await logout()
|
||||
redirect('/login')
|
||||
}
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const session = await auth()
|
||||
|
||||
if (!session?.user) {
|
||||
redirect('/login')
|
||||
}
|
||||
|
||||
const user = session.user as any
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||
<nav className="bg-white dark:bg-gray-800 shadow">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between h-16 items-center">
|
||||
<div className="flex items-center space-x-8">
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white">
|
||||
MemoHanzi <span className="text-sm font-normal text-gray-500">记汉字</span>
|
||||
</h1>
|
||||
<Link
|
||||
href="/collections"
|
||||
className="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200"
|
||||
>
|
||||
Collections
|
||||
</Link>
|
||||
<Link
|
||||
href="/hanzi"
|
||||
className="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200"
|
||||
>
|
||||
Search Hanzi
|
||||
</Link>
|
||||
<Link
|
||||
href="/settings"
|
||||
className="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200"
|
||||
>
|
||||
Settings
|
||||
</Link>
|
||||
{(user.role === 'ADMIN' || user.role === 'MODERATOR') && (
|
||||
<Link
|
||||
href="/admin/import"
|
||||
className="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200"
|
||||
>
|
||||
Import
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">
|
||||
{user.name || user.email}
|
||||
</span>
|
||||
<form action={logoutAction}>
|
||||
<button
|
||||
type="submit"
|
||||
className="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200"
|
||||
>
|
||||
Sign Out
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="mb-8">
|
||||
<h2 className="text-3xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
Welcome back{user.name ? `, ${user.name}` : ''}!
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Start learning Chinese characters with spaced repetition
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
|
||||
Due Cards
|
||||
</h3>
|
||||
<p className="text-3xl font-bold text-blue-600 dark:text-blue-400 mb-2">
|
||||
0
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
No cards due right now
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
|
||||
Total Learned
|
||||
</h3>
|
||||
<p className="text-3xl font-bold text-green-600 dark:text-green-400 mb-2">
|
||||
0
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Characters mastered
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
|
||||
Daily Goal
|
||||
</h3>
|
||||
<p className="text-3xl font-bold text-purple-600 dark:text-purple-400 mb-2">
|
||||
0/50
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Cards reviewed today
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-6">
|
||||
<h3 className="text-lg font-semibold text-blue-900 dark:text-blue-300 mb-2">
|
||||
Quick Actions
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<Link
|
||||
href="/collections"
|
||||
className="bg-white dark:bg-gray-800 p-4 rounded-lg hover:shadow-md transition-shadow"
|
||||
>
|
||||
<h4 className="font-semibold text-gray-900 dark:text-white mb-1">
|
||||
Browse Collections
|
||||
</h4>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
View and manage your hanzi collections
|
||||
</p>
|
||||
</Link>
|
||||
<Link
|
||||
href="/collections/new"
|
||||
className="bg-white dark:bg-gray-800 p-4 rounded-lg hover:shadow-md transition-shadow"
|
||||
>
|
||||
<h4 className="font-semibold text-gray-900 dark:text-white mb-1">
|
||||
Create Collection
|
||||
</h4>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Start a new hanzi collection
|
||||
</p>
|
||||
</Link>
|
||||
<Link
|
||||
href="/hanzi"
|
||||
className="bg-white dark:bg-gray-800 p-4 rounded-lg hover:shadow-md transition-shadow"
|
||||
>
|
||||
<h4 className="font-semibold text-gray-900 dark:text-white mb-1">
|
||||
Search Hanzi
|
||||
</h4>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Find hanzi by character, pinyin, or meaning
|
||||
</p>
|
||||
</Link>
|
||||
</div>
|
||||
<p className="text-sm text-blue-800 dark:text-blue-400 mt-4">
|
||||
More features coming soon: Learning sessions, progress tracking, and more!
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
368
src/app/(app)/hanzi/[id]/page.tsx
Normal file
368
src/app/(app)/hanzi/[id]/page.tsx
Normal file
@@ -0,0 +1,368 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { useParams, useRouter } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { getHanzi } from "@/actions/hanzi"
|
||||
import { getUserCollections } from "@/actions/collections"
|
||||
|
||||
type HanziDetail = {
|
||||
id: string
|
||||
simplified: string
|
||||
radical: string | null
|
||||
frequency: number | null
|
||||
forms: Array<{
|
||||
id: string
|
||||
traditional: string
|
||||
isDefault: boolean
|
||||
transcriptions: Array<{
|
||||
type: string
|
||||
value: string
|
||||
}>
|
||||
meanings: Array<{
|
||||
language: string
|
||||
meaning: string
|
||||
orderIndex: number
|
||||
}>
|
||||
classifiers: Array<{
|
||||
classifier: string
|
||||
}>
|
||||
}>
|
||||
hskLevels: Array<{
|
||||
level: string
|
||||
}>
|
||||
partsOfSpeech: Array<{
|
||||
pos: string
|
||||
}>
|
||||
}
|
||||
|
||||
export default function HanziDetailPage() {
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
const hanziId = params.id as string
|
||||
|
||||
const [hanzi, setHanzi] = useState<HanziDetail | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Add to collection state
|
||||
const [showAddModal, setShowAddModal] = useState(false)
|
||||
const [collections, setCollections] = useState<Array<{ id: string; name: string }>>([])
|
||||
|
||||
useEffect(() => {
|
||||
loadHanzi()
|
||||
}, [hanziId])
|
||||
|
||||
const loadHanzi = async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const result = await getHanzi(hanziId)
|
||||
if (result.success && result.data) {
|
||||
setHanzi(result.data)
|
||||
} else {
|
||||
setError(result.message || "Failed to load hanzi")
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "An error occurred")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddToCollection = async () => {
|
||||
const result = await getUserCollections()
|
||||
if (result.success && result.data) {
|
||||
setCollections(result.data)
|
||||
setShowAddModal(true)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center">
|
||||
<p className="text-gray-600 dark:text-gray-400">Loading...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error || !hanzi) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<p className="text-red-600 dark:text-red-400 mb-4">{error || "Hanzi not found"}</p>
|
||||
<Link href="/hanzi" className="text-blue-600 hover:underline">
|
||||
Back to Search
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const defaultForm = hanzi.forms.find((f) => f.isDefault) || hanzi.forms[0]
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||
<nav className="bg-white dark:bg-gray-800 shadow">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between h-16 items-center">
|
||||
<div className="flex items-center space-x-8">
|
||||
<Link href="/dashboard">
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white cursor-pointer">
|
||||
MemoHanzi <span className="text-sm font-normal text-gray-500">记汉字</span>
|
||||
</h1>
|
||||
</Link>
|
||||
<Link
|
||||
href="/hanzi"
|
||||
className="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200"
|
||||
>
|
||||
Search Hanzi
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main className="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{/* Header */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-8 mb-8">
|
||||
<div className="flex items-start justify-between mb-6">
|
||||
<div>
|
||||
<div className="text-8xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
{hanzi.simplified}
|
||||
</div>
|
||||
{defaultForm && defaultForm.traditional !== hanzi.simplified && (
|
||||
<p className="text-2xl text-gray-600 dark:text-gray-400 mb-2">
|
||||
Traditional: {defaultForm.traditional}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{hanzi.hskLevels.map((level) => (
|
||||
<span
|
||||
key={level.level}
|
||||
className="text-sm bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200 px-3 py-1 rounded text-center"
|
||||
>
|
||||
{level.level.toUpperCase()}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleAddToCollection}
|
||||
className="bg-blue-600 text-white px-6 py-2 rounded-lg hover:bg-blue-700"
|
||||
>
|
||||
Add to Collection
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Main Information */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 mb-8">
|
||||
{/* Transcriptions */}
|
||||
{defaultForm && defaultForm.transcriptions.length > 0 && (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<h3 className="text-xl font-semibold text-gray-900 dark:text-white mb-4">
|
||||
Transcriptions
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{defaultForm.transcriptions.map((trans, index) => (
|
||||
<div key={index} className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400 capitalize">
|
||||
{trans.type}:
|
||||
</span>
|
||||
<span className="text-lg font-medium text-gray-900 dark:text-white">
|
||||
{trans.value}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Meanings */}
|
||||
{defaultForm && defaultForm.meanings.length > 0 && (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<h3 className="text-xl font-semibold text-gray-900 dark:text-white mb-4">
|
||||
Meanings
|
||||
</h3>
|
||||
<ol className="list-decimal list-inside space-y-2">
|
||||
{defaultForm.meanings.map((meaning, index) => (
|
||||
<li key={index} className="text-gray-900 dark:text-white">
|
||||
{meaning.meaning}
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 ml-2">
|
||||
({meaning.language})
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Additional Information */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 mb-8">
|
||||
{/* Radical & Frequency */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
Character Info
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{hanzi.radical && (
|
||||
<div>
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">Radical:</span>
|
||||
<p className="text-lg font-medium text-gray-900 dark:text-white">
|
||||
{hanzi.radical}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{hanzi.frequency !== null && (
|
||||
<div>
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">Frequency:</span>
|
||||
<p className="text-lg font-medium text-gray-900 dark:text-white">
|
||||
{hanzi.frequency}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Parts of Speech */}
|
||||
{hanzi.partsOfSpeech.length > 0 && (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
Parts of Speech
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{hanzi.partsOfSpeech.map((pos, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200 px-3 py-1 rounded text-sm"
|
||||
>
|
||||
{pos.pos}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Classifiers */}
|
||||
{defaultForm && defaultForm.classifiers.length > 0 && (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
Classifiers
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{defaultForm.classifiers.map((classifier, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="bg-purple-100 dark:bg-purple-900 text-purple-800 dark:text-purple-200 px-3 py-1 rounded text-lg"
|
||||
>
|
||||
{classifier.classifier}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* All Forms */}
|
||||
{hanzi.forms.length > 1 && (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<h3 className="text-xl font-semibold text-gray-900 dark:text-white mb-4">
|
||||
All Forms
|
||||
</h3>
|
||||
<div className="space-y-6">
|
||||
{hanzi.forms.map((form, index) => (
|
||||
<div key={form.id} className="border-b border-gray-200 dark:border-gray-700 pb-6 last:border-0 last:pb-0">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<h4 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{form.traditional}
|
||||
</h4>
|
||||
{form.isDefault && (
|
||||
<span className="text-xs bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 px-2 py-1 rounded">
|
||||
Default
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{form.transcriptions.length > 0 && (
|
||||
<div className="mb-3">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-1">Transcriptions:</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{form.transcriptions.map((trans, i) => (
|
||||
<span key={i} className="text-sm text-gray-900 dark:text-white">
|
||||
{trans.type}: <strong>{trans.value}</strong>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{form.meanings.length > 0 && (
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-1">Meanings:</p>
|
||||
<ol className="list-decimal list-inside text-sm">
|
||||
{form.meanings.map((meaning, i) => (
|
||||
<li key={i} className="text-gray-900 dark:text-white">
|
||||
{meaning.meaning}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{/* Add to Collection Modal */}
|
||||
{showAddModal && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full p-6">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-xl font-bold text-gray-900 dark:text-white">
|
||||
Add to Collection
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setShowAddModal(false)}
|
||||
className="text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{collections.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-4">
|
||||
You don't have any collections yet.
|
||||
</p>
|
||||
<Link
|
||||
href="/collections/new"
|
||||
className="inline-block bg-blue-600 text-white px-6 py-2 rounded-lg hover:bg-blue-700"
|
||||
>
|
||||
Create Collection
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2 max-h-96 overflow-y-auto">
|
||||
{collections.map((collection) => (
|
||||
<button
|
||||
key={collection.id}
|
||||
onClick={() => router.push(`/collections/${collection.id}`)}
|
||||
className="w-full text-left p-3 rounded-lg bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 text-gray-900 dark:text-white"
|
||||
>
|
||||
{collection.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
283
src/app/(app)/hanzi/page.tsx
Normal file
283
src/app/(app)/hanzi/page.tsx
Normal file
@@ -0,0 +1,283 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import Link from "next/link"
|
||||
import { searchHanzi } from "@/actions/hanzi"
|
||||
|
||||
type HanziResult = {
|
||||
id: string
|
||||
simplified: string
|
||||
traditional: string | null
|
||||
pinyin: string | null
|
||||
meaning: string | null
|
||||
hskLevels: string[]
|
||||
radical: string | null
|
||||
frequency: number | null
|
||||
}
|
||||
|
||||
const HSK_LEVELS = ["new-1", "new-2", "new-3", "new-4", "new-5", "new-6", "old-1", "old-2", "old-3", "old-4", "old-5", "old-6"]
|
||||
|
||||
export default function HanziSearchPage() {
|
||||
const [query, setQuery] = useState("")
|
||||
const [hskLevel, setHskLevel] = useState<string>("")
|
||||
const [results, setResults] = useState<HanziResult[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [total, setTotal] = useState(0)
|
||||
const [hasMore, setHasMore] = useState(false)
|
||||
const [offset, setOffset] = useState(0)
|
||||
const limit = 20
|
||||
|
||||
const handleSearch = async (newOffset: number = 0) => {
|
||||
if (!query.trim()) {
|
||||
setError("Please enter a search query")
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const result = await searchHanzi(
|
||||
query,
|
||||
hskLevel || undefined,
|
||||
limit,
|
||||
newOffset
|
||||
)
|
||||
|
||||
if (result.success && result.data) {
|
||||
setResults(result.data.hanzi)
|
||||
setTotal(result.data.total)
|
||||
setHasMore(result.data.hasMore)
|
||||
setOffset(newOffset)
|
||||
} else {
|
||||
setError(result.message || "Failed to search hanzi")
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "An error occurred")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyPress = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
handleSearch(0)
|
||||
}
|
||||
}
|
||||
|
||||
const handleNextPage = () => {
|
||||
handleSearch(offset + limit)
|
||||
}
|
||||
|
||||
const handlePrevPage = () => {
|
||||
handleSearch(Math.max(0, offset - limit))
|
||||
}
|
||||
|
||||
const currentPage = Math.floor(offset / limit) + 1
|
||||
const totalPages = Math.ceil(total / limit)
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||
<nav className="bg-white dark:bg-gray-800 shadow">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between h-16 items-center">
|
||||
<div className="flex items-center space-x-8">
|
||||
<Link href="/dashboard">
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white cursor-pointer">
|
||||
MemoHanzi <span className="text-sm font-normal text-gray-500">记汉字</span>
|
||||
</h1>
|
||||
</Link>
|
||||
<Link
|
||||
href="/hanzi"
|
||||
className="text-sm text-gray-900 dark:text-gray-200 font-medium"
|
||||
>
|
||||
Search Hanzi
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="mb-8">
|
||||
<h2 className="text-3xl font-bold text-gray-900 dark:text-white mb-2">Search Hanzi</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Search by character, pinyin, or meaning
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Search Form */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6 mb-8">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium mb-2 text-gray-900 dark:text-white">
|
||||
Search Query
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyPress={handleKeyPress}
|
||||
placeholder="Enter character, pinyin, or meaning..."
|
||||
className="block w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2 text-gray-900 dark:text-white">
|
||||
HSK Level (optional)
|
||||
</label>
|
||||
<select
|
||||
value={hskLevel}
|
||||
onChange={(e) => setHskLevel(e.target.value)}
|
||||
className="block w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
disabled={loading}
|
||||
>
|
||||
<option value="">All Levels</option>
|
||||
{HSK_LEVELS.map((level) => (
|
||||
<option key={level} value={level}>
|
||||
{level.toUpperCase()}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => handleSearch(0)}
|
||||
disabled={loading || !query.trim()}
|
||||
className="mt-4 bg-blue-600 text-white px-6 py-2 rounded-lg hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? "Searching..." : "Search"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-6">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results */}
|
||||
{results.length > 0 && (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Found {total} result{total !== 1 ? "s" : ""}
|
||||
{hskLevel && ` for HSK ${hskLevel.toUpperCase()}`}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Page {currentPage} of {totalPages}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-8">
|
||||
{results.map((hanzi) => (
|
||||
<Link
|
||||
key={hanzi.id}
|
||||
href={`/hanzi/${hanzi.id}`}
|
||||
className="bg-white dark:bg-gray-800 rounded-lg shadow p-6 hover:shadow-lg transition-shadow"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="text-4xl font-bold text-gray-900 dark:text-white">
|
||||
{hanzi.simplified}
|
||||
</div>
|
||||
{hanzi.hskLevels.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{hanzi.hskLevels.map((level) => (
|
||||
<span
|
||||
key={level}
|
||||
className="text-xs bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200 px-2 py-1 rounded"
|
||||
>
|
||||
{level.toUpperCase()}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hanzi.traditional && hanzi.traditional !== hanzi.simplified && (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-2">
|
||||
Traditional: {hanzi.traditional}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{hanzi.pinyin && (
|
||||
<p className="text-lg text-gray-700 dark:text-gray-300 mb-2">
|
||||
{hanzi.pinyin}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{hanzi.meaning && (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 line-clamp-2">
|
||||
{hanzi.meaning}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{hanzi.radical && (
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">
|
||||
Radical: {hanzi.radical}
|
||||
</p>
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex justify-center items-center gap-4">
|
||||
<button
|
||||
onClick={handlePrevPage}
|
||||
disabled={offset === 0}
|
||||
className="px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-white rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 disabled:bg-gray-100 dark:disabled:bg-gray-800 disabled:text-gray-400 disabled:cursor-not-allowed"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
|
||||
<span className="text-gray-600 dark:text-gray-400">
|
||||
Page {currentPage} of {totalPages}
|
||||
</span>
|
||||
|
||||
<button
|
||||
onClick={handleNextPage}
|
||||
disabled={!hasMore}
|
||||
className="px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-white rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 disabled:bg-gray-100 dark:disabled:bg-gray-800 disabled:text-gray-400 disabled:cursor-not-allowed"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{!loading && results.length === 0 && query && (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-12 text-center">
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-4">
|
||||
No hanzi found matching "{query}"
|
||||
{hskLevel && ` in HSK ${hskLevel.toUpperCase()}`}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Try a different search term or remove the HSK filter
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Initial State */}
|
||||
{!loading && results.length === 0 && !query && (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-12 text-center">
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-4">
|
||||
Enter a search term to find hanzi
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Search by simplified character, traditional character, pinyin, or English meaning
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
71
src/app/(app)/settings/page.tsx
Normal file
71
src/app/(app)/settings/page.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { auth } from '@/lib/auth'
|
||||
import { redirect } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { getPreferences, getAvailableLanguages } from '@/actions/preferences'
|
||||
import SettingsForm from './settings-form'
|
||||
|
||||
export default async function SettingsPage() {
|
||||
const session = await auth()
|
||||
|
||||
if (!session?.user) {
|
||||
redirect('/login')
|
||||
}
|
||||
|
||||
const user = session.user as any
|
||||
const preferencesResult = await getPreferences()
|
||||
const languagesResult = await getAvailableLanguages()
|
||||
|
||||
if (!preferencesResult.success || !languagesResult.success) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<p className="text-red-600 dark:text-red-400">Error loading preferences</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||
<nav className="bg-white dark:bg-gray-800 shadow">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between h-16 items-center">
|
||||
<div className="flex items-center space-x-8">
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white">
|
||||
MemoHanzi <span className="text-sm font-normal text-gray-500">记汉字</span>
|
||||
</h1>
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200"
|
||||
>
|
||||
Dashboard
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">
|
||||
{user.name || user.email}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="mb-8">
|
||||
<h2 className="text-3xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
Settings
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Manage your account and learning preferences
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<SettingsForm
|
||||
user={user}
|
||||
preferences={preferencesResult.data!}
|
||||
languages={languagesResult.data!}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
284
src/app/(app)/settings/settings-form.tsx
Normal file
284
src/app/(app)/settings/settings-form.tsx
Normal file
@@ -0,0 +1,284 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { updateProfile, updatePassword } from '@/actions/auth'
|
||||
import { updatePreferences, type UserPreferences, type Language } from '@/actions/preferences'
|
||||
|
||||
type SettingsFormProps = {
|
||||
user: { id: string; name: string | null; email: string }
|
||||
preferences: UserPreferences
|
||||
languages: Language[]
|
||||
}
|
||||
|
||||
export default function SettingsForm({ user, preferences, languages }: SettingsFormProps) {
|
||||
// Profile state
|
||||
const [name, setName] = useState(user.name || '')
|
||||
const [email, setEmail] = useState(user.email)
|
||||
const [profileMessage, setProfileMessage] = useState('')
|
||||
const [profileLoading, setProfileLoading] = useState(false)
|
||||
|
||||
// Password state
|
||||
const [currentPassword, setCurrentPassword] = useState('')
|
||||
const [newPassword, setNewPassword] = useState('')
|
||||
const [passwordMessage, setPasswordMessage] = useState('')
|
||||
const [passwordLoading, setPasswordLoading] = useState(false)
|
||||
|
||||
// Preferences state
|
||||
const [prefs, setPrefs] = useState(preferences)
|
||||
const [prefsMessage, setPrefsMessage] = useState('')
|
||||
const [prefsLoading, setPrefsLoading] = useState(false)
|
||||
|
||||
const handleProfileSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setProfileMessage('')
|
||||
setProfileLoading(true)
|
||||
|
||||
try {
|
||||
const result = await updateProfile(name, email)
|
||||
setProfileMessage(result.message || (result.success ? 'Profile updated' : 'Update failed'))
|
||||
} catch (err) {
|
||||
setProfileMessage('An error occurred')
|
||||
} finally {
|
||||
setProfileLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handlePasswordSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setPasswordMessage('')
|
||||
setPasswordLoading(true)
|
||||
|
||||
try {
|
||||
const result = await updatePassword(currentPassword, newPassword)
|
||||
if (result.success) {
|
||||
setCurrentPassword('')
|
||||
setNewPassword('')
|
||||
}
|
||||
setPasswordMessage(result.message || (result.success ? 'Password updated' : 'Update failed'))
|
||||
} catch (err) {
|
||||
setPasswordMessage('An error occurred')
|
||||
} finally {
|
||||
setPasswordLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handlePreferencesSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setPrefsMessage('')
|
||||
setPrefsLoading(true)
|
||||
|
||||
try {
|
||||
const result = await updatePreferences(prefs)
|
||||
setPrefsMessage(result.message || (result.success ? 'Preferences updated' : 'Update failed'))
|
||||
} catch (err) {
|
||||
setPrefsMessage('An error occurred')
|
||||
} finally {
|
||||
setPrefsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Profile Section */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<h3 className="text-xl font-semibold text-gray-900 dark:text-white mb-4">
|
||||
Profile
|
||||
</h3>
|
||||
<form onSubmit={handleProfileSubmit} className="space-y-4">
|
||||
{profileMessage && (
|
||||
<div className={`px-4 py-3 rounded ${
|
||||
profileMessage.includes('success') || profileMessage.includes('updated')
|
||||
? 'bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 text-green-700 dark:text-green-400'
|
||||
: 'bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-700 dark:text-red-400'
|
||||
}`}>
|
||||
{profileMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md dark:bg-gray-700 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md dark:bg-gray-700 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={profileLoading}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{profileLoading ? 'Saving...' : 'Save Profile'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Password Section */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<h3 className="text-xl font-semibold text-gray-900 dark:text-white mb-4">
|
||||
Change Password
|
||||
</h3>
|
||||
<form onSubmit={handlePasswordSubmit} className="space-y-4">
|
||||
{passwordMessage && (
|
||||
<div className={`px-4 py-3 rounded ${
|
||||
passwordMessage.includes('success') || passwordMessage.includes('updated')
|
||||
? 'bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 text-green-700 dark:text-green-400'
|
||||
: 'bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-700 dark:text-red-400'
|
||||
}`}>
|
||||
{passwordMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Current Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md dark:bg-gray-700 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
New Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
minLength={6}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md dark:bg-gray-700 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={passwordLoading}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{passwordLoading ? 'Updating...' : 'Update Password'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Learning Preferences Section */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<h3 className="text-xl font-semibold text-gray-900 dark:text-white mb-4">
|
||||
Learning Preferences
|
||||
</h3>
|
||||
<form onSubmit={handlePreferencesSubmit} className="space-y-4">
|
||||
{prefsMessage && (
|
||||
<div className={`px-4 py-3 rounded ${
|
||||
prefsMessage.includes('success') || prefsMessage.includes('updated')
|
||||
? 'bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 text-green-700 dark:text-green-400'
|
||||
: 'bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-700 dark:text-red-400'
|
||||
}`}>
|
||||
{prefsMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Preferred Language
|
||||
</label>
|
||||
<select
|
||||
value={prefs.preferredLanguageId}
|
||||
onChange={(e) => setPrefs({ ...prefs, preferredLanguageId: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md dark:bg-gray-700 dark:text-white"
|
||||
>
|
||||
{languages.map((lang) => (
|
||||
<option key={lang.id} value={lang.id}>
|
||||
{lang.name} ({lang.nativeName})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Character Display
|
||||
</label>
|
||||
<select
|
||||
value={prefs.characterDisplay}
|
||||
onChange={(e) => setPrefs({ ...prefs, characterDisplay: e.target.value as any })}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md dark:bg-gray-700 dark:text-white"
|
||||
>
|
||||
<option value="SIMPLIFIED">Simplified</option>
|
||||
<option value="TRADITIONAL">Traditional</option>
|
||||
<option value="BOTH">Both</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Cards Per Session: {prefs.cardsPerSession}
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="5"
|
||||
max="100"
|
||||
value={prefs.cardsPerSession}
|
||||
onChange={(e) => setPrefs({ ...prefs, cardsPerSession: parseInt(e.target.value) })}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Daily Goal: {prefs.dailyGoal} cards
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="10"
|
||||
max="500"
|
||||
step="10"
|
||||
value={prefs.dailyGoal}
|
||||
onChange={(e) => setPrefs({ ...prefs, dailyGoal: parseInt(e.target.value) })}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="allowManualDifficulty"
|
||||
checked={prefs.allowManualDifficulty}
|
||||
onChange={(e) => setPrefs({ ...prefs, allowManualDifficulty: e.target.checked })}
|
||||
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||
/>
|
||||
<label htmlFor="allowManualDifficulty" className="ml-2 block text-sm text-gray-700 dark:text-gray-300">
|
||||
Allow manual difficulty adjustment
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={prefsLoading}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{prefsLoading ? 'Saving...' : 'Save Preferences'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
128
src/app/(auth)/login/page.tsx
Normal file
128
src/app/(auth)/login/page.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { login } from '@/actions/auth'
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter()
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setIsLoading(true)
|
||||
|
||||
try {
|
||||
const result = await login(email, password)
|
||||
|
||||
if (result.success) {
|
||||
router.push('/dashboard')
|
||||
router.refresh()
|
||||
} else {
|
||||
setError(result.message || 'Login failed')
|
||||
}
|
||||
} catch (err) {
|
||||
setError('An unexpected error occurred')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gray-50 dark:bg-gray-900 px-4">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
MemoHanzi
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400">记汉字</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-8">
|
||||
<h2 className="text-2xl font-semibold text-gray-900 dark:text-white mb-6">
|
||||
Sign In
|
||||
</h2>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{error && (
|
||||
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-700 dark:text-red-400 px-4 py-3 rounded">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="email"
|
||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||
>
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={6}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{isLoading ? 'Signing in...' : 'Sign In'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 text-center">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Don't have an account?{' '}
|
||||
<Link
|
||||
href="/register"
|
||||
className="text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 font-medium"
|
||||
>
|
||||
Sign up
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 text-center">
|
||||
<Link
|
||||
href="/"
|
||||
className="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200"
|
||||
>
|
||||
← Back to home
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
151
src/app/(auth)/register/page.tsx
Normal file
151
src/app/(auth)/register/page.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { register } from '@/actions/auth'
|
||||
|
||||
export default function RegisterPage() {
|
||||
const router = useRouter()
|
||||
const [name, setName] = useState('')
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setIsLoading(true)
|
||||
|
||||
try {
|
||||
const result = await register(email, password, name)
|
||||
|
||||
if (result.success) {
|
||||
// After successful registration, redirect to login
|
||||
router.push('/login?registered=true')
|
||||
} else {
|
||||
setError(result.message || 'Registration failed')
|
||||
}
|
||||
} catch (err) {
|
||||
setError('An unexpected error occurred')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gray-50 dark:bg-gray-900 px-4">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
MemoHanzi
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400">记汉字</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-8">
|
||||
<h2 className="text-2xl font-semibold text-gray-900 dark:text-white mb-6">
|
||||
Create Account
|
||||
</h2>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{error && (
|
||||
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-700 dark:text-red-400 px-4 py-3 rounded">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="name"
|
||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||
>
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
required
|
||||
minLength={2}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
|
||||
placeholder="Your name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="email"
|
||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||
>
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={6}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
At least 6 characters
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{isLoading ? 'Creating account...' : 'Create Account'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 text-center">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Already have an account?{' '}
|
||||
<Link
|
||||
href="/login"
|
||||
className="text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 font-medium"
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 text-center">
|
||||
<Link
|
||||
href="/"
|
||||
className="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200"
|
||||
>
|
||||
← Back to home
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
3
src/app/api/auth/[...nextauth]/route.ts
Normal file
3
src/app/api/auth/[...nextauth]/route.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { handlers } from "@/lib/auth"
|
||||
|
||||
export const { GET, POST } = handlers
|
||||
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
@@ -13,8 +13,8 @@ const geistMono = Geist_Mono({
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
title: "MemoHanzi - Remember Hanzi, effortlessly",
|
||||
description: "A self-hosted web application for learning Chinese characters using spaced repetition (SM-2 algorithm)",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
62
src/app/page.tsx
Normal file
62
src/app/page.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import Link from "next/link";
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gradient-to-b from-white to-gray-50 dark:from-gray-900 dark:to-black">
|
||||
<main className="flex flex-col items-center justify-center px-8 py-16 text-center max-w-4xl">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-6xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
MemoHanzi
|
||||
</h1>
|
||||
<p className="text-4xl text-gray-600 dark:text-gray-400 mb-2">
|
||||
记汉字
|
||||
</p>
|
||||
<p className="text-xl text-gray-500 dark:text-gray-500 italic">
|
||||
Remember Hanzi, effortlessly
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p className="text-lg text-gray-700 dark:text-gray-300 mb-12 max-w-2xl">
|
||||
A self-hosted web application for learning Chinese characters using spaced repetition.
|
||||
Master Hanzi with the proven SM-2 algorithm.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-4 mb-16">
|
||||
<Link
|
||||
href="/login"
|
||||
className="px-8 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-medium"
|
||||
>
|
||||
Get Started
|
||||
</Link>
|
||||
<Link
|
||||
href="/register"
|
||||
className="px-8 py-3 bg-white dark:bg-gray-800 text-gray-900 dark:text-white border border-gray-300 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors font-medium"
|
||||
>
|
||||
Sign Up
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 w-full max-w-3xl">
|
||||
<div className="p-6 bg-white dark:bg-gray-800 rounded-lg shadow-sm">
|
||||
<h3 className="text-lg font-semibold mb-2 text-gray-900 dark:text-white">Spaced Repetition</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400 text-sm">
|
||||
Learn efficiently with the scientifically-proven SM-2 algorithm
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-6 bg-white dark:bg-gray-800 rounded-lg shadow-sm">
|
||||
<h3 className="text-lg font-semibold mb-2 text-gray-900 dark:text-white">HSK Collections</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400 text-sm">
|
||||
Access complete HSK vocabulary or create your own collections
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-6 bg-white dark:bg-gray-800 rounded-lg shadow-sm">
|
||||
<h3 className="text-lg font-semibold mb-2 text-gray-900 dark:text-white">Self-Hosted</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400 text-sm">
|
||||
Your data stays yours. Deploy and control your own instance
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
113
src/lib/auth.ts
Normal file
113
src/lib/auth.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import NextAuth from "next-auth"
|
||||
import { PrismaAdapter } from "@auth/prisma-adapter"
|
||||
import CredentialsProvider from "next-auth/providers/credentials"
|
||||
import bcrypt from "bcrypt"
|
||||
import { prisma } from "./prisma"
|
||||
import { UserRole } from "@prisma/client"
|
||||
|
||||
export const { handlers, signIn, signOut, auth } = NextAuth({
|
||||
adapter: PrismaAdapter(prisma),
|
||||
providers: [
|
||||
CredentialsProvider({
|
||||
name: "Credentials",
|
||||
credentials: {
|
||||
email: { label: "Email", type: "email" },
|
||||
password: { label: "Password", type: "password" }
|
||||
},
|
||||
async authorize(credentials) {
|
||||
if (!credentials?.email || !credentials?.password) {
|
||||
return null
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email: credentials.email as string }
|
||||
})
|
||||
|
||||
if (!user || !user.isActive) {
|
||||
return null
|
||||
}
|
||||
|
||||
const isPasswordValid = await bcrypt.compare(
|
||||
credentials.password as string,
|
||||
user.password
|
||||
)
|
||||
|
||||
if (!isPasswordValid) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
}
|
||||
}
|
||||
})
|
||||
],
|
||||
session: {
|
||||
strategy: "jwt",
|
||||
maxAge: 30 * 24 * 60 * 60, // 30 days
|
||||
},
|
||||
pages: {
|
||||
signIn: "/login",
|
||||
signOut: "/",
|
||||
error: "/login",
|
||||
},
|
||||
callbacks: {
|
||||
async jwt({ token, user }) {
|
||||
if (user) {
|
||||
token.id = user.id
|
||||
token.role = (user as any).role
|
||||
}
|
||||
return token
|
||||
},
|
||||
async session({ session, token }) {
|
||||
if (session.user && token.id) {
|
||||
(session.user as any).id = token.id as string
|
||||
(session.user as any).role = token.role as string
|
||||
}
|
||||
return session
|
||||
}
|
||||
},
|
||||
secret: process.env.NEXTAUTH_SECRET,
|
||||
})
|
||||
|
||||
/**
|
||||
* Check if the current user is an admin
|
||||
*/
|
||||
export async function isAdmin(): Promise<boolean> {
|
||||
const session = await auth()
|
||||
return (session?.user as any)?.role === UserRole.ADMIN
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the current user is an admin or moderator
|
||||
*/
|
||||
export async function isAdminOrModerator(): Promise<boolean> {
|
||||
const session = await auth()
|
||||
return (
|
||||
(session?.user as any)?.role === UserRole.ADMIN ||
|
||||
(session?.user as any)?.role === UserRole.MODERATOR
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Require admin role, throw error if not authorized
|
||||
*/
|
||||
export async function requireAdmin() {
|
||||
const admin = await isAdmin()
|
||||
if (!admin) {
|
||||
throw new Error("Unauthorized: Admin access required")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Require admin or moderator role, throw error if not authorized
|
||||
*/
|
||||
export async function requireAdminOrModerator() {
|
||||
const authorized = await isAdminOrModerator()
|
||||
if (!authorized) {
|
||||
throw new Error("Unauthorized: Admin or Moderator access required")
|
||||
}
|
||||
}
|
||||
250
src/lib/import/csv-parser.test.ts
Normal file
250
src/lib/import/csv-parser.test.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
import { describe, it, expect } from "vitest"
|
||||
import { parseCSV, generateCSVTemplate } from "./csv-parser"
|
||||
|
||||
describe("parseCSV", () => {
|
||||
it("should parse valid CSV with all fields", () => {
|
||||
const csv = `simplified,traditional,pinyin,meaning,hsk_level,radical,frequency,pos,classifiers
|
||||
爱好,愛好,ài hào,"to like; hobby","new-1,old-3",爫,4902,"n,v",个`
|
||||
|
||||
const { result, data } = parseCSV(csv)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.imported).toBe(1)
|
||||
expect(result.failed).toBe(0)
|
||||
expect(data).toHaveLength(1)
|
||||
expect(data[0].simplified).toBe("爱好")
|
||||
expect(data[0].radical).toBe("爫")
|
||||
expect(data[0].frequency).toBe(4902)
|
||||
expect(data[0].hskLevels).toEqual(["new-1", "old-3"])
|
||||
expect(data[0].partsOfSpeech).toEqual(["n", "v"])
|
||||
expect(data[0].forms).toHaveLength(1)
|
||||
expect(data[0].forms[0].traditional).toBe("愛好")
|
||||
expect(data[0].forms[0].classifiers).toEqual(["个"])
|
||||
})
|
||||
|
||||
it("should parse CSV with only required fields", () => {
|
||||
const csv = `simplified,traditional,pinyin,meaning
|
||||
好,好,hǎo,good`
|
||||
|
||||
const { result, data } = parseCSV(csv)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.imported).toBe(1)
|
||||
expect(data[0].simplified).toBe("好")
|
||||
expect(data[0].radical).toBeUndefined()
|
||||
expect(data[0].frequency).toBeUndefined()
|
||||
expect(data[0].hskLevels).toEqual([])
|
||||
expect(data[0].partsOfSpeech).toEqual([])
|
||||
})
|
||||
|
||||
it("should parse multiple rows", () => {
|
||||
const csv = `simplified,traditional,pinyin,meaning
|
||||
好,好,hǎo,good
|
||||
爱,愛,ài,love
|
||||
你,你,nǐ,you`
|
||||
|
||||
const { result, data } = parseCSV(csv)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.imported).toBe(3)
|
||||
expect(data).toHaveLength(3)
|
||||
})
|
||||
|
||||
it("should handle quoted values with commas", () => {
|
||||
const csv = `simplified,traditional,pinyin,meaning
|
||||
好,好,hǎo,"good, fine, nice"`
|
||||
|
||||
const { result, data } = parseCSV(csv)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(data[0].forms[0].meanings[0].meaning).toBe("good, fine, nice")
|
||||
})
|
||||
|
||||
it("should handle quoted values with semicolons (multiple meanings)", () => {
|
||||
const csv = `simplified,traditional,pinyin,meaning
|
||||
好,好,hǎo,"good; fine; nice"`
|
||||
|
||||
const { result, data } = parseCSV(csv)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(data[0].forms[0].meanings).toHaveLength(3)
|
||||
expect(data[0].forms[0].meanings[0].meaning).toBe("good")
|
||||
expect(data[0].forms[0].meanings[1].meaning).toBe("fine")
|
||||
expect(data[0].forms[0].meanings[2].meaning).toBe("nice")
|
||||
})
|
||||
|
||||
it("should handle escaped quotes in values", () => {
|
||||
const csv = `simplified,traditional,pinyin,meaning
|
||||
好,好,hǎo,"He said ""good"""`
|
||||
|
||||
const { result, data } = parseCSV(csv)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(data[0].forms[0].meanings[0].meaning).toBe('He said "good"')
|
||||
})
|
||||
|
||||
it("should skip empty lines", () => {
|
||||
const csv = `simplified,traditional,pinyin,meaning
|
||||
好,好,hǎo,good
|
||||
|
||||
爱,愛,ài,love
|
||||
|
||||
你,你,nǐ,you`
|
||||
|
||||
const { result, data } = parseCSV(csv)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.imported).toBe(3)
|
||||
})
|
||||
|
||||
it("should parse comma-separated HSK levels", () => {
|
||||
const csv = `simplified,traditional,pinyin,meaning,hsk_level
|
||||
好,好,hǎo,good,"new-1,old-2,old-3"`
|
||||
|
||||
const { result, data } = parseCSV(csv)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(data[0].hskLevels).toEqual(["new-1", "old-2", "old-3"])
|
||||
})
|
||||
|
||||
it("should parse comma-separated parts of speech", () => {
|
||||
const csv = `simplified,traditional,pinyin,meaning,hsk_level,radical,frequency,pos
|
||||
好,好,hǎo,good,,,,"adj,v,n"`
|
||||
|
||||
const { result, data } = parseCSV(csv)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(data[0].partsOfSpeech).toEqual(["adj", "v", "n"])
|
||||
})
|
||||
|
||||
it("should parse comma-separated classifiers", () => {
|
||||
const csv = `simplified,traditional,pinyin,meaning,hsk_level,radical,frequency,pos,classifiers
|
||||
好,好,hǎo,good,,,,,"个,只,条"`
|
||||
|
||||
const { result, data } = parseCSV(csv)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(data[0].forms[0].classifiers).toEqual(["个", "只", "条"])
|
||||
})
|
||||
|
||||
it("should parse frequency as number", () => {
|
||||
const csv = `simplified,traditional,pinyin,meaning,hsk_level,radical,frequency
|
||||
好,好,hǎo,good,,,1234`
|
||||
|
||||
const { result, data } = parseCSV(csv)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(data[0].frequency).toBe(1234)
|
||||
})
|
||||
|
||||
it("should return error for empty CSV", () => {
|
||||
const csv = ""
|
||||
|
||||
const { result, data } = parseCSV(csv)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.errors).toHaveLength(1)
|
||||
expect(result.errors[0].error).toContain("Invalid CSV headers")
|
||||
})
|
||||
|
||||
it("should return error for invalid headers", () => {
|
||||
const csv = `wrong,headers
|
||||
好,好`
|
||||
|
||||
const { result, data } = parseCSV(csv)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.errors).toHaveLength(1)
|
||||
expect(result.errors[0].error).toContain("Invalid CSV headers")
|
||||
})
|
||||
|
||||
it("should return error for missing required fields", () => {
|
||||
const csv = `simplified,traditional,pinyin,meaning
|
||||
好,好,,good`
|
||||
|
||||
const { result, data } = parseCSV(csv)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.failed).toBe(1)
|
||||
expect(result.errors).toHaveLength(1)
|
||||
})
|
||||
|
||||
it("should continue parsing after errors", () => {
|
||||
const csv = `simplified,traditional,pinyin,meaning
|
||||
好,好,hǎo,good
|
||||
爱,愛,,love
|
||||
你,你,nǐ,you`
|
||||
|
||||
const { result, data } = parseCSV(csv)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.imported).toBe(2)
|
||||
expect(result.failed).toBe(1)
|
||||
expect(data).toHaveLength(2)
|
||||
})
|
||||
|
||||
it("should set first form as default", () => {
|
||||
const csv = `simplified,traditional,pinyin,meaning
|
||||
好,好,hǎo,good`
|
||||
|
||||
const { result, data } = parseCSV(csv)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(data[0].forms[0].isDefault).toBe(true)
|
||||
})
|
||||
|
||||
it("should create pinyin transcription", () => {
|
||||
const csv = `simplified,traditional,pinyin,meaning
|
||||
好,好,hǎo,good`
|
||||
|
||||
const { result, data } = parseCSV(csv)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(data[0].forms[0].transcriptions).toHaveLength(1)
|
||||
expect(data[0].forms[0].transcriptions[0].type).toBe("pinyin")
|
||||
expect(data[0].forms[0].transcriptions[0].value).toBe("hǎo")
|
||||
})
|
||||
|
||||
it("should set language code to English", () => {
|
||||
const csv = `simplified,traditional,pinyin,meaning
|
||||
好,好,hǎo,good`
|
||||
|
||||
const { result, data } = parseCSV(csv)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(data[0].forms[0].meanings[0].languageCode).toBe("en")
|
||||
})
|
||||
|
||||
it("should assign order indices to meanings", () => {
|
||||
const csv = `simplified,traditional,pinyin,meaning
|
||||
好,好,hǎo,"good; fine; nice"`
|
||||
|
||||
const { result, data } = parseCSV(csv)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(data[0].forms[0].meanings[0].orderIndex).toBe(0)
|
||||
expect(data[0].forms[0].meanings[1].orderIndex).toBe(1)
|
||||
expect(data[0].forms[0].meanings[2].orderIndex).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe("generateCSVTemplate", () => {
|
||||
it("should generate valid CSV template", () => {
|
||||
const template = generateCSVTemplate()
|
||||
|
||||
expect(template).toContain("simplified,traditional,pinyin,meaning")
|
||||
expect(template).toContain("爱好,愛好,ài hào")
|
||||
|
||||
const lines = template.split("\n")
|
||||
expect(lines).toHaveLength(2) // Header + example
|
||||
})
|
||||
|
||||
it("should have parseable template", () => {
|
||||
const template = generateCSVTemplate()
|
||||
|
||||
const { result } = parseCSV(template)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.imported).toBe(1)
|
||||
})
|
||||
})
|
||||
249
src/lib/import/csv-parser.ts
Normal file
249
src/lib/import/csv-parser.ts
Normal file
@@ -0,0 +1,249 @@
|
||||
import { z } from "zod"
|
||||
import type {
|
||||
CSVRow,
|
||||
ParsedHanzi,
|
||||
ImportResult,
|
||||
ImportError,
|
||||
} from "./types"
|
||||
|
||||
/**
|
||||
* Zod schema for CSV row validation
|
||||
*/
|
||||
const CSVRowSchema = z.object({
|
||||
simplified: z.string().min(1),
|
||||
traditional: z.string().min(1),
|
||||
pinyin: z.string().min(1),
|
||||
meaning: z.string().min(1),
|
||||
hsk_level: z.string().optional(),
|
||||
radical: z.string().optional(),
|
||||
frequency: z.string().optional(),
|
||||
pos: z.string().optional(),
|
||||
classifiers: z.string().optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Parse CSV format
|
||||
* Expected format:
|
||||
* simplified,traditional,pinyin,meaning,hsk_level,radical,frequency,pos,classifiers
|
||||
*/
|
||||
export function parseCSV(csvString: string): {
|
||||
result: ImportResult
|
||||
data: ParsedHanzi[]
|
||||
} {
|
||||
const errors: ImportError[] = []
|
||||
const parsed: ParsedHanzi[] = []
|
||||
const lines = csvString.trim().split("\n")
|
||||
|
||||
if (lines.length === 0) {
|
||||
return {
|
||||
result: {
|
||||
success: false,
|
||||
imported: 0,
|
||||
failed: 0,
|
||||
errors: [{ error: "Empty CSV file" }],
|
||||
},
|
||||
data: [],
|
||||
}
|
||||
}
|
||||
|
||||
// Parse header
|
||||
const headerLine = lines[0]
|
||||
const headers = parseCSVLine(headerLine)
|
||||
|
||||
if (!validateHeaders(headers)) {
|
||||
return {
|
||||
result: {
|
||||
success: false,
|
||||
imported: 0,
|
||||
failed: 0,
|
||||
errors: [{
|
||||
error: `Invalid CSV headers. Expected at least: simplified,traditional,pinyin,meaning. Got: ${headers.join(",")}`,
|
||||
}],
|
||||
},
|
||||
data: [],
|
||||
}
|
||||
}
|
||||
|
||||
// Parse data rows
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const line = lines[i].trim()
|
||||
if (!line) continue // Skip empty lines
|
||||
|
||||
try {
|
||||
const values = parseCSVLine(line)
|
||||
const row = parseCSVRow(headers, values)
|
||||
const validationResult = CSVRowSchema.safeParse(row)
|
||||
|
||||
if (!validationResult.success) {
|
||||
throw new Error(
|
||||
validationResult.error.errors
|
||||
.map(e => `${e.path.join(".")}: ${e.message}`)
|
||||
.join(", ")
|
||||
)
|
||||
}
|
||||
|
||||
const parsedEntry = transformCSVRow(validationResult.data)
|
||||
parsed.push(parsedEntry)
|
||||
} catch (error) {
|
||||
const simplified = line.split(",")[0] || "unknown"
|
||||
errors.push({
|
||||
line: i + 1,
|
||||
character: simplified,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
result: {
|
||||
success: errors.length === 0,
|
||||
imported: parsed.length,
|
||||
failed: errors.length,
|
||||
errors,
|
||||
},
|
||||
data: parsed,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a CSV line handling quoted values
|
||||
*/
|
||||
function parseCSVLine(line: string): string[] {
|
||||
const values: string[] = []
|
||||
let current = ""
|
||||
let inQuotes = false
|
||||
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const char = line[i]
|
||||
const nextChar = line[i + 1]
|
||||
|
||||
if (char === '"') {
|
||||
if (inQuotes && nextChar === '"') {
|
||||
// Escaped quote
|
||||
current += '"'
|
||||
i++
|
||||
} else {
|
||||
// Toggle quote state
|
||||
inQuotes = !inQuotes
|
||||
}
|
||||
} else if (char === "," && !inQuotes) {
|
||||
// End of field
|
||||
values.push(current.trim())
|
||||
current = ""
|
||||
} else {
|
||||
current += char
|
||||
}
|
||||
}
|
||||
|
||||
// Add last field
|
||||
values.push(current.trim())
|
||||
|
||||
return values
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate CSV headers
|
||||
*/
|
||||
function validateHeaders(headers: string[]): boolean {
|
||||
const required = ["simplified", "traditional", "pinyin", "meaning"]
|
||||
return required.every(h => headers.includes(h))
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert CSV values array to row object
|
||||
*/
|
||||
function parseCSVRow(headers: string[], values: string[]): CSVRow {
|
||||
const row: any = {}
|
||||
headers.forEach((header, index) => {
|
||||
const value = values[index]?.trim()
|
||||
if (value) {
|
||||
row[header] = value
|
||||
}
|
||||
})
|
||||
return row as CSVRow
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform CSV row to ParsedHanzi format
|
||||
*/
|
||||
function transformCSVRow(row: CSVRow): ParsedHanzi {
|
||||
// Parse HSK levels (comma-separated)
|
||||
const hskLevels = row.hsk_level
|
||||
? row.hsk_level.split(",").map(l => l.trim())
|
||||
: []
|
||||
|
||||
// Parse parts of speech (comma-separated)
|
||||
const partsOfSpeech = row.pos
|
||||
? row.pos.split(",").map(p => p.trim())
|
||||
: []
|
||||
|
||||
// Parse frequency
|
||||
const frequency = row.frequency
|
||||
? parseInt(row.frequency, 10)
|
||||
: undefined
|
||||
|
||||
// Parse classifiers (comma-separated)
|
||||
const classifiers = row.classifiers
|
||||
? row.classifiers.split(",").map(c => c.trim())
|
||||
: []
|
||||
|
||||
// Parse meanings (semicolon-separated)
|
||||
const meanings = row.meaning.split(";").map((m, index) => ({
|
||||
languageCode: "en",
|
||||
meaning: m.trim(),
|
||||
orderIndex: index,
|
||||
}))
|
||||
|
||||
return {
|
||||
simplified: row.simplified,
|
||||
radical: row.radical,
|
||||
frequency,
|
||||
hskLevels,
|
||||
partsOfSpeech,
|
||||
forms: [
|
||||
{
|
||||
traditional: row.traditional,
|
||||
isDefault: true,
|
||||
transcriptions: [
|
||||
{
|
||||
type: "pinyin",
|
||||
value: row.pinyin,
|
||||
},
|
||||
],
|
||||
meanings,
|
||||
classifiers,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate CSV template
|
||||
*/
|
||||
export function generateCSVTemplate(): string {
|
||||
const headers = [
|
||||
"simplified",
|
||||
"traditional",
|
||||
"pinyin",
|
||||
"meaning",
|
||||
"hsk_level",
|
||||
"radical",
|
||||
"frequency",
|
||||
"pos",
|
||||
"classifiers",
|
||||
]
|
||||
|
||||
const example = [
|
||||
"爱好",
|
||||
"愛好",
|
||||
"ài hào",
|
||||
"to like; hobby",
|
||||
"new-1,old-3",
|
||||
"爫",
|
||||
"4902",
|
||||
"n,v",
|
||||
"个",
|
||||
]
|
||||
|
||||
return [headers.join(","), example.join(",")].join("\n")
|
||||
}
|
||||
300
src/lib/import/hsk-json-parser.test.ts
Normal file
300
src/lib/import/hsk-json-parser.test.ts
Normal file
@@ -0,0 +1,300 @@
|
||||
import { describe, it, expect } from "vitest"
|
||||
import { parseHSKJson, validateHSKJsonEntry } from "./hsk-json-parser"
|
||||
|
||||
describe("parseHSKJson", () => {
|
||||
it("should parse valid single JSON entry", () => {
|
||||
const json = JSON.stringify({
|
||||
simplified: "爱好",
|
||||
radical: "爫",
|
||||
level: ["new-1", "old-3"],
|
||||
frequency: 4902,
|
||||
pos: ["n", "v"],
|
||||
forms: [
|
||||
{
|
||||
traditional: "愛好",
|
||||
transcriptions: {
|
||||
pinyin: "ài hào",
|
||||
numeric: "ai4 hao4",
|
||||
},
|
||||
meanings: ["to like; hobby"],
|
||||
classifiers: ["个"],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const { result, data } = parseHSKJson(json)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.imported).toBe(1)
|
||||
expect(result.failed).toBe(0)
|
||||
expect(result.errors).toHaveLength(0)
|
||||
expect(data).toHaveLength(1)
|
||||
expect(data[0].simplified).toBe("爱好")
|
||||
expect(data[0].radical).toBe("爫")
|
||||
expect(data[0].frequency).toBe(4902)
|
||||
expect(data[0].hskLevels).toEqual(["new-1", "old-3"])
|
||||
expect(data[0].partsOfSpeech).toEqual(["n", "v"])
|
||||
expect(data[0].forms).toHaveLength(1)
|
||||
expect(data[0].forms[0].traditional).toBe("愛好")
|
||||
expect(data[0].forms[0].isDefault).toBe(true)
|
||||
})
|
||||
|
||||
it("should parse valid JSON array", () => {
|
||||
const json = JSON.stringify([
|
||||
{
|
||||
simplified: "爱",
|
||||
forms: [
|
||||
{
|
||||
traditional: "愛",
|
||||
transcriptions: { pinyin: "ài" },
|
||||
meanings: ["to love"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
simplified: "好",
|
||||
forms: [
|
||||
{
|
||||
traditional: "好",
|
||||
transcriptions: { pinyin: "hǎo" },
|
||||
meanings: ["good"],
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
const { result, data } = parseHSKJson(json)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.imported).toBe(2)
|
||||
expect(data).toHaveLength(2)
|
||||
})
|
||||
|
||||
it("should handle missing optional fields", () => {
|
||||
const json = JSON.stringify({
|
||||
simplified: "好",
|
||||
forms: [
|
||||
{
|
||||
traditional: "好",
|
||||
transcriptions: { pinyin: "hǎo" },
|
||||
meanings: ["good"],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const { result, data } = parseHSKJson(json)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(data[0].radical).toBeUndefined()
|
||||
expect(data[0].frequency).toBeUndefined()
|
||||
expect(data[0].hskLevels).toEqual([])
|
||||
expect(data[0].partsOfSpeech).toEqual([])
|
||||
})
|
||||
|
||||
it("should split semicolon-separated meanings", () => {
|
||||
const json = JSON.stringify({
|
||||
simplified: "好",
|
||||
forms: [
|
||||
{
|
||||
traditional: "好",
|
||||
transcriptions: { pinyin: "hǎo" },
|
||||
meanings: ["good; fine; nice"],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const { result, data } = parseHSKJson(json)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(data[0].forms[0].meanings).toHaveLength(3)
|
||||
expect(data[0].forms[0].meanings[0].meaning).toBe("good")
|
||||
expect(data[0].forms[0].meanings[1].meaning).toBe("fine")
|
||||
expect(data[0].forms[0].meanings[2].meaning).toBe("nice")
|
||||
})
|
||||
|
||||
it("should handle multiple forms with second form not being default", () => {
|
||||
const json = JSON.stringify({
|
||||
simplified: "爱",
|
||||
forms: [
|
||||
{
|
||||
traditional: "愛",
|
||||
transcriptions: { pinyin: "ài" },
|
||||
meanings: ["to love"],
|
||||
},
|
||||
{
|
||||
traditional: "爱",
|
||||
transcriptions: { pinyin: "ài" },
|
||||
meanings: ["to love (simplified)"],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const { result, data } = parseHSKJson(json)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(data[0].forms).toHaveLength(2)
|
||||
expect(data[0].forms[0].isDefault).toBe(true)
|
||||
expect(data[0].forms[1].isDefault).toBe(false)
|
||||
})
|
||||
|
||||
it("should handle multiple transcription types", () => {
|
||||
const json = JSON.stringify({
|
||||
simplified: "好",
|
||||
forms: [
|
||||
{
|
||||
traditional: "好",
|
||||
transcriptions: {
|
||||
pinyin: "hǎo",
|
||||
numeric: "hao3",
|
||||
wadegiles: "hao3",
|
||||
},
|
||||
meanings: ["good"],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const { result, data } = parseHSKJson(json)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(data[0].forms[0].transcriptions).toHaveLength(3)
|
||||
expect(data[0].forms[0].transcriptions.map(t => t.type)).toContain("pinyin")
|
||||
expect(data[0].forms[0].transcriptions.map(t => t.type)).toContain("numeric")
|
||||
expect(data[0].forms[0].transcriptions.map(t => t.type)).toContain("wadegiles")
|
||||
})
|
||||
|
||||
it("should return error for invalid JSON", () => {
|
||||
const json = "{ invalid json }"
|
||||
|
||||
const { result, data } = parseHSKJson(json)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.imported).toBe(0)
|
||||
expect(result.errors).toHaveLength(1)
|
||||
expect(result.errors[0].error).toContain("Invalid JSON")
|
||||
expect(data).toHaveLength(0)
|
||||
})
|
||||
|
||||
it("should return error for missing required fields", () => {
|
||||
const json = JSON.stringify({
|
||||
simplified: "好",
|
||||
// Missing forms
|
||||
})
|
||||
|
||||
const { result, data } = parseHSKJson(json)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.failed).toBe(1)
|
||||
expect(result.errors).toHaveLength(1)
|
||||
expect(data).toHaveLength(0)
|
||||
})
|
||||
|
||||
it("should return error for empty simplified field", () => {
|
||||
const json = JSON.stringify({
|
||||
simplified: "",
|
||||
forms: [
|
||||
{
|
||||
traditional: "好",
|
||||
transcriptions: { pinyin: "hǎo" },
|
||||
meanings: ["good"],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const { result, data } = parseHSKJson(json)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.errors).toHaveLength(1)
|
||||
})
|
||||
|
||||
it("should return error for empty meanings array", () => {
|
||||
const json = JSON.stringify({
|
||||
simplified: "好",
|
||||
forms: [
|
||||
{
|
||||
traditional: "好",
|
||||
transcriptions: { pinyin: "hǎo" },
|
||||
meanings: [],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const { result, data } = parseHSKJson(json)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.errors).toHaveLength(1)
|
||||
})
|
||||
|
||||
it("should continue parsing after errors", () => {
|
||||
const json = JSON.stringify([
|
||||
{
|
||||
simplified: "好",
|
||||
forms: [
|
||||
{
|
||||
traditional: "好",
|
||||
transcriptions: { pinyin: "hǎo" },
|
||||
meanings: ["good"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
simplified: "", // Invalid
|
||||
forms: [
|
||||
{
|
||||
traditional: "x",
|
||||
transcriptions: { pinyin: "x" },
|
||||
meanings: ["x"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
simplified: "爱",
|
||||
forms: [
|
||||
{
|
||||
traditional: "愛",
|
||||
transcriptions: { pinyin: "ài" },
|
||||
meanings: ["love"],
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
const { result, data } = parseHSKJson(json)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.imported).toBe(2)
|
||||
expect(result.failed).toBe(1)
|
||||
expect(data).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe("validateHSKJsonEntry", () => {
|
||||
it("should validate correct entry", () => {
|
||||
const entry = {
|
||||
simplified: "好",
|
||||
forms: [
|
||||
{
|
||||
traditional: "好",
|
||||
transcriptions: { pinyin: "hǎo" },
|
||||
meanings: ["good"],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const result = validateHSKJsonEntry(entry)
|
||||
|
||||
expect(result.valid).toBe(true)
|
||||
expect(result.errors).toHaveLength(0)
|
||||
})
|
||||
|
||||
it("should return errors for invalid entry", () => {
|
||||
const entry = {
|
||||
simplified: "",
|
||||
forms: [],
|
||||
}
|
||||
|
||||
const result = validateHSKJsonEntry(entry)
|
||||
|
||||
expect(result.valid).toBe(false)
|
||||
expect(result.errors.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
161
src/lib/import/hsk-json-parser.ts
Normal file
161
src/lib/import/hsk-json-parser.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import { z } from "zod"
|
||||
import type {
|
||||
HSKJsonEntry,
|
||||
HSKJsonForm,
|
||||
ParsedHanzi,
|
||||
ParsedHanziForm,
|
||||
ImportResult,
|
||||
ImportError,
|
||||
} from "./types"
|
||||
|
||||
/**
|
||||
* Zod schema for HSK JSON validation
|
||||
*/
|
||||
const HSKJsonFormSchema = z.object({
|
||||
traditional: z.string().min(1),
|
||||
transcriptions: z.object({
|
||||
pinyin: z.string().min(1),
|
||||
numeric: z.string().optional(),
|
||||
wadegiles: z.string().optional(),
|
||||
}).catchall(z.string().optional()),
|
||||
meanings: z.array(z.string().min(1)).min(1),
|
||||
classifiers: z.array(z.string()).optional(),
|
||||
})
|
||||
|
||||
const HSKJsonEntrySchema = z.object({
|
||||
simplified: z.string().min(1),
|
||||
radical: z.string().optional(),
|
||||
level: z.array(z.string()).optional(),
|
||||
frequency: z.number().int().positive().optional(),
|
||||
pos: z.array(z.string()).optional(),
|
||||
forms: z.array(HSKJsonFormSchema).min(1),
|
||||
})
|
||||
|
||||
/**
|
||||
* Parse HSK JSON format
|
||||
* Source: https://github.com/drkameleon/complete-hsk-vocabulary
|
||||
*/
|
||||
export function parseHSKJson(jsonString: string): {
|
||||
result: ImportResult
|
||||
data: ParsedHanzi[]
|
||||
} {
|
||||
const errors: ImportError[] = []
|
||||
const parsed: ParsedHanzi[] = []
|
||||
let entries: unknown[]
|
||||
|
||||
// Parse JSON
|
||||
try {
|
||||
const data = JSON.parse(jsonString)
|
||||
entries = Array.isArray(data) ? data : [data]
|
||||
} catch (error) {
|
||||
return {
|
||||
result: {
|
||||
success: false,
|
||||
imported: 0,
|
||||
failed: 0,
|
||||
errors: [{ error: `Invalid JSON: ${error instanceof Error ? error.message : "Unknown error"}` }],
|
||||
},
|
||||
data: [],
|
||||
}
|
||||
}
|
||||
|
||||
// Validate and transform each entry
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
try {
|
||||
const entry = HSKJsonEntrySchema.parse(entries[i])
|
||||
const parsedEntry = transformHSKJsonEntry(entry)
|
||||
parsed.push(parsedEntry)
|
||||
} catch (error) {
|
||||
const simplified = (entries[i] as any)?.simplified || "unknown"
|
||||
const errorMessage = error instanceof z.ZodError
|
||||
? error.errors.map(e => `${e.path.join(".")}: ${e.message}`).join(", ")
|
||||
: error instanceof Error
|
||||
? error.message
|
||||
: "Unknown error"
|
||||
|
||||
errors.push({
|
||||
line: i + 1,
|
||||
character: simplified,
|
||||
error: errorMessage,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
result: {
|
||||
success: errors.length === 0,
|
||||
imported: parsed.length,
|
||||
failed: errors.length,
|
||||
errors,
|
||||
},
|
||||
data: parsed,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform HSK JSON entry to ParsedHanzi format
|
||||
*/
|
||||
function transformHSKJsonEntry(entry: HSKJsonEntry): ParsedHanzi {
|
||||
return {
|
||||
simplified: entry.simplified,
|
||||
radical: entry.radical,
|
||||
frequency: entry.frequency,
|
||||
hskLevels: entry.level || [],
|
||||
partsOfSpeech: entry.pos || [],
|
||||
forms: entry.forms.map((form, index) => transformHSKJsonForm(form, index === 0)),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform HSK JSON form to ParsedHanziForm format
|
||||
*/
|
||||
function transformHSKJsonForm(form: HSKJsonForm, isDefault: boolean): ParsedHanziForm {
|
||||
// Extract transcriptions
|
||||
const transcriptions = Object.entries(form.transcriptions)
|
||||
.filter(([_, value]) => value !== undefined)
|
||||
.map(([type, value]) => ({
|
||||
type,
|
||||
value: value!,
|
||||
}))
|
||||
|
||||
// Parse meanings (can be semicolon-separated or array)
|
||||
const meanings = form.meanings.flatMap((meaningStr, index) =>
|
||||
meaningStr.split(";").map((m, subIndex) => ({
|
||||
languageCode: "en", // Default to English
|
||||
meaning: m.trim(),
|
||||
orderIndex: index * 100 + subIndex,
|
||||
}))
|
||||
)
|
||||
|
||||
return {
|
||||
traditional: form.traditional,
|
||||
isDefault,
|
||||
transcriptions,
|
||||
meanings,
|
||||
classifiers: form.classifiers || [],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a single HSK JSON entry
|
||||
*/
|
||||
export function validateHSKJsonEntry(entry: unknown): {
|
||||
valid: boolean
|
||||
errors: string[]
|
||||
} {
|
||||
try {
|
||||
HSKJsonEntrySchema.parse(entry)
|
||||
return { valid: true, errors: [] }
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return {
|
||||
valid: false,
|
||||
errors: error.errors.map(e => `${e.path.join(".")}: ${e.message}`),
|
||||
}
|
||||
}
|
||||
return {
|
||||
valid: false,
|
||||
errors: [error instanceof Error ? error.message : "Unknown error"],
|
||||
}
|
||||
}
|
||||
}
|
||||
77
src/lib/import/types.ts
Normal file
77
src/lib/import/types.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Types for HSK JSON and CSV import formats
|
||||
*/
|
||||
|
||||
export interface HSKJsonForm {
|
||||
traditional: string
|
||||
transcriptions: {
|
||||
pinyin: string
|
||||
numeric?: string
|
||||
wadegiles?: string
|
||||
[key: string]: string | undefined
|
||||
}
|
||||
meanings: string[]
|
||||
classifiers?: string[]
|
||||
}
|
||||
|
||||
export interface HSKJsonEntry {
|
||||
simplified: string
|
||||
radical?: string
|
||||
level?: string[]
|
||||
frequency?: number
|
||||
pos?: string[]
|
||||
forms: HSKJsonForm[]
|
||||
}
|
||||
|
||||
export interface CSVRow {
|
||||
simplified: string
|
||||
traditional: string
|
||||
pinyin: string
|
||||
meaning: string
|
||||
hsk_level?: string
|
||||
radical?: string
|
||||
frequency?: string
|
||||
pos?: string
|
||||
classifiers?: string
|
||||
}
|
||||
|
||||
export interface ParsedHanzi {
|
||||
simplified: string
|
||||
radical?: string
|
||||
frequency?: number
|
||||
forms: ParsedHanziForm[]
|
||||
hskLevels: string[]
|
||||
partsOfSpeech: string[]
|
||||
}
|
||||
|
||||
export interface ParsedHanziForm {
|
||||
traditional: string
|
||||
isDefault: boolean
|
||||
transcriptions: ParsedTranscription[]
|
||||
meanings: ParsedMeaning[]
|
||||
classifiers: string[]
|
||||
}
|
||||
|
||||
export interface ParsedTranscription {
|
||||
type: string
|
||||
value: string
|
||||
}
|
||||
|
||||
export interface ParsedMeaning {
|
||||
languageCode: string
|
||||
meaning: string
|
||||
orderIndex: number
|
||||
}
|
||||
|
||||
export interface ImportResult {
|
||||
success: boolean
|
||||
imported: number
|
||||
failed: number
|
||||
errors: ImportError[]
|
||||
}
|
||||
|
||||
export interface ImportError {
|
||||
line?: number
|
||||
character?: string
|
||||
error: string
|
||||
}
|
||||
773
src/lib/learning/sm2.test.ts
Normal file
773
src/lib/learning/sm2.test.ts
Normal file
@@ -0,0 +1,773 @@
|
||||
import { describe, it, expect } from "vitest"
|
||||
import {
|
||||
INITIAL_PROGRESS,
|
||||
calculateCorrectAnswer,
|
||||
calculateIncorrectAnswer,
|
||||
Difficulty,
|
||||
selectCardsForSession,
|
||||
generateWrongAnswers,
|
||||
shuffleOptions,
|
||||
type CardProgress,
|
||||
type SelectableCard,
|
||||
type HanziOption,
|
||||
} from "./sm2"
|
||||
|
||||
/**
|
||||
* Unit tests for SM-2 Algorithm
|
||||
*
|
||||
* Tests the spaced repetition algorithm implementation
|
||||
* following the specification exactly.
|
||||
*/
|
||||
|
||||
describe("SM-2 Algorithm", () => {
|
||||
describe("INITIAL_PROGRESS", () => {
|
||||
it("should have correct initial values", () => {
|
||||
expect(INITIAL_PROGRESS.easeFactor).toBe(2.5)
|
||||
expect(INITIAL_PROGRESS.interval).toBe(1)
|
||||
expect(INITIAL_PROGRESS.consecutiveCorrect).toBe(0)
|
||||
expect(INITIAL_PROGRESS.incorrectCount).toBe(0)
|
||||
expect(INITIAL_PROGRESS.lastReviewDate).toBeNull()
|
||||
expect(INITIAL_PROGRESS.nextReviewDate).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("calculateCorrectAnswer", () => {
|
||||
it("should set interval to 1 for first correct answer", () => {
|
||||
const progress: CardProgress = {
|
||||
...INITIAL_PROGRESS,
|
||||
}
|
||||
|
||||
const result = calculateCorrectAnswer(progress, new Date("2025-01-01"))
|
||||
|
||||
expect(result.interval).toBe(1)
|
||||
expect(result.consecutiveCorrect).toBe(1)
|
||||
expect(result.easeFactor).toBe(2.6) // 2.5 + 0.1
|
||||
expect(result.nextReviewDate).toEqual(new Date("2025-01-02"))
|
||||
})
|
||||
|
||||
it("should set interval to 6 for second correct answer", () => {
|
||||
const progress: CardProgress = {
|
||||
easeFactor: 2.6,
|
||||
interval: 1,
|
||||
consecutiveCorrect: 1,
|
||||
incorrectCount: 0,
|
||||
lastReviewDate: new Date("2025-01-01"),
|
||||
nextReviewDate: new Date("2025-01-02"),
|
||||
}
|
||||
|
||||
const result = calculateCorrectAnswer(progress, new Date("2025-01-02"))
|
||||
|
||||
expect(result.interval).toBe(6)
|
||||
expect(result.consecutiveCorrect).toBe(2)
|
||||
expect(result.easeFactor).toBe(2.7) // 2.6 + 0.1
|
||||
expect(result.nextReviewDate).toEqual(new Date("2025-01-08"))
|
||||
})
|
||||
|
||||
it("should multiply interval by easeFactor for third+ correct answer", () => {
|
||||
const progress: CardProgress = {
|
||||
easeFactor: 2.7,
|
||||
interval: 6,
|
||||
consecutiveCorrect: 2,
|
||||
incorrectCount: 0,
|
||||
lastReviewDate: new Date("2025-01-02"),
|
||||
nextReviewDate: new Date("2025-01-08"),
|
||||
}
|
||||
|
||||
const result = calculateCorrectAnswer(progress, new Date("2025-01-08"))
|
||||
|
||||
// 6 * 2.7 = 16.2, rounded = 16
|
||||
expect(result.interval).toBe(16)
|
||||
expect(result.consecutiveCorrect).toBe(3)
|
||||
expect(result.easeFactor).toBeCloseTo(2.8) // 2.7 + 0.1
|
||||
expect(result.nextReviewDate).toEqual(new Date("2025-01-24"))
|
||||
})
|
||||
|
||||
it("should continue increasing ease factor with each correct answer", () => {
|
||||
const progress: CardProgress = {
|
||||
easeFactor: 3.0,
|
||||
interval: 50,
|
||||
consecutiveCorrect: 5,
|
||||
incorrectCount: 2,
|
||||
lastReviewDate: new Date("2025-01-01"),
|
||||
nextReviewDate: new Date("2025-02-20"),
|
||||
}
|
||||
|
||||
const result = calculateCorrectAnswer(progress, new Date("2025-02-20"))
|
||||
|
||||
// 50 * 3.0 = 150
|
||||
expect(result.interval).toBe(150)
|
||||
expect(result.consecutiveCorrect).toBe(6)
|
||||
expect(result.easeFactor).toBe(3.1) // 3.0 + 0.1
|
||||
expect(result.incorrectCount).toBe(2) // Should not change
|
||||
})
|
||||
|
||||
it("should use current date by default", () => {
|
||||
const progress: CardProgress = {
|
||||
...INITIAL_PROGRESS,
|
||||
}
|
||||
|
||||
const before = new Date()
|
||||
const result = calculateCorrectAnswer(progress)
|
||||
const after = new Date()
|
||||
|
||||
// Next review should be approximately 1 day from now
|
||||
const expectedMin = new Date(before)
|
||||
expectedMin.setDate(expectedMin.getDate() + 1)
|
||||
const expectedMax = new Date(after)
|
||||
expectedMax.setDate(expectedMax.getDate() + 1)
|
||||
|
||||
expect(result.nextReviewDate.getTime()).toBeGreaterThanOrEqual(
|
||||
expectedMin.getTime()
|
||||
)
|
||||
expect(result.nextReviewDate.getTime()).toBeLessThanOrEqual(
|
||||
expectedMax.getTime()
|
||||
)
|
||||
})
|
||||
|
||||
it("should handle large intervals correctly", () => {
|
||||
const progress: CardProgress = {
|
||||
easeFactor: 2.5,
|
||||
interval: 365,
|
||||
consecutiveCorrect: 10,
|
||||
incorrectCount: 0,
|
||||
lastReviewDate: new Date("2025-01-01"),
|
||||
nextReviewDate: new Date("2026-01-01"),
|
||||
}
|
||||
|
||||
const result = calculateCorrectAnswer(progress, new Date("2026-01-01"))
|
||||
|
||||
// 365 * 2.5 = 912.5, rounded = 913
|
||||
expect(result.interval).toBe(913)
|
||||
expect(result.consecutiveCorrect).toBe(11)
|
||||
})
|
||||
})
|
||||
|
||||
describe("calculateIncorrectAnswer", () => {
|
||||
it("should reset interval to 1", () => {
|
||||
const progress: CardProgress = {
|
||||
easeFactor: 2.7,
|
||||
interval: 16,
|
||||
consecutiveCorrect: 3,
|
||||
incorrectCount: 0,
|
||||
lastReviewDate: new Date("2025-01-01"),
|
||||
nextReviewDate: new Date("2025-01-17"),
|
||||
}
|
||||
|
||||
const result = calculateIncorrectAnswer(progress, new Date("2025-01-17"))
|
||||
|
||||
expect(result.interval).toBe(1)
|
||||
expect(result.nextReviewDate).toEqual(new Date("2025-01-18"))
|
||||
})
|
||||
|
||||
it("should reset consecutiveCorrect to 0", () => {
|
||||
const progress: CardProgress = {
|
||||
easeFactor: 2.7,
|
||||
interval: 16,
|
||||
consecutiveCorrect: 5,
|
||||
incorrectCount: 1,
|
||||
lastReviewDate: new Date("2025-01-01"),
|
||||
nextReviewDate: new Date("2025-01-17"),
|
||||
}
|
||||
|
||||
const result = calculateIncorrectAnswer(progress, new Date("2025-01-17"))
|
||||
|
||||
expect(result.consecutiveCorrect).toBe(0)
|
||||
})
|
||||
|
||||
it("should decrease easeFactor by 0.2", () => {
|
||||
const progress: CardProgress = {
|
||||
easeFactor: 2.5,
|
||||
interval: 6,
|
||||
consecutiveCorrect: 2,
|
||||
incorrectCount: 0,
|
||||
lastReviewDate: new Date("2025-01-01"),
|
||||
nextReviewDate: new Date("2025-01-07"),
|
||||
}
|
||||
|
||||
const result = calculateIncorrectAnswer(progress, new Date("2025-01-07"))
|
||||
|
||||
expect(result.easeFactor).toBe(2.3) // 2.5 - 0.2
|
||||
})
|
||||
|
||||
it("should not decrease easeFactor below 1.3", () => {
|
||||
const progress: CardProgress = {
|
||||
easeFactor: 1.4,
|
||||
interval: 1,
|
||||
consecutiveCorrect: 0,
|
||||
incorrectCount: 5,
|
||||
lastReviewDate: new Date("2025-01-01"),
|
||||
nextReviewDate: new Date("2025-01-02"),
|
||||
}
|
||||
|
||||
const result = calculateIncorrectAnswer(progress, new Date("2025-01-02"))
|
||||
|
||||
// 1.4 - 0.2 = 1.2, but minimum is 1.3
|
||||
expect(result.easeFactor).toBe(1.3)
|
||||
})
|
||||
|
||||
it("should increment incorrectCount", () => {
|
||||
const progress: CardProgress = {
|
||||
easeFactor: 2.5,
|
||||
interval: 6,
|
||||
consecutiveCorrect: 2,
|
||||
incorrectCount: 0,
|
||||
lastReviewDate: new Date("2025-01-01"),
|
||||
nextReviewDate: new Date("2025-01-07"),
|
||||
}
|
||||
|
||||
const result = calculateIncorrectAnswer(progress, new Date("2025-01-07"))
|
||||
|
||||
expect(result.incorrectCount).toBe(1)
|
||||
})
|
||||
|
||||
it("should use current date by default", () => {
|
||||
const progress: CardProgress = {
|
||||
easeFactor: 2.5,
|
||||
interval: 6,
|
||||
consecutiveCorrect: 2,
|
||||
incorrectCount: 0,
|
||||
lastReviewDate: new Date("2025-01-01"),
|
||||
nextReviewDate: new Date("2025-01-07"),
|
||||
}
|
||||
|
||||
const before = new Date()
|
||||
const result = calculateIncorrectAnswer(progress)
|
||||
const after = new Date()
|
||||
|
||||
// Next review should be approximately 1 day from now
|
||||
const expectedMin = new Date(before)
|
||||
expectedMin.setDate(expectedMin.getDate() + 1)
|
||||
const expectedMax = new Date(after)
|
||||
expectedMax.setDate(expectedMax.getDate() + 1)
|
||||
|
||||
expect(result.nextReviewDate.getTime()).toBeGreaterThanOrEqual(
|
||||
expectedMin.getTime()
|
||||
)
|
||||
expect(result.nextReviewDate.getTime()).toBeLessThanOrEqual(
|
||||
expectedMax.getTime()
|
||||
)
|
||||
})
|
||||
|
||||
it("should handle multiple consecutive incorrect answers", () => {
|
||||
let progress: CardProgress = {
|
||||
easeFactor: 2.5,
|
||||
interval: 16,
|
||||
consecutiveCorrect: 3,
|
||||
incorrectCount: 0,
|
||||
lastReviewDate: new Date("2025-01-01"),
|
||||
nextReviewDate: new Date("2025-01-17"),
|
||||
}
|
||||
|
||||
// First incorrect
|
||||
let result = calculateIncorrectAnswer(progress, new Date("2025-01-17"))
|
||||
expect(result.easeFactor).toBe(2.3)
|
||||
expect(result.incorrectCount).toBe(1)
|
||||
|
||||
// Second incorrect
|
||||
progress = {
|
||||
easeFactor: result.easeFactor,
|
||||
interval: result.interval,
|
||||
consecutiveCorrect: result.consecutiveCorrect,
|
||||
incorrectCount: result.incorrectCount,
|
||||
lastReviewDate: new Date("2025-01-17"),
|
||||
nextReviewDate: result.nextReviewDate,
|
||||
}
|
||||
result = calculateIncorrectAnswer(progress, new Date("2025-01-18"))
|
||||
expect(result.easeFactor).toBeCloseTo(2.1) // 2.3 - 0.2
|
||||
expect(result.incorrectCount).toBe(2)
|
||||
|
||||
// Third incorrect
|
||||
progress = {
|
||||
easeFactor: result.easeFactor,
|
||||
interval: result.interval,
|
||||
consecutiveCorrect: result.consecutiveCorrect,
|
||||
incorrectCount: result.incorrectCount,
|
||||
lastReviewDate: new Date("2025-01-18"),
|
||||
nextReviewDate: result.nextReviewDate,
|
||||
}
|
||||
result = calculateIncorrectAnswer(progress, new Date("2025-01-19"))
|
||||
expect(result.easeFactor).toBeCloseTo(1.9) // 2.1 - 0.2
|
||||
expect(result.incorrectCount).toBe(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe("selectCardsForSession", () => {
|
||||
const now = new Date("2025-01-15T10:00:00Z")
|
||||
|
||||
it("should select due cards only", () => {
|
||||
const cards: SelectableCard[] = [
|
||||
{
|
||||
id: "1",
|
||||
nextReviewDate: new Date("2025-01-14T10:00:00Z"), // Due
|
||||
incorrectCount: 0,
|
||||
consecutiveCorrect: 1,
|
||||
manualDifficulty: Difficulty.NORMAL,
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
nextReviewDate: new Date("2025-01-16T10:00:00Z"), // Not due
|
||||
incorrectCount: 0,
|
||||
consecutiveCorrect: 1,
|
||||
manualDifficulty: Difficulty.NORMAL,
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
nextReviewDate: new Date("2025-01-13T10:00:00Z"), // Due
|
||||
incorrectCount: 0,
|
||||
consecutiveCorrect: 1,
|
||||
manualDifficulty: Difficulty.NORMAL,
|
||||
},
|
||||
]
|
||||
|
||||
const selected = selectCardsForSession(cards, 10, now)
|
||||
|
||||
expect(selected.length).toBe(2)
|
||||
expect(selected.map((c) => c.id)).toContain("1")
|
||||
expect(selected.map((c) => c.id)).toContain("3")
|
||||
expect(selected.map((c) => c.id)).not.toContain("2")
|
||||
})
|
||||
|
||||
it("should exclude suspended cards", () => {
|
||||
const cards: SelectableCard[] = [
|
||||
{
|
||||
id: "1",
|
||||
nextReviewDate: new Date("2025-01-14T10:00:00Z"),
|
||||
incorrectCount: 0,
|
||||
consecutiveCorrect: 1,
|
||||
manualDifficulty: Difficulty.NORMAL,
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
nextReviewDate: new Date("2025-01-14T10:00:00Z"),
|
||||
incorrectCount: 0,
|
||||
consecutiveCorrect: 1,
|
||||
manualDifficulty: Difficulty.SUSPENDED,
|
||||
},
|
||||
]
|
||||
|
||||
const selected = selectCardsForSession(cards, 10, now)
|
||||
|
||||
expect(selected.length).toBe(1)
|
||||
expect(selected[0].id).toBe("1")
|
||||
})
|
||||
|
||||
it("should prioritize HARD over NORMAL over EASY", () => {
|
||||
const cards: SelectableCard[] = [
|
||||
{
|
||||
id: "easy",
|
||||
nextReviewDate: new Date("2025-01-14T10:00:00Z"),
|
||||
incorrectCount: 0,
|
||||
consecutiveCorrect: 1,
|
||||
manualDifficulty: Difficulty.EASY,
|
||||
},
|
||||
{
|
||||
id: "hard",
|
||||
nextReviewDate: new Date("2025-01-14T10:00:00Z"),
|
||||
incorrectCount: 0,
|
||||
consecutiveCorrect: 1,
|
||||
manualDifficulty: Difficulty.HARD,
|
||||
},
|
||||
{
|
||||
id: "normal",
|
||||
nextReviewDate: new Date("2025-01-14T10:00:00Z"),
|
||||
incorrectCount: 0,
|
||||
consecutiveCorrect: 1,
|
||||
manualDifficulty: Difficulty.NORMAL,
|
||||
},
|
||||
]
|
||||
|
||||
const selected = selectCardsForSession(cards, 10, now)
|
||||
|
||||
expect(selected[0].id).toBe("hard")
|
||||
expect(selected[1].id).toBe("normal")
|
||||
expect(selected[2].id).toBe("easy")
|
||||
})
|
||||
|
||||
it("should sort by nextReviewDate ascending", () => {
|
||||
const cards: SelectableCard[] = [
|
||||
{
|
||||
id: "1",
|
||||
nextReviewDate: new Date("2025-01-14T10:00:00Z"),
|
||||
incorrectCount: 0,
|
||||
consecutiveCorrect: 1,
|
||||
manualDifficulty: Difficulty.NORMAL,
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
nextReviewDate: new Date("2025-01-12T10:00:00Z"),
|
||||
incorrectCount: 0,
|
||||
consecutiveCorrect: 1,
|
||||
manualDifficulty: Difficulty.NORMAL,
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
nextReviewDate: new Date("2025-01-13T10:00:00Z"),
|
||||
incorrectCount: 0,
|
||||
consecutiveCorrect: 1,
|
||||
manualDifficulty: Difficulty.NORMAL,
|
||||
},
|
||||
]
|
||||
|
||||
const selected = selectCardsForSession(cards, 10, now)
|
||||
|
||||
expect(selected[0].id).toBe("2") // Oldest
|
||||
expect(selected[1].id).toBe("3")
|
||||
expect(selected[2].id).toBe("1") // Newest
|
||||
})
|
||||
|
||||
it("should prioritize higher incorrectCount", () => {
|
||||
const cards: SelectableCard[] = [
|
||||
{
|
||||
id: "1",
|
||||
nextReviewDate: new Date("2025-01-14T10:00:00Z"),
|
||||
incorrectCount: 1,
|
||||
consecutiveCorrect: 1,
|
||||
manualDifficulty: Difficulty.NORMAL,
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
nextReviewDate: new Date("2025-01-14T10:00:00Z"),
|
||||
incorrectCount: 3,
|
||||
consecutiveCorrect: 1,
|
||||
manualDifficulty: Difficulty.NORMAL,
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
nextReviewDate: new Date("2025-01-14T10:00:00Z"),
|
||||
incorrectCount: 2,
|
||||
consecutiveCorrect: 1,
|
||||
manualDifficulty: Difficulty.NORMAL,
|
||||
},
|
||||
]
|
||||
|
||||
const selected = selectCardsForSession(cards, 10, now)
|
||||
|
||||
expect(selected[0].id).toBe("2") // incorrectCount: 3
|
||||
expect(selected[1].id).toBe("3") // incorrectCount: 2
|
||||
expect(selected[2].id).toBe("1") // incorrectCount: 1
|
||||
})
|
||||
|
||||
it("should prioritize lower consecutiveCorrect", () => {
|
||||
const cards: SelectableCard[] = [
|
||||
{
|
||||
id: "1",
|
||||
nextReviewDate: new Date("2025-01-14T10:00:00Z"),
|
||||
incorrectCount: 0,
|
||||
consecutiveCorrect: 3,
|
||||
manualDifficulty: Difficulty.NORMAL,
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
nextReviewDate: new Date("2025-01-14T10:00:00Z"),
|
||||
incorrectCount: 0,
|
||||
consecutiveCorrect: 1,
|
||||
manualDifficulty: Difficulty.NORMAL,
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
nextReviewDate: new Date("2025-01-14T10:00:00Z"),
|
||||
incorrectCount: 0,
|
||||
consecutiveCorrect: 2,
|
||||
manualDifficulty: Difficulty.NORMAL,
|
||||
},
|
||||
]
|
||||
|
||||
const selected = selectCardsForSession(cards, 10, now)
|
||||
|
||||
expect(selected[0].id).toBe("2") // consecutiveCorrect: 1
|
||||
expect(selected[1].id).toBe("3") // consecutiveCorrect: 2
|
||||
expect(selected[2].id).toBe("1") // consecutiveCorrect: 3
|
||||
})
|
||||
|
||||
it("should include new cards (null nextReviewDate)", () => {
|
||||
const cards: SelectableCard[] = [
|
||||
{
|
||||
id: "1",
|
||||
nextReviewDate: null, // New card
|
||||
incorrectCount: 0,
|
||||
consecutiveCorrect: 0,
|
||||
manualDifficulty: Difficulty.NORMAL,
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
nextReviewDate: new Date("2025-01-14T10:00:00Z"),
|
||||
incorrectCount: 0,
|
||||
consecutiveCorrect: 1,
|
||||
manualDifficulty: Difficulty.NORMAL,
|
||||
},
|
||||
]
|
||||
|
||||
const selected = selectCardsForSession(cards, 10, now)
|
||||
|
||||
expect(selected.length).toBe(2)
|
||||
expect(selected[0].id).toBe("1") // New cards first
|
||||
expect(selected[1].id).toBe("2")
|
||||
})
|
||||
|
||||
it("should limit to cardsPerSession", () => {
|
||||
const cards: SelectableCard[] = Array.from({ length: 20 }, (_, i) => ({
|
||||
id: `${i}`,
|
||||
nextReviewDate: new Date("2025-01-14T10:00:00Z"),
|
||||
incorrectCount: 0,
|
||||
consecutiveCorrect: 1,
|
||||
manualDifficulty: Difficulty.NORMAL,
|
||||
}))
|
||||
|
||||
const selected = selectCardsForSession(cards, 5, now)
|
||||
|
||||
expect(selected.length).toBe(5)
|
||||
})
|
||||
|
||||
it("should apply all sorting criteria in correct order", () => {
|
||||
const cards: SelectableCard[] = [
|
||||
{
|
||||
id: "easy-old-high-low",
|
||||
nextReviewDate: new Date("2025-01-12T10:00:00Z"),
|
||||
incorrectCount: 5,
|
||||
consecutiveCorrect: 1,
|
||||
manualDifficulty: Difficulty.EASY,
|
||||
},
|
||||
{
|
||||
id: "hard-new-low-high",
|
||||
nextReviewDate: new Date("2025-01-14T10:00:00Z"),
|
||||
incorrectCount: 1,
|
||||
consecutiveCorrect: 5,
|
||||
manualDifficulty: Difficulty.HARD,
|
||||
},
|
||||
{
|
||||
id: "hard-old-low-low",
|
||||
nextReviewDate: new Date("2025-01-12T10:00:00Z"),
|
||||
incorrectCount: 1,
|
||||
consecutiveCorrect: 1,
|
||||
manualDifficulty: Difficulty.HARD,
|
||||
},
|
||||
{
|
||||
id: "normal-old-high-low",
|
||||
nextReviewDate: new Date("2025-01-12T10:00:00Z"),
|
||||
incorrectCount: 5,
|
||||
consecutiveCorrect: 1,
|
||||
manualDifficulty: Difficulty.NORMAL,
|
||||
},
|
||||
]
|
||||
|
||||
const selected = selectCardsForSession(cards, 10, now)
|
||||
|
||||
// Expected order:
|
||||
// 1. HARD difficulty has priority
|
||||
// 2. Among HARD: older date (2025-01-12) before newer (2025-01-14)
|
||||
// 3. Then NORMAL difficulty
|
||||
// 4. Then EASY difficulty
|
||||
expect(selected[0].id).toBe("hard-old-low-low")
|
||||
expect(selected[1].id).toBe("hard-new-low-high")
|
||||
expect(selected[2].id).toBe("normal-old-high-low")
|
||||
expect(selected[3].id).toBe("easy-old-high-low")
|
||||
})
|
||||
|
||||
it("should handle empty card list", () => {
|
||||
const selected = selectCardsForSession([], 10, now)
|
||||
expect(selected.length).toBe(0)
|
||||
})
|
||||
|
||||
it("should handle all cards being suspended", () => {
|
||||
const cards: SelectableCard[] = [
|
||||
{
|
||||
id: "1",
|
||||
nextReviewDate: new Date("2025-01-14T10:00:00Z"),
|
||||
incorrectCount: 0,
|
||||
consecutiveCorrect: 1,
|
||||
manualDifficulty: Difficulty.SUSPENDED,
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
nextReviewDate: new Date("2025-01-14T10:00:00Z"),
|
||||
incorrectCount: 0,
|
||||
consecutiveCorrect: 1,
|
||||
manualDifficulty: Difficulty.SUSPENDED,
|
||||
},
|
||||
]
|
||||
|
||||
const selected = selectCardsForSession(cards, 10, now)
|
||||
expect(selected.length).toBe(0)
|
||||
})
|
||||
|
||||
it("should handle all cards not being due", () => {
|
||||
const cards: SelectableCard[] = [
|
||||
{
|
||||
id: "1",
|
||||
nextReviewDate: new Date("2025-01-16T10:00:00Z"),
|
||||
incorrectCount: 0,
|
||||
consecutiveCorrect: 1,
|
||||
manualDifficulty: Difficulty.NORMAL,
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
nextReviewDate: new Date("2025-01-17T10:00:00Z"),
|
||||
incorrectCount: 0,
|
||||
consecutiveCorrect: 1,
|
||||
manualDifficulty: Difficulty.NORMAL,
|
||||
},
|
||||
]
|
||||
|
||||
const selected = selectCardsForSession(cards, 10, now)
|
||||
expect(selected.length).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("generateWrongAnswers", () => {
|
||||
const correctAnswer: HanziOption = {
|
||||
id: "1",
|
||||
simplified: "好",
|
||||
pinyin: "hǎo",
|
||||
hskLevel: "new-1",
|
||||
}
|
||||
|
||||
it("should generate 3 wrong answers", () => {
|
||||
const sameHskOptions: HanziOption[] = [
|
||||
correctAnswer,
|
||||
{ id: "2", simplified: "你", pinyin: "nǐ", hskLevel: "new-1" },
|
||||
{ id: "3", simplified: "我", pinyin: "wǒ", hskLevel: "new-1" },
|
||||
{ id: "4", simplified: "他", pinyin: "tā", hskLevel: "new-1" },
|
||||
{ id: "5", simplified: "的", pinyin: "de", hskLevel: "new-1" },
|
||||
]
|
||||
|
||||
const wrongAnswers = generateWrongAnswers(correctAnswer, sameHskOptions)
|
||||
|
||||
expect(wrongAnswers.length).toBe(3)
|
||||
expect(wrongAnswers).not.toContain("hǎo") // Should not include correct
|
||||
})
|
||||
|
||||
it("should not include correct answer", () => {
|
||||
const sameHskOptions: HanziOption[] = [
|
||||
correctAnswer,
|
||||
{ id: "2", simplified: "你", pinyin: "nǐ", hskLevel: "new-1" },
|
||||
{ id: "3", simplified: "我", pinyin: "wǒ", hskLevel: "new-1" },
|
||||
{ id: "4", simplified: "他", pinyin: "tā", hskLevel: "new-1" },
|
||||
]
|
||||
|
||||
const wrongAnswers = generateWrongAnswers(correctAnswer, sameHskOptions)
|
||||
|
||||
expect(wrongAnswers).not.toContain("hǎo")
|
||||
})
|
||||
|
||||
it("should not include duplicate pinyin", () => {
|
||||
const sameHskOptions: HanziOption[] = [
|
||||
correctAnswer,
|
||||
{ id: "2", simplified: "好", pinyin: "hǎo", hskLevel: "new-1" }, // Duplicate pinyin
|
||||
{ id: "3", simplified: "你", pinyin: "nǐ", hskLevel: "new-1" },
|
||||
{ id: "4", simplified: "我", pinyin: "wǒ", hskLevel: "new-1" },
|
||||
{ id: "5", simplified: "他", pinyin: "tā", hskLevel: "new-1" },
|
||||
{ id: "6", simplified: "的", pinyin: "de", hskLevel: "new-1" },
|
||||
]
|
||||
|
||||
const wrongAnswers = generateWrongAnswers(correctAnswer, sameHskOptions)
|
||||
|
||||
expect(wrongAnswers.length).toBe(3)
|
||||
// Should only have 1 instance of each pinyin
|
||||
const uniquePinyin = new Set(wrongAnswers)
|
||||
expect(uniquePinyin.size).toBe(3)
|
||||
})
|
||||
|
||||
it("should throw error if not enough options", () => {
|
||||
const sameHskOptions: HanziOption[] = [
|
||||
correctAnswer,
|
||||
{ id: "2", simplified: "你", pinyin: "nǐ", hskLevel: "new-1" },
|
||||
{ id: "3", simplified: "我", pinyin: "wǒ", hskLevel: "new-1" },
|
||||
// Only 2 other options, need 3
|
||||
]
|
||||
|
||||
expect(() =>
|
||||
generateWrongAnswers(correctAnswer, sameHskOptions)
|
||||
).toThrow("Not enough wrong answers available")
|
||||
})
|
||||
|
||||
it("should randomize the selection", () => {
|
||||
const sameHskOptions: HanziOption[] = [
|
||||
correctAnswer,
|
||||
{ id: "2", simplified: "你", pinyin: "nǐ", hskLevel: "new-1" },
|
||||
{ id: "3", simplified: "我", pinyin: "wǒ", hskLevel: "new-1" },
|
||||
{ id: "4", simplified: "他", pinyin: "tā", hskLevel: "new-1" },
|
||||
{ id: "5", simplified: "的", pinyin: "de", hskLevel: "new-1" },
|
||||
{ id: "6", simplified: "是", pinyin: "shì", hskLevel: "new-1" },
|
||||
{ id: "7", simplified: "在", pinyin: "zài", hskLevel: "new-1" },
|
||||
{ id: "8", simplified: "有", pinyin: "yǒu", hskLevel: "new-1" },
|
||||
]
|
||||
|
||||
// Run multiple times and check that we get different results
|
||||
const results = new Set<string>()
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const wrongAnswers = generateWrongAnswers(correctAnswer, sameHskOptions)
|
||||
results.add(wrongAnswers.sort().join(","))
|
||||
}
|
||||
|
||||
// Should have at least 2 different combinations (very likely with 7 options)
|
||||
expect(results.size).toBeGreaterThan(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe("shuffleOptions", () => {
|
||||
it("should return array of same length", () => {
|
||||
const options = ["a", "b", "c", "d"]
|
||||
const shuffled = shuffleOptions(options)
|
||||
|
||||
expect(shuffled.length).toBe(4)
|
||||
})
|
||||
|
||||
it("should contain all original elements", () => {
|
||||
const options = ["a", "b", "c", "d"]
|
||||
const shuffled = shuffleOptions(options)
|
||||
|
||||
expect(shuffled).toContain("a")
|
||||
expect(shuffled).toContain("b")
|
||||
expect(shuffled).toContain("c")
|
||||
expect(shuffled).toContain("d")
|
||||
})
|
||||
|
||||
it("should not mutate original array", () => {
|
||||
const options = ["a", "b", "c", "d"]
|
||||
const original = [...options]
|
||||
shuffleOptions(options)
|
||||
|
||||
expect(options).toEqual(original)
|
||||
})
|
||||
|
||||
it("should produce different orders", () => {
|
||||
const options = ["a", "b", "c", "d", "e", "f"]
|
||||
|
||||
// Run multiple times and check that we get different results
|
||||
const results = new Set<string>()
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const shuffled = shuffleOptions(options)
|
||||
results.add(shuffled.join(","))
|
||||
}
|
||||
|
||||
// Should have at least 2 different orderings (very likely with 6 elements)
|
||||
expect(results.size).toBeGreaterThan(1)
|
||||
})
|
||||
|
||||
it("should handle single element array", () => {
|
||||
const options = ["a"]
|
||||
const shuffled = shuffleOptions(options)
|
||||
|
||||
expect(shuffled).toEqual(["a"])
|
||||
})
|
||||
|
||||
it("should handle empty array", () => {
|
||||
const options: string[] = []
|
||||
const shuffled = shuffleOptions(options)
|
||||
|
||||
expect(shuffled).toEqual([])
|
||||
})
|
||||
|
||||
it("should work with different types", () => {
|
||||
const options = [1, 2, 3, 4, 5]
|
||||
const shuffled = shuffleOptions(options)
|
||||
|
||||
expect(shuffled.length).toBe(5)
|
||||
expect(shuffled).toContain(1)
|
||||
expect(shuffled).toContain(2)
|
||||
expect(shuffled).toContain(3)
|
||||
expect(shuffled).toContain(4)
|
||||
expect(shuffled).toContain(5)
|
||||
})
|
||||
})
|
||||
})
|
||||
275
src/lib/learning/sm2.ts
Normal file
275
src/lib/learning/sm2.ts
Normal file
@@ -0,0 +1,275 @@
|
||||
/**
|
||||
* SM-2 Algorithm Implementation
|
||||
*
|
||||
* Implements the SuperMemo SM-2 spaced repetition algorithm
|
||||
* as specified in the MemoHanzi specification.
|
||||
*
|
||||
* Reference: https://www.supermemo.com/en/archives1990-2015/english/ol/sm2
|
||||
*/
|
||||
|
||||
/**
|
||||
* Progress data for a single card
|
||||
*/
|
||||
export interface CardProgress {
|
||||
easeFactor: number
|
||||
interval: number // in days
|
||||
consecutiveCorrect: number
|
||||
incorrectCount: number
|
||||
lastReviewDate: Date | null
|
||||
nextReviewDate: Date | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Initial values for a new card
|
||||
*/
|
||||
export const INITIAL_PROGRESS: CardProgress = {
|
||||
easeFactor: 2.5,
|
||||
interval: 1,
|
||||
consecutiveCorrect: 0,
|
||||
incorrectCount: 0,
|
||||
lastReviewDate: null,
|
||||
nextReviewDate: null,
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of calculating the next review
|
||||
*/
|
||||
export interface ReviewResult {
|
||||
easeFactor: number
|
||||
interval: number
|
||||
consecutiveCorrect: number
|
||||
incorrectCount: number
|
||||
nextReviewDate: Date
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the next review for a correct answer
|
||||
*
|
||||
* @param progress Current card progress
|
||||
* @param reviewDate Date of the review (defaults to now)
|
||||
* @returns Updated progress values
|
||||
*/
|
||||
export function calculateCorrectAnswer(
|
||||
progress: CardProgress,
|
||||
reviewDate: Date = new Date()
|
||||
): ReviewResult {
|
||||
let newInterval: number
|
||||
let newEaseFactor: number
|
||||
let newConsecutiveCorrect: number
|
||||
|
||||
// Calculate new interval based on consecutive correct count
|
||||
if (progress.consecutiveCorrect === 0) {
|
||||
newInterval = 1
|
||||
} else if (progress.consecutiveCorrect === 1) {
|
||||
newInterval = 6
|
||||
} else {
|
||||
newInterval = Math.round(progress.interval * progress.easeFactor)
|
||||
}
|
||||
|
||||
// Increase ease factor (making future intervals longer)
|
||||
newEaseFactor = progress.easeFactor + 0.1
|
||||
|
||||
// Increment consecutive correct count
|
||||
newConsecutiveCorrect = progress.consecutiveCorrect + 1
|
||||
|
||||
// Calculate next review date
|
||||
const nextReviewDate = new Date(reviewDate)
|
||||
nextReviewDate.setDate(nextReviewDate.getDate() + newInterval)
|
||||
|
||||
return {
|
||||
easeFactor: newEaseFactor,
|
||||
interval: newInterval,
|
||||
consecutiveCorrect: newConsecutiveCorrect,
|
||||
incorrectCount: progress.incorrectCount,
|
||||
nextReviewDate,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the next review for an incorrect answer
|
||||
*
|
||||
* @param progress Current card progress
|
||||
* @param reviewDate Date of the review (defaults to now)
|
||||
* @returns Updated progress values
|
||||
*/
|
||||
export function calculateIncorrectAnswer(
|
||||
progress: CardProgress,
|
||||
reviewDate: Date = new Date()
|
||||
): ReviewResult {
|
||||
// Reset interval to 1 day
|
||||
const newInterval = 1
|
||||
|
||||
// Reset consecutive correct count
|
||||
const newConsecutiveCorrect = 0
|
||||
|
||||
// Decrease ease factor (but not below 1.3)
|
||||
const newEaseFactor = Math.max(1.3, progress.easeFactor - 0.2)
|
||||
|
||||
// Increment incorrect count
|
||||
const newIncorrectCount = progress.incorrectCount + 1
|
||||
|
||||
// Calculate next review date (1 day from now)
|
||||
const nextReviewDate = new Date(reviewDate)
|
||||
nextReviewDate.setDate(nextReviewDate.getDate() + newInterval)
|
||||
|
||||
return {
|
||||
easeFactor: newEaseFactor,
|
||||
interval: newInterval,
|
||||
consecutiveCorrect: newConsecutiveCorrect,
|
||||
incorrectCount: newIncorrectCount,
|
||||
nextReviewDate,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Difficulty enum matching the Prisma schema
|
||||
*/
|
||||
export enum Difficulty {
|
||||
EASY = "EASY",
|
||||
NORMAL = "NORMAL",
|
||||
HARD = "HARD",
|
||||
SUSPENDED = "SUSPENDED",
|
||||
}
|
||||
|
||||
/**
|
||||
* Card for selection with progress and metadata
|
||||
*/
|
||||
export interface SelectableCard {
|
||||
id: string
|
||||
nextReviewDate: Date | null
|
||||
incorrectCount: number
|
||||
consecutiveCorrect: number
|
||||
manualDifficulty: Difficulty
|
||||
}
|
||||
|
||||
/**
|
||||
* Select cards for a learning session
|
||||
*
|
||||
* Algorithm:
|
||||
* 1. Filter out SUSPENDED cards
|
||||
* 2. Filter cards that are due (nextReviewDate <= now)
|
||||
* 3. Apply priority: HARD cards first, NORMAL, then EASY
|
||||
* 4. Sort by: nextReviewDate ASC, incorrectCount DESC, consecutiveCorrect ASC
|
||||
* 5. Limit to cardsPerSession
|
||||
*
|
||||
* @param cards Available cards
|
||||
* @param cardsPerSession Maximum number of cards to select
|
||||
* @param now Current date (defaults to now)
|
||||
* @returns Selected cards for the session
|
||||
*/
|
||||
export function selectCardsForSession(
|
||||
cards: SelectableCard[],
|
||||
cardsPerSession: number,
|
||||
now: Date = new Date()
|
||||
): SelectableCard[] {
|
||||
// Filter out suspended cards
|
||||
const activeCards = cards.filter(
|
||||
(card) => card.manualDifficulty !== Difficulty.SUSPENDED
|
||||
)
|
||||
|
||||
// Filter cards that are due (nextReviewDate <= now or null for new cards)
|
||||
const dueCards = activeCards.filter(
|
||||
(card) => card.nextReviewDate === null || card.nextReviewDate <= now
|
||||
)
|
||||
|
||||
// Apply difficulty priority and sort
|
||||
const sortedCards = dueCards.sort((a, b) => {
|
||||
// Priority by difficulty: HARD > NORMAL > EASY
|
||||
const difficultyPriority = {
|
||||
[Difficulty.HARD]: 0,
|
||||
[Difficulty.NORMAL]: 1,
|
||||
[Difficulty.EASY]: 2,
|
||||
[Difficulty.SUSPENDED]: 3, // Should not appear due to filter
|
||||
}
|
||||
|
||||
const aPriority = difficultyPriority[a.manualDifficulty]
|
||||
const bPriority = difficultyPriority[b.manualDifficulty]
|
||||
|
||||
if (aPriority !== bPriority) {
|
||||
return aPriority - bPriority
|
||||
}
|
||||
|
||||
// Sort by nextReviewDate (null = new cards, should come first)
|
||||
if (a.nextReviewDate === null && b.nextReviewDate !== null) return -1
|
||||
if (a.nextReviewDate !== null && b.nextReviewDate === null) return 1
|
||||
if (a.nextReviewDate !== null && b.nextReviewDate !== null) {
|
||||
const dateCompare = a.nextReviewDate.getTime() - b.nextReviewDate.getTime()
|
||||
if (dateCompare !== 0) return dateCompare
|
||||
}
|
||||
|
||||
// Sort by incorrectCount DESC (more incorrect = higher priority)
|
||||
if (a.incorrectCount !== b.incorrectCount) {
|
||||
return b.incorrectCount - a.incorrectCount
|
||||
}
|
||||
|
||||
// Sort by consecutiveCorrect ASC (fewer correct = higher priority)
|
||||
return a.consecutiveCorrect - b.consecutiveCorrect
|
||||
})
|
||||
|
||||
// Limit to cardsPerSession
|
||||
return sortedCards.slice(0, cardsPerSession)
|
||||
}
|
||||
|
||||
/**
|
||||
* Hanzi option for wrong answer generation
|
||||
*/
|
||||
export interface HanziOption {
|
||||
id: string
|
||||
simplified: string
|
||||
pinyin: string
|
||||
hskLevel: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate wrong answers for a multiple choice question
|
||||
*
|
||||
* Selects 3 random incorrect pinyin from the same HSK level,
|
||||
* ensuring no duplicates.
|
||||
*
|
||||
* @param correctAnswer The correct hanzi
|
||||
* @param sameHskOptions Available hanzi from the same HSK level
|
||||
* @returns Array of 3 wrong pinyin options
|
||||
*/
|
||||
export function generateWrongAnswers(
|
||||
correctAnswer: HanziOption,
|
||||
sameHskOptions: HanziOption[]
|
||||
): string[] {
|
||||
// Filter out the correct answer and any with duplicate pinyin
|
||||
const candidates = sameHskOptions.filter(
|
||||
(option) =>
|
||||
option.id !== correctAnswer.id && option.pinyin !== correctAnswer.pinyin
|
||||
)
|
||||
|
||||
// If not enough candidates, throw error
|
||||
if (candidates.length < 3) {
|
||||
throw new Error(
|
||||
`Not enough wrong answers available. Need 3, found ${candidates.length}`
|
||||
)
|
||||
}
|
||||
|
||||
// Fisher-Yates shuffle
|
||||
const shuffled = [...candidates]
|
||||
for (let i = shuffled.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1))
|
||||
;[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]
|
||||
}
|
||||
|
||||
// Take first 3
|
||||
return shuffled.slice(0, 3).map((option) => option.pinyin)
|
||||
}
|
||||
|
||||
/**
|
||||
* Shuffle an array of options (for randomizing answer positions)
|
||||
* Uses Fisher-Yates shuffle algorithm
|
||||
*
|
||||
* @param options Array to shuffle
|
||||
* @returns Shuffled array
|
||||
*/
|
||||
export function shuffleOptions<T>(options: T[]): T[] {
|
||||
const shuffled = [...options]
|
||||
for (let i = shuffled.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1))
|
||||
;[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]
|
||||
}
|
||||
return shuffled
|
||||
}
|
||||
13
src/lib/prisma.ts
Normal file
13
src/lib/prisma.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
|
||||
const globalForPrisma = globalThis as unknown as {
|
||||
prisma: PrismaClient | undefined
|
||||
}
|
||||
|
||||
export const prisma =
|
||||
globalForPrisma.prisma ??
|
||||
new PrismaClient({
|
||||
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
|
||||
})
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma
|
||||
172
src/lib/validations/auth.test.ts
Normal file
172
src/lib/validations/auth.test.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
loginSchema,
|
||||
registerSchema,
|
||||
updatePasswordSchema,
|
||||
updateProfileSchema,
|
||||
} from './auth'
|
||||
|
||||
describe('Auth Validation Schemas', () => {
|
||||
describe('loginSchema', () => {
|
||||
it('should validate correct login data', () => {
|
||||
const result = loginSchema.safeParse({
|
||||
email: 'test@example.com',
|
||||
password: 'password123',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('should reject invalid email', () => {
|
||||
const result = loginSchema.safeParse({
|
||||
email: 'invalid-email',
|
||||
password: 'password123',
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('should reject empty email', () => {
|
||||
const result = loginSchema.safeParse({
|
||||
email: '',
|
||||
password: 'password123',
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('should reject empty password', () => {
|
||||
const result = loginSchema.safeParse({
|
||||
email: 'test@example.com',
|
||||
password: '',
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('registerSchema', () => {
|
||||
it('should validate correct registration data', () => {
|
||||
const result = registerSchema.safeParse({
|
||||
email: 'test@example.com',
|
||||
password: 'password123',
|
||||
name: 'Test User',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('should reject password shorter than 8 characters', () => {
|
||||
const result = registerSchema.safeParse({
|
||||
email: 'test@example.com',
|
||||
password: 'short',
|
||||
name: 'Test User',
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
expect(result.error.issues[0].path).toContain('password')
|
||||
}
|
||||
})
|
||||
|
||||
it('should reject invalid email format', () => {
|
||||
const result = registerSchema.safeParse({
|
||||
email: 'not-an-email',
|
||||
password: 'password123',
|
||||
name: 'Test User',
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
expect(result.error.issues[0].path).toContain('email')
|
||||
}
|
||||
})
|
||||
|
||||
it('should reject empty name', () => {
|
||||
const result = registerSchema.safeParse({
|
||||
email: 'test@example.com',
|
||||
password: 'password123',
|
||||
name: '',
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
expect(result.error.issues[0].path).toContain('name')
|
||||
}
|
||||
})
|
||||
|
||||
it('should accept minimum valid password (8 characters)', () => {
|
||||
const result = registerSchema.safeParse({
|
||||
email: 'test@example.com',
|
||||
password: '12345678',
|
||||
name: 'Test User',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('updatePasswordSchema', () => {
|
||||
it('should validate correct password update data', () => {
|
||||
const result = updatePasswordSchema.safeParse({
|
||||
currentPassword: 'oldpassword',
|
||||
newPassword: 'newpassword123',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('should reject new password shorter than 8 characters', () => {
|
||||
const result = updatePasswordSchema.safeParse({
|
||||
currentPassword: 'oldpassword',
|
||||
newPassword: 'short',
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
expect(result.error.issues[0].path).toContain('newPassword')
|
||||
}
|
||||
})
|
||||
|
||||
it('should reject empty current password', () => {
|
||||
const result = updatePasswordSchema.safeParse({
|
||||
currentPassword: '',
|
||||
newPassword: 'newpassword123',
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateProfileSchema', () => {
|
||||
it('should validate correct profile update with name', () => {
|
||||
const result = updateProfileSchema.safeParse({
|
||||
name: 'New Name',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('should validate correct profile update with email', () => {
|
||||
const result = updateProfileSchema.safeParse({
|
||||
email: 'newemail@example.com',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('should validate correct profile update with both fields', () => {
|
||||
const result = updateProfileSchema.safeParse({
|
||||
name: 'New Name',
|
||||
email: 'newemail@example.com',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('should reject invalid email format', () => {
|
||||
const result = updateProfileSchema.safeParse({
|
||||
email: 'invalid-email',
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('should accept empty object (no updates)', () => {
|
||||
const result = updateProfileSchema.safeParse({})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('should accept undefined values', () => {
|
||||
const result = updateProfileSchema.safeParse({
|
||||
name: undefined,
|
||||
email: undefined,
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
22
src/lib/validations/auth.ts
Normal file
22
src/lib/validations/auth.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const loginSchema = z.object({
|
||||
email: z.string().email('Invalid email address'),
|
||||
password: z.string().min(6, 'Password must be at least 6 characters'),
|
||||
})
|
||||
|
||||
export const registerSchema = z.object({
|
||||
email: z.string().email('Invalid email address'),
|
||||
password: z.string().min(6, 'Password must be at least 6 characters'),
|
||||
name: z.string().min(2, 'Name must be at least 2 characters'),
|
||||
})
|
||||
|
||||
export const updatePasswordSchema = z.object({
|
||||
currentPassword: z.string().min(1, 'Current password is required'),
|
||||
newPassword: z.string().min(6, 'New password must be at least 6 characters'),
|
||||
})
|
||||
|
||||
export const updateProfileSchema = z.object({
|
||||
name: z.string().min(2, 'Name must be at least 2 characters').optional(),
|
||||
email: z.string().email('Invalid email address').optional(),
|
||||
})
|
||||
159
src/lib/validations/preferences.test.ts
Normal file
159
src/lib/validations/preferences.test.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { updatePreferencesSchema } from './preferences'
|
||||
|
||||
describe('Preferences Validation Schemas', () => {
|
||||
describe('updatePreferencesSchema', () => {
|
||||
it('should validate correct preferences data', () => {
|
||||
const result = updatePreferencesSchema.safeParse({
|
||||
characterDisplay: 'SIMPLIFIED',
|
||||
cardsPerSession: 20,
|
||||
dailyGoal: 50,
|
||||
transcriptionType: 'pinyin',
|
||||
removalThreshold: 10,
|
||||
allowManualDifficulty: true,
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('should validate TRADITIONAL character display', () => {
|
||||
const result = updatePreferencesSchema.safeParse({
|
||||
characterDisplay: 'TRADITIONAL',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('should validate BOTH character display', () => {
|
||||
const result = updatePreferencesSchema.safeParse({
|
||||
characterDisplay: 'BOTH',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('should reject invalid character display', () => {
|
||||
const result = updatePreferencesSchema.safeParse({
|
||||
characterDisplay: 'INVALID',
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('should reject cardsPerSession below 5', () => {
|
||||
const result = updatePreferencesSchema.safeParse({
|
||||
cardsPerSession: 4,
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('should accept cardsPerSession of 5 (minimum)', () => {
|
||||
const result = updatePreferencesSchema.safeParse({
|
||||
cardsPerSession: 5,
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('should reject dailyGoal below 10', () => {
|
||||
const result = updatePreferencesSchema.safeParse({
|
||||
dailyGoal: 9,
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('should accept dailyGoal of 10 (minimum)', () => {
|
||||
const result = updatePreferencesSchema.safeParse({
|
||||
dailyGoal: 10,
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('should accept cardsPerSession of 100 (maximum)', () => {
|
||||
const result = updatePreferencesSchema.safeParse({
|
||||
cardsPerSession: 100,
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('should accept dailyGoal of 500 (maximum)', () => {
|
||||
const result = updatePreferencesSchema.safeParse({
|
||||
dailyGoal: 500,
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('should reject cardsPerSession above 100', () => {
|
||||
const result = updatePreferencesSchema.safeParse({
|
||||
cardsPerSession: 101,
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('should reject dailyGoal above 500', () => {
|
||||
const result = updatePreferencesSchema.safeParse({
|
||||
dailyGoal: 501,
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('should accept empty object (no updates)', () => {
|
||||
const result = updatePreferencesSchema.safeParse({})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('should validate partial updates', () => {
|
||||
const result = updatePreferencesSchema.safeParse({
|
||||
cardsPerSession: 30,
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('should accept preferredLanguageId as string', () => {
|
||||
const result = updatePreferencesSchema.safeParse({
|
||||
preferredLanguageId: 'some-uuid-string',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('should reject removalThreshold below 5', () => {
|
||||
const result = updatePreferencesSchema.safeParse({
|
||||
removalThreshold: 4,
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('should accept removalThreshold of 5 (minimum)', () => {
|
||||
const result = updatePreferencesSchema.safeParse({
|
||||
removalThreshold: 5,
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('should accept removalThreshold of 50 (maximum)', () => {
|
||||
const result = updatePreferencesSchema.safeParse({
|
||||
removalThreshold: 50,
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('should reject removalThreshold above 50', () => {
|
||||
const result = updatePreferencesSchema.safeParse({
|
||||
removalThreshold: 51,
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('should validate boolean allowManualDifficulty', () => {
|
||||
const result = updatePreferencesSchema.safeParse({
|
||||
allowManualDifficulty: false,
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('should validate different transcription types', () => {
|
||||
const types = ['pinyin', 'zhuyin', 'wade-giles', 'ipa']
|
||||
types.forEach((type) => {
|
||||
const result = updatePreferencesSchema.safeParse({
|
||||
transcriptionType: type,
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
11
src/lib/validations/preferences.ts
Normal file
11
src/lib/validations/preferences.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const updatePreferencesSchema = z.object({
|
||||
preferredLanguageId: z.string().optional(),
|
||||
characterDisplay: z.enum(['SIMPLIFIED', 'TRADITIONAL', 'BOTH']).optional(),
|
||||
transcriptionType: z.string().optional(),
|
||||
cardsPerSession: z.number().int().min(5).max(100).optional(),
|
||||
dailyGoal: z.number().int().min(10).max(500).optional(),
|
||||
removalThreshold: z.number().int().min(5).max(50).optional(),
|
||||
allowManualDifficulty: z.boolean().optional(),
|
||||
})
|
||||
41
src/types/index.ts
Normal file
41
src/types/index.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
// Standard Server Action result type
|
||||
export type ActionResult<T = void> = {
|
||||
success: boolean
|
||||
data?: T
|
||||
message?: string
|
||||
errors?: Record<string, string[]>
|
||||
}
|
||||
|
||||
// User types
|
||||
export type UserRole = 'USER' | 'ADMIN' | 'MODERATOR'
|
||||
|
||||
export type SafeUser = {
|
||||
id: string
|
||||
email: string
|
||||
name: string | null
|
||||
role: UserRole
|
||||
isActive: boolean
|
||||
createdAt: Date
|
||||
}
|
||||
|
||||
// Auth types
|
||||
export type LoginCredentials = {
|
||||
email: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export type RegisterData = {
|
||||
email: string
|
||||
password: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export type UpdatePasswordData = {
|
||||
currentPassword: string
|
||||
newPassword: string
|
||||
}
|
||||
|
||||
export type UpdateProfileData = {
|
||||
name?: string
|
||||
email?: string
|
||||
}
|
||||
4
test-results/.last-run.json
Normal file
4
test-results/.last-run.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"status": "passed",
|
||||
"failedTests": []
|
||||
}
|
||||
56
testdata/sample-hsk.json
vendored
Normal file
56
testdata/sample-hsk.json
vendored
Normal file
@@ -0,0 +1,56 @@
|
||||
[
|
||||
{
|
||||
"simplified": "爱好",
|
||||
"radical": "爫",
|
||||
"level": ["new-1", "old-3"],
|
||||
"frequency": 4902,
|
||||
"pos": ["n", "v"],
|
||||
"forms": [
|
||||
{
|
||||
"traditional": "愛好",
|
||||
"transcriptions": {
|
||||
"pinyin": "ài hào",
|
||||
"numeric": "ai4 hao4"
|
||||
},
|
||||
"meanings": ["to like; hobby"],
|
||||
"classifiers": ["个"]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"simplified": "八",
|
||||
"radical": "八",
|
||||
"level": ["new-1", "old-1"],
|
||||
"frequency": 325,
|
||||
"pos": ["num"],
|
||||
"forms": [
|
||||
{
|
||||
"traditional": "八",
|
||||
"transcriptions": {
|
||||
"pinyin": "bā",
|
||||
"numeric": "ba1"
|
||||
},
|
||||
"meanings": ["eight"],
|
||||
"classifiers": []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"simplified": "爸爸",
|
||||
"radical": "父",
|
||||
"level": ["new-1", "old-1"],
|
||||
"frequency": 1290,
|
||||
"pos": ["n"],
|
||||
"forms": [
|
||||
{
|
||||
"traditional": "爸爸",
|
||||
"transcriptions": {
|
||||
"pinyin": "bà ba",
|
||||
"numeric": "ba4 ba5"
|
||||
},
|
||||
"meanings": ["dad; father"],
|
||||
"classifiers": ["个", "位"]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
120
testresult.txt
Normal file
120
testresult.txt
Normal file
@@ -0,0 +1,120 @@
|
||||
|
||||
> memohanzi@0.1.0 test:ci
|
||||
> npm run test:unit && npm run test:integration && npm run test:e2e
|
||||
|
||||
|
||||
> memohanzi@0.1.0 test:unit
|
||||
> vitest run --coverage
|
||||
|
||||
|
||||
RUN v2.1.9 /Users/shardegger/Projects/memohanzi
|
||||
Coverage enabled with v8
|
||||
|
||||
✓ src/lib/validations/auth.test.ts (18 tests) 3ms
|
||||
✓ src/lib/validations/preferences.test.ts (21 tests) 3ms
|
||||
|
||||
Test Files 2 passed (2)
|
||||
Tests 39 passed (39)
|
||||
Start at 08:22:51
|
||||
Duration 484ms (transform 28ms, setup 108ms, collect 30ms, tests 6ms, environment 310ms, prepare 61ms)
|
||||
|
||||
% Coverage report from v8
|
||||
-------------------|---------|----------|---------|---------|-------------------
|
||||
File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
|
||||
-------------------|---------|----------|---------|---------|-------------------
|
||||
All files | 13.37 | 70.58 | 4.76 | 13.37 |
|
||||
memohanzi | 0 | 0 | 0 | 0 |
|
||||
...nt.config.mjs | 0 | 0 | 0 | 0 | 1-18
|
||||
...ss.config.mjs | 0 | 0 | 0 | 0 | 1-7
|
||||
...tion.setup.ts | 0 | 0 | 0 | 0 | 1-54
|
||||
memohanzi/e2e | 0 | 0 | 0 | 0 |
|
||||
auth.spec.ts | 0 | 0 | 0 | 0 | 1-265
|
||||
settings.spec.ts | 0 | 0 | 0 | 0 | 1-356
|
||||
memohanzi/prisma | 0 | 0 | 0 | 0 |
|
||||
seed.ts | 0 | 0 | 0 | 0 | 1-87
|
||||
...zi/src/actions | 0 | 0 | 0 | 0 |
|
||||
...ation.test.ts | 0 | 0 | 0 | 0 | 1-271
|
||||
auth.ts | 0 | 0 | 0 | 0 | 1-317
|
||||
...ation.test.ts | 0 | 0 | 0 | 0 | 1-288
|
||||
preferences.ts | 0 | 0 | 0 | 0 | 1-145
|
||||
memohanzi/src/app | 0 | 0 | 0 | 0 |
|
||||
layout.tsx | 0 | 0 | 0 | 0 | 1-34
|
||||
page.tsx | 0 | 0 | 0 | 0 | 1-62
|
||||
...app)/dashboard | 0 | 0 | 0 | 0 |
|
||||
page.tsx | 0 | 0 | 0 | 0 | 1-119
|
||||
...(app)/settings | 0 | 0 | 0 | 0 |
|
||||
page.tsx | 0 | 0 | 0 | 0 | 1-71
|
||||
...ings-form.tsx | 0 | 0 | 0 | 0 | 1-284
|
||||
...p/(auth)/login | 0 | 0 | 0 | 0 |
|
||||
page.tsx | 0 | 0 | 0 | 0 | 1-128
|
||||
...auth)/register | 0 | 0 | 0 | 0 |
|
||||
page.tsx | 0 | 0 | 0 | 0 | 1-151
|
||||
.../[...nextauth] | 0 | 0 | 0 | 0 |
|
||||
route.ts | 0 | 0 | 0 | 0 | 1-3
|
||||
memohanzi/src/lib | 0 | 0 | 0 | 0 |
|
||||
auth.ts | 0 | 0 | 0 | 0 | 1-73
|
||||
prisma.ts | 0 | 0 | 0 | 0 | 1-13
|
||||
...ib/validations | 100 | 100 | 100 | 100 |
|
||||
auth.test.ts | 100 | 100 | 100 | 100 |
|
||||
auth.ts | 100 | 100 | 100 | 100 |
|
||||
...ences.test.ts | 100 | 100 | 100 | 100 |
|
||||
preferences.ts | 100 | 100 | 100 | 100 |
|
||||
...anzi/src/types | 0 | 0 | 0 | 0 |
|
||||
index.ts | 0 | 0 | 0 | 0 |
|
||||
-------------------|---------|----------|---------|---------|-------------------
|
||||
|
||||
> memohanzi@0.1.0 test:integration
|
||||
> vitest run --config vitest.integration.config.ts
|
||||
|
||||
|
||||
RUN v2.1.9 /Users/shardegger/Projects/memohanzi
|
||||
|
||||
stdout | src/actions/auth.integration.test.ts
|
||||
🔗 Connecting to test database...
|
||||
|
||||
stdout | src/actions/auth.integration.test.ts
|
||||
✅ Connected to test database
|
||||
|
||||
✓ src/actions/auth.integration.test.ts (19 tests) 1685ms
|
||||
stdout | src/actions/auth.integration.test.ts
|
||||
🔌 Disconnecting from test database...
|
||||
|
||||
stdout | src/actions/preferences.integration.test.ts
|
||||
🔗 Connecting to test database...
|
||||
|
||||
stdout | src/actions/preferences.integration.test.ts
|
||||
✅ Connected to test database
|
||||
|
||||
stdout | src/actions/preferences.integration.test.ts > Preferences Server Actions - Integration Tests > getAvailableLanguages > should handle empty language list
|
||||
prisma:error
|
||||
Invalid `prisma.language.deleteMany()` invocation in
|
||||
/Users/shardegger/Projects/memohanzi/src/actions/preferences.integration.test.ts:280:29
|
||||
|
||||
277
|
||||
278 it('should handle empty language list', async () => {
|
||||
279 // Delete all languages
|
||||
→ 280 await prisma.language.deleteMany(
|
||||
Error occurred during query execution:
|
||||
ConnectorError(ConnectorError { user_facing_error: None, kind: QueryError(PostgresError { code: "23001", message: "update or delete on table \"languages\" violates RESTRICT setting of foreign key constraint \"user_preferences_preferredLanguageId_fkey\" on table \"user_preferences\"", severity: "ERROR", detail: Some("Key (id)=(cmi5odqtq001tlw1vkvmnrx1z) is referenced from table \"user_preferences\"."), column: None, hint: None }), transient: false })
|
||||
|
||||
stdout | src/actions/preferences.integration.test.ts
|
||||
🔌 Disconnecting from test database...
|
||||
|
||||
❯ src/actions/preferences.integration.test.ts (16 tests | 1 failed) 1377ms
|
||||
× Preferences Server Actions - Integration Tests > getAvailableLanguages > should handle empty language list 89ms
|
||||
→
|
||||
Invalid `prisma.language.deleteMany()` invocation in
|
||||
/Users/shardegger/Projects/memohanzi/src/actions/preferences.integration.test.ts:280:29
|
||||
|
||||
277
|
||||
278 it('should handle empty language list', async () => {
|
||||
279 // Delete all languages
|
||||
→ 280 await prisma.language.deleteMany(
|
||||
Error occurred during query execution:
|
||||
ConnectorError(ConnectorError { user_facing_error: None, kind: QueryError(PostgresError { code: "23001", message: "update or delete on table \"languages\" violates RESTRICT setting of foreign key constraint \"user_preferences_preferredLanguageId_fkey\" on table \"user_preferences\"", severity: "ERROR", detail: Some("Key (id)=(cmi5odqtq001tlw1vkvmnrx1z) is referenced from table \"user_preferences\"."), column: None, hint: None }), transient: false })
|
||||
|
||||
Test Files 1 failed | 1 passed (2)
|
||||
Tests 1 failed | 34 passed (35)
|
||||
Start at 08:22:51
|
||||
Duration 3.33s (transform 38ms, setup 34ms, collect 73ms, tests 3.06s, environment 0ms, prepare 40ms)
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
|
||||
36
vitest.config.ts
Normal file
36
vitest.config.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import path from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
globals: true,
|
||||
setupFiles: ['./vitest.setup.ts'],
|
||||
include: ['src/**/*.test.ts', 'src/**/*.test.tsx'],
|
||||
exclude: [
|
||||
'node_modules/**',
|
||||
'**/*.integration.test.ts',
|
||||
'e2e/**',
|
||||
],
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'json', 'html'],
|
||||
exclude: [
|
||||
'node_modules/',
|
||||
'.next/',
|
||||
'coverage/',
|
||||
'**/*.config.ts',
|
||||
'**/*.config.js',
|
||||
'**/types.ts',
|
||||
'**/*.d.ts',
|
||||
],
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
})
|
||||
19
vitest.integration.config.ts
Normal file
19
vitest.integration.config.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
import path from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'node',
|
||||
globals: true,
|
||||
include: ['src/**/*.integration.test.ts'],
|
||||
setupFiles: ['./vitest.integration.setup.ts'],
|
||||
testTimeout: 30000, // 30 seconds per test
|
||||
hookTimeout: 120000, // 120 seconds for hooks (container startup)
|
||||
fileParallelism: false, // Run files sequentially to share container
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
})
|
||||
54
vitest.integration.setup.ts
Normal file
54
vitest.integration.setup.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { beforeAll, afterAll, beforeEach } from 'vitest'
|
||||
import { prisma } from './src/lib/prisma'
|
||||
|
||||
// Integration tests require Docker Compose to be running
|
||||
// Run: docker compose up -d
|
||||
// The database should be accessible at localhost:5432
|
||||
|
||||
// Setup for integration tests
|
||||
beforeAll(async () => {
|
||||
console.log('🔗 Connecting to test database...')
|
||||
|
||||
// Check if DATABASE_URL is set
|
||||
if (!process.env.DATABASE_URL) {
|
||||
throw new Error(
|
||||
'DATABASE_URL is not set. Make sure Docker Compose is running and .env has DATABASE_URL'
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
await prisma.$connect()
|
||||
console.log('✅ Connected to test database')
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to connect to database. Is Docker Compose running?')
|
||||
console.error(' Run: docker compose up -d')
|
||||
throw error
|
||||
}
|
||||
})
|
||||
|
||||
beforeEach(async () => {
|
||||
// Clean database before each test (in correct order due to foreign keys)
|
||||
await prisma.sessionReview.deleteMany()
|
||||
await prisma.learningSession.deleteMany()
|
||||
await prisma.collectionItem.deleteMany()
|
||||
await prisma.collection.deleteMany()
|
||||
await prisma.userHanziProgress.deleteMany()
|
||||
await prisma.account.deleteMany()
|
||||
await prisma.session.deleteMany()
|
||||
await prisma.verificationToken.deleteMany()
|
||||
await prisma.userPreference.deleteMany()
|
||||
await prisma.user.deleteMany()
|
||||
await prisma.hanziMeaning.deleteMany()
|
||||
await prisma.hanziTranscription.deleteMany()
|
||||
await prisma.hanziForm.deleteMany()
|
||||
await prisma.hanziHSKLevel.deleteMany()
|
||||
await prisma.hanziPOS.deleteMany()
|
||||
await prisma.hanziClassifier.deleteMany()
|
||||
await prisma.hanzi.deleteMany()
|
||||
await prisma.language.deleteMany()
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
console.log('🔌 Disconnecting from test database...')
|
||||
await prisma.$disconnect()
|
||||
})
|
||||
8
vitest.setup.ts
Normal file
8
vitest.setup.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import '@testing-library/jest-dom'
|
||||
import { expect, afterEach } from 'vitest'
|
||||
import { cleanup } from '@testing-library/react'
|
||||
|
||||
// Cleanup after each test
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
})
|
||||
Reference in New Issue
Block a user